Skip to content

Commit f44852c

Browse files
committed
Overdrive cables: kill the horizontal "inertia" past cliff lips
The centerline lift took the terrain max within ±0.85·(lenAB/numSeg) ALONG the cable. A max-filter along the path is a morphological dilation of the terrain profile: on a monotone slope every point takes the height ~19 elmos uphill of it, so the whole profile shifts horizontally downhill by a constant amount — cables carried cliff-top height well past the lip before dropping, tracing the ground curvature perfectly but with a consistent sideways push, as if the wire had momentum. The wide window predates the kink-adaptive tessellation and was doing anti-clip duty that clustering now covers. Split the job in two, both inside placeRibbonVertices so trunk and twig invocations replay identically: - The profile scan max-filters over ONE scan-grid cell (lenAB/G), just enough to catch heightmap texels between scan samples. Bonus: kinks stop being smeared across two cells, so curvature clustering lands harder exactly on the lip. - Each EMITTED vertex then takes the max of the scan profile over ~0.55 of the gap to each emitted neighbour. Both endpoints of every gap jointly cover its scan samples with midpoint overlap — the same stay-above-terrain envelope as before, but the dilation radius shrinks with clustering: at a cliff lip it degenerates to the vertex's own one-cell sample, on sparse stretches it grows with the gap, which is invisible because sparse means flat. Endpoints no longer sample terrain BEYOND the cable ends either (the old symmetric window reached ~19 elmos past the pylon), removing the matching overshoot at anchors on cliff edges.
1 parent 7b0c36f commit f44852c

1 file changed

Lines changed: 68 additions & 27 deletions

File tree

LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -297,21 +297,29 @@ vec2 wigglyCablePoint(vec2 a, vec2 d, vec2 perpAB, float t, float lenAB,
297297
return base + perpCanon * n;
298298
}
299299

