Tutorial
⏱️ ~55 minutes 🎓 Intermediate 🛠️ Three.js · GLSL · Noise

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.

Prerequisites

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)
}

Continue Learning

🛠

Experiment in Playground

Generate your own terrain — write and run Perlin noise + Three.js code in the browser.

Open Playground → View Simulation ↗