Home Games Shader Sandbox

Game Dev Mechanics: Procedural Terrain Generation — How It Works

Procedural Terrain Drag to orbit • Scroll to zoom
fBm octaves: 6 • Domain warp ON
seed: 0

What Is Procedural Terrain Generation?

Open any modern open-world game — Minecraft, No Man's Sky, Valheim, Terraria — and you're standing on terrain that no human artist hand-sculpted. It was generated algorithmically at runtime from a handful of seed values. This is procedural terrain generation: the art (and science) of writing code that produces naturalistic, believable landscapes without manual authoring.

The payoff is enormous. A single algorithm parameterized by one integer seed can produce effectively infinite, unique worlds. Storage costs collapse — you store the seed, not the geometry. And because the algorithm is deterministic, the same seed always reproduces the same world, which is critical for multiplayer consistency and save-file integrity.

The challenge is equally large: naive random numbers produce noise that looks like static, not mountains. The secret is coherent noise — randomness that varies smoothly across space so nearby points are similar but distant points diverge. The workhorse algorithm behind almost every terrain system is Perlin noise (and its modern successor, simplex noise), combined with a layering technique called fractal Brownian motion (fBm).

The Problem with Pure Randomness

Imagine sampling a height value for every point on a grid using Math.random(). Each sample is independent — the height at position $(x, y)$ has no relationship to the height at $(x+1, y)$. The result is a surface that looks like television static: jagged, spiky, and completely unnatural.

Real terrain has structure at multiple scales simultaneously. There are continental-scale gradients, regional mountain ranges, local hills, and fine surface roughness — all present at once, each scale blending smoothly into the next. To replicate this, we need a noise function with two key properties:

  • Continuity: nearby inputs produce similar outputs (no sudden jumps)
  • Isotropy: the statistical properties are the same in all directions

Perlin Noise: The Core Algorithm

Ken Perlin invented his eponymous noise in 1983 while working on the film Tron at MAGI. It won him an Academy Award for Technical Achievement. The key insight is to define randomness on a grid of gradient vectors, then interpolate between them smoothly.

Here's how 2D Perlin noise works step by step:

Step 1 — Grid and Gradients

Divide space into a regular integer grid. At each grid corner, assign a random unit-length gradient vector $\vec{g}$. The same corner always gets the same gradient (determined by a hash of its coordinates), so the function is deterministic.

Step 2 — Dot Products

For a sample point $P = (x, y)$, identify the four surrounding grid corners. For each corner $C_i$, compute the offset vector from that corner to $P$:

$$\vec{d}_i = P - C_i$$

Then take the dot product of the gradient at that corner with the offset vector:

$$n_i = \vec{g}_i \cdot \vec{d}_i$$

This gives four scalar influence values — one per corner.

Step 3 — Smooth Interpolation

Interpolate the four values using the fractional position of $P$ within its grid cell. Crucially, do not use linear interpolation — it produces visible grid-aligned artifacts. Perlin's original paper used a cubic ease curve:

$$f(t) = 3t^2 - 2t^3$$

The improved version (2002) uses a quintic that also has zero second derivative at endpoints, eliminating curvature discontinuities:

$$f(t) = 6t^5 - 15t^4 + 10t^3$$

With the smooth weights $u = f(\text{frac}(x))$ and $v = f(\text{frac}(y))$, we bilinearly interpolate the four dot products:

$$\text{noise}(x,y) = \text{lerp}(\text{lerp}(n_{00},\, n_{10},\, u),\; \text{lerp}(n_{01},\, n_{11},\, u),\; v)$$

The result is a smooth scalar field in roughly $[-1, 1]$.

// Simplified 2D Perlin noise (illustrative)
function fade(t) {
  return t * t * t * (t * (t * 6 - 15) + 10); // 6t^5 - 15t^4 + 10t^3
}

function lerp(a, b, t) {
  return a + t * (b - a);
}

function grad(hash, x, y) {
  // Map hash to one of 8 gradient directions
  const h = hash & 7;
  const u = h < 4 ? x : y;
  const v = h < 4 ? y : x;
  return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}

function noise2D(x, y, perm) {
  const xi = Math.floor(x) & 255;
  const yi = Math.floor(y) & 255;
  const xf = x - Math.floor(x);
  const yf = y - Math.floor(y);

  const u = fade(xf);
  const v = fade(yf);

  const aa = perm[perm[xi    ] + yi    ];
  const ab = perm[perm[xi    ] + yi + 1];
  const ba = perm[perm[xi + 1] + yi    ];
  const bb = perm[perm[xi + 1] + yi + 1];

  return lerp(
    lerp(grad(aa, xf,   yf  ), grad(ba, xf-1, yf  ), u),
    lerp(grad(ab, xf,   yf-1), grad(bb, xf-1, yf-1), u),
    v
  );
}

