Procedural Terrain

Generating landscapes with noise and vertex displacement

Terrain from Code

Procedural terrain is one of the most satisfying things to build in 3D. The idea is simple: start with a flat PlaneGeometry, then displace each vertex's height using a noise function. The noise creates the hills, valleys, and ridges that make it look like real landscape. Since the plane is just a BufferGeometry, we have direct access to every vertex.

A Simple Heightmap

The most basic terrain uses layered sine and cosine waves to displace vertex heights. This won't look realistic on its own, but it demonstrates the core technique. Every terrain generator works more or less the same way, just with better noise and more detail.

typescript
// Create a subdivided plane
const geometry = new THREE.PlaneGeometry(10, 10, 128, 128);
geometry.rotateX(-Math.PI / 2); // Lay it flat (XZ plane)

const positions = geometry.attributes.position;

for (let i = 0; i < positions.count; i++) {
  const x = positions.getX(i);
  const z = positions.getZ(i);

  // Simple height function using layered waves
  const height = Math.sin(x * 0.5) * Math.cos(z * 0.5) * 1.5
               + Math.sin(x * 1.2 + z * 0.8) * 0.5;

  positions.setY(i, height);
}

geometry.computeVertexNormals();

Value Noise

Real terrain uses coherent noise: random values that change smoothly. A simple approach is value noise: assign random values to a grid, then interpolate between them. Here's a basic 2D implementation:

typescript
// Simple hash function for repeatable pseudo-random values
function hash(x, y) {
  let n = x * 127.1 + y * 311.7;
  n = Math.sin(n) * 43758.5453;
  return n - Math.floor(n);
}

// Smooth interpolation (smoothstep)
function smoothstep(t) {
  return t * t * (3 - 2 * t);
}

// 2D value noise
function noise2D(x, y) {
  const ix = Math.floor(x);
  const iy = Math.floor(y);
  const fx = x - ix;
  const fy = y - iy;

  // Smooth the fractional parts
  const sx = smoothstep(fx);
  const sy = smoothstep(fy);

  // Get random values at the four corners
  const n00 = hash(ix, iy);
  const n10 = hash(ix + 1, iy);
  const n01 = hash(ix, iy + 1);
  const n11 = hash(ix + 1, iy + 1);

  // Bilinear interpolation
  const nx0 = n00 + (n10 - n00) * sx;
  const nx1 = n01 + (n11 - n01) * sx;
  return nx0 + (nx1 - nx0) * sy;
}

Fractal Brownian Motion (fBm)

A single layer of noise looks blobby. Real terrain has detail at every scale, i.e. large mountains, medium hills, small rocks. Fractal Brownian motion layers multiple octaves of noise at increasing frequency and decreasing amplitude:

typescript
function fbm(x, y, octaves = 6) {
  let value = 0;
  let amplitude = 1;
  let frequency = 1;
  let maxValue = 0; // For normalization

  for (let i = 0; i < octaves; i++) {
    value += noise2D(x * frequency, y * frequency) * amplitude;
    maxValue += amplitude;

    amplitude *= 0.5;   // Each octave is half as strong
    frequency *= 2.0;   // Each octave is twice as detailed
  }

  return value / maxValue; // Normalize to 0-1
}

// Apply to terrain
for (let i = 0; i < positions.count; i++) {
  const x = positions.getX(i);
  const z = positions.getZ(i);
  positions.setY(i, fbm(x * 0.3, z * 0.3, 6) * 3);
}

More octaves = more detail but slower computation. For realtime terrain, 4-6 octaves is usually enough.

Coloring by Height

Flat-colored terrain looks artificial. A common technique is to assign vertex colors based on height. Deep blue for water, green for lowlands, brown for mountains, white for snow:

typescript
// Add a color attribute to the geometry
const colors = new Float32Array(positions.count * 3);
const color = new THREE.Color();

for (let i = 0; i < positions.count; i++) {
  const height = positions.getY(i);
  const normalized = (height + 1) / 4; // Map to 0-1 range

  if (normalized < 0.2) {
    color.set(0x2266aa);       // Deep water
  } else if (normalized < 0.3) {
    color.set(0x44aa66);       // Shore / lowland
  } else if (normalized < 0.6) {
    color.set(0x338833);       // Forest
  } else if (normalized < 0.8) {
    color.set(0x886644);       // Mountain rock
  } else {
    color.set(0xeeeeff);       // Snow
  }

  colors[i * 3] = color.r;
  colors[i * 3 + 1] = color.g;
  colors[i * 3 + 2] = color.b;
}

geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

const material = new THREE.MeshLambertMaterial({
  vertexColors: true
});

Animated Terrain

Since the position attribute can be updated every frame, you can animate terrain in realtime. Adding a time offset to the noise input creates flowing, wave-like terrain that is useful for water surfaces, alien landscapes, or visualizers.

typescript
function updateTerrain(time) {
  const positions = geometry.attributes.position;

  for (let i = 0; i < positions.count; i++) {
    const x = positions.getX(i);
    const z = positions.getZ(i);

    // Offset noise coordinates by time
    const height = fbm(x * 0.3 + time * 0.2, z * 0.3 + time * 0.1, 4) * 2;
    positions.setY(i, height);
  }

  positions.needsUpdate = true;
  geometry.computeVertexNormals();
}

// In your animation loop
function tick() {
  const elapsed = clock.getElapsedTime();
  updateTerrain(elapsed);
  renderer.render(scene, camera);
  requestAnimationFrame(tick);
}

Terrain with Ridged Noise

Standard fBm produces rolling hills. For sharper, more dramatic landscapes with ridges and valleys, you can use ridged noise, take the absolute value of the noise and invert it, then raise it to a power. The sharp creases where noise crosses zero become mountain ridges.

typescript
function ridgedNoise(x, y, octaves = 6) {
  let value = 0;
  let amplitude = 1;
  let frequency = 1;
  let weight = 1;

  for (let i = 0; i < octaves; i++) {
    let n = noise2D(x * frequency, y * frequency);

    // Create ridges: abs creates a V shape, 1-abs inverts it to peaks
    n = 1.0 - Math.abs(n * 2 - 1);
    n = n * n; // Sharpen the ridges

    // Weight by previous octave (detail concentrates on ridges)
    n *= weight;
    weight = Math.min(n * 2, 1);

    value += n * amplitude;
    amplitude *= 0.5;
    frequency *= 2.0;
  }

  return value;
}
←   Custom GeometriesThe Spectrum of Materials   →