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.

← Three.js From ZeroS15-03 · Portfolio & Career

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

  1. Heightmap — a 2D grid of heights from layered noise.
  2. Plane geometry, vertex-displaced — the canonical "noise on a plane" trick.
  3. Biome by height — water below 0, grass 0-3, rock 3-6, snow 6+.
  4. Sun + sky shader — gradient sky, directional sun, fog matching.
  5. 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

"Terrain looks weirdly flat-shaded." Missing geo.computeVertexNormals() after displacement. Three.js can't auto-compute normals from a displaced PlaneGeometry.
"Biomes blend abruptly." smoothstep ranges too tight. Widen them: smoothstep(0.0, 1.5, vHeight) instead of smoothstep(0.5, 0.7, vHeight).
"Walking falls through the terrain." Camera Y not synced to terrain height — you're not running getHeightAt each frame. Or your getHeightAt does point-in-plane interpolation wrong.

Exercises

  1. Add foliage. 2000 instanced trees, placed where biome = grass. Use InstancedMesh (S1-09). 60fps at 2000 trees is normal.
  2. Save your screenshot. Find the perfect angle. Press a key to capture as PNG (canvas.toBlob). This is your portfolio header.
  3. Make it infinite. Stream terrain chunks based on camera position. Each chunk noise-generated. The "no edge" demo.