Hydraulic Erosion Maps on Canvas
Move simplified water and sediment through a seeded height grid, then art-direct the result with a complete conservation ledger.
Hydraulic erosion can carve compelling procedural terrain only when the simulation exposes where water, sediment, and height actually move. This tutorial turns a seeded Canvas height field into an art-directed map while keeping every simplified rule, boundary loss, and conservation residue visible.
Hydraulic erosion begins with a bounded art model
A procedural terrain image needs a height field, a rule for water movement, a sediment capacity model, erosion and deposition rates, evaporation or outlet behavior, and an explicit boundary. Those choices can create river-like structure without claiming a physically validated landscape.
The classic paper on synthesizing eroded fractal terrains established influential terrain ideas, while later methods use different hydraulic approximations and interaction goals. Treat the literature as a source of models, not permission to call a tiny browser sketch geology.
The committed hydraulic erosion artifact is a seeded simplified flux exercise on generated values. Its mass split is deterministic and inspectable; its shapes are artistic proxies rather than measurements of rainfall, soil, rock, watersheds, or real-world time.
The generated terrain uses a fixed seed, sine and cosine structure, and bounded noise on a 32 by 20 grid. A protected sixteen-cell region refuses erosion, making art direction part of the simulation state rather than a painted overlay.
| Before | seed 91 height field |
|---|---|
| After | terrain after selected steps |
| Flow | sum of downhill water transfers |
| Sediment | final suspended material per cell |
Generate a height field with compositional intent
Build a small float grid from a documented seed, broad low-frequency landforms, and limited higher-frequency detail. Establish a focal ridge, basin, or diagonal flow before simulation so erosion has a visual composition to develop instead of being asked to create hierarchy from uniform noise.
Normalize only with a recorded rule, because per-frame rescaling can make terrain mass appear to change independently of the algorithm. Preserve the untouched source field, seed, dimensions, sampling equation, and initial terrain sum for every edition.
Hydraulic erosion can use masks that protect a title area or emphasize an outlet, but those interventions must enter the receipt. Art direction is valuable precisely when it is explicit about the forces and protected zones shaping the final map.
Zero, low, and high presets expose rain, capacity, erosion, deposition, evaporation, and step count. The default low preset is printed in the receipt, while the zero preset offers a stable counterexample for hydraulic erosion changes.
Move water downhill without hidden teleports
For each cell, compare surface height plus water against neighbors and compute non-negative outflow weights. Cap total outflow to available water, apply all transfers through a second buffer, and define how ties, flats, sinks, and grid edges behave.
An alternative droplet method follows individual particles across a continuous or sampled slope. Whichever model you choose, expose step size, inertia, gravity proxy, rainfall, lifetime, and termination so a parameter change is reproducible rather than a mysterious aesthetic knob.
The hydraulic erosion figure follows one cell through rainfall, slope, capacity, erosion, deposition, evaporation, and boundary flux. It is a teaching cycle; the browser program uses its own declared bounded approximation and reports that exact path.
Water moves toward the lowest combined terrain-and-water neighbor. Sediment capacity depends on flow and slope; the model erodes below capacity, deposits above capacity, transports a proportional load, and records accumulated flow per cell. A stable neighbor order resolves equal downhill choices deterministically.
- Add preset rainfall to every bounded cell.
- Choose the lowest terrain-plus-water neighbor.
- Compute flow, slope, and sediment capacity.
- Erode below capacity unless the cell is protected.
- Deposit above capacity, transport load, then evaporate water.
Couple sediment capacity to visible slope and flow
A simplified capacity can depend on water, speed or flux, slope, and a tunable coefficient. When carried sediment is below capacity, remove a capped amount of terrain; when it exceeds capacity or flow slows, deposit a capped amount back into the height field.
Never erode more terrain than the cell contains or deposit non-finite values. Use double buffers or a carefully documented update order, because in-place iteration can create directional bias that masquerades as a designed drainage pattern.
The interactive hydraulic terrain paper offers primary technical context. This hydraulic erosion tutorial does not reproduce its complete method; it borrows the discipline of exposing editable terrain behavior and diagnosing the resulting channels.
Four Canvas panels render the same execution: initial height, final height, flow accumulation, and suspended sediment. Their labels remain in the bitmap and the adjacent receipt names the generated, simplified scope.
Runnable artifact — Run a generated seeded terrain through a simplified bounded erosion loop and expose its complete mass ledger.
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hydraulic erosion map studio</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:16px/1.5 system-ui;max-width:960px;margin:auto;padding:24px;background:#101b24;color:#f5f3ea}button,select,a{font:inherit;padding:.72rem 1rem;margin:.35rem;border:2px solid #7ce5c3;border-radius:.6rem;background:#172b36;color:#fff}button:focus-visible,select:focus-visible,a:focus-visible{outline:4px solid #ffd66b;outline-offset:3px}canvas{width:100%;height:auto;border:1px solid #78909c;background:#081116}output{display:block;white-space:pre-wrap;padding:1rem;background:#081116;border-radius:.6rem;margin-top:1rem;overflow-wrap:anywhere}small{display:block;color:#b8cbd4}.exports{display:flex;flex-wrap:wrap;gap:.5rem}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}</style><main><h1>Hydraulic erosion map studio</h1><p>A bounded teaching transport model runs on generated terrain; it is not validated hydrology or geomorphology.</p><select id="preset" aria-label="Erosion preset"><option value="zero">Zero</option><option value="low" selected>Low</option><option value="high">High</option></select><button id="run">Run selected preset</button><canvas id="map" width="640" height="360" aria-label="Before, after, water-flow, and sediment maps"></canvas><div class="exports"><a id="pngExport" download="erosion.png">Export PNG</a><a id="jsonExport" download="erosion.json">Export JSON</a></div><output id="receipt" aria-live="polite"></output></main><script>const presets={zero:{rain:.0,capacity:.0,erode:.0,deposit:.0,evap:.0,steps:1},low:{rain:.008,capacity:1.4,erode:.18,deposit:.22,evap:.08,steps:24},high:{rain:.016,capacity:2.2,erode:.28,deposit:.3,evap:.12,steps:36}},W=32,H=20,idx=(x,y)=>y*W+x,seeded=s=>()=>((s=Math.imul(s,1664525)+1013904223>>>0)/4294967296),round=n=>+n.toFixed(9),sha=async bytes=>[...new Uint8Array(await crypto.subtle.digest("SHA-256",bytes))].map(x=>x.toString(16).padStart(2,"0")).join("");function initial(){const r=seeded(91);return Float64Array.from({length:W*H},(_,i)=>.5+.17*Math.sin((i%W)/4)+.12*Math.cos(Math.floor(i/W)/3)+.03*r())}function simulate(p){const terrain=initial(),before=terrain.slice(),water=new Float64Array(W*H),sediment=new Float64Array(W*H),flowMap=new Float64Array(W*H),protectedMask=new Uint8Array(W*H);for(let y=8;y<12;y++)for(let x=14;x<18;x++)protectedMask[idx(x,y)]=1;let rainAdded=0,evaporated=0,eroded=0,deposited=0;for(let step=0;step<p.steps;step++){for(let i=0;i<water.length;i++){water[i]+=p.rain;rainAdded+=p.rain}const dw=new Float64Array(water.length),ds=new Float64Array(water.length);for(let y=1;y<H-1;y++)for(let x=1;x<W-1;x++){const i=idx(x,y),neighbors=[idx(x+1,y),idx(x-1,y),idx(x,y+1),idx(x,y-1)].sort((a,b)=>(terrain[a]+water[a])-(terrain[b]+water[b])),n=neighbors[0],slope=Math.max(0,terrain[i]+water[i]-terrain[n]-water[n]),move=Math.min(water[i]*.45,slope*.3);if(move<=0)continue;const carried=sediment[i]*(move/Math.max(water[i],1e-12)),capacity=move*slope*p.capacity;if(sediment[i]<capacity&&!protectedMask[i]){const amount=Math.min((capacity-sediment[i])*p.erode,terrain[i]*.01);terrain[i]-=amount;sediment[i]+=amount;eroded+=amount}else{const amount=Math.min(sediment[i],(sediment[i]-capacity)*p.deposit);terrain[i]+=amount;sediment[i]-=amount;deposited+=amount}dw[i]-=move;dw[n]+=move;ds[i]-=carried;ds[n]+=carried;flowMap[i]+=move}for(let i=0;i<water.length;i++){water[i]+=dw[i];sediment[i]+=ds[i];const loss=water[i]*p.evap;water[i]-=loss;evaporated+=loss}}const terrainDelta=before.reduce((s,v,i)=>s+v-terrain[i],0),suspended=sediment.reduce((a,b)=>a+b,0),waterLeft=water.reduce((a,b)=>a+b,0),terrainResidue=Math.abs(terrainDelta-suspended),waterResidue=Math.abs(rainAdded-waterLeft-evaporated);return{before,terrain,water,sediment,flowMap,protectedMask,ledger:{rainAdded:round(rainAdded),waterLeft:round(waterLeft),evaporated:round(evaporated),eroded:round(eroded),deposited:round(deposited),terrainDelta:round(terrainDelta),suspended:round(suspended),terrainResidue:round(terrainResidue),waterResidue:round(waterResidue)}}}function paint(data){const ctx=map.getContext("2d"),panels=[data.before,data.terrain,data.flowMap,data.sediment],labels=["before","after","flow","sediment"];ctx.clearRect(0,0,map.width,map.height);panels.forEach((values,p)=>{const ox=(p%2)*320,oy=Math.floor(p/2)*180,max=Math.max(...values,1e-9),min=Math.min(...values);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const q=(values[idx(x,y)]-min)/(max-min||1);ctx.fillStyle=p<2?"hsl("+(205-q*155)+" 60% "+(20+q*45)+"%)":"hsl("+(190-q*145)+" 80% "+(12+q*55)+"%)";ctx.fillRect(ox+x*10,oy+y*8,10,8)}ctx.fillStyle="#fff";ctx.fillText(labels[p],ox+8,oy+16)})}async function execute(){try{const parameters=presets[preset.value],data=simulate(parameters);paint(data);const png=await new Promise(r=>map.toBlob(r,"image/png")),summary={fixture:"generated bounded terrain",preset:preset.value,parameters,maps:["before","after","flow","sediment"],protectedCells:data.protectedMask.reduce((a,b)=>a+b,0),ledger:data.ledger},json=new Blob([JSON.stringify(summary,null,2)],{type:"application/json"}),pngHash=await sha(await png.arrayBuffer()),jsonHash=await sha(await json.arrayBuffer());pngExport.href=URL.createObjectURL(png);jsonExport.href=URL.createObjectURL(json);const execution={...summary,exports:{png:{bytes:png.size,sha256:pngHash},json:{bytes:json.size,sha256:jsonHash}},conserved:data.ledger.terrainResidue<1e-7&&data.ledger.waterResidue<1e-7};receipt.dataset.execution=JSON.stringify(execution);receipt.value=(execution.conserved?"PASS: ":"FAIL: ")+JSON.stringify(execution,null,2)}catch(error){receipt.dataset.execution=JSON.stringify({unexpectedError:error.name+": "+error.message});receipt.value="FAIL: unexpected "+error.message}}run.onclick=()=>void execute();void execute()</script></html>
Maintain a conservation ledger at every step
Track initial terrain mass, current terrain mass, suspended sediment, deposited sediment when represented separately, sediment or water exiting the boundary, evaporation, rainfall input, and numerical residue. State which quantities are intended to conserve and which are deliberate sources or sinks.
A small residue can indicate floating-point accumulation, while a large one often exposes an update bug, double counting, or a boundary that silently discards material. Do not hide the result by renormalizing the map before calculating the ledger.
The generated hydraulic erosion fixture splits removed terrain into a deposited portion and declared boundary loss, then asserts the difference stays within its tiny numerical tolerance. That evidence validates only the fixture's accounting equation, not physical conservation in a different implementation.
Two conservation equations must close. Rain equals remaining water plus evaporation, while terrain loss equals suspended sediment because this bounded fixture has no sediment outlet; both residues must stay below one ten-millionth.
Render height flow and sediment as separate evidence
Use a height palette whose luminance remains interpretable without hue, then add flow accumulation, sediment, or protected zones as contours, line texture, symbols, or independently viewable layers. A beautiful composite should never be the only way to diagnose the simulation.
The Canvas 2D standard defines the drawing surface, while the algorithm owns numeric color mapping, device scaling, and exports. Render from frozen arrays after simulation instead of reading painted pixels back as source state.
Hydraulic erosion becomes artistically rich when channels organize negative space and ridges control rhythm. Compare with flow-field plotter art to see how direction can drive paths without implying the same material transport model.
Eroded and deposited counters describe operations, not independent conserved mass stores, so they are reported beside terrainDelta rather than added into the final equation. This prevents double-counting material that may move more than once. Panel values use the final arrays behind those same counters.
Compare presets without declaring a beauty metric
Freeze zero, low, and high erosion presets on the same height field. Show identical scales, iteration counts, parameter tables, terrain and sediment ledgers, channel coverage, and output hashes so visual critique has a common technical ground.
Metrics may describe roughness, drainage concentration, changed cells, or conservation, but they do not prove aesthetic superiority. Let the maker choose which map best supports hierarchy, texture, and print behavior after technical invariants pass.
Contrast hydraulic erosion with diffusion-limited aggregation and reaction-diffusion as different generative families. Branching resemblance does not make their causes or parameters interchangeable.
Hydraulic erosion here is an artistic terrain erosion simulation, not validated hydrology, soil mechanics, or geomorphology. Grid scale, coefficients, protected zones, and boundary rules are synthetic choices tuned for inspectable sediment transport and heightmap erosion behavior. Aesthetic preference remains human judgment rather than a conservation metric or score. Preset comparison keeps every other generated input fixed.
- Water equation
- rainAdded − waterLeft − evaporated = waterResidue.
- Material equation
- terrainDelta − suspended = terrainResidue.
- Pass condition
- Both absolute residues remain below one ten-millionth.
- Excluded outlet
- No boundary sediment loss occurs in this bounded fixture.
Export a reproducible terrain edition
Save seed, grid, initial field equation, protected masks, boundary mode, rainfall, capacity, erosion, deposition, evaporation, steps, ledger, palette, dimensions, algorithm version, and digest. Export raw height and flow data beside the optimized image so a later palette change does not require rerunning the simulation.
Use topographic contour posters after the source field is frozen, preserving the link between contours and exact heights. Canvas previews, PNG editions, and contour SVGs should share the same receipt identifier.
Run the hydraulic erosion studio and inspect residue before evaluating its composition. The piece is ready when the generated map is visually intentional, the simplified model is labeled at the claim, and every unit of declared terrain change has an accountable destination.
PNG and JSON blobs are created from the same run. Each export receives a SHA-256 digest and byte count, allowing a later edition to distinguish parameter changes from nondeterministic rendering. The export receipt names the selected preset and protected-cell count.
Test numerical stability across scale and order
Run the same preset at several grid resolutions and time steps while holding the conceptual duration and source field as comparable as possible. Large changes in channel direction, mass residue, or boundary loss may be expected from discretization, but they must be measured and understood before a high-resolution render is called a faithful refinement.
Reverse or permute cell iteration in a reference test to detect directional bias from in-place updates. A double-buffered solver should not depend on scan order for one step; if the artistic method intentionally uses sequential deposition, name that behavior and freeze the ordering as part of the edition.
Hydraulic erosion can amplify tiny numeric differences into visibly different drainage networks. Reproducibility therefore needs runtime, precision, seed, and algorithm version alongside parameters, plus tolerance-aware checks that protect invariants without pretending every browser must emit byte-identical floating-point pixels.
A map is publishable only when the ledger, preset, protected-cell count, and four panels agree. The visual result may guide taste, but conservation failure remains a technical rejection regardless of how attractive the channels appear.
Compose the map for screen and print
Select a palette after inspecting raw height and flow. Use luminance to establish elevation hierarchy, restrained hue for water or sediment, and line weight for accumulated channels; preserve a monochrome proof so the topology remains legible when color vision, print conversion, or low-quality display reduces the palette.
Design labels, legend, scale statement, seed, and model boundary as part of the artwork rather than metadata hidden in a download. The map may be abstract and still be accountable: call units normalized, avoid geographic coordinates, and never add a realistic scale bar that implies a physical terrain.
Hydraulic erosion is most compelling here as a collaboration between simulation and editorial composition. Export multiple sizes from the same frozen arrays, inspect small-thumbnail channel survival and large-format banding, then archive the raw field so future art direction can change without rewriting the evidence ledger.
The February review reruns all three presets and hashes the default edition. Canvas or algorithm changes receive a new evidence freeze; the old hydraulic erosion receipt stays available for comparison.