Rivers: hydrology-derived flow network, carved channels, flowing ribbons - #91
Open
painfulexistence wants to merge 7 commits into
Open
Rivers: hydrology-derived flow network, carved channels, flowing ribbons#91painfulexistence wants to merge 7 commits into
painfulexistence wants to merge 7 commits into
Conversation
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
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.
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
BuildRiverNetworkcall for a flow-map loader and the ribbon + shader are unchanged.Pipeline
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 intoSpline-smoothed source→mouth polylines (width ∝ √drainage).BuildHydrology) — derives a river-incision carve field and returns a carved height source. The channel floor is cut to the water surface minusbedDepthand 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.BuildRibbonMeshhelper (a general spline-ribbon strip: roads/trails/rivers), sitting in the channel with banks rising either side.MeshType::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 skipsRIVERlikeGRASS(GL/GLES only for now).Reuse / refactor
Spline<glm::vec2>instead of a hand-rolled Catmull-Rom.BuildRibbonMesh(spline_mesh.hpp) is a general draped-ribbon helper;BuildRiverMeshis a thin drape-and-delegate wrapper.MakeFbmHeightSourceout ofTerrainStreamer::Initso 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-onlynative +-D__EMSCRIPTEN__; both shaders passglslangValidator. 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/CarveParamsexpose 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