300-
// Lift y to the max heightmap value sampled within ±fullStep along dirH.
301-
// Linear interpolation between adjacent segment vertices can dip below
302-
// terrain on convex/rolling slopes — taking the max within a window that
303-
// covers the next vertex's position guarantees adjacent envelopes overlap
304-
// at the segment midpoint, so the rendered ribbon stays above any peak in
305-
// the gap. Used by the main ribbon (centerline lift) and twig emitter
306-
// (spawn point lift) so they share the same vertical anchor.
307-
float maxHeightInWindow(vec2 p, vec2 dirH, float fullStep) {
300+
// Lift y to the max heightmap value sampled within ±window along dirH.
301+
// Linear interpolation between adjacent samples can dip below terrain on
302+
// convex/rolling slopes — taking the max within a window that reaches most of
303+
// the way to the neighbouring sample makes adjacent envelopes overlap, so the
304+
// profile built from the samples stays above any sub-sample terrain peak.
305+
//
306+
// A max-filter along the path is a morphological DILATION of the terrain
307+
// profile: on a monotone slope every sample takes the height ~0.85·window
308+
// uphill of it, i.e. the whole profile shifts horizontally downhill by a
309+
// constant amount. Size `window` to the SMALLEST span that still catches
310+
// sub-sample terrain — an oversized window reads as the cable overshooting
311+
// cliff lips like it had inertia. placeRibbonVertices passes the scan-grid
312+
// cell span (not the emitted-vertex span) for exactly this reason; the
313+
// per-EMITTED-vertex anti-clip is handled there with windows sized to the
314+
// local clustered gap.
315+
float maxHeightInWindow(vec2 p, vec2 dirH, float window) {
308316
float yMax = heightAtWorldPos(p);
309-
yMax = max(yMax, heightAtWorldPos(p + dirH * (fullStep * 0.30)));
310-
yMax = max(yMax, heightAtWorldPos(p - dirH * (fullStep * 0.30)));
311-
yMax = max(yMax, heightAtWorldPos(p + dirH * (fullStep * 0.55)));
312-
yMax = max(yMax, heightAtWorldPos(p - dirH * (fullStep * 0.55)));
313-
yMax = max(yMax, heightAtWorldPos(p + dirH * (fullStep * 0.85)));
314-
yMax = max(yMax, heightAtWorldPos(p - dirH * (fullStep * 0.85)));
317+
yMax = max(yMax, heightAtWorldPos(p + dirH * (window * 0.30)));
318+
yMax = max(yMax, heightAtWorldPos(p - dirH * (window * 0.30)));
319+
yMax = max(yMax, heightAtWorldPos(p + dirH * (window * 0.55)));
320+
yMax = max(yMax, heightAtWorldPos(p - dirH * (window * 0.55)));
321+
yMax = max(yMax, heightAtWorldPos(p + dirH * (window * 0.85)));
322+
yMax = max(yMax, heightAtWorldPos(p - dirH * (window * 0.85)));
315323
return yMax;
316324
}
317325

@@ -349,17 +357,26 @@ int placeRibbonVertices(vec2 a, vec2 d, vec2 perpAB, float lenAB, float arcDh,
349357
out float yBaseArr[MAX_SEGMENTS + 1],
350358
out float alongArr[MAX_SEGMENTS + 1]) {
351359
vec2 dirH = (lenAB > 0.0) ? d / lenAB : vec2(1.0, 0.0);
352-
float fullStep = lenAB / float(numSeg); // max-filter window ~ avg emit span
353360
int G = clamp(PLACEMENT_OVERSAMPLE * numSeg, 1, MAX_GRID);
354-
355-
// Profile scan: max-filtered terrain height along the cable path. Its second
361+
float gridStep = lenAB / float(G); // scan-cell span
362+
363+
// Profile scan: terrain height along the cable path, max-filtered only over
364+
// ONE scan cell — just enough to catch terrain between scan samples, NOT the
365+
// old ±0.85·(lenAB/numSeg) window. That global-average window dilated the
366+
// profile by ~19 elmos along the cable, which on any monotone slope is a
367+
// constant horizontal shift downhill: cables carried cliff-top height well
368+
// past the lip before dropping, as if they had inertia. The anti-clip duty
369+
// the wide window used to carry moved to the per-emitted-vertex pass below,
370+
// where the window is sized to the LOCAL clustered gap. The profile's second
356371
// difference (below) is the along-cable curvature — directional by
357-
// construction (crossing a ridge spikes it, running along a crest does not).
372+
// construction (crossing a ridge spikes it, running along a crest does not);
373+
// the narrower filter also stops smearing kinks across two cells, so
374+
// clustering lands harder exactly on the lip.
358375
float yCgrid[MAX_GRID + 1];
359376
for (int i = 0; i <= G; i++) {
360377
float tg = float(i) / float(G);
361378
vec2 pg = wigglyCablePoint(a, d, perpAB, tg, lenAB, arcDh, effAmp, seed);
362-
yCgrid[i] = maxHeightInWindow(pg, dirH, fullStep); // base terrain profile
379+
yCgrid[i] = maxHeightInWindow(pg, dirH, gridStep); // base terrain profile
363380
}
364381

365382
// Per-cell importance = uniform floor (1.0, keeps flats at equal arc spacing)
@@ -374,10 +391,8 @@ int placeRibbonVertices(vec2 a, vec2 d, vec2 perpAB, float lenAB, float arcDh,
374391
}
375392
float wStep = cum[G] / float(numSeg);
376393

377-
int gi = 0;
378-
int prevIdx = -1;
379-
float along = 0.0;
380-
vec3 prevBase = vec3(0.0);
394+
int gi = 0;
395+
int prevIdx = -1;
381396
for (int k = 0; k <= numSeg; k++) {
382397
// Pick the grid index whose cumulative weight is nearest k·wStep. gi and k
383398
// both advance monotonically → one linear sweep. The max(prevIdx+1) guard
@@ -389,18 +404,44 @@ int placeRibbonVertices(vec2 a, vec2 d, vec2 perpAB, float lenAB, float arcDh,
389404
if (gi < G && (target - cum[gi]) > (cum[gi+1] - target)) idx = gi + 1;
390405
idx = min(max(idx, prevIdx + 1), G);
391406
prevIdx = idx;
407+
idxArr[k] = idx;
408+
}
409+
410+
// Per-vertex anti-clip lift, sized to the LOCAL emitted gaps: each vertex
411+
// takes the max of the scan profile over ~0.55 of the gap to each emitted
412+
// neighbour, so the two endpoints of every gap jointly cover all its scan
413+
// samples with overlap at the midpoint (0.55 + 0.55 > 1) — the same
414+
// envelope guarantee the old global-average window gave, but the dilation
415+
// radius now shrinks with clustering: at a cliff lip, where curvature
416+
// packs vertices into adjacent scan cells, the lift degenerates to the
417+
// vertex's own (one-cell-filtered) sample and the "inertia" overshoot is
418+
// bounded by one scan cell instead of ±0.85·(lenAB/numSeg). On sparse
419+
// stretches the window grows with the gap — but sparse means flat, where
420+
// the max is invisible. int() truncation keeps the reach strictly inside
421+
// the gap for adjacent-cell neighbours (nothing to cover between them).
422+
float along = 0.0;
423+
vec3 prevBase = vec3(0.0);
424+
for (int k = 0; k <= numSeg; k++) {
425+
int iC = idxArr[k];
426+
int iL = (k > 0) ? idxArr[k-1] : iC;
427+
int iR = (k < numSeg) ? idxArr[k+1] : iC;
428+
int lo = max(iC - int(float(iC - iL) * 0.55), 0);
429+
int hi = min(iC + int(float(iR - iC) * 0.55), G);
430+
float yB = yCgrid[iC];
431+
for (int i = lo; i <= hi; i++) {
432+
yB = max(yB, yCgrid[i]);
433+
}
392434

393435
// Accumulate 3D arc length over the emitted vertices. The clearance pad is
394436
// a uniform per-cable Navg shift, so it cancels in consecutive distances —
395437
// accumulating over the bare (xz, yBase) points matches emitTentHalf's
396438
// center3D-based accumulation exactly.
397-
vec2 p = wigglyCablePoint(a, d, perpAB, float(idx) / float(G), lenAB, arcDh, effAmp, seed);
398-
vec3 base = vec3(p.x, yCgrid[idx], p.y);
439+
vec2 p = wigglyCablePoint(a, d, perpAB, float(iC) / float(G), lenAB, arcDh, effAmp, seed);
440+
vec3 base = vec3(p.x, yB, p.y);
399441
if (k > 0) along += distance(prevBase, base);
400442
prevBase = base;
401443

402-
idxArr[k] = idx;
403-
yBaseArr[k] = yCgrid[idx];
444+
yBaseArr[k] = yB;
404445
alongArr[k] = along;
405446
}
406447
return G;

0 commit comments

Comments
 (0)