Skip to content

The sea to the horizon, and a shoreline that would not hold still - #18

Merged
Lincoln504 merged 4 commits into
pr/11-palette-regions-water-beachfrom
pr/12-horizon-and-shoreline
Jul 29, 2026
Merged

The sea to the horizon, and a shoreline that would not hold still#18
Lincoln504 merged 4 commits into
pr/11-palette-regions-water-beachfrom
pr/12-horizon-and-shoreline

Conversation

@Lincoln504

Copy link
Copy Markdown
Collaborator
  • The sea now ends at the horizon instead of at its own rim
  • The water wins a tie with the ground it is standing on
  • Reversed-Z on a float depth buffer, and no far plane at all
  • Water of no depth is not visible, and that is what was flickering

7 files changed, 289 insertions(+), 29 deletions(-) · gate green at 324df65 (fmt, clippy, workspace tests against a real adapter, wasm build, headless-Chromium smoke) · payload 499337 gz (8811 from the previous PR, against the 6 MB NFR-2 budget)

Part of a 12-PR stack over pr/00-ci. Based on pr/11-palette-regions-water-beach; retargets to main once that lands.

⚠️ Please merge with a merge commit — squash or rebase rewrites the SHAs and breaks every PR above this one in the stack.

The water was a quad six units across. From any raised camera its far
rim was inside the frame — a trapezoid of sea with hard diagonal
corners, and above it the sky dome carrying on downward into the space
where the horizon should have been. Measured on a column of the island
scene at a low pitch, that band held **one colour, exactly, for thirty
rows** and then fell 64 levels into the water. Nothing about the shading
was wrong; there simply was no more sea.

Both halves of the report are this one defect: the light water "not
reaching the scene edge" is the rim, and the "faintly visible lighter
blue" is the flat sky sitting below the horizon on the far side of it.

Two constants, and the second is what made the first affordable.

**The far plane is nearly free; the near plane is not.** Depth
resolution is set almost entirely by the near plane: measured at 24
bits, moving far from 50 to 4000 changes the world-space size of one
depth step by a tenth of a percent at every distance — 4.768e-6 world
units at the terrain against 4.764e-6. So the far plane is now set by
what has to be *in* the scene rather than by what precision can afford.

**The sea reaches 2000.** Two requirements, and the second is the
load-bearing one: the rim has to sit within about a pixel of the true
horizon at every zoom the camera allows, and it has to be past the
distance at which the air has already taken the water — haze reaches
99.9% by twenty-seven units on the clearest sky, so beyond that the sea
is the colour of the sky above it and the join cannot be seen wherever
it falls.

Bigger is not free — the plane is two triangles and the fragment's world
position is interpolated across them — so I measured rather than argued:
in a coast frame the sky-and-far-sea band moved by 11.8% of its pixels,
the mid sea by **0.0%**, and land-and-shore by 0.6%. The ripples, the
surf and the shoreline are untouched.

The new test is geometric rather than pictorial: find the row where the
frame steps from sky to water, and compare it with where a horizontal
plane's vanishing line actually falls under this projection, taken from
the camera rather than from a remembered number. As shipped the step
lands 3.6 rows low — the last rows of sea are hazed to nearly the sky's
own colour, so the sharpest change sits just inside the water. At six
units it sat 26.6 rows low, and further with every step the camera
rises. The bound is 8.

**One test had to be repaired to keep meaning what it says.**
`water_reads_its_colour_from_the_terrain_beneath_it` averaged the whole
frame, and with the sea running to the horizon most of what it averaged
is open ocean with no bed under it — water that cannot respond to the
level moving and must not. Its signal fell to 2.2 levels against a
3-level threshold. Windowed to the map's own footprint, projected from
the camera, the same measurement reads 15.4, and the threshold is now 8.
That also closes the 3% margin flagged in the branch audit: the test was
weak for the same reason it broke.
You had it: beach geometry that is past the surf line and ought to be
under the sea is not *deep enough* under it, so which surface a pixel
gets is a coin toss that lands differently as the camera moves.

The coincidence is deliberate and it is in the terrace's own comment.
`dd01c2a` planes the strand as an apron "measured from the waterline, so
it meets the water at exactly the water's own height and there is no step
where the two join" — which is what makes the join stepless and also what
guarantees a band of ground coplanar with the water plane. Before that
commit the shore crossed the plane transversally and there was nothing to
fight over. That is the "it wasn't happening a bit ago".

Two coplanar surfaces have no depth order. What decides is how each
side's arithmetic happens to round, and the two sides do not share
arithmetic: the terrain takes its MVP from the matrix texture, the water
multiplies its own pre-composed view-projection by its model matrix.
NVIDIA's depth-precision analysis names exactly this — pre-composing view
and projection introduces perturbations that "lead to depth swaps or
indistinguishable values" between two draws of the same surface.

So bias the comparison rather than separate the geometry. Sinking the
apron would move the coincidence rather than remove it, and it would put
a step at the waterline — the thing the terrace exists to avoid. And the
tie has a correct answer: wherever the water shader is still running, the
bed is below the surface (it discards otherwise), so the water genuinely
is in front and losing the test is always wrong.

