- water particles move by heightmap
- number of particles in cell = water level = pressure = ability to erode
- water level map is temp (clear on start of frame), not hold any real mass of water or sediments. water level map affect on water pressure and sediment speed but cant cause loss of water or soil mass by design. race write to water level is ok on GPU, no atomic need read water level map + land heightmap = total height, then apply tilt angle of whole map, then calculate gravity movement particles direction.
- dx/dt can be > then cell size
- tilt angle of whole map adjustable by sliders realtime
- wrap modes: not wrap, solid border: water and sediment cant go outside not wrap: respawn: water and sediment out of map respawn as rain of water, sediment added to height level to keep mass (in GPU mode ignore sediment loss to optimize). wrap XY: properly to move water an sediment
- dynamic manual and auto scale height prevent erode to 0
- CPU · Lagrangian reference (
sim.js) — a DOM-free, particle-based river/erosion model driven by uniform rainfall (respawn = rain) and evaporation/domain-exit (death). It is thefile://fallback and the mass-conservation sanity check. Meandering emerges from the flow↔bed↔erosion feedback: a confinement term (k3) pulls flow into existing channels, and an outer-bank (lateral) erosion term (k4) migrates channels sideways into bends. - WebGPU · atomic-free Eulerian (
webgpu.js) — the same physics reformulated as a grid-resident model so every compute pass is a gather (exactly one writer per cell/tracer). No atomics, no scatter hazards. It reproduces the self-consistent flow↔bed feedback that makes meanders emerge, but it will not bit-match the CPU (different carrier — by design).
# CPU only works straight from disk:
open river/index.html # file:// — CPU backend
# WebGPU needs a secure context (localhost/HTTPS):
cd river && python3 -m http.server 8000
# then visit http://localhost:8000/ and pick "WebGPU" in the backend dropdownIf navigator.gpu / window.isSecureContext is unavailable, the WebGPU option is
disabled in the UI with a note.
river/
index.html script loading, canvas (#cv 2D + #gpuCv WebGPU), backend select
src/ common.js · sim.js · engine.js · webgpu.js · gui.js
test.js node: CPU determinism + soil-mass balance + 1000-step meander gate
tools/ validate-wgsl.js — offline WGSL structural guard (wgsl_reflect)
tools/validate-wgsl.js parses the three WGSL modules (webgpu.js exports
WGSL / WGSL_RENDER / WGSL_BLIT) with wgsl_reflect and asserts the
structural invariants a real browser (Tint/Naga) rejects at pipeline-creation
time — entry points present, @group(0) bindings unique & in range, the Sim
uniform exactly 128 bytes, and that every JS bind group matches the exact
binding set each entry point needs (the layout: 'auto' failure mode: an
extra/missing binding throws). Run it without a GPU:
npm install # installs wgsl_reflect into tools/
npm run validate:wgslThe WebGPU init is also hardened: each compute entry point is created under a
validation error scope and reported by name; smoothHeight and tracerAdvect
are optional and degrade gracefully (the sim still runs without them) instead of
taking down the whole backend.
A second offline harness, tools/smoke-gpu.js, runs the full init → step → exportToCPU path against a usage-validating fake WebGPU (no real GPU). It
enforces the same rules Chrome does — a copy source needs COPY_SRC, a copy
dest needs COPY_DST, writeBuffer needs COPY_DST, mapAsync(READ) needs
MAP_READ, and copy offsets/sizes must be 4-byte aligned & in range. It catches
the "buffer usage … does not include" class of bug without a browser:
node tools/smoke-gpu.jsSoil mass is conserved exactly on the CPU (the float64/rounding ledger:
bedMass + transit + exported + roundLoss == initialBed). The GPU tracks the
same quantity via a staged reduction (sum hC + sum s + exported); semi-Lagrangian
advection makes it approximately conserved (reported like the example reports
energy drift). Water w is a driver/diagnostic, not conserved.
Computed on the CPU (RiverCPU.updateCurl) and uploaded to the GPU every
CURL_EVERY steps — the simplex noise is never ported to WGSL (the single
biggest risk-reducer).
Terrain is transferred on switch; the w/s (Eulerian water/sediment) state is
intentionally not transferred (different models — documented in the UI).
GPU→CPU re-baselines the ledger from the read-back heightmap.
- Verified in this repo (headless node):
- CPU determinism, mass balance (the float64/rounding ledger is exact — lateral
relocation is a within-bed move and never touches
bedMass/SED/exported), and the 1000-step meander gate: significant heightmap change and channel-scale change concentrated into a minority of the grid (channels, not a diffuse sheet). Seenpm test/node test.js. - WebGPU WGSL structural validation (
npm run validate:wgsl) — all three modules parse, bindings are unique/in-range, theSimuniform is 128 bytes, and each JS bind group matches its entry point's exact binding set.
- CPU determinism, mass balance (the float64/rounding ledger is exact — lateral
relocation is a within-bed move and never touches
- The WebGPU backend has not been executed on a real GPU in this environment
(no GPU/secure context here). The structural checks above are the offline
safety net; the remaining risk is runtime semantic validation by Tint/Naga,
which
wgsl_reflectcannot fully replicate. When run in a browser,initsurfaces the exact failing entry point, and optional passes (smooth,tracerAdvect,lateralCompute/lateralApply, tracer plotting) degrade instead of killing the backend. The GPUlateralErodemirror uses the water gradient (noPron GPU) as the channel indicator — a documented backend divergence, not a bug. Sinuosity itself is a browser/visual check. - The GPU water field (
wB) is rebuilt every step from the actual tracer density (atomic splat + decay, matching the CPU's particle-countPr), so its volume tracks the particles and pools in low areas (lakes) instead of a uniform rain sheet.water scale/water alpha(View sliders) tune the gain/opacity. - GPU observation is throttled (
obsEverysteps) — the sim advances every step, but the stats reduction + CPU-mirror readback (combined into one double-buffered buffer, onemapAsync) run only every N steps, or on demand for export/fallback. Validate the meander shape and frame-time in a browser; tuning knobs (k1..k4,latThr, threshold, tilt) are shared so the two backends can be visually matched. requestRescaleon the GPU path only updates the render auto-range; a full heightmap remap pass is intentionally left out of the GPU model.