Skip to content

Erosion as a heightmap filter, not a bake - #7

Merged
Lincoln504 merged 8 commits into
pr/00-cifrom
pr/01-erosion-filter
Jul 29, 2026
Merged

Erosion as a heightmap filter, not a bake#7
Lincoln504 merged 8 commits into
pr/00-cifrom
pr/01-erosion-filter

Conversation

@Lincoln504

Copy link
Copy Markdown
Collaborator
  • Erosion filter: branching gullies and ridges as a heightmap filter
  • Erosion: streaks that run the whole face, plus drainage shading
  • Erosion: fix drainage's white patches; cite the technique everywhere
  • Sediment overlay, coherent material, erosion as an amount
  • Picking follows the terrain it can see
  • Erosion: thirteen controls down to seven, chosen by measurement
  • Three bugs the integration audit turned up
  • Colour space, material coverage, and two determinism hardenings

28 files changed, 2419 insertions(+), 162 deletions(-) · gate green at 9c97353 (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 on pr/00-ci; 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.

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

Copy link
Copy Markdown
Collaborator Author

This is the erosion-filter work from #2 — that issue links feature/erosion-filter, which is the same content, split into a reviewable stack rather than one 69-commit branch.

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.

@Lincoln504
Lincoln504 merged commit 76d0ba4 into pr/00-ci 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