All articles
25 min read

Terrain Generation: How to Create Stunning Procedural Landscapes With Code

terrain generationprocedural terrainheightmapterrain generation algorithmprocedural landscapegenerative artcreative codingJavaScriptcanvas

Stand on any mountain and look out at the ridgelines receding into haze. Every slope, every valley, every cliff face follows patterns that are simultaneously random and deeply structured. Terrain generation is the art of recreating these patterns in code — producing convincing procedural landscapes from nothing but mathematics. It is one of the oldest and most rewarding challenges in computer graphics, and it remains at the heart of every open-world game, flight simulator, film VFX pipeline, and generative art project that depicts a natural world.

The core idea is deceptively simple: a heightmap is a 2D grid of numbers where each cell represents the elevation at that point. Render those elevations as colors, as columns, or as a mesh, and you get a landscape. The challenge lies in choosing which numbers fill the grid. Random values produce jagged static. The right terrain generation algorithm produces mountains with foothills, river valleys that carve downhill, and coastlines with fractal complexity.

This guide covers eight distinct techniques for procedural terrain, each with a complete, working JavaScript example you can paste into your browser. We will progress from a basic heightmap to physically-based erosion simulations and finally to artistic landscape compositions. No libraries, no frameworks — just HTML Canvas and the math behind the mountains.

A Brief History of Terrain Generation

Procedural terrain has a lineage stretching back to the earliest days of computer graphics. In 1986, Alain Fournier, Don Fussell, and Loren Carpenter published their landmark paper on stochastic subdivision of surfaces, describing what became the diamond-square algorithm. Carpenter had already used fractal mountains in the Genesis sequence of Star Trek II: The Wrath of Khan (1982), one of the first uses of fractal terrain in film.

Ken Perlin’s noise function, developed in 1983 for Tron, became the default tool for procedural terrain once developers discovered that layering multiple octaves of Perlin noise (a technique called fractal Brownian motion, or fBm) produces landscapes with natural-looking detail at every scale. This remains the most widely used approach today.

The next revolution was erosion simulation. In the 1990s and 2000s, researchers developed algorithms that simulate millions of years of rain, thermal weathering, and sediment transport in seconds. Hydraulic erosion carves river valleys and alluvial fans. Thermal erosion collapses steep slopes into talus fields. These physically-based techniques transform synthetic noise into terrain that looks genuinely geological.

Modern terrain generation combines all of these. Games like Minecraft, No Man’s Sky, and Horizon Zero Dawn use procedural generation pipelines that start with noise, apply erosion, and layer biome-specific detail. Film studios use tools like Terragen and World Machine to create photorealistic CG environments. And creative coders use the same mathematics to produce abstract landscape art that blurs the line between cartography and painting.

Applications of Procedural Terrain

  • Game development: open-world games generate terrain at runtime to create vast explorable worlds without hand-sculpting every hill
  • Film and VFX: CG landscapes for establishing shots, alien worlds, and fantasy environments
  • Flight simulators: real-time terrain rendering over enormous geographic areas
  • Cartography and GIS: synthetic terrain for testing mapping software and visualization tools
  • Generative art: abstract landscapes, mountain silhouettes, and topographic prints
  • Scientific visualization: modeling geological processes, watershed analysis, and climate simulation

1. Basic Heightmap Terrain

We begin at the foundation: a 2D grid of elevation values rendered as grayscale pixels. This example uses value noise — the simplest form of coherent noise. Random values are placed on a coarse grid and then bilinearly interpolated to fill the spaces between. The result is a smooth heightmap that looks like gently rolling hills seen from above.

Value noise is not as visually sophisticated as Perlin or simplex noise, but it is trivial to implement and understand. Every terrain generation algorithm ultimately produces a heightmap like this; the differences lie in how the elevation values are computed.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Basic Heightmap Terrain</title>
<style>body{margin:0;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111;}canvas{border-radius:4px;}</style>
</head>
<body>
<canvas id="c" width="600" height="600"></canvas>
<script>
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var W = 600, H = 600;

// Value noise with bilinear interpolation
var gridSize = 8; // coarse grid cells
var grid = [];
for (var i = 0; i <= gridSize; i++) {
  grid[i] = [];
  for (var j = 0; j <= gridSize; j++) {
    grid[i][j] = Math.random();
  }
}

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

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

function valueNoise(x, y) {
  var gx = (x / W) * gridSize;
  var gy = (y / H) * gridSize;
  var ix = Math.floor(gx);
  var iy = Math.floor(gy);
  var fx = smoothstep(gx - ix);
  var fy = smoothstep(gy - iy);
  var ix1 = Math.min(ix + 1, gridSize);
  var iy1 = Math.min(iy + 1, gridSize);
  var top = lerp(grid[ix][iy], grid[ix1][iy], fx);
  var bot = lerp(grid[ix][iy1], grid[ix1][iy1], fx);
  return lerp(top, bot, fy);
}

var img = ctx.createImageData(W, H);
for (var y = 0; y < H; y++) {
  for (var x = 0; x < W; x++) {
    var h = valueNoise(x, y);
    var v = Math.floor(h * 255);
    var idx = (y * W + x) * 4;
    img.data[idx] = v;
    img.data[idx + 1] = v;
    img.data[idx + 2] = v;
    img.data[idx + 3] = 255;
  }
}
ctx.putImageData(img, 0, 0);