Fractal Brownian Motion: Layering Octaves

A single octave of Perlin noise produces smooth, gently rolling hills — beautiful but monotonous. Real terrain has detail at many scales simultaneously. The solution is to sum multiple noise samples at different frequencies and amplitudes. Each layer is called an octave.

Two parameters control each octave:

  • Lacunarity $\lambda$ — how much the frequency multiplies between octaves (typically $\approx 2.0$, meaning each octave is twice as fine-grained)
  • Persistence (gain) $p$ — how much the amplitude multiplies between octaves (typically $\approx 0.5$, meaning each octave contributes half as much)

The fBm formula for $N$ octaves is:

$$H(x, y) = \sum_{i=0}^{N-1} p^i \cdot \text{noise}\!\left(\lambda^i x,\; \lambda^i y\right)$$

function fbm(x, y, octaves, lacunarity, persistence, noiseFn) {
  let value = 0;
  let amplitude = 1.0;
  let frequency = 1.0;
  let maxValue = 0; // for normalization

  for (let i = 0; i < octaves; i++) {
    value += amplitude * noiseFn(x * frequency, y * frequency);
    maxValue += amplitude;
    amplitude *= persistence;  // each octave quieter
    frequency *= lacunarity;   // each octave finer
  }

  return value / maxValue; // normalize to [-1, 1]
}

With 6–8 octaves, lacunarity of 2.0, and persistence of 0.5, this produces terrain indistinguishable from real heightmaps at a glance. The low-frequency octaves define the broad mountain shapes; high-frequency octaves add rocky surface detail.

Domain Warping: Adding Drama

Even fBm terrain can look too regular — mountains tend to be round and evenly spaced. Domain warping (introduced by Inigo Quilez) adds a dramatic twist: instead of sampling noise at the raw coordinates, first distort those coordinates with another noise function.

$$H(x, y) = \text{fBm}\!\left(x + k\cdot\text{fBm}(x, y),\; y + k\cdot\text{fBm}(x+5.2,\; y+1.3)\right)$$

The result: ridges and valleys that twist, river-like erosion patterns, overhanging cliffs, and swirling formations that look genuinely geological. The constant offsets $(5.2, 1.3)$ ensure the two inner fBm calls are decorrelated; $k$ controls the warp intensity.

function warpedTerrain(x, y) {
  const warpStrength = 1.5;
  // Two offset samples for the warp vector
  const wx = fbm(x + 0.0, y + 0.0, 4, 2.0, 0.5, noise2D);
  const wy = fbm(x + 5.2, y + 1.3, 4, 2.0, 0.5, noise2D);
  // Sample the "real" noise at the warped coordinates
  return fbm(
    x + warpStrength * wx,
    y + warpStrength * wy,
    6, 2.0, 0.5, noise2D
  );
}

From Heightmap to Geometry

Once you have a height function $H(x, y)$, you can build a mesh. The most straightforward approach is a regular grid of vertices where the $y$ (or $z$) coordinate is set by $H$:

// Three.js terrain mesh from a height function
function buildTerrain(size, resolution, heightFn) {
  const geometry = new THREE.PlaneGeometry(
    size, size, resolution - 1, resolution - 1
  );
  const positions = geometry.attributes.position;

  for (let i = 0; i < positions.count; i++) {
    const x = positions.getX(i);
    const z = positions.getZ(i); // PlaneGeometry lies in XZ after rotation
    const h = heightFn(x / size + 0.5, z / size + 0.5);
    positions.setY(i, h * maxHeight);
  }

  geometry.computeVertexNormals(); // critical for correct lighting
  return geometry;
}

Recomputing vertex normals after displacement is essential — Three.js's computeVertexNormals() calculates the average of surrounding face normals for each vertex, giving smooth shading across the mesh.

Biomes and Color Mapping

Height alone doesn't make a compelling world. Real games layer biome logic on top: sample both a height map and a separate moisture/temperature noise map, then look up the biome (and thus the ground texture or color) in a 2D table. This is the Whittaker biome model, popularized in procedural generation by Minecraft's biome system.

Even without full biome logic, a simple height-based color ramp dramatically improves visual fidelity:

  • Below sea level: deep blue water
  • Low altitude: sandy beach, then green grassland
  • Mid altitude: darker forest green, transitioning to rocky grey
  • High altitude: bare rock, then snow-capped peaks
