crispigt.

Curving the waterline

2026-05-13
C#C++BuoyancyPhysicsUnity

The clip in Phase 4 cuts every triangle against a straight line between the two edge intersections. That's wrong, technically, the real waterline on the triangle's plane is the curve where the wave surface meets the triangle, and a Gerstner crest can put it several centimetres away from the chord. For a cube with edges measured in metres on waves measured in centimetres, that error is invisible. For a bunny chunked into 250 triangles riding chop with a wavelength of the same order as the triangle size, it starts to show.

So an adaptive clipper that walks the chord between the two clip points, samples the wave there, and bends the wet polygon's boundary to follow the real surface.

Where the clipping should live

First architectural question, do we add the refinement to the C++ DLL, or do it in C# and feed pre-clipped sub-triangles to the DLL?

I went back and forth on this. Putting it in the DLL keeps everything in one place and avoids marshalling more data. But the DLL would then need to know about the wave function, exactly the coupling I broke in "Waves, finally" by switching to per-vertex heights. Either I'd pass a function pointer across the P/Invoke boundary (slow, awkward), or pass a pre-sampled height grid (large, awkward), or let the DLL re-sample by making it know about Gerstner (couples it to the wave model again).

The cleaner split I realized is adding a second DLL entry point ComputeBuoyancyFromTriangles, that's a pure integrator. No classification, no clipping. You hand it a list of sub-triangles you've already decided are wet, with one water height per vertex, and it accumulates force and torque. Internally it just calls the existing closed-form accumulateBuoyancy in a loop. Then do the adaptive clip in C#, where WaveManager.SampleHeight(x, z, t) is one call away. Build a refined wet sub-mesh, then ship it to the new entry point. Last but not least we add a bool adaptiveClipping on BuoyancyController. Off gives the linear path through the original ComputeBuoyancy (DLL clips). On gives the adaptive path through ComputeBuoyancyFromTriangles (C# clips, we let the user implement a clipper).

That way the existing fast linear path stays exactly as it was, same DLL function, same numbers, no regressions on the cube, and the adaptive path is opt-in for bodies that need it. The clipping logic exists in exactly one place per path.

What the linear clip is missing

Two clipped vertices P1 and P2 on the chord. The wave function h(x, z, t) doesn't agree with the triangle plane along the segment between them. Sample the chord at, say, four interior points, at each one compute

e=planey(x,z)h(x,z,t)e = \mathrm{plane}_y(x, z) - h(x, z, t)

If ee stays near zero across the chord, the linear approximation is fine. If ee goes negative in the middle, the wave is bulging up into what the linear clip called dry, a crest pushing against the hull that we're under-counting. If ee goes positive, the wave dips below the chord, a trough where we're over-counting wet area that isn't actually wet.

The first version of the clipper handled only the crest case. It walked the chord, watched for ee changing sign, and inserted a refined vertex at the zero crossing, which captures the bulge accurately. Numbers looked plausible, gizmo looked plausible, called it done.

The asymmetry that I missed

Talked through the design after writing it and realised the trough case was silently broken. If the wave dips below the chord and stays there, ee is positive throughout, no sign flip ever happens, no vertex gets inserted, and the wet polygon stays exactly as the linear clipper produced it. Crest correction, present. Trough correction, absent. Creates a asymmetry in the forces.

Over a full wave cycle that doesn't average out. It's a consistent bias toward over-estimating wet volume, which means a consistent extra upward force, which means the body floats slightly high. For a moment I thought the fix was to actually cut the polygon, open up a "dry hole" along the chord where the wave dips below it, which is genuinely complicated. But there's a much simpler fix sitting in plain sight, courtesy of how I implemented theaccumulateBuoyancy.

The DLL's closed-form integrator is symbolic. It doesn't clip internally. You hand it three vertices with three water heights, and it computes

F=ρg3(ha+hb+hcyaybyc)SF = \frac{\rho g}{3}(h_a + h_b + h_c - y_a - y_b - y_c) \cdot \mathbf{S}

a linear interpolation of (hy)(h - y) across the triangle. If a vertex has hv<yvh_v < y_v, that vertex contributes a negative depth to the integral. The formula doesn't care, it just integrates linearly.

So the symmetric fix is, at every chord sample mm (not just sign flips), append a polygon vertex at mm with its true wave height hmh_m. The geometry of the wet polygon is unchanged, still the chord-bounded shape from the linear clip, but the per-vertex water heights now follow the actual wave.

for (int k = 1; k <= refineSamples; k++)
{
    float u = (float)k / (refineSamples + 1);
    Vector3 m = Vector3.Lerp(p1, p2, u);
    float hm = wm.SampleHeight(m.x, m.z, t);
    polyVerts.Add(m);
    polyHeights.Add(hm);
}

Crest sample, hm>myh_m > m_y, sub-triangles get extra positive depth, integral grows, missing wet area gets credited. Trough sample, hm<myh_m < m_y, sub-triangles get negative depth, integral shrinks, spurious wet area gets debited. Same code, same closed-form, opposite sign. No polygon cutting needed. The previous version's sign-flip refinement also works, it just only fires for the crest case. The fix replaces it with an unconditional sample at every step.

It works, mostly

Bunny floating on the same Gerstner waves as before, four refinement samples per chord, qualitatively the bobbing looks slightly more responsive on crests, though it's honestly hard to tell by eye whether that's real or confirmation bias. The bias-correction effect from the asymmetry fix should be a few percent at most for normal mesh densities, it's not a visual-grade change.

The cost is real, though. With four refinement samples, the bunny at 7k triangles and the dragon at 7k triangles for buoyancy, frame rate dropped from comfortable triple-digits to 50 fps. The wet polygon for a clipped triangle that was three vertices in the linear path is now seven in the adaptive path, and every one of them needs a WaveManager.SampleHeight call, which is itself a Newton iteration over three Gerstner waves.

The good news is the linear path is still fast and is still the default, the toggle exists because adaptive clipping is an opt-in for cases where the linear approximation visibly fails. The Stanford bunny is probably not one of those cases at the wave scales we're using. A sailboat hull on swell might be, I have however not tested yet.

What's next

There's a lot of low-hanging fruit on the height-sampling side. Each SampleHeight does a 2-iteration Newton solve on the Gerstner inverse, evaluating three waves with three trig calls each per iteration. That's ~18 transcendentals per sample, called several thousand times per FixedUpdate. Burst-compiling the wave evaluator and parallelising across vertices is the obvious next step.

The other open thread is honestly measuring whether adaptive clipping makes any visible difference on these meshes. A quantitative A/B, two identical bunnies, one linear and one adaptive, time-averaged height and tilt over a few seconds, would say more than my eyes can.