@@ -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