function heightToColor(h, seaLevel) {
  if (h < seaLevel)        return new THREE.Color(0x1a6fa8); // water
  if (h < seaLevel + 0.02) return new THREE.Color(0xc2b280); // sand
  if (h < seaLevel + 0.15) return new THREE.Color(0x4a7c3f); // grass
  if (h < seaLevel + 0.30) return new THREE.Color(0x3d5a27); // forest
  if (h < seaLevel + 0.45) return new THREE.Color(0x7d7d7d); // rock
  return new THREE.Color(0xf0f0f0);                           // snow
}

Performance Considerations

Terrain generation has real performance demands, especially for large open worlds:

Chunking

Rather than generating one enormous mesh, divide the world into fixed-size chunks. Only generate chunks near the player; discard or pool distant ones. Minecraft's 16×16×256 chunk system is the canonical example. This bounds memory usage and allows background generation of new chunks.

Level of Detail (LoD)

Distant terrain doesn't need high vertex density — a chunk 500 meters away can use a 16×16 mesh where a nearby chunk uses 128×128. Systems like CDLOD (Continuous Distance-Dependent Level of Detail) smoothly transition between LoD levels using morphing to avoid popping artifacts.

GPU Generation

Perlin noise is embarrassingly parallel — each vertex height is independent. Modern games run the entire heightmap computation in a compute shader, generating and uploading the geometry without touching the CPU. Horizon Zero Dawn's terrain system generates geometry entirely on the GPU.

Caching and Seeds

Because the noise function is deterministic, you can regenerate any chunk from its world-space coordinates without storing it. Only store modifications (player-dug holes, placed blocks) as deltas from the procedural baseline — this is exactly how Minecraft works.

Real-World Examples

Minecraft uses a combination of 3D Perlin noise (for caves and overhangs), 2D heightmap noise (for surface terrain), and biome noise to produce its iconic worlds. The system has been significantly revised across versions, with 1.18 introducing a dramatic overhaul to 3D terrain generation that enabled much more dramatic cliffs and cave systems.

No Man's Sky takes procedural generation to an extreme: every planet in the galaxy is generated from a seed, with terrain, flora, fauna, atmosphere, and even the physics constants varying. Hello Games uses a layered noise approach with domain warping to produce the wildly alien landscapes.

Valheim uses a biome map seeded from the world seed, then generates terrain within each biome using Perlin noise tuned per-biome. The result is coherent large-scale geography (the Mistlands biome is always in the east) while the fine details vary.

The Elder Scrolls: Daggerfall (1996) was one of the first games to use procedural terrain generation for a massive world — six times the size of Great Britain — a remarkable technical achievement for its era.

Going Further: Erosion Simulation

The most advanced terrain systems don't just generate noise — they simulate the geological processes that shape real terrain. Hydraulic erosion simulates raindrops flowing downhill, picking up sediment and depositing it in valleys. Run for thousands of iterations on a heightmap and you get realistic riverbeds, alluvial fans, and sharp ridgelines that pure noise can never produce.

The algorithm maintains a sediment value per droplet. At each step, the droplet moves to the lowest neighbor, and the difference between its current speed-based erosion capacity and its current sediment load determines whether it erodes the terrain or deposits sediment:

$$\Delta h = \min(\text{capacity} - \text{sediment},\; h_{\text{current}} - h_{\text{next}}) \cdot \text{erosionRate}$$

Tools like World Machine and Gaea are essentially sophisticated erosion simulation pipelines built on top of noise generation, and the terrain they export is used in AAA games like Fortnite and Battlefield.

Putting It All Together

A practical terrain generation pipeline looks like this:

  1. Generate a heightmap using fBm Perlin/simplex noise, optionally with domain warping
  2. Apply biome logic using a secondary temperature/moisture noise map
  3. Simulate erosion (optional but dramatically improves quality)
  4. Build a mesh from the heightmap, chunked and LoD'd appropriately
  5. Apply materials — height/slope-based texture blending, normal maps
  6. Place objects procedurally — trees, rocks, buildings — using density noise and placement rules

Each step is independently tunable, which makes procedural terrain generation as much an art as a science. The algorithm gives you the raw material; the parameters shape the aesthetic. Tweak persistence and you move between gentle plains and jagged badlands. Adjust domain warp intensity and your world goes from orderly to alien. This creative control — combined with effectively infinite variety — is why procedural terrain generation has been a cornerstone of game development for four decades, and remains one of the most satisfying systems to implement.

Comments

Like this article? Consider supporting us

Your support helps us keep creating free game dev content, tutorials, and tools.

Free

$0 /month

Newsletter and public posts

  • Newsletter access
  • Public posts & updates
  • Community access

Studio Backer

$25 /month

Direct impact on development with your name in the credits

  • Everything in Supporter
  • Your name in game credits
  • Priority feature requests
  • Direct developer access
  • Monthly asset downloads