Erosion as a heightmap filter, not a bake - #7
Merged
Conversation
A per-point erosion filter — no simulation, no droplets, no neighbourhood iteration — laid on top of whatever the generator (or an imported PNG) produced. Implements the technique described in Rune Skovbo Johansen's "Fast and Gorgeous Erosion Filter" (2026), written from the published description; the structure, naming and constants here are our own. crates/burysu-core/src/erosion.rs CPU reference + the explanation crates/burysu-gpu/src/erosion.wgsl compute kernel, mirrors it op-for-op crates/burysu-gpu/tests/erosion.rs GPU/CPU parity, three parameter sets Directional stripe waves are blended from a 4x4 block of jittered cell pivots and partially renormalized, so gullies keep an even depth where neighbouring cells disagree. Each octave tilts the local slope, which is what makes the next one branch off it. A mask closes wherever the ground is too flat to carry a gully — peaks, valley floors, and the crest and floor of every gully already cut — and there the octave fades to the previous one instead, which is what keeps peaks pointed and ridge lines unbroken. Two deliberate departures, both because we filter a grid rather than differentiate an analytic height function: - `flow_reach` picks the scale the input slope is read at. Reading it at one texel makes gullies follow every local bump and break up; reading it over a few gully wavelengths lets one run a whole mountain face. - `effective_octaves` drops octaves that land under Nyquist for the map size, so a 256 preview and a 1024 export agree on everything both resolve. The filter lives in the terrain's document fields and re-runs after every generate, so it is non-destructive: every slider is an ordinary undoable edit, nothing compounds, and re-rolling the seed re-rolls the gullies. Snapshotting to a PNG (brush, thermal/hydraulic erode) clears it, since by then it is baked into the pixels. The shared lattice hash moves to its own lattice.wgsl, prepended to both kernel sources — one copy, still bit-identical to the CPU one.
Two things were wrong with how the filter read on real terrain. Branching was coupled to strength. Each octave steers the next by adding its own slope to the flow direction, and that slope scales with `strength` — so turning erosion up past the level the published defaults assume didn't carve deeper, it stirred the gullies into a tangle of leaf-shaped blobs. An octave's slope works out to exactly `strength * tau`, the same at every octave, so `assumed_slope` is now measured against that. Branching is now independent of both `strength` and `scale`. The slope was read one texel wide. On a fractal base that makes every gully follow the local bumps instead of the mountain, and they break up within a wavelength. `flow_reach` sets the scale the input gradient is read at, in gully wavelengths; at 3 a gully runs a whole face. A ramp now grows parallel dendritic gullies straight downhill and a cone grows a radial drainage network, which is the check that says the direction field is right. Defaults re-tuned against those two fixes: finer gullies, more octaves, and a slope onset that actually closes on gentle ground instead of saturating everywhere. `MIN_TEXELS_PER_GULLY` drops to 4, which buys back the fine octaves that were being culled at 256. Drainage: the filter already computed a ridge map internally, so it now writes it out, and the terrain material bleaches a narrow band around the gully floors — pale sediment lines, not water, which keeps every material preset working. The reduced height range moves into the params struct via a GPU-side copy, because compat mode allows four storage buffers per compute stage and the ridge output needed the fourth.
Three separate faults behind the patches, all in the drainage shading: The ridge map was seeded from altitude, like the height's fade target. That is right for the height — it is what keeps peaks pointed — but for drainage it means a wide flat basin reads as one enormous crease, and the shading floods the whole thing. It now starts at zero, so ground the recursion never opened on stays neutral and only real gully structure registers. The fragment stage point-sampled the ridge texture. Where the render grid and the heightmap are the same resolution that is one texel per quad, so drainage came out as hard-edged blocks. It is read per vertex now and the rasterizer interpolates it, the same way height and normal already work. The bleach was absolute — `luma * 1.1 + 0.3` clips to flat white on anything already bright, so snow and pale rock blew out. It is relative now: desaturate towards the material's own brightness, lift slightly, clamp. Attribution now appears wherever someone might look: FSD (as FR-TER-3a), the implementation roadmap, the UX plan, the README, both shader headers, and the Terrain Lab panel itself, which links the article in the UI.
Three problems, one branch. The drainage shading never worked. It parked its opacity in environment texel 5, which write_frame_uniforms rewrites every frame with the clock and the cloud parameters — so what the material actually read as "drainage strength" was elapsed time, which is why it started as nothing and grew into white patches. Moved it to a free texel and documented the whole table in the one function that owns it, so the next feature can't take an occupied slot. The random colour patches in the valleys were the material, not the erosion. Layer selection read slope from a single central difference, and the filter's finest gullies are a few texels wide, so slope crossed the grass/rock/snow windows within one valley and scattered the layers into blotches. It now averages five slope magnitudes across the landform — magnitudes, not a wider difference, which would also shrink the number the windows are calibrated against. The lighting normal is untouched. In place of the bleach, each material preset carries its own sediment tone, deposited where the ridge map says the crease is deep and the landform has flattened, at an opacity the user sets. Steepness thins the deposit rather than forbidding it — a gully wall still shows scree. Simulated erosion is now an amount rather than a tally. thermal_passes and hydraulic_passes live on the terrain and the renderer re-derives from the generator, so the Lab shows a slider and a stepper per kind that go down as well as up, and undo carries two integers instead of an embedded heightmap. The t/h keys and `erode <kind> <amount>` still add passes, so scripts replay unchanged. Full re-derive at 512² with the 240-pass ceiling on both: ~9 ms. Pixel-level regression tests for both, since neither is visible to the CPU unit tests: sediment must change the frame, and passes dialled up and back to zero must restore it exactly.
Click picking ray-marched a CPU heightfield rebuilt from the generator parameters alone, so on eroded terrain it tested against a surface that hasn't been on screen since the filter landed — and now that the simulated passes are re-derived rather than baked, they drifted out of it too. Rays missed by whole gullies, which the user experiences as clicking doing nothing. The mirror now comes off the GPU instead. `Heightmap::from_raw` applies the same normalization as `Heightmap::generate`, so the readback is a drop-in for what the cache held, and it is correct for everything the renderer does — filter, thermal, hydraulic, brush strokes — without wasm re-running any of it or having to stay in step with the kernels. The request key is set when the readback is issued, so a slider drag queues one per distinct terrain and replies overtaken by a newer one are discarded. Height scale is read live: it scales the world, not the heightmap, and must not cost a readback. `refresh_terrain_pick` moved after `sync_scene` in `after_change` — reading back before the sync would mirror the previous terrain. Also: the pass ceiling is now exported as `burysu_max_erosion_passes` so the Lab's slider range comes from the engine that enforces it, and the pass number fields commit on change rather than on every keystroke, since the edit behind them is debounced and re-renders the field.
The filter had grown a visible block of six sliders and a collapsed drawer of seven more. Rather than argue about which were useful, each was swept over its slider range and scored by mean per-channel difference in a rendered frame. Two groups came out. Octave count was the clearest cut. Past what the map can resolve the kernel already dropped octaves at Nyquist, so from 4 to 8 the slider changed *nothing* — at the default gully scale, five eighths of its travel, with no indication of where it went dead. It is now derived from the map size and the gully scale, which also means 512² carries finer branches than 256² for free and an export simply carries further. Flow reach had the same shape: all its effect was below 3 and the default already sat in the flat part, so the slider could only make things worse. Lacunarity, gain and flow persistence measured flat across everything except their broken ends. All four are now constants at the value that won, documented with what happens either side. Ridge and crease rounding were the two weakest controls in the filter. They are one `rounding` now, which is worth more than either was alone and lets `rounding_at`'s mix disappear from both the reference and the kernel. What survives is seven controls, all visible, no advanced drawer — and each one measurably moves the picture across its whole range, which the pruned ones could not claim. Same treatment for the simulated passes: the number steppers go, the sliders stay, and the value reads out in the label like every other control in the lab. `ErosionFilter` carries `#[serde(default)]` so documents written against any version of the field set still load — including the autosave in a browser tab that predates this commit. Test for it. The kernel shrinks with the schema: FilterParams drops from sixteen scalars to ten, and GPU/CPU parity still holds on both height and ridge map.
All three are in erosion code from this branch, and all three are the kind that look fine until a specific sequence. **Zero strength left its sediment behind.** The filter kernel returns early when there is nothing to carve, but the ridge map lives in its own buffer, so the previous run's ridges survived and the material kept painting alluvium down gullies that no longer existed. Drag strength to zero and the geometry flattened while the streaks stayed. The early-return path now clears the ridge buffer — which needed COPY_DST on it, a flag the fix was silently missing until the test exercised it. **A hidden terrain moved the water.** `sync_scene` only updated `terrain_height_scale` when it found a *visible* terrain, otherwise keeping whatever the last one had. The picker independently falls back to `DEFAULT_TERRAIN_HEIGHT_SCALE`. So hiding a terrain with a non-default height scale drew water at one height and picked it at another. The renderer resets to the same constant now, and imports it from the picker's crate instead of keeping its own copy of 0.55. **Every DSL terrain line wiped the erosion settings.** `Action::Terrain` built a fresh `ObjectKind::Terrain` with `erosion: None` and zero passes and `SetKind`-ed it over the existing terrain, so `terrain mountains` after an hour in the Erosion panel threw that hour away. It now clones the existing kind and mutates only what a style owns — generator, size, palette — which is the rule `Action::Material` already followed. A test each: the sediment must vanish with the strength that drew it, a hidden terrain must render identically to no terrain at all, and `mountains` over a tuned terrain must preserve both the filter and the pass counts.
**The whole palette was a gamma too light.** The render target is `Rgba8UnormSrgb`, so the hardware encodes on write and a fragment shader's output is already linear light — but every material layer, sky palette and object tint was authored as if it were sRGB. Verdant's grass, written as a dark green, displayed as a pale sage. That is why the terrain has read pastel and washed since M3. Colour now crosses spaces in exactly one place: the upload. The document stays sRGB, which is what a hex code means, what `<input type="color">` returns, and what a colour picked off a photograph is; `write_material`, `write_environment` and `object_color` convert on the way to the GPU. The palette literals are untouched and still read as the colours they name. Doing it in the UI instead would have made the document's meaning depend on which control last wrote to it. **Three presets had holes in their layer coverage.** Where no layer's altitude *and* slope window contains a fragment, the shader's `+ 1e-4` floor makes all four weights equal and the albedo becomes the unweighted mean of the whole palette — a grey belonging to no layer, blotched over exactly the ground that falls in the gap. Desert left bare low mid-slopes uncovered, volcanic left low steep ground, arctic left a band between where snow stops holding and where rock began. The fix is three window edges; the test that found them sweeps the whole (altitude, slope) square against the same blend the shader performs, so the next preset cannot ship with one. **Two determinism hardenings.** `lattice` ended with a division by 2²⁴; WGSL only guarantees division to 2.5 ULP, so CPU/GPU bit-identity — the contract the entire terrain stack rests on — was a property we happened to get rather than one we were owed. It is a reciprocal multiply now, exact by definition in both languages. And the flow stencil's ceiling was `size/4`, which bound at gully size 0.167 — a third of the way along the slider — after which the stencil silently stopped widening with the gullies it measures for.
Collaborator
Author
|
This is the erosion-filter work from #2 — that issue links The whole stack is #6 (CI) → this → #8 … #18. Reviewing them in order is the intended path; each is green on the full gate on its own. |
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.
28 files changed, 2419 insertions(+), 162 deletions(-)· gate green at9c97353(fmt, clippy, workspace tests against a real adapter, wasm build, headless-Chromium smoke) · payload 355699 gz (113029 from the previous PR, against the 6 MB NFR-2 budget)Part of a 12-PR stack over
pr/00-ci. Based onpr/00-ci; retargets tomainonce that lands.