Tutorial · Intermediate · ~60 min
Three.js · Perlin Noise · Erosion

Procedural Terrain with Hydraulic Erosion

Generate a mountain landscape in the browser using fractal Brownian motion, then run a droplet-based hydraulic erosion simulation to carve realistic valleys, rivers, and talus fans — all without a server or build step.

1Generate a height map with fBm

Fractal Brownian Motion (fBm) stacks several frequencies of Perlin noise (called octaves). Each octave is scaled by a persistence factor and a lacunarity multiplier:

// Minimal 2D Perlin-style noise (value noise for brevity) function hash(x, y) { const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return n - Math.floor(n); } function smoothNoise(x, y) { const ix = Math.floor(x), iy = Math.floor(y); const fx = x - ix, fy = y - iy; const ux = fx * fx * (3 - 2 * fx); const uy = fy * fy * (3 - 2 * fy); const a = hash(ix, iy), b = hash(ix+1, iy); const c = hash(ix, iy+1), d = hash(ix+1, iy+1); return a + (b-a)*ux + (c-a)*uy + (b-a+a-b-c+d)*ux*uy; } function fbm(x, y, octaves = 6, persistence = 0.5, lacunarity = 2.0) { let value = 0, amplitude = 1, frequency = 1, maxValue = 0; for (let i = 0; i < octaves; i++) { value += amplitude * smoothNoise(x * frequency, y * frequency); maxValue += amplitude; amplitude *= persistence; frequency *= lacunarity; } return value / maxValue; // normalised 0…1 } // Build flat Float32Array height map const N = 512; const heightMap = new Float32Array(N * N); for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) heightMap[y * N + x] = fbm(x / 128, y / 128);
Tip: For a more natural look, apply domain warping before sampling fBm: fbm(x + fbm(x+0.1, y+1.3), y + fbm(x+5.2, y+1.7)). This makes the terrain look "twisted" — great for badlands and volcanic terrain styles.

2Build a Three.js terrain mesh

Use PlaneGeometry and update its vertex buffer to reflect the height map. Displace each vertex in the Y direction:

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; const SEGMENTS = 511; // N-1 for N-point grid const SIZE = 100; const HEIGHT = 20; // vertical scale factor const geometry = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS); geometry.rotateX(-Math.PI / 2); // make it horizontal const positions = geometry.attributes.position; for (let i = 0; i < positions.count; i++) { const h = heightMap[i]; // already normalised 0…1 positions.setY(i, h * HEIGHT); } positions.needsUpdate = true; geometry.computeVertexNormals(); const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ vertexColors: true, wireframe: false })); scene.add(mesh);

3Colour by altitude

Add a colours attribute to the geometry. Map height bands to terrain biomes — deep water, shallow beach, grass, rock, snow:

const palette = [ { t: 0.00, r: 0.02, g: 0.07, b: 0.35 }, // deep water { t: 0.25, r: 0.05, g: 0.18, b: 0.55 }, // shallow water { t: 0.30, r: 0.82, g: 0.78, b: 0.58 }, // sand { t: 0.40, r: 0.34, g: 0.61, b: 0.26 }, // grass { t: 0.65, r: 0.40, g: 0.35, b: 0.30 }, // rock { t: 1.00, r: 0.95, g: 0.95, b: 0.98 } // snow ]; function samplePalette(t) { for (let i = 0; i < palette.length - 1; i++) { const lo = palette[i], hi = palette[i + 1]; if (t <= hi.t) { const s = (t - lo.t) / (hi.t - lo.t); return { r: lo.r + (hi.r-lo.r)*s, g: lo.g + (hi.g-lo.g)*s, b: lo.b + (hi.b-lo.b)*s }; } } return palette[palette.length - 1]; } const colors = new Float32Array(positions.count * 3); for (let i = 0; i < positions.count; i++) { const h = heightMap[i]; const c = samplePalette(h); colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b; } geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

4Hydraulic erosion simulation

Drop a virtual water raindrop at a random position. It slides down the steepest gradient, picks up sediment, and deposits it when it slows. Repeat for 50,000–150,000 droplets:

function erode(heightMap, N, numDroplets = 80000) { for (let d = 0; d < numDroplets; d++) { let px = Math.random() * (N - 2) + 1; let py = Math.random() * (N - 2) + 1; let vx = 0, vy = 0; // velocity let water = 1, sediment = 0; const inertia = 0.05, capacity = 8, erosion = 0.3, deposition = 0.3, gravity = 4, evaporation = 0.01; for (let step = 0; step < 128; step++) { const ix = Math.floor(px), iy = Math.floor(py); if (ix < 1 || ix >= N-1 || iy < 1 || iy >= N-1) break; // Bilinear height and gradient const fx = px - ix, fy = py - iy; const h00 = heightMap[iy*N+ix], h10 = heightMap[iy*N+ix+1]; const h01 = heightMap[(iy+1)*N+ix], h11 = heightMap[(iy+1)*N+ix+1]; const gx = (h10-h00)*(1-fy) + (h11-h01)*fy; const gy = (h01-h00)*(1-fx) + (h11-h10)*fx; vx = vx * inertia - gx * (1 - inertia); vy = vy * inertia - gy * (1 - inertia); const speed = Math.sqrt(vx*vx + vy*vy); if (speed < 1e-6) break; vx /= speed; vy /= speed; const sedCap = Math.max(-( gx*vx + gy*vy ), 0.01) * speed * water * capacity; if (sediment > sedCap) { // deposit const dep = (sediment - sedCap) * deposition; sediment -= dep; heightMap[iy*N+ix] += dep * (1-fx)*(1-fy); heightMap[iy*N+ix+1] += dep * fx*(1-fy); heightMap[(iy+1)*N+ix] += dep * (1-fx)*fy; heightMap[(iy+1)*N+ix+1] += dep * fx*fy; } else { // erode const er = Math.min((sedCap - sediment) * erosion, 0.05); sediment += er; heightMap[iy*N+ix] -= er * (1-fx)*(1-fy); heightMap[iy*N+ix+1] -= er * fx*(1-fy); heightMap[(iy+1)*N+ix] -= er * (1-fx)*fy; heightMap[(iy+1)*N+ix+1] -= er * fx*fy; } px += vx; py += vy; water *= (1 - evaporation); } } }
Performance: Running 80,000 droplets on the CPU takes 200–500 ms. Move the loop into a Web Worker so the UI thread stays responsive while erosion processes. Transfer the Float32Array buffer with postMessage(..., [buffer]) for zero-copy transfer.

5Add water plane and lighting

// Flat semi-transparent water plane at sea level const waterGeo = new THREE.PlaneGeometry(SIZE, SIZE); waterGeo.rotateX(-Math.PI / 2); const waterMat = new THREE.MeshStandardMaterial({ color: 0x1a6fa8, transparent: true, opacity: 0.75, roughness: 0.05 }); const water = new THREE.Mesh(waterGeo, waterMat); water.position.y = HEIGHT * 0.27; // 27% of max height = sea level scene.add(water); // Directional (sun) light with shadows const sun = new THREE.DirectionalLight(0xfff5e0, 2.5); sun.position.set(60, 80, 40); sun.castShadow = true; sun.shadow.mapSize.width = sun.shadow.mapSize.height = 2048; sun.shadow.camera.near = 0.5; sun.shadow.camera.far = 300; sun.shadow.camera.left = sun.shadow.camera.bottom = -80; sun.shadow.camera.right = sun.shadow.camera.top = 80; scene.add(sun); scene.add(new THREE.AmbientLight(0x8090a0, 0.4)); renderer.shadowMap.enabled = true;

6Performance and LOD notes