Both bias terms, because the failure has both shapes. The constant covers
the flat apron seen from above; the slope term covers the same apron seen
at a grazing angle, where a pixel spans a long way in depth and a fixed
offset is nothing. Dawn requires `depthBiasClamp` of its Vulkan backend,
so both are available on every target.

**No test, and I want to be plain about why.** I could not reproduce the
artifact in the native offscreen path at all: zero sand/water
classification flips for a sub-pixel camera move across map 256/512/1024,
pitch 0.22 through 1.35, shore 1.0/0.3/0.1, and far plane 50 and 4000. A
test written here would pass before and after, which is worse than no
test. `depth24plus` is a *minimum* guarantee and Dawn maps it per backend,
so the browser and native wgpu can differ in effective precision — which
is consistent with seeing it in the app and not in the harness.

Along the way, three things measured and ruled out. The far plane raised
last commit: 227 flips against 224 at the old value, noise. The water's
`discard`: 460 crawling pixels against 458 with it disabled. A taller
beach berm: 952 → 924 → 1133 as the apron rise triples, so separating the
surfaces vertically does not help.
Depth precision was the one thing under the shoreline fight I could
change without redesigning the shore, so it goes first.

A standard projection stores something proportional to 1/z, which spends
almost all of its resolution within a few near-planes of the camera.
Reversing the range — near plane to 1, infinity to 0 — puts the dense end
of a float's exponent where the sparse end of 1/z was, and the two
nonlinearities cancel. Precision comes out near-uniform across the view,
and the far plane stops mattering enough to need choosing, so it is gone:
`perspective_infinite_reverse_rh`, which also removes the last thing that
could clip the sea short of the horizon.

`depth24plus` is a *minimum* guarantee rather than a format, and Dawn
maps it onto whatever the backend offers — so the browser and this
crate's own harness need not agree about how many bits the shoreline
gets, which is consistent with the fight showing up in the app and not
here. `depth32float` takes that variable out, and reversed-Z is only
correct on a float buffer anyway.

Four things had to move together and each one is a silent failure on its
own: the format, `Greater` in every camera-facing pipeline, a depth clear
of 0 rather than 1, and the projection. The sun's shadow pass is
deliberately *not* reversed — it projects orthographically, so depth is
already linear and there is nothing to buy, and `fs_shadow` writes its
clip z into an r32float map that both shading passes compare against.

Two unprojections had assumed the old range and both are corrected. The
sky builds its view ray from the inverse view-projection at ndc z 0 and
1; under reversed-Z, z = 0 is infinity and cannot be unprojected at all —
its w is zero — so the sky came out black above a hard line. It now runs
the ray from the near plane to a point partway out, which is all it ever
needed since only the direction is used. `screen_ray` had the same bug
and would have mis-picked; same fix.

The water's depth bias flips sign with the convention: nearer is now
*greater*, so winning the tie means biasing up.

**This is not the shoreline fix and I do not want it read as one.** More
precision cannot order two surfaces that are at the same depth, and the
beach terrace makes the ground meet the water plane at about a third of a
degree — an intersection so tangential that any error at all moves the
visible waterline a long way sideways. That is a geometry problem and it
needs a geometry answer.
The shoreline fight is a *grazing* one, and grazing contacts cannot be
won by precision or by bias. The beach terrace planes the strand to meet
the water plane at about a third of a degree — measured on the island's
shore, the apron climbs 0.0017 world units across 0.29 of them. At that
angle any error at all, from quantisation or from the two surfaces
reaching the rasteriser through different matrix chains, is amplified by
1/tan(0.34°) — about a hundred and seventy fold — straight into the
horizontal position of the visible waterline. Reversed-Z made the error
smaller. It could not change the amplification.

So stop trying to win the argument and make it not matter. If the water
contributes nothing where it is shallowest, then whichever surface the
depth test happens to pick, the pixel comes out the same.

That turns out to be a bug fix rather than a trick. Alpha reached
**0.38** at zero depth and stopped there — a sheet of 38%-opaque water
laid over ground with no water on it, ending on a hard edge. Water of no
depth is not visible; the near end of the ramp has to reach zero for the
same reason the far end has to reach exactly one, which this shader
already argued at length two lines above. It now fades in from nothing
over six thousandths of a world unit, a fifth of the depth at which the
bed stops showing at all.

This is the standard shoreline depth-fade — render the water after the
opaque terrain, take the difference between the surface and what is
behind it, drive alpha with it — with one simplification the usual
formulation does not get: the depth comes from `bed_at`, which this
shader already samples to decide whether there is water here at all, so
there is no second pass and no depth texture to bind.

The foam stays outside the fade. Surf is *at* the waterline by
definition, and fading it out with the water it breaks on deletes it.

Costs, accepted. The shoreline is softer than it was, because it is now a
gradient rather than a contour — which is what a shore looks like, but it
is a change in the picture and not only in its stability. And a shore
this shallow spreads that gradient a long way horizontally, by the same
amplification that caused the trouble.
@Lincoln504
Lincoln504 merged commit 2c41c0f into pr/11-palette-regions-water-beach Jul 29, 2026
2 checks passed
Lincoln504 added a commit that referenced this pull request Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant