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
Grid size: 512×512 = 262,144 vertices — runs at 60
FPS on mid-range hardware. For 1024×1024 consider disabling shadows
or using an instanced grass layer only near the camera.
LOD:THREE.LOD lets you swap in
lower-resolution meshes at distance. Pre-bake 3 resolution levels
(512, 256, 128) and switch at 50 m / 150 m.
Normal map: For extra detail without more geometry,
bake a normal map from the high-res height map using standard tools
and apply it to a coarser mesh.
Real-time erosion on GPU: Encode height in a float
render target, run the erosion kernel as a GLSL fragment shader, and
use ping-pong framebuffers. Achieves 10× speedup for large terrains.
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.