The sea to the horizon, and a shoreline that would not hold still - #18
Merged
Lincoln504 merged 4 commits intoJul 29, 2026
Merged
Conversation
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
merged commit Jul 29, 2026
2c41c0f
into
pr/11-palette-regions-water-beach
2 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
7 files changed, 289 insertions(+), 29 deletions(-)· gate green at324df65(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 onpr/11-palette-regions-water-beach; retargets tomainonce that lands.