// Click to regenerate with new random grid
canvas.addEventListener('click', function() {
  for (var i = 0; i <= gridSize; i++) {
    for (var j = 0; j <= gridSize; j++) {
      grid[i][j] = Math.random();
    }
  }
  for (var y = 0; y < H; y++) {
    for (var x = 0; x < W; x++) {
      var h = valueNoise(x, y);
      var v = Math.floor(h * 255);
      var idx = (y * W + x) * 4;
      img.data[idx] = v;
      img.data[idx + 1] = v;
      img.data[idx + 2] = v;
      img.data[idx + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
});
</script>
</body>
</html>

The smoothstep function is crucial. Without it, the interpolation produces visible grid artifacts at cell boundaries. Smoothstep remaps the fractional position through a cubic curve that has zero derivative at both endpoints, creating seamless transitions between cells. Click the canvas to generate a fresh terrain with new random grid values.

2. Perlin Noise Terrain With Biome Coloring

Real terrain is not grayscale. Water fills the low areas, sand borders the shore, grass covers the plains, exposed rock appears on steep slopes, and snow caps the peaks. This example generates a procedural terrain using multi-octave fractal Brownian motion (fBm) and then applies biome colors based on elevation thresholds.

The fBm technique layers multiple passes of noise at increasing frequency and decreasing amplitude. The first octave defines the broad continental shapes. Each subsequent octave adds finer detail — ridgelines, rocky outcrops, small variations. The sum of all octaves produces terrain with fractal complexity at every scale, just like real geography.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Perlin Noise Terrain with Biomes</title>
<style>body{margin:0;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111;}canvas{border-radius:4px;}</style>
</head>
<body>
<canvas id="c" width="600" height="600"></canvas>
<script>
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var W = 600, H = 600;

// Simple hash-based gradient noise (Perlin-style)
var perm = [];
var grad2 = [[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]];
function initPerm() {
  perm = [];
  for (var i = 0; i < 256; i++) perm[i] = i;
  for (var i = 255; i > 0; i--) {
    var j = Math.floor(Math.random() * (i + 1));
    var tmp = perm[i]; perm[i] = perm[j]; perm[j] = tmp;
  }
  for (var i = 0; i < 256; i++) perm[256 + i] = perm[i];
}
initPerm();

function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dot2(g, x, y) { return g[0] * x + g[1] * y; }

function perlin2(x, y) {
  var X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
  var xf = x - Math.floor(x), yf = y - Math.floor(y);
  var u = fade(xf), v = fade(yf);
  var aa = perm[perm[X] + Y] % 8;
  var ab = perm[perm[X] + Y + 1] % 8;
  var ba = perm[perm[X + 1] + Y] % 8;
  var bb = perm[perm[X + 1] + Y + 1] % 8;
  var x1 = lerp(dot2(grad2[aa], xf, yf), dot2(grad2[ba], xf - 1, yf), u);
  var x2 = lerp(dot2(grad2[ab], xf, yf - 1), dot2(grad2[bb], xf - 1, yf - 1), u);
  return lerp(x1, x2, v);
}

function fbm(x, y, octaves, lacunarity, gain) {
  var sum = 0, amp = 1, freq = 1, max = 0;
  for (var i = 0; i < octaves; i++) {
    sum += perlin2(x * freq, y * freq) * amp;
    max += amp;
    amp *= gain;
    freq *= lacunarity;
  }
  return sum / max;
}

function biomeColor(h) {
  // h ranges roughly -1 to 1, remap to 0-1
  var n = (h + 1) * 0.5;
  if (n < 0.35) return [30, 80, 160];       // deep water
  if (n < 0.42) return [50, 120, 200];      // shallow water
  if (n < 0.45) return [210, 190, 140];     // sand
  if (n < 0.55) return [50, 150, 50];       // grass
  if (n < 0.65) return [30, 120, 30];       // forest
  if (n < 0.75) return [100, 90, 70];       // rock
  if (n < 0.85) return [130, 120, 100];     // high rock
  return [240, 240, 250];                    // snow
}

function generate() {
  var img = ctx.createImageData(W, H);
  var scale = 5;
  for (var y = 0; y < H; y++) {
    for (var x = 0; x < W; x++) {
      var nx = x / W * scale;
      var ny = y / H * scale;
      var h = fbm(nx, ny, 6, 2.0, 0.5);
      var col = biomeColor(h);
      var idx = (y * W + x) * 4;
      img.data[idx] = col[0];
      img.data[idx + 1] = col[1];
      img.data[idx + 2] = col[2];
      img.data[idx + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
}

generate();
canvas.addEventListener('click', function() {
  initPerm();
  generate();
});
</script>
</body>
</html>

Notice how the biome thresholds create natural-looking transitions. Water fills the lowest 42% of the elevation range. A thin strip of sand borders the waterline. Grass and forest cover the mid-elevations. Rock appears on the high slopes, and snow caps anything above 85%. Click to regenerate with a completely new permutation table, which produces an entirely different continent.

3. Diamond-Square Algorithm

The diamond-square algorithm (also called midpoint displacement) is one of the oldest terrain generation algorithms. It works on a square grid whose side length is a power of two plus one (e.g., 129, 257, 513). The four corners are seeded with random values. Then the algorithm alternates between two steps:

  • Diamond step: find the midpoint of each square and set its value to the average of the four corners plus a random offset.
  • Square step: find the midpoint of each diamond (formed by the previous step) and set its value to the average of its neighbors plus a random offset.

At each level, the random offset range is multiplied by a roughness factor (typically 0.5). This makes large-scale features smooth and small-scale features noisy — exactly like real terrain. A lower roughness value produces gentle rolling hills; a higher value produces jagged mountains.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Diamond-Square Terrain</title>
<style>body{margin:0;display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:100vh;background:#111;color:#fff;font-family:sans-serif;}canvas{border-radius:4px;}label{margin:10px;}</style>
</head>
<body>
<canvas id="c" width="600" height="600"></canvas>
<label>Roughness: <input type="range" id="rough" min="20" max="80" value="50"> <span id="rv">0.50</span></label>
<script>
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var SIZE = 257; // 2^8 + 1

function diamondSquare(roughness) {
  var map = new Float32Array(SIZE * SIZE);
  function get(x, y) { return map[y * SIZE + x]; }
  function set(x, y, v) { map[y * SIZE + x] = v; }

  // Seed corners
  set(0, 0, Math.random());
  set(SIZE - 1, 0, Math.random());
  set(0, SIZE - 1, Math.random());
  set(SIZE - 1, SIZE - 1, Math.random());

  var step = SIZE - 1;
  var scale = 1.0;

  while (step > 1) {
    var half = step / 2;

    // Diamond step
    for (var y = 0; y < SIZE - 1; y += step) {
      for (var x = 0; x < SIZE - 1; x += step) {
        var avg = (get(x, y) + get(x + step, y) + get(x, y + step) + get(x + step, y + step)) / 4;
        set(x + half, y + half, avg + (Math.random() - 0.5) * scale);
      }
    }

    // Square step
    for (var y = 0; y < SIZE; y += half) {
      for (var x = ((y / half) % 2 === 0) ? half : 0; x < SIZE; x += step) {
        var sum = 0, count = 0;
        if (y - half >= 0) { sum += get(x, y - half); count++; }
        if (y + half < SIZE) { sum += get(x, y + half); count++; }
        if (x - half >= 0) { sum += get(x - half, y); count++; }
        if (x + half < SIZE) { sum += get(x + half, y); count++; }
        set(x, y, sum / count + (Math.random() - 0.5) * scale);
      }
    }

    scale *= roughness;
    step = half;
  }

  return map;
}

function render(roughness) {
  var map = diamondSquare(roughness);
  // Find min/max for normalization
  var min = Infinity, max = -Infinity;
  for (var i = 0; i < map.length; i++) {
    if (map[i] < min) min = map[i];
    if (map[i] > max) max = map[i];
  }
  var range = max - min || 1;

  var img = ctx.createImageData(600, 600);
  for (var py = 0; py < 600; py++) {
    for (var px = 0; px < 600; px++) {
      var mx = Math.floor(px / 600 * (SIZE - 1));
      var my = Math.floor(py / 600 * (SIZE - 1));
      var h = (map[my * SIZE + mx] - min) / range;
      // Terrain coloring
      var r, g, b;
      if (h < 0.3) { r = 30; g = 60 + h * 200; b = 160; }
      else if (h < 0.35) { r = 194; g = 178; b = 128; }
      else if (h < 0.6) { r = 40 + (h - 0.35) * 80; g = 130 + (h - 0.35) * 60; b = 40; }
      else if (h < 0.8) { r = 100 + (h - 0.6) * 150; g = 90 + (h - 0.6) * 100; b = 70; }
      else { r = 220 + (h - 0.8) * 150; g = 220 + (h - 0.8) * 150; b = 230 + (h - 0.8) * 100; }
      var idx = (py * 600 + px) * 4;
      img.data[idx] = Math.min(255, Math.max(0, Math.floor(r)));
      img.data[idx + 1] = Math.min(255, Math.max(0, Math.floor(g)));
      img.data[idx + 2] = Math.min(255, Math.max(0, Math.floor(b)));
      img.data[idx + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
}

var slider = document.getElementById('rough');
var rv = document.getElementById('rv');
function update() {
  var roughness = parseInt(slider.value) / 100;
  rv.textContent = roughness.toFixed(2);
  render(roughness);
}
slider.addEventListener('input', update);
update();
canvas.addEventListener('click', update);
</script>
</body>
</html>

Drag the roughness slider to see how it affects the terrain character. At 0.20, you get smooth, rolling plains. At 0.80, you get craggy, mountainous terrain with deep valleys. The diamond-square algorithm is fast but has a known weakness: it can produce visible axis-aligned artifacts because the diamond and square steps sample along grid-aligned patterns. For production terrain, fBm noise is generally preferred, but diamond-square remains an elegant and instructive algorithm.

4. Hydraulic Erosion Simulation

Noise-generated terrain looks plausible from a distance, but it lacks the telltale signatures of water: river valleys, gullies, alluvial fans, and the dendritic (tree-like) drainage networks that are visible in any satellite photograph. Hydraulic erosion adds these features by simulating the physics of rainfall. Each rain droplet carries a tiny amount of kinetic energy. It flows downhill, picks up sediment from the ground, and deposits that sediment when it slows down. Over thousands of iterations, rivers emerge naturally.

This example starts with fBm terrain and then drops 50,000 simulated rain particles onto it. Each particle follows the gradient downhill, eroding material from steep sections and depositing it in flat areas. The result is terrain with carved valleys and smooth riverbeds.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hydraulic Erosion</title>
<style>body{margin:0;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111;}canvas{border-radius:4px;}</style>
</head>
<body>
<canvas id="c" width="600" height="600"></canvas>
<script>
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var N = 256;

// Permutation table for noise
var perm = [];
function initPerm() {
  perm = [];
  for (var i = 0; i < 256; i++) perm[i] = i;
  for (var i = 255; i > 0; i--) {
    var j = Math.floor(Math.random() * (i + 1));
    var t = perm[i]; perm[i] = perm[j]; perm[j] = t;
  }
  for (var i = 0; i < 256; i++) perm[256 + i] = perm[i];
}
initPerm();

var grad2 = [[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function perlin2(x, y) {
  var X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
  var xf = x - Math.floor(x), yf = y - Math.floor(y);
  var u = fade(xf), v = fade(yf);
  var aa = perm[perm[X] + Y] % 8, ab = perm[perm[X] + Y + 1] % 8;
  var ba = perm[perm[X + 1] + Y] % 8, bb = perm[perm[X + 1] + Y + 1] % 8;
  var x1 = lerp(grad2[aa][0]*xf+grad2[aa][1]*yf, grad2[ba][0]*(xf-1)+grad2[ba][1]*yf, u);
  var x2 = lerp(grad2[ab][0]*xf+grad2[ab][1]*(yf-1), grad2[bb][0]*(xf-1)+grad2[bb][1]*(yf-1), u);
  return lerp(x1, x2, v);
}

function fbm(x, y) {
  var sum = 0, amp = 1, freq = 1, max = 0;
  for (var i = 0; i < 6; i++) {
    sum += perlin2(x * freq, y * freq) * amp;
    max += amp; amp *= 0.5; freq *= 2;
  }
  return (sum / max + 1) * 0.5;
}

// Build heightmap
var hmap = new Float32Array(N * N);
function buildTerrain() {
  for (var y = 0; y < N; y++) {
    for (var x = 0; x < N; x++) {
      hmap[y * N + x] = fbm(x / N * 4, y / N * 4);
    }
  }
}
buildTerrain();

function getH(x, y) {
  x = Math.max(0, Math.min(N - 1, Math.floor(x)));
  y = Math.max(0, Math.min(N - 1, Math.floor(y)));
  return hmap[y * N + x];
}

function setH(x, y, v) {
  x = Math.max(0, Math.min(N - 1, Math.floor(x)));
  y = Math.max(0, Math.min(N - 1, Math.floor(y)));
  hmap[y * N + x] = v;
}

// Gradient at a point
function gradient(x, y) {
  var h = getH(x, y);
  var gx = getH(x + 1, y) - getH(x - 1, y);
  var gy = getH(x, y + 1) - getH(x, y - 1);
  return [gx, gy];
}

// Simulate hydraulic erosion
function erode(drops) {
  var inertia = 0.05;
  var capacity = 4;
  var deposition = 0.3;
  var erosion = 0.3;
  var evaporation = 0.01;
  var gravity = 4;
  var minSlope = 0.01;

  for (var d = 0; d < drops; d++) {
    var px = Math.random() * (N - 2) + 1;
    var py = Math.random() * (N - 2) + 1;
    var dx = 0, dy = 0;
    var speed = 1;
    var water = 1;
    var sediment = 0;

    for (var step = 0; step < 80; step++) {
      var ix = Math.floor(px), iy = Math.floor(py);
      var g = gradient(ix, iy);
      dx = dx * inertia - g[0] * (1 - inertia);
      dy = dy * inertia - g[1] * (1 - inertia);
      var len = Math.sqrt(dx * dx + dy * dy);
      if (len > 0) { dx /= len; dy /= len; }

      var nx = px + dx;
      var ny = py + dy;
      if (nx < 1 || nx >= N - 1 || ny < 1 || ny >= N - 1) break;

      var hOld = getH(ix, iy);
      var hNew = getH(Math.floor(nx), Math.floor(ny));
      var hDiff = hNew - hOld;

      var c = Math.max(-hDiff, minSlope) * speed * water * capacity;

      if (sediment > c || hDiff > 0) {
        var deposit = (hDiff > 0) ? Math.min(hDiff, sediment) : (sediment - c) * deposition;
        sediment -= deposit;
        setH(ix, iy, getH(ix, iy) + deposit);
      } else {
        var erodeAmt = Math.min((c - sediment) * erosion, -hDiff);
        sediment += erodeAmt;
        setH(ix, iy, getH(ix, iy) - erodeAmt);
      }

      speed = Math.sqrt(Math.max(0, speed * speed + hDiff * gravity));
      water *= (1 - evaporation);
      px = nx;
      py = ny;
    }
  }
}

function renderMap() {
  var img = ctx.createImageData(600, 600);
  var min = Infinity, max = -Infinity;
  for (var i = 0; i < hmap.length; i++) {
    if (hmap[i] < min) min = hmap[i];
    if (hmap[i] > max) max = hmap[i];
  }
  var range = max - min || 1;
  for (var py = 0; py < 600; py++) {
    for (var px = 0; px < 600; px++) {
      var mx = Math.floor(px / 600 * N);
      var my = Math.floor(py / 600 * N);
      var h = (hmap[my * N + mx] - min) / range;
      var r, g, b;
      if (h < 0.32) { r = 30; g = 70 + h * 180; b = 160; }
      else if (h < 0.36) { r = 190; g = 175; b = 120; }
      else if (h < 0.58) { var t = (h - 0.36) / 0.22; r = 50 + t * 30; g = 130 - t * 20; b = 40; }
      else if (h < 0.78) { var t = (h - 0.58) / 0.20; r = 100 + t * 60; g = 90 + t * 30; b = 70; }
      else { r = 230; g = 230; b = 240; }
      var idx = (py * 600 + px) * 4;
      img.data[idx] = r; img.data[idx+1] = g; img.data[idx+2] = b; img.data[idx+3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
  ctx.fillStyle = '#fff';
  ctx.font = '14px sans-serif';
  ctx.fillText('Click to erode (50k drops each click)', 10, 20);
}

renderMap();

canvas.addEventListener('click', function() {
  erode(50000);
  renderMap();
});
</script>
</body>
</html>

Click the canvas repeatedly and watch the terrain transform. After the first pass, subtle valleys begin to appear. After three or four passes, you will see distinct river channels and smoother valley floors where sediment has accumulated. The erosion parameters — capacity, deposition rate, inertia — control the character of the water. High inertia produces long, sweeping river bends. High capacity lets water carry more sediment further before dropping it. Experiment with these values to create different geological styles.

5. Thermal Erosion

Water is not the only force that shapes terrain. Thermal erosion (also called talus erosion) simulates the weathering process where steep slopes collapse under gravity. When the height difference between two neighboring cells exceeds a threshold called the talus angle, material slides from the higher cell to the lower one. Over many iterations, this rounds off sharp peaks and creates scree slopes — the piles of loose rock you see at the base of cliffs.

Thermal erosion is computationally simpler than hydraulic erosion and pairs well with it. Run hydraulic erosion first to carve valleys, then thermal erosion to soften the ridgelines and fill the valley edges with debris.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thermal Erosion</title>
<style>body{margin:0;display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:100vh;background:#111;color:#fff;font-family:sans-serif;}canvas{border-radius:4px;}div{margin:8px;}</style>
</head>
<body>
<canvas id="c" width="600" height="600"></canvas>
<div>Click to apply 20 thermal erosion passes. Talus angle: <input type="range" id="talus" min="1" max="20" value="8"> <span id="tv">0.08</span></div>
<script>
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var N = 256;

var perm = [];
function initPerm() {
  perm = [];
  for (var i = 0; i < 256; i++) perm[i] = i;
  for (var i = 255; i > 0; i--) {
    var j = Math.floor(Math.random() * (i + 1));
    var t = perm[i]; perm[i] = perm[j]; perm[j] = t;
  }
  for (var i = 0; i < 256; i++) perm[256 + i] = perm[i];
}
initPerm();

var grad2 = [[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]];
function fade(t) { return t*t*t*(t*(t*6-15)+10); }
function lerp(a,b,t) { return a+(b-a)*t; }
function perlin2(x,y) {
  var X=Math.floor(x)&255, Y=Math.floor(y)&255;
  var xf=x-Math.floor(x), yf=y-Math.floor(y);
  var u=fade(xf), v=fade(yf);
  var aa=perm[perm[X]+Y]%8, ab=perm[perm[X]+Y+1]%8;
  var ba=perm[perm[X+1]+Y]%8, bb=perm[perm[X+1]+Y+1]%8;
  var x1=lerp(grad2[aa][0]*xf+grad2[aa][1]*yf, grad2[ba][0]*(xf-1)+grad2[ba][1]*yf, u);
  var x2=lerp(grad2[ab][0]*xf+grad2[ab][1]*(yf-1), grad2[bb][0]*(xf-1)+grad2[bb][1]*(yf-1), u);
  return lerp(x1,x2,v);
}

function fbm(x,y) {
  var s=0,a=1,f=1,m=0;
  for(var i=0;i<6;i++){s+=perlin2(x*f,y*f)*a;m+=a;a*=0.5;f*=2;}
  return (s/m+1)*0.5;
}

var hmap = new Float32Array(N*N);
function buildTerrain() {
  initPerm();
  for(var y=0;y<N;y++) for(var x=0;x<N;x++) hmap[y*N+x]=fbm(x/N*4,y/N*4);
}
buildTerrain();

var dx8 = [-1,0,1,-1,1,-1,0,1];
var dy8 = [-1,-1,-1,0,0,1,1,1];

function thermalErode(passes, talusAngle) {
  for (var p = 0; p < passes; p++) {
    for (var y = 1; y < N-1; y++) {
      for (var x = 1; x < N-1; x++) {
        var h = hmap[y*N+x];
        var maxDiff = 0;
        var totalDiff = 0;
        var diffs = [];
        for (var d = 0; d < 8; d++) {
          var nx = x+dx8[d], ny = y+dy8[d];
          var diff = h - hmap[ny*N+nx];
          if (diff > talusAngle) {
            diffs.push({d:d, diff:diff});
            totalDiff += diff;
            if (diff > maxDiff) maxDiff = diff;
          } else {
            diffs.push({d:d, diff:0});
          }
        }
        if (totalDiff > 0) {
          var move = maxDiff * 0.5;
          for (var i = 0; i < diffs.length; i++) {
            if (diffs[i].diff > 0) {
              var share = move * (diffs[i].diff / totalDiff);
              var nx = x+dx8[diffs[i].d], ny = y+dy8[diffs[i].d];
              hmap[ny*N+nx] += share;
              hmap[y*N+x] -= share;
            }
          }
        }
      }
    }
  }
}

function renderMap() {
  var img = ctx.createImageData(600,600);
  var min=Infinity,max=-Infinity;
  for(var i=0;i<hmap.length;i++){if(hmap[i]<min)min=hmap[i];if(hmap[i]>max)max=hmap[i];}
  var range=max-min||1;
  for(var py=0;py<600;py++){
    for(var px=0;px<600;px++){
      var mx=Math.floor(px/600*N), my=Math.floor(py/600*N);
      var h=(hmap[my*N+mx]-min)/range;
      var r,g,b;
      if(h<0.32){r=30;g=70+h*180;b=160;}
      else if(h<0.36){r=190;g=175;b=120;}
      else if(h<0.6){var t=(h-0.36)/0.24;r=50+t*40;g=130-t*10;b=40;}
      else if(h<0.8){var t=(h-0.6)/0.2;r=110+t*60;g=95+t*30;b=70+t*10;}
      else{r=230;g=230;b=240;}
      var idx=(py*600+px)*4;
      img.data[idx]=r;img.data[idx+1]=g;img.data[idx+2]=b;img.data[idx+3]=255;
    }
  }
  ctx.putImageData(img,0,0);
}

renderMap();

var talusSlider = document.getElementById('talus');
var tv = document.getElementById('tv');
talusSlider.addEventListener('input', function() {
  tv.textContent = (parseInt(talusSlider.value)/100).toFixed(2);
});

canvas.addEventListener('click', function() {
  var talusAngle = parseInt(talusSlider.value) / 100;
  thermalErode(20, talusAngle);
  renderMap();
});
</script>
</body>
</html>

Each click applies 20 passes of thermal erosion. Watch how the sharp ridges gradually soften and the valleys fill with material. A low talus angle (0.01–0.04) causes aggressive erosion that flattens the terrain quickly. A higher value (0.10–0.20) preserves more of the original structure and only collapses the steepest faces. In geology, this mirrors the difference between soft sedimentary rock (which weathers easily) and hard granite (which holds steep faces for millennia).

6. 3D Terrain Renderer

So far we have viewed our terrain from directly above, as a map. This example renders the heightmap in perspective — a voxel-column renderer inspired by the Voxel Space engine used in the 1992 game Comanche. The algorithm casts rays from the camera position outward, and for each ray it steps through the terrain grid from near to far. At each step, it draws a vertical column from the terrain height to the top of the previously drawn terrain, creating an occlusion-correct 3D view with atmospheric fog.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Terrain Renderer</title>
<style>body{margin:0;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111;}canvas{border-radius:4px;}</style>
</head>
<body>
<canvas id="c" width="800" height="400"></canvas>
<script>
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var W = 800, H = 400;
var N = 256;

// Noise setup
var perm = [];
for (var i = 0; i < 256; i++) perm[i] = i;
for (var i = 255; i > 0; i--) {
  var j = Math.floor(Math.random()*(i+1));
  var t = perm[i]; perm[i] = perm[j]; perm[j] = t;
}
for (var i = 0; i < 256; i++) perm[256+i] = perm[i];

var grad2=[[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]];
function fade(t){return t*t*t*(t*(t*6-15)+10);}
function lerp(a,b,t){return a+(b-a)*t;}
function perlin2(x,y){
  var X=Math.floor(x)&255,Y=Math.floor(y)&255;
  var xf=x-Math.floor(x),yf=y-Math.floor(y);
  var u=fade(xf),v=fade(yf);
  var aa=perm[perm[X]+Y]%8,ab=perm[perm[X]+Y+1]%8;
  var ba=perm[perm[X+1]+Y]%8,bb=perm[perm[X+1]+Y+1]%8;
  var x1=lerp(grad2[aa][0]*xf+grad2[aa][1]*yf,grad2[ba][0]*(xf-1)+grad2[ba][1]*yf,u);
  var x2=lerp(grad2[ab][0]*xf+grad2[ab][1]*(yf-1),grad2[bb][0]*(xf-1)+grad2[bb][1]*(yf-1),u);
  return lerp(x1,x2,v);
}
function fbm(x,y){
  var s=0,a=1,f=1,m=0;
  for(var i=0;i<6;i++){s+=perlin2(x*f,y*f)*a;m+=a;a*=0.5;f*=2;}
  return(s/m+1)*0.5;
}

// Generate heightmap and color map
var hmap = new Float32Array(N*N);
var cmap = new Uint8Array(N*N*3);
for(var y=0;y<N;y++){
  for(var x=0;x<N;x++){
    var h = fbm(x/N*4, y/N*4);
    hmap[y*N+x] = h;
    var idx=(y*N+x)*3;
    if(h<0.35){cmap[idx]=30;cmap[idx+1]=80;cmap[idx+2]=160;}
    else if(h<0.40){cmap[idx]=194;cmap[idx+1]=178;cmap[idx+2]=128;}
    else if(h<0.60){cmap[idx]=50;cmap[idx+1]=130;cmap[idx+2]=40;}
    else if(h<0.78){cmap[idx]=110;cmap[idx+1]=95;cmap[idx+2]=75;}
    else{cmap[idx]=230;cmap[idx+1]=230;cmap[idx+2]=240;}
  }
}

var camX = N/2, camY = N*0.8, camAngle = -0.3, camHeight = 0.6;

function renderTerrain() {
  // Sky gradient
  var skyGrad = ctx.createLinearGradient(0,0,0,H);
  skyGrad.addColorStop(0,'#1a2a4a');
  skyGrad.addColorStop(0.5,'#5a8ab5');
  skyGrad.addColorStop(1,'#c8dce8');
  ctx.fillStyle = skyGrad;
  ctx.fillRect(0,0,W,H);

  var fogR=180,fogG=200,fogB=220;
  var maxDist = 200;
  var heightScale = 300;
  var horizon = H * 0.5;

  // For each screen column
  var imgData = ctx.getImageData(0,0,W,H);
  var pixels = imgData.data;

  for(var col=0;col<W;col++){
    var angle = camAngle + (col/W - 0.5) * 1.2;
    var cosA = Math.cos(angle), sinA = Math.sin(angle);
    var maxY = H; // lowest screen y drawn so far (start from bottom)

    for(var dist=1;dist<maxDist;dist+=0.5){
      var wx = camX + cosA * dist;
      var wy = camY + sinA * dist;

      var ix = Math.floor(wx) & (N-1);
      var iy = Math.floor(wy) & (N-1);
      var h = hmap[iy*N+ix];

      // Project to screen
      var screenY = Math.floor(horizon - (h - camHeight) * heightScale / dist);
      if(screenY < 0) screenY = 0;

      if(screenY < maxY){
        // Get terrain color
        var ci = (iy*N+ix)*3;
        var tr=cmap[ci],tg=cmap[ci+1],tb=cmap[ci+2];

        // Apply fog
        var fog = Math.min(1, dist/maxDist);
        fog = fog * fog; // quadratic falloff
        var r = Math.floor(tr*(1-fog)+fogR*fog);
        var g = Math.floor(tg*(1-fog)+fogG*fog);
        var b = Math.floor(tb*(1-fog)+fogB*fog);

        // Draw column from screenY to maxY
        for(var sy=screenY;sy<maxY;sy++){
          var pi = (sy*W+col)*4;
          pixels[pi]=r; pixels[pi+1]=g; pixels[pi+2]=b; pixels[pi+3]=255;
        }
        maxY = screenY;
      }
    }
  }
  ctx.putImageData(imgData,0,0);
  ctx.fillStyle = '#fff';
  ctx.font = '13px sans-serif';
  ctx.fillText('Arrow keys to move, A/D to rotate', 10, 20);
}

renderTerrain();

var keys = {};
document.addEventListener('keydown',function(e){keys[e.key]=true;e.preventDefault();});
document.addEventListener('keyup',function(e){keys[e.key]=false;});

function gameLoop(){
  var moved = false;
  var speed = 1.5;
  if(keys['ArrowUp']||keys['w']){camX+=Math.cos(camAngle)*speed;camY+=Math.sin(camAngle)*speed;moved=true;}
  if(keys['ArrowDown']||keys['s']){camX-=Math.cos(camAngle)*speed;camY-=Math.sin(camAngle)*speed;moved=true;}
  if(keys['ArrowLeft']||keys['a']){camAngle-=0.03;moved=true;}
  if(keys['ArrowRight']||keys['d']){camAngle+=0.03;moved=true;}
  if(keys['q']){camHeight+=0.01;moved=true;}
  if(keys['e']){camHeight-=0.01;moved=true;}
  if(moved) renderTerrain();
  requestAnimationFrame(gameLoop);
}
gameLoop();
</script>
</body>
</html>

Use the arrow keys (or WASD) to explore the 3D landscape. Q and E adjust the camera height. The fog blends distant terrain into a hazy blue-gray, creating atmospheric depth. This is the same basic approach used in the classic Voxel Space engine: for each screen column, march a ray from near to far and draw vertical strips where the terrain is visible above previously drawn columns. It is surprisingly effective for a technique that uses no 3D math beyond basic projection.

7. Infinite Scrolling Terrain

Real-world terrain extends in every direction without end. This example demonstrates chunk-based procedural terrain that generates new sections as you scroll. The terrain is divided into chunks (tiles), and only the chunks visible on screen are generated and cached. As you move with the arrow keys, new chunks are created on the fly and old ones are discarded. Because the noise function is deterministic (same input always produces the same output), revisiting a location regenerates the same terrain.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Infinite Scrolling Terrain</title>
<style>body{margin:0;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111;}canvas{border-radius:4px;}</style>
</head>
<body>
<canvas id="c" width="600" height="600"></canvas>
<script>
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var W = 600, H = 600;

// Deterministic seeded noise
var SEED = 42;
function hash(x, y) {
  var h = SEED + x * 374761393 + y * 668265263;
  h = (h ^ (h >> 13)) * 1274126177;
  h = h ^ (h >> 16);
  return (h & 0x7fffffff) / 0x7fffffff;
}

function smoothNoise(x, y) {
  var ix = Math.floor(x), iy = Math.floor(y);
  var fx = x - ix, fy = y - iy;
  fx = fx*fx*(3-2*fx);
  fy = fy*fy*(3-2*fy);
  var a = hash(ix, iy), b = hash(ix+1, iy);
  var c = hash(ix, iy+1), d = hash(ix+1, iy+1);
  return a+(b-a)*fx + (c-a)*fy + (a-b-c+d)*fx*fy;
}

function fbm(x, y) {
  var v = 0, a = 1, f = 1, m = 0;
  for (var i = 0; i < 6; i++) {
    v += smoothNoise(x*f, y*f) * a;
    m += a; a *= 0.5; f *= 2;
  }
  return v / m;
}

function biomeColor(h) {
  if (h < 0.35) return [30, 80, 160];
  if (h < 0.42) return [50, 120, 200];
  if (h < 0.45) return [210, 190, 140];
  if (h < 0.55) return [50, 150, 50];
  if (h < 0.65) return [30, 120, 30];
  if (h < 0.78) return [110, 95, 75];
  return [235, 235, 245];
}

var CHUNK = 64;
var chunkCache = {};

function getChunkKey(cx, cy) { return cx + ',' + cy; }

function generateChunk(cx, cy) {
  var key = getChunkKey(cx, cy);
  if (chunkCache[key]) return chunkCache[key];
  var imgData = ctx.createImageData(CHUNK, CHUNK);
  for (var py = 0; py < CHUNK; py++) {
    for (var px = 0; px < CHUNK; px++) {
      var wx = (cx * CHUNK + px) / 150;
      var wy = (cy * CHUNK + py) / 150;
      var h = fbm(wx, wy);
      var col = biomeColor(h);
      var idx = (py * CHUNK + px) * 4;
      imgData.data[idx] = col[0];
      imgData.data[idx+1] = col[1];
      imgData.data[idx+2] = col[2];
      imgData.data[idx+3] = 255;
    }
  }
  chunkCache[key] = imgData;
  // Limit cache size
  var keys = Object.keys(chunkCache);
  if (keys.length > 200) {
    for (var i = 0; i < 50; i++) delete chunkCache[keys[i]];
  }
  return imgData;
}

var camX = 0, camY = 0;
var scrollSpeed = 8;

function render() {
  var startCX = Math.floor(camX / CHUNK);
  var startCY = Math.floor(camY / CHUNK);
  var offX = -(camX % CHUNK);
  var offY = -(camY % CHUNK);
  if (offX > 0) { offX -= CHUNK; startCX--; }
  if (offY > 0) { offY -= CHUNK; startCY--; }

  var cols = Math.ceil(W / CHUNK) + 2;
  var rows = Math.ceil(H / CHUNK) + 2;

  for (var r = 0; r < rows; r++) {
    for (var c = 0; c < cols; c++) {
      var cx = startCX + c;
      var cy = startCY + r;
      var chunk = generateChunk(cx, cy);
      var dx = offX + c * CHUNK;
      var dy = offY + r * CHUNK;
      ctx.putImageData(chunk, dx, dy);
    }
  }

  ctx.fillStyle = '#fff';
  ctx.font = '13px sans-serif';
  ctx.fillText('Arrow keys to scroll. Position: ' + Math.floor(camX) + ', ' + Math.floor(camY), 10, 20);
}

render();

var keys = {};
document.addEventListener('keydown', function(e) { keys[e.key] = true; e.preventDefault(); });
document.addEventListener('keyup', function(e) { keys[e.key] = false; });

function loop() {
  var moved = false;
  if (keys['ArrowUp'] || keys['w']) { camY -= scrollSpeed; moved = true; }
  if (keys['ArrowDown'] || keys['s']) { camY += scrollSpeed; moved = true; }
  if (keys['ArrowLeft'] || keys['a']) { camX -= scrollSpeed; moved = true; }
  if (keys['ArrowRight'] || keys['d']) { camX += scrollSpeed; moved = true; }
  if (moved) render();
  requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>

Scroll in any direction and the terrain extends forever. The hash-based noise function is seeded with a constant (42), so the same world coordinates always produce the same terrain. This is the fundamental principle behind games like Minecraft: the world is deterministic from its seed, so chunks can be generated on demand and discarded when no longer visible. The cache prevents re-computing recently visited chunks, and a size limit prevents memory from growing unbounded.

8. Generative Terrain Art

Our final example steps away from simulation and into art. This procedural landscape composition layers mountain silhouettes with atmospheric perspective, places procedural trees on the hillsides, and adds a reflective lake in the foreground. Each layer is drawn at a different opacity and color saturation to create depth — distant mountains are pale and blue, while foreground elements are dark and detailed. Click to generate a new composition each time.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Generative Terrain Art</title>
<style>body{margin:0;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111;}canvas{border-radius:4px;}</style>
</head>
<body>
<canvas id="c" width="800" height="400"></canvas>
<script>
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var W = 800, H = 400;

function rand(a, b) { return a + Math.random() * (b - a); }

// Simple 1D noise for mountain silhouettes
function noise1D(x, seed) {
  var ix = Math.floor(x);
  var fx = x - ix;
  fx = fx * fx * (3 - 2 * fx);
  function h(n) {
    var v = Math.sin((n + seed) * 127.1 + seed * 311.7) * 43758.5453;
    return v - Math.floor(v);
  }
  return h(ix) * (1 - fx) + h(ix + 1) * fx;
}

function fbm1D(x, seed, octaves) {
  var v = 0, a = 1, f = 1, m = 0;
  for (var i = 0; i < octaves; i++) {
    v += noise1D(x * f, seed + i * 100) * a;
    m += a; a *= 0.5; f *= 2;
  }
  return v / m;
}

function compose() {
  // Sky gradient
  var skyTop = [20 + rand(0,30), 20 + rand(0,30), 60 + rand(0,40)];
  var skyMid = [120 + rand(0,60), 100 + rand(0,60), 140 + rand(0,40)];
  var skyBot = [200 + rand(0,40), 150 + rand(0,50), 120 + rand(0,40)];

  var grad = ctx.createLinearGradient(0, 0, 0, H * 0.6);
  grad.addColorStop(0, 'rgb(' + Math.floor(skyTop[0]) + ',' + Math.floor(skyTop[1]) + ',' + Math.floor(skyTop[2]) + ')');
  grad.addColorStop(0.5, 'rgb(' + Math.floor(skyMid[0]) + ',' + Math.floor(skyMid[1]) + ',' + Math.floor(skyMid[2]) + ')');
  grad.addColorStop(1, 'rgb(' + Math.floor(skyBot[0]) + ',' + Math.floor(skyBot[1]) + ',' + Math.floor(skyBot[2]) + ')');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);

  // Sun/moon
  var sunX = rand(W * 0.2, W * 0.8);
  var sunY = rand(H * 0.05, H * 0.25);
  var sunR = rand(20, 50);
  var sunGlow = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 4);
  sunGlow.addColorStop(0, 'rgba(255,240,200,0.8)');
  sunGlow.addColorStop(0.3, 'rgba(255,200,150,0.2)');
  sunGlow.addColorStop(1, 'rgba(255,150,100,0)');
  ctx.fillStyle = sunGlow;
  ctx.fillRect(0, 0, W, H * 0.6);
  ctx.beginPath();
  ctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(255,245,220,0.9)';
  ctx.fill();

  // Mountain layers (back to front)
  var layers = 5;
  var waterLine = H * rand(0.6, 0.72);
  var baseSeed = Math.random() * 1000;

  for (var L = 0; L < layers; L++) {
    var depth = L / layers; // 0 = farthest
    var baseY = H * 0.25 + depth * (waterLine - H * 0.25);
    var amplitude = H * (0.08 + depth * 0.15);
    var seed = baseSeed + L * 77;

    // Color: far layers are blue/pale, near layers are dark/green
    var fogFactor = 1 - depth;
    var r = Math.floor(30 + fogFactor * 120 + depth * 20);
    var g = Math.floor(50 + fogFactor * 80 + depth * 40);
    var b = Math.floor(80 + fogFactor * 100 - depth * 30);

    ctx.beginPath();
    ctx.moveTo(0, H);
    for (var x = 0; x <= W; x += 2) {
      var nx = x / W * (3 + depth * 3);
      var h = fbm1D(nx, seed, 5 + Math.floor(depth * 3));
      var y = baseY - h * amplitude;
      ctx.lineTo(x, y);
    }
    ctx.lineTo(W, H);
    ctx.closePath();
    ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
    ctx.fill();

    // Trees on closer layers
    if (L >= 3) {
      var treeCount = 15 + Math.floor(depth * 30);
      for (var t = 0; t < treeCount; t++) {
        var tx = rand(0, W);
        var tnx = tx / W * (3 + depth * 3);
        var th = fbm1D(tnx, seed, 5 + Math.floor(depth * 3));
        var ty = baseY - th * amplitude;
        var treeH = rand(6, 18) * (0.5 + depth);
        var treeW = treeH * rand(0.3, 0.5);
        var treeR = Math.max(10, r - 30);
        var treeG = Math.max(20, g - 20 + Math.floor(rand(-10,10)));
        var treeB = Math.max(10, b - 40);

        ctx.beginPath();
        ctx.moveTo(tx, ty);
        ctx.lineTo(tx - treeW/2, ty + treeH);
        ctx.lineTo(tx + treeW/2, ty + treeH);
        ctx.closePath();
        ctx.fillStyle = 'rgb(' + treeR + ',' + treeG + ',' + treeB + ')';
        ctx.fill();
      }
    }
  }

  // Water
  var waterGrad = ctx.createLinearGradient(0, waterLine, 0, H);
  waterGrad.addColorStop(0, 'rgba(30,60,100,0.85)');
  waterGrad.addColorStop(1, 'rgba(15,30,60,0.95)');
  ctx.fillStyle = waterGrad;
  ctx.fillRect(0, waterLine, W, H - waterLine);

  // Water reflections: flip the mountain region
  ctx.save();
  ctx.globalAlpha = 0.25;
  ctx.translate(0, waterLine * 2);
  ctx.scale(1, -1);
  // Redraw mountains as reflection
  for (var L = 0; L < layers; L++) {
    var depth = L / layers;
    var baseY = H * 0.25 + depth * (waterLine - H * 0.25);
    var amplitude = H * (0.08 + depth * 0.15);
    var seed = baseSeed + L * 77;
    var fogFactor = 1 - depth;
    var r = Math.floor(20 + fogFactor * 80 + depth * 15);
    var g = Math.floor(40 + fogFactor * 60 + depth * 30);
    var b = Math.floor(70 + fogFactor * 90 - depth * 20);
    ctx.beginPath();
    ctx.moveTo(0, H);
    for (var x = 0; x <= W; x += 2) {
      var nx = x / W * (3 + depth * 3);
      var h = fbm1D(nx, seed, 5 + Math.floor(depth * 3));
      var y = baseY - h * amplitude;
      ctx.lineTo(x, y);
    }
    ctx.lineTo(W, H);
    ctx.closePath();
    ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
    ctx.fill();
  }
  ctx.restore();

  // Ripple lines on water
  ctx.globalAlpha = 0.15;
  ctx.strokeStyle = '#8ab';
  ctx.lineWidth = 0.5;
  for (var wy = waterLine + 5; wy < H; wy += rand(3, 8)) {
    ctx.beginPath();
    for (var x = 0; x < W; x += 3) {
      var wobble = Math.sin(x * 0.05 + wy * 0.1) * 1.5;
      if (x === 0) ctx.moveTo(x, wy + wobble);
      else ctx.lineTo(x, wy + wobble);
    }
    ctx.stroke();
  }
  ctx.globalAlpha = 1;

  // Mist in valley
  for (var m = 0; m < 3; m++) {
    var mistY = waterLine - rand(10, 50);
    var mistGrad = ctx.createLinearGradient(0, mistY - 15, 0, mistY + 15);
    mistGrad.addColorStop(0, 'rgba(200,210,220,0)');
    mistGrad.addColorStop(0.5, 'rgba(200,210,220,0.15)');
    mistGrad.addColorStop(1, 'rgba(200,210,220,0)');
    ctx.fillStyle = mistGrad;
    ctx.fillRect(0, mistY - 15, W, 30);
  }
}

compose();
canvas.addEventListener('click', function() { compose(); });
</script>
</body>
</html>

Click to generate a new landscape. Each composition features a unique sky palette, sun position, mountain profile, tree distribution, water level, and mist placement. The atmospheric perspective — distant layers rendered in pale, desaturated tones while near layers are dark and detailed — is the single most important technique for creating convincing depth in a 2D landscape painting. Combined with the water reflection (achieved by redrawing the mountains vertically flipped at reduced opacity) and subtle ripple lines, the result is a complete procedural landscape artwork generated in milliseconds.

Performance Considerations

Terrain generation can be computationally intensive. Here are practical tips for keeping your implementations responsive:

  • Use typed arrays. Float32Array and Uint8Array are dramatically faster than regular JavaScript arrays for large heightmaps because they avoid boxing overhead and enable CPU cache-friendly memory access.
  • Generate off-screen. Compute your heightmap into an ImageData buffer and call putImageData once, rather than calling fillRect for every pixel. A single putImageData call can be 10–50x faster than thousands of individual draw calls.
  • Cache chunks. For infinite terrain, generate each chunk once and cache the resulting ImageData. Only regenerate when the viewport moves to uncached regions.
  • Use Web Workers for erosion. Hydraulic and thermal erosion are CPU-heavy but embarrassingly parallelizable. Move the simulation to a Web Worker so the main thread stays responsive.
  • Level of detail. Render distant terrain at lower resolution and near terrain at full resolution. For the 3D renderer, increase the step size for distant rays.
  • Avoid getImageData in loops. Reading pixels back from the canvas is slow. Keep a separate data structure (your heightmap array) and only use the canvas for display.

Combining Techniques

The most convincing procedural terrain comes from combining multiple algorithms in a pipeline. A typical production workflow looks like this:

  1. Base shape: Generate broad continental forms with low-frequency fBm noise. This defines where mountains, plains, and ocean basins go.
  2. Detail noise: Add high-frequency noise octaves for rocky detail, small ridges, and surface variation.
  3. Domain warping: Distort the noise coordinates themselves with another noise function. This creates twisting, organic ridge patterns that look far more natural than straight fBm.
  4. Hydraulic erosion: Simulate rainfall to carve river valleys and smooth out the terrain along waterways.
  5. Thermal erosion: Collapse steep slopes to create realistic scree fields and soften ridgelines.
  6. Biome assignment: Use elevation and moisture (a second noise layer or a distance-from-water calculation) to assign biome types — desert, grassland, forest, tundra, snow.
  7. Detail placement: Scatter vegetation, rocks, and structures using Poisson disc sampling or density maps derived from the biome data.

Each stage builds on the output of the previous one, progressively refining raw noise into terrain that tells a geological story. The key insight is that no single algorithm produces great terrain. It is the combination — noise for structure, erosion for realism, biomes for variety — that makes a procedural landscape feel like a real place.

Going Further With Terrain Generation

The eight examples in this guide cover the foundational algorithms, but terrain generation is a deep field with decades of research. Here are directions to explore next:

  • Ridged multifractal noise: Take the absolute value of noise and invert it. The result produces sharp ridgelines that look like real mountain ranges, far more convincing than plain fBm.
  • Voronoi-based terrain: Use Worley (cellular) noise to create plateau-and-canyon landscapes reminiscent of mesas and badlands.
  • GPU terrain: Move the entire pipeline to WebGL shaders. A fragment shader can generate and render terrain in real time at millions of pixels per frame.
  • Marching cubes: For true 3D terrain with caves and overhangs, store a 3D density field and extract surfaces using the marching cubes algorithm.
  • Real-world data: Load actual elevation data from sources like SRTM or Mapbox Terrain and use the techniques from this guide to enhance or extend it procedurally.

Explore more generative art on Lumitree, where every branch grows into a unique procedural world. For related topics, see the procedural generation guide for broader algorithmic techniques, the Perlin noise art tutorial for mastering the noise functions behind terrain, or the noise texture deep-dive for advanced procedural texture techniques.

Related articles