A heightmap made of noise looks like noise. Run water over it, and only if that water remembers where it went, it starts to look like land.
This is one HTML file, a valley between two rocky massifs, on Three.js 0.186.1 with WebGPU and TSL. I use AI to write a lot of the code. I have 25+ years of JavaScript, game development, modding and level design, and the order below is the part I don't hand over.
The order
Here's the order I run on every regenerate. The passes stay separate on purpose, because each one undoes something the previous one did, and merging them is how the valley disappears.
- Sculpt. Simplex FBM for the base, ridged noise for the mountains, and a sine path down the middle. Height gets lerped toward that path so the wash starts flat enough to walk.
- Stream power. Steepest-descent drainage over the whole grid, then incision from drainage area and slope, solved implicitly from low to high (Braun and Willett, 2013). This is what cuts the dendritic gullies. Droplets only scratch a slope, they never build that network.
- Put the wash back. The fluvial pass will eat the valley floor, so I lerp it back toward the path height. Something has to stay flat enough to walk on.
- Droplets. Hydraulic erosion with inertia, capacity and evaporation. It only bites above about 40°. Flats carry sediment, and they don't get scratched.
- Thermal. Past a talus angle, about 65° here, material slides to the lower neighbour.
- Upsample from the 512 simulation grid to the 1024 mesh. Rills are stretched noise, high frequency across the slope and low frequency along it, so they run downhill. Then a shorter stream-power pass, the wash restored again, then Voronoi cracks on the steep faces.
- Fold. A heightmap cannot store an overhang, so vertices past about 45° get pushed downhill in XZ and the cliff bulges over the scree.
- Bake colour from slope, concavity, wear and deposit. Rock on the steep, sand where sediment landed and along the wash, grass on the rest.
The incision step is the one to keep. A is drainage area, r is the downstream cell, and U is a little uplift so the peaks don't all wash away:
const F = kdt * Math.pow(Math.min(A[c], acap), mexp) / rd[c];
map[c] = (map[c] + F * map[r]) / (1 + F);
map[c] += U[c];
It's solved from the bottom up, so the receiver already has its new height when you write into it. A large kdt stays stable, and it can still turn the ridges into knives and erase the valley, so I leave it low and put the wash back afterwards.
Droplets get a gate, otherwise they comb the whole map including the path you just protected:
const cellS = WORLD / (n - 1);
const sTan = Math.sqrt(gx * gx + gy * gy) / cellS;
const steep = smooth(0.839, 1.19, sTan); // tan 40° → tan 50°
smooth ramps from 0 to 1. Below 40° the droplet deposits or walks on, and by 50° it erodes at full strength, spread over a small brush. Skip the brush and you get single-pixel trenches that read as scratches in the texture.
The fold is a cheat, and if you look straight up a cliff you can see the vertices shoved downhill. From the path, the bulge reads as rock over talus, which is what a level wants.
The frame is that file. The first generate is the slow one: 512 for the simulation, 1024 for the mesh, and 130k droplets unless you pull the slider down before you regenerate. Drag to orbit. R regenerates.
WebGPU only, on Three.js 0.186.1, with no WebGL fallback. If the browser has no WebGPU, the page stops. This copy bakes the colour and skips the photo tiles I use on my machine (a rock face, forest floor, mud, sand). Same heightfield, one file. Open it through a server, because file:// will not load the modules. php -S localhost:8000 or npx serve in the folder is enough.
- Open it on its own
terrain.html, the project-
SKILL.md
I had the mesh at 1536 while I was staring at cliff faces. This copy is 1024. Change MESH if you want the heavier one, and change nothing else.