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

Frequently Asked Questions

What will I learn in this tutorial?

Build a procedurally generated 3D terrain using Perlin noise and simulate hydraulic erosion to carve realistic valleys, rivers, and ridges.

What topics are covered in this tutorial?

This tutorial covers: Generate height map with fBm, Build a Three.js terrain mesh, Colour by altitude, Hydraulic erosion simulation, Add water plane and lighting, Performance and LOD notes.

What tools and technologies does this tutorial use?

This tutorial uses Three.js, Perlin Noise, Erosion.

How long does this tutorial take?

This tutorial takes approximately 60 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.