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.