Procedural Terrain Generation
Landscape generation with Perlin noise: a flat plane becomes mountainous terrain by displacing vertices according to multiple octaves of layered noise (fBm). This tutorial covers the CPU heightmap approach (modifying geometry vertices), the GPU vertex shader approach, normal recalculation for correct lighting, and altitude-based colour grading from ocean to snow.
- Finished Three.js Basics and WebGL Shaders Intro
- Understanding of fBm noise from GLSL Fire Shader
Perlin Noise in JavaScript
We'll use a simple gradient noise implementation. The
simplex-noise npm package is the easiest production
option, but for learning let's write the core from scratch:
// Minimal 2D gradient noise (Perlin-style)
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
function grad(hash, x, y) {
const h = hash & 3;
return ((h === 0 ? x : h === 1 ? -x : h === 2 ? y : -y)
+ (h < 2 ? y : -y)) * 0.5; // simple 4-gradient set
}
// Permutation table
const P = new Uint8Array(512);
const perm = Array.from({length: 256}, (_, i) => i);
for (let i = 255; i > 0; i--) { // shuffle
const j = Math.floor(Math.random() * (i + 1));
[perm[i], perm[j]] = [perm[j], perm[i]];
}
for (let i = 0; i < 512; i++) P[i] = perm[i & 255];
function noise2D(x, y) {
const xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
const xf = x - Math.floor(x), yf = y - Math.floor(y);
const u = fade(xf), v = fade(yf);
const a = P[xi] + yi, b = P[xi+1] + yi;
return lerp(
lerp(grad(P[a], xf, yf), grad(P[b], xf-1, yf), u),
lerp(grad(P[a+1], xf, yf-1), grad(P[b+1], xf-1, yf-1), u),
v
);
}
For production use,
import { createNoise2D } from 'simplex-noise' — faster,
better quality, works identically for this tutorial.
fBm Heightmap Generation
Stack 6 octaves to get natural-looking terrain with both large-scale mountains and fine detail:
function fbm(x, y, octaves = 6) {
let value = 0, amp = 0.5, freq = 1;
for (let i = 0; i < octaves; i++) {
value += amp * noise2D(x * freq, y * freq);
amp *= 0.5;
freq *= 2.1; // slight lacunarity >2 adds irregularity
}
return value; // range ≈ [-1, 1]
}
// Apply a "continent" mask — edges taper to ocean
function heightAt(nx, ny) {
// nx, ny in [-1, 1] — normalised domain coordinates
const edge = 1 - Math.max(Math.abs(nx), Math.abs(ny)); // 0 at edge, 1 at center
const terrain = fbm(nx * 3, ny * 3); // scale to taste
return terrain * edge; // zero at borders = ocean coastline
}
Apply to PlaneGeometry Vertices
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.168/build/three.module.js';
const SEG = 200; // grid resolution (201×201 vertices)
const SIZE = 100; // world size in units
const HEIGHT_SCALE = 20; // max terrain height
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEG, SEG);
geo.rotateX(-Math.PI / 2); // lay flat (XZ plane)
const pos = geo.attributes.position;
const colors = [];
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
// Normalised coords [-1, 1]
const nx = x / (SIZE / 2);
const nz = z / (SIZE / 2);
const h = heightAt(nx, nz) * HEIGHT_SCALE;
pos.setY(i, h); // displace upward
}
pos.needsUpdate = true;
Recalculate Normals
After modifying vertex positions, the original normals are wrong. Three.js can recompute them automatically:
geo.computeVertexNormals(); // ← recompute normals from displaced positions
// Now MeshStandardMaterial will light the terrain correctly
const mat = new THREE.MeshStandardMaterial({
vertexColors: true, // we'll assign per-vertex colour next
wireframe: false,
side: THREE.FrontSide,
});
Altitude Colour Grading
Assign colour to each vertex based on height: deep blue (water) → sand → green → rock → white (snow):
const palette = [
{ h: -2.0, col: [0.05, 0.12, 0.40] }, // deep water
{ h: -0.5, col: [0.10, 0.25, 0.55] }, // shallow water
{ h: 0.5, col: [0.82, 0.76, 0.55] }, // sand
{ h: 3.0, col: [0.25, 0.52, 0.22] }, // grass
{ h: 8.0, col: [0.35, 0.30, 0.25] }, // rocky
{ h: 14.0, col: [0.80, 0.85, 0.90] }, // snow
{ h: 20.0, col: [1.00, 1.00, 1.00] }, // peak snow
];
function heightColor(h) {
for (let i = 0; i < palette.length - 1; i++) {
if (h <= palette[i+1].h) {
const t = (h - palette[i].h) / (palette[i+1].h - palette[i].h);
return palette[i].col.map((c, j) => c + t * (palette[i+1].col[j] - c));
}
}
return palette[palette.length - 1].col;
}
for (let i = 0; i < pos.count; i++) {
const [r, g, b] = heightColor(pos.getY(i));
colors.push(r, g, b);
}
geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
GPU Vertex Shader Approach
For animated terrain (rising water, earthquake etc.) or infinite scrolling, compute the height in the vertex shader to avoid reuploading the CPU buffer:
// Bake the noise function into GLSL and displace in vertex shader
const terrainVS = /* glsl */`
uniform float uTime;
uniform float uScale;
varying float vHeight;
// --- paste noise + fbm GLSL functions here ---
// (same implementation as fire tutorial, adapted for 2D)
void main() {
vec2 np = position.xz / uScale; // normalised coords
float h = fbm(np * 3.0 + uTime * 0.02) * 20.0; // animated!
// Ocean floor mask at edges
float edgeMask = 1.0 - max(abs(np.x), abs(np.y));
h *= edgeMask;
vHeight = h;
vec3 displaced = vec3(position.x, h, position.z);
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}
`;
Terrain Chunking for Infinite Worlds
A single 400-vertex plane is fine for demos. For infinite worlds, divide into chunks, generate on demand, and recycle distant chunks:
const CHUNK_SIZE = 50, CHUNK_SEG = 64;
const chunks = new Map();
function chunkKey(cx, cz) { return `${cx},${cz}`; }
function getOrCreateChunk(cx, cz) {
const key = chunkKey(cx, cz);
if (chunks.has(key)) return chunks.get(key);
const geo = new THREE.PlaneGeometry(CHUNK_SIZE, CHUNK_SIZE, CHUNK_SEG, CHUNK_SEG);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const wx = pos.getX(i) + cx * CHUNK_SIZE;
const wz = pos.getZ(i) + cz * CHUNK_SIZE;
pos.setY(i, fbm(wx * 0.02, wz * 0.02) * 20);
}
pos.needsUpdate = true;
geo.computeVertexNormals();
const mesh = new THREE.Mesh(geo, terrainMat);
mesh.position.set(cx * CHUNK_SIZE, 0, cz * CHUNK_SIZE);
scene.add(mesh);
chunks.set(key, mesh);
return mesh;
}
// In animate(): load chunks around camera, cull far chunks
function updateChunks(camX, camZ) {
const range = 3;
const cx = Math.round(camX / CHUNK_SIZE);
const cz = Math.round(camZ / CHUNK_SIZE);
for (let dx = -range; dx <= range; dx++)
for (let dz = -range; dz <= range; dz++)
getOrCreateChunk(cx + dx, cz + dz);
// (remove distant chunks for memory efficiency)
}