Skip to content

Rivers: hydrology-derived flow network, carved channels, flowing ribbons - #91

Open
painfulexistence wants to merge 7 commits into
mainfrom
claude/terrain-aaa-streaming-4dwaok
Open

Rivers: hydrology-derived flow network, carved channels, flowing ribbons#91
painfulexistence wants to merge 7 commits into
mainfrom
claude/terrain-aaa-streaming-4dwaok

Conversation

@painfulexistence

Copy link
Copy Markdown
Owner

Adds a river subsystem to the streamed terrain — a hydrology-derived flow network, real channel erosion, and draped flowing water ribbons.

What & why

Rivers can't be evaluated per-tile (a tile's flow depends on everything uphill), so this runs one global pass at load and feeds the result back into the terrain. It's the procedural stand-in for a Gaea flow/rivers export: swap the BuildRiverNetwork call for a flow-map loader and the ribbon + shader are unchanged.

Pipeline

  • Flow network (TerrainFlow) — samples the exact height source on a coarse global grid, fills depressions (priority-flood + epsilon, Barnes 2014 — the one thing that turns an un-eroded FBm's thousands of local pits into long connected rivers), routes D8 flow, accumulates drainage, and traces high-drainage cells into Spline-smoothed source→mouth polylines (width ∝ √drainage).
  • Erosion / carving (BuildHydrology) — derives a river-incision carve field and returns a carved height source. The channel floor is cut to the water surface minus bedDepth and flattened (terrain lowered to the floor, not by a constant); the water surface is the priority-flood filled elevation (monotone downstream, hugs terrain, filled basins → lakes — no canyon-cutting). The demo feeds this to the terrain, so tiles/colliders/grass/scatter all follow the incised valleys.
  • Ribbon mesh — the water drapes on the carved water-surface profile via a new reusable BuildRibbonMesh helper (a general spline-ribbon strip: roads/trails/rivers), sitting in the channel with banks rising either side.
  • RenderingMeshType::RIVER + RiverMaterial + river.vert/frag: two downstream-scrolling noise bands perturb the normal, fresnel deep→shallow blend, sun glint, bank foam, terrain-matched aerial fog. Drawn in the transparent tail of the forward pass (after terrain fills depth; depth-test on, depth-write off, alpha blend) — correct ordering without a new pass. WebGPU skips RIVER like GRASS (GL/GLES only for now).

Reuse / refactor

  • Smoothing now uses the engine's existing Spline<glm::vec2> instead of a hand-rolled Catmull-Rom.
  • New BuildRibbonMesh (spline_mesh.hpp) is a general draped-ribbon helper; BuildRiverMesh is a thin drape-and-delegate wrapper.
  • Factored MakeFbmHeightSource out of TerrainStreamer::Init so callers can obtain the exact base height before the streamer exists (hydrology needs it up front).

Verification

Data, geometry, and carve layers are image-verified offline against the demo's exact height source: dendritic network seated in the valleys, ribbons following/widening downstream, and a cross-section showing 5–7 m of water over a flat carved floor with 0 in-channel poke-through (was clipping badly before carving). All touched TUs pass g++ -fsyntax-only native + -D__EMSCRIPTEN__; both shaders pass glslangValidator. CI runs the real cross-platform build. The water-surface shader look needs in-app eyes — GL can't run in this environment.

Follow-ups: priority-flood leaves a few grid-straight reaches across large flats; the carve channel is wider than the ribbon (dry gentle bank at the water's edge); RiverProps/CarveParams expose the tuning knobs. WebGPU RIVER (WGSL port) is deferred, matching grass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VTva2GkjNrzoFzKW819ayq


Generated by Claude Code

claude added 7 commits July 13, 2026 20:08
Derives a river network the way Gaea's flow export would, but procedurally:
one global pass at load computes where water flows across the whole terrain,
so rivers sit in the real valleys and converge like a natural drainage tree.

- Priority-flood depression filling (Barnes 2014): an un-eroded FBm surface is
  full of local pits that trap water and fragment rivers into stubs; flooding
  inward from the map edge raises each pit to its spill lip (+epsilon) so every
  cell has a monotone downhill path out. This is the single thing that turns
  disconnected puddles into long connected rivers (verified: river node count
  6x'd after adding it).
- D8 flow routing + drainage accumulation on the filled surface, then trace
  high-drainage cells into source->mouth polylines, Catmull-Rom smoothed, width
  scaled by sqrt(drainage). Routing uses the filled heights; the true height is
  kept for draping the ribbon later.

BuildRiverNetwork(height01, worldSize, heightScale, params) -> polylines. This
is the data half of the river feature; the ribbon mesh + water shader consume
it next. Swapping in a Gaea flow-map loader keeps the rest of the pipeline.

Verified offline against the demo's exact height source (seed 20260705, 512^2
grid): 64 rivers, dendritic, seated in the terrain's low valleys.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTva2GkjNrzoFzKW819ayq
Complete river subsystem for the streamed terrain — both halves the user
identified as necessary to actually see a river: the flow DATA and the ribbon
GEOMETRY, wired together.

Data (TerrainFlow, prior commit + BuildRiverMesh here):
- global flow accumulation with priority-flood depression filling, traced into
  smoothed source->mouth polylines (rivers sit in the terrain's real valleys,
  converge like a drainage tree). Verified offline against the demo height field
  (64 rivers, dendritic).
- BuildRiverMesh drapes each polyline into a triangle-strip ribbon: y resampled
  from the fine height source per vertex (hugs the bed), UV.x bank-to-bank, UV.y
  accumulating downstream for flow scroll, tangent = flow dir. Verified offline
  (top-down rasterize: ribbons follow the valleys, widen downstream, ~25k tris).

Rendering:
- MeshType::RIVER + RiverMaterial (Transparent queue, cull none) + river.vert/
  frag: two downstream-scrolling noise bands perturb the normal, fresnel deep->
  shallow blend, sun glint, bank foam, terrain-matched aerial fog. glslang-clean.
- Drawn in the transparent tail of ForwardOpaquePass (after terrain fills depth;
  depth-test on, depth-write off, alpha blend) — correct ordering without a new
  pass. WebGPU skips RIVER like GRASS (GL/GLES only for now).
- RiverComponent builds the network + one world-spanning ribbon mesh at attach
  from a StreamingTerrainComponent's height source; demo adds a Rivers object.

Verified: g++ -fsyntax-only native + -D__EMSCRIPTEN__ across engine + demo;
glslangValidator on both shaders; offline network + mesh renders. The GL water
look needs in-app eyes (can't run GL here) — data/geometry are image-verified.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTva2GkjNrzoFzKW819ayq
…lper

Addresses the review: the water was draped over raw terrain so bumps poked
through it, and the bespoke Catmull-Rom / ribbon code duplicated the existing
Spline. Fixes all three.

Erosion (the real fix for the clipping):
- BuildHydrology now derives the network AND a river-incision carve field from
  the base height, and returns a carved height source. The channel floor is cut
  to the water surface minus bedDepth and FLATTENED (terrain lowered *to* the
  floor, not by a constant), so the bed is level under the water. The water
  surface is the priority-flood FILLED elevation per node (already monotone
  downstream, hugs terrain, filled basins = lakes) — no canyon-cutting.
- The demo builds hydrology up front and feeds carvedHeight01 to the terrain,
  so tiles/colliders/grass/scatter all follow the incised valleys; the ribbon
  drapes on the water-surface profile, sitting in the channel with banks rising
  either side. Verified offline: cross-section shows 5-7 m of water over a flat
  carved floor, 0 in-channel poke-through (was clipping badly), and a shaded
  relief shows the channels incised into the terrain.

Reuse (the review's other point):
- BuildRiverNetwork now smooths with the engine's Spline<glm::vec2> instead of a
  hand-rolled Catmull-Rom.
- New reusable BuildRibbonMesh (spline_mesh.hpp) — a general draped-ribbon strip
  from a centreline + half-widths (roads/trails/rivers); BuildRiverMesh is now a
  thin drape-and-delegate wrapper.
- Factored MakeFbmHeightSource out of TerrainStreamer::Init so callers can get
  the exact base height before the streamer exists (hydrology needs it up front).

Verified: g++ -fsyntax-only native + -D__EMSCRIPTEN__ across engine + demo;
offline carve/mesh/relief renders. Water look still needs in-app eyes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTva2GkjNrzoFzKW819ayq
Two fixes from playtest feedback (river was a flat horizontal plane; water
clipped through the bed):

- Water surface follows the valley. surfaceY was the priority-flood FILLED
  elevation, which is deliberately flat across basins, so the ribbon read as a
  level sheet. It's now the true terrain elevation lightly smoothed along the
  river (a few moving-average passes) — the water drops with the valley grade
  (~55 m over the demo's biggest river) while staying a calm sheet.
- No more poke-through. The carve now stores the channel FLOOR ELEVATION and
  the terrain becomes min(base, floor), evaluated at full resolution — a coarse
  grid can no longer leave the bed above the floor (the old depth field did,
  where full-res base outran the grid-sampled base: up to ~8 m pokes). The flat
  floor is also clamped to at least one grid cell wide so thin streams (whose
  channel is narrower than a cell) still land on the grid instead of falling
  between cells. Offline: 0 in-channel poke samples across all 64 rivers (was
  4000+), flat carved floor with banks rising either side.

Verified: offline cross-section / slope / poke harnesses; g++ -fsyntax-only
native + -D__EMSCRIPTEN__.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTva2GkjNrzoFzKW819ayq
Playtest feedback — the carve wasn't visible, water overlapped terrain, and
grass grew in the river.

- Cache staleness (the likely main cause of "can't see the carve / water
  overlaps"): the disk tile cache hashes only the noise params + a custom-fn
  flag, NOT the carve, so changing the carve formula across commits while
  cacheVersion stayed 2 replayed stale (old/uncarved) tiles under the
  carve-aware water. Bumped cacheVersion to 4 so bakes regenerate. (Deleting
  cache/terrain does the same by hand.)
- Deeper, broader incision: demo CarveParams bedDepth 10 m, channelWiden 2.2,
  bankBlend 3.5 — a 3 m channel is invisible against 500 m relief. Still 0
  poke-through (deeper floor only increases the margin).
- No grass in rivers: new StreamingTerrainProps::grassMaskFn (thread-safe
  per-point suppression [0,1], probabilistic for a feathered edge); the grass
  worker skips masked blades. The demo feeds a mask keyed on the carve amount
  so blades stop where the channel is cut.

Verified: g++ -fsyntax-only native + -D__EMSCRIPTEN__; offline carve harness
still 0 in-channel pokes at the deeper depth.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTva2GkjNrzoFzKW819ayq
The flat-bottomed carve left the water ribbon as a narrow sheet perched
at the top of a wide flat pool — its edges hung above the channel floor,
so from altitude the river read as a suspended constant-width band.

Recut the incision as a U matched to the ribbon width: deepest (bedDepth)
below the water surface at the centreline, rising back to exactly the
water surface at the waterline (half-width = the ribbon's half-width),
then ramping above the terrain past it so nothing else is carved. Now the
same-width ribbon fills the channel edge-to-edge and its banks meet the
ground at the waterline instead of floating.

- CarveParams: drop channelWiden; U is cut to the river width. Defaults
  bedDepth 6, bankBlend 1.5. Demo uses bedDepth 12 so valleys read from
  altitude.
- RiverNetworkParams.minWidth 1.5 -> 6 (also the channel half-width; keep
  it above the ~5 m carve grid cell so thin streams still land on cells).
- RiverMeshParams.bankLift 0.4 -> 0.1 (the U meets the water here now).
- Bump demo cacheVersion 4 -> 5 (carve shape changed; the disk cache
  hashes only noise params, so old bakes would otherwise replay).

Offline cross-section check: 0 in-channel pokes, carved floor rises to
the water surface at the banks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTva2GkjNrzoFzKW819ayq
The traced network ran across slopes, not down valleys: the coarse D8
grid can only place a node at a cell centre, so on a side-slope the
centreline sat partway up the valley wall. Rendered over uncarved (stale)
tiles this read as water floating across a steep face.

Offline measurement (landform-scale slope + valley-drop over the demo's
exact height field) confirmed it: nodes averaged 18.5 deg slope — as steep
as random ground (19.8 deg) — and 20% weren't in a local low at all.

Fix: after tracing, slide each node perpendicular to its flow to the
lowest ground within thalwegSnapRadius (default 150 m), sampled on a
mildly-smoothed height so it seeks the valley floor and not a one-cell
noise pit, then smooth the snapped path so the ribbon isn't a zig-zag.

Result on the same field: mean slope 18.5 -> 13.9 deg (now clearly below
the 19.8 deg baseline), valley-drop +16.8 -> +29.7 m, nodes off the floor
20% -> 7%, still 0 in-channel carve pokes. Top-down hillshade shows the
network tracing valley creases instead of cutting across them.

Also tried low-passing the routing height first; measured strictly worse
(it blurs away the valleys the router needs), so not included.

Bump demo cacheVersion 5 -> 6: the route moved, so the carved height
changed and old bakes must not be replayed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTva2GkjNrzoFzKW819ayq
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.

2 participants