Three.js From Zero · Article s15-03
Procedural Terrain Showcase
Procedural Terrain Showcase is Article s15-03 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.
Season 15 · Article 03 · Portfolio & Career
A walkable landscape: noise-based heightmap, biome blending, atmospheric fog, sun and sky. Generates an infinite playground. Pretty, technical, and demonstrates four advanced concepts (noise math, vertex displacement, color interpolation, fog math) without saying any of them out loud.
The architecture
- Heightmap — a 2D grid of heights from layered noise.
- Plane geometry, vertex-displaced — the canonical "noise on a plane" trick.
- Biome by height — water below 0, grass 0-3, rock 3-6, snow 6+.
- Sun + sky shader — gradient sky, directional sun, fog matching.
- First-person controls — walk on the terrain.
Heightmap from noise
const SIZE = 256;
const heights = new Float32Array(SIZE * SIZE);
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const u = x / SIZE * 5;
const v = y / SIZE * 5;
// Layered fbm noise: 4 octaves
let h = 0, amp = 1, freq = 1;
for (let o = 0; o < 4; o++) {
h += noise2d(u * freq, v * freq) * amp;
amp *= 0.5;
freq *= 2;
}
heights[y * SIZE + x] = h * 3; // scale to world units
}
}
Four octaves is the sweet spot. Fewer = boring. More = noisy/microdetail. You want the silhouette of mountains, not pebbles.
Displacing the plane
const geo = new THREE.PlaneGeometry(50, 50, SIZE - 1, SIZE - 1);
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
pos.setZ(i, heights[i]);
}
geo.computeVertexNormals(); // crucial — without this, lighting is wrong
geo.attributes.position.needsUpdate = true;
const terrain = new THREE.Mesh(geo, terrainMaterial);
terrain.rotation.x = -Math.PI / 2; // lay flat
scene.add(terrain);
Biome colors via shader
const terrainMaterial = new THREE.ShaderMaterial({
vertexShader: /*glsl*/`
varying float vHeight;
varying vec3 vNormal;
void main() {
vHeight = position.z;
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /*glsl*/`
varying float vHeight;
varying vec3 vNormal;
void main() {
vec3 water = vec3(0.1, 0.3, 0.6);
vec3 sand = vec3(0.85, 0.75, 0.5);
vec3 grass = vec3(0.2, 0.5, 0.15);
vec3 rock = vec3(0.45, 0.40, 0.38);
vec3 snow = vec3(0.95, 0.95, 1.0);
vec3 col = water;
col = mix(col, sand, smoothstep(-0.2, 0.5, vHeight));
col = mix(col, grass, smoothstep(0.5, 1.5, vHeight));
col = mix(col, rock, smoothstep(2.5, 4.0, vHeight));
col = mix(col, snow, smoothstep(5.0, 7.0, vHeight));
// Lambert lighting
float diff = max(dot(vNormal, normalize(vec3(0.5, 0.8, 0.4))), 0.0);
col *= 0.3 + diff * 0.9;
gl_FragColor = vec4(col, 1.0);
}
`,
});
Layered mix(prev, next, smoothstep(...)) calls cascade through biomes. The smoothstep ranges control the transition smoothness. Tweak in lil-gui for the perfect look.
Sky + fog
const skyColor = '#88c4f0';
scene.background = new THREE.Color(skyColor);
scene.fog = new THREE.Fog(skyColor, 20, 80);
const sun = new THREE.DirectionalLight('#ffd9b0', 1.2);
sun.position.set(20, 30, 20);
scene.add(sun);
scene.add(new THREE.HemisphereLight('#88c4f0', '#3a3a2a', 0.4));
Fog matching the sky color is mandatory — anything else creates a visible "world edge." The hemisphere light fills shadows with a sky-colored ambient, which feels much more outdoor than pure ambient.
Walk it
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
const controls = new PointerLockControls(camera, renderer.domElement);
canvas.addEventListener('click', () => controls.lock());
// In loop: get terrain height at camera XZ, anchor camera Y above it
const cam = controls.getObject();
const h = getHeightAt(cam.position.x, cam.position.z);
cam.position.y = h + 1.7; // eye height
PointerLockControls = FPS-style mouse look. The terrain follow keeps the camera at "person walking" altitude.
Common first-time pitfalls
geo.computeVertexNormals() after displacement. Three.js can't auto-compute normals from a displaced PlaneGeometry.smoothstep(0.0, 1.5, vHeight) instead of smoothstep(0.5, 0.7, vHeight).Exercises
- Add foliage. 2000 instanced trees, placed where biome = grass. Use InstancedMesh (S1-09). 60fps at 2000 trees is normal.
- Save your screenshot. Find the perfect angle. Press a key to capture as PNG (canvas.toBlob). This is your portfolio header.
- Make it infinite. Stream terrain chunks based on camera position. Each chunk noise-generated. The "no edge" demo.
UP NEXT
S15-04 — Showcasing on X / Twitter → How to capture a demo that goes viral.