Learn Creative Coding (#57) - Erosion and Growth Simulation

in StemSocial11 days ago

Learn Creative Coding (#57) - Erosion and Growth Simulation

cc-banner

Eleven episodes into the emergent systems arc now. Grid automata (ep047-049), free-moving flocks (ep050-051), continuous chemistry (ep052-053), formal grammars (ep054-055), autonomous crawlers and slime molds (ep056). Every one of those systems builds structure from nothing -- patterns appearing where there were none. Today we go the other direction too. Destruction as a creative act. Erosion.

Natural landscapes aren't designed. They're carved. Water falls on mountains for thousands of years and the result is valleys, river networks, sediment plains, canyons. Wind strips away loose material and leaves behind hard ridges. Ice cracks rocks apart. The terrain we see is what's LEFT after erosion removed everything else. The sculpture is what remains when you stop chipping.

And then there's growth -- the opposite process. Crystals that assemble atom by atom. Lichen spreading across a rock face. Mineral deposits accumulating in cave formations. Corrosion eating into metal and leaving behind fractal patterns. DLA from episode 56 was growth. Today we pair it with erosion and let both processes shape terrain together.

The creative coding angle: start with a noise heightmap (we built those in episode 12 and episode 35). Apply thousands of simulated raindrops. Watch valleys carve themselves, rivers form, sediment accumulate in flat areas. The before-and-after is dramatic -- random noise transforms into something that looks like satellite imagery. No artist drew those river networks. The water did.

The heightmap: our canvas

Everything starts with a 2D grid of height values. Each cell is a floating-point number representing terrain elevation. Higher values = mountains. Lower values = valleys. We'll use the noise from episode 12 to generate initial terrain:

const W = 512;
const H = 512;
const heightmap = new Float32Array(W * H);

// simple value noise for initial terrain (from ep012)
function hash(px, py) {
  let h = px * 374761393 + py * 668265263;
  h = (h ^ (h >> 13)) * 1274126177;
  return (h & 0x7fffffff) / 0x7fffffff;
}

function noise2d(x, y, scale) {
  const ix = Math.floor(x / scale);
  const iy = Math.floor(y / scale);
  const fx = (x / scale) - ix;
  const fy = (y / scale) - iy;

  const sx = fx * fx * (3 - 2 * fx);
  const sy = fy * fy * (3 - 2 * fy);

  const tl = hash(ix, iy);
  const tr = hash(ix + 1, iy);
  const bl = hash(ix, iy + 1);
  const br = hash(ix + 1, iy + 1);

  const top = tl + (tr - tl) * sx;
  const bot = bl + (br - bl) * sx;
  return top + (bot - top) * sy;
}

// multi-octave noise for more interesting terrain
function fbm(x, y, octaves) {
  let value = 0;
  let amplitude = 1;
  let frequency = 1;
  let maxVal = 0;

  for (let i = 0; i < octaves; i++) {
    value += noise2d(x * frequency, y * frequency, 80) * amplitude;
    maxVal += amplitude;
    amplitude *= 0.5;
    frequency *= 2;
  }
  return value / maxVal;
}

// fill heightmap
for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    heightmap[y * W + x] = fbm(x, y, 6);
  }
}

Six octaves of fractal noise give us terrain with both large-scale features (mountains, plains) and small-scale detail (bumps, ridges). The heightmap values range from 0 to 1. At this point it's just random hills -- no valleys, no drainage patterns, no river networks. That all comes from erosion.

Rendering the heightmap

Before we erode anything, we need to see it. A simple grayscale render plus contour lines:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = W;
canvas.height = H;

function renderHeightmap(hmap) {
  const imgData = ctx.createImageData(W, H);

  for (let i = 0; i < W * H; i++) {
    const h = hmap[i];

    // color by elevation
    let r, g, b;
    if (h < 0.3) {
      // low: water/plains (blue-green)
      r = Math.floor(40 + h * 120);
      g = Math.floor(80 + h * 200);
      b = Math.floor(60 + h * 100);
    } else if (h < 0.6) {
      // mid: grass/forest (green)
      const t = (h - 0.3) / 0.3;
      r = Math.floor(80 + t * 60);
      g = Math.floor(140 - t * 30);
      b = Math.floor(50 + t * 20);
    } else {
      // high: rock/snow (gray to white)
      const t = (h - 0.6) / 0.4;
      r = Math.floor(140 + t * 115);
      g = Math.floor(130 + t * 125);
      b = Math.floor(110 + t * 145);
    }

    imgData.data[i * 4 + 0] = r;
    imgData.data[i * 4 + 1] = g;
    imgData.data[i * 4 + 2] = b;
    imgData.data[i * 4 + 3] = 255;
  }

  ctx.putImageData(imgData, 0, 0);
}

renderHeightmap(heightmap);

Low areas get blue-green (water, plains). Mid elevations get green (vegetation). High areas get gray-white (rock, snow). This hypsometric coloring is what real topographic maps use. It turns an abstract grid of numbers into something your brain immediately reads as terrain.

Hydraulic erosion: the droplet model

Here's the core idea. Spawn a water droplet at a random position on the heightmap. The droplet has a position, a direction, a speed, some water volume, and a sediment load (how much dirt it's carrying). Each step:

  1. Compute the terrain gradient at the droplet's position (which way is downhill)
  2. Update the droplet's direction -- mix the old direction with the gradient (inertia vs gravity)
  3. Move the droplet one step in the new direction
  4. Compute sediment capacity -- fast water on steep slopes can carry more
  5. If carrying less than capacity: erode (pick up sediment from the terrain)
  6. If carrying more than capacity: deposit (drop sediment onto the terrain)
  7. Evaporate a small fraction of the water
  8. Repeat until the droplet runs out of water or leaves the map

Thousands of droplets over thousands of steps carve rivers, fill valleys, and build sediment fans. No explicit "carve a river" code anywhere. The rivers emerge from the physics.

function erodeDroplet(hmap, startX, startY) {
  let x = startX;
  let y = startY;
  let dx = 0;
  let dy = 0;
  let speed = 0;
  let water = 1.0;
  let sediment = 0;

  const inertia = 0.3;
  const gravity = 4.0;
  const evapRate = 0.02;
  const depositRate = 0.3;
  const erodeRate = 0.3;
  const minSlope = 0.01;
  const capacity = 4.0;

  const maxSteps = 80;

  for (let step = 0; step < maxSteps; step++) {
    const ix = Math.floor(x);
    const iy = Math.floor(y);
    if (ix < 1 || ix >= W - 1 || iy < 1 || iy >= H - 1) break;

    // bilinear gradient
    const idx = iy * W + ix;
    const h00 = hmap[idx];
    const h10 = hmap[idx + 1];
    const h01 = hmap[idx + W];
    const h11 = hmap[idx + W + 1];

    // fractional position within cell
    const fx = x - ix;
    const fy = y - iy;

    // gradient (partial derivatives)
    const gx = (h10 - h00) * (1 - fy) + (h11 - h01) * fy;
    const gy = (h01 - h00) * (1 - fx) + (h11 - h10) * fx;

    // interpolated height at current position
    const hCurrent = h00 * (1 - fx) * (1 - fy) +
                     h10 * fx * (1 - fy) +
                     h01 * (1 - fx) * fy +
                     h11 * fx * fy;

    // update direction with inertia
    dx = dx * inertia - gx * (1 - inertia);
    dy = dy * inertia - gy * (1 - inertia);

    // normalize direction
    const len = Math.sqrt(dx * dx + dy * dy);
    if (len < 0.0001) break;  // stuck in flat area
    dx /= len;
    dy /= len;

    // move
    const newX = x + dx;
    const newY = y + dy;

    // height at new position
    const nix = Math.floor(newX);
    const niy = Math.floor(newY);
    if (nix < 0 || nix >= W - 1 || niy < 0 || niy >= H - 1) break;

    const nfx = newX - nix;
    const nfy = newY - niy;
    const nidx = niy * W + nix;
    const hNew = hmap[nidx] * (1 - nfx) * (1 - nfy) +
                 hmap[nidx + 1] * nfx * (1 - nfy) +
                 hmap[nidx + W] * (1 - nfx) * nfy +
                 hmap[nidx + W + 1] * nfx * nfy;

    const heightDiff = hNew - hCurrent;

    // sediment capacity based on slope and speed
    const slope = Math.max(-heightDiff, minSlope);
    const sedCapacity = slope * speed * water * capacity;

    if (sediment > sedCapacity || heightDiff > 0) {
      // deposit sediment
      const depositAmount = (heightDiff > 0)
        ? Math.min(sediment, heightDiff)
        : (sediment - sedCapacity) * depositRate;

      sediment -= depositAmount;
      // deposit to the 4 surrounding cells (bilinear)
      hmap[idx] += depositAmount * (1 - fx) * (1 - fy);
      hmap[idx + 1] += depositAmount * fx * (1 - fy);
      hmap[idx + W] += depositAmount * (1 - fx) * fy;
      hmap[idx + W + 1] += depositAmount * fx * fy;
    } else {
      // erode
      const erodeAmount = Math.min(
        (sedCapacity - sediment) * erodeRate,
        -heightDiff
      );

      sediment += erodeAmount;
      hmap[idx] -= erodeAmount * (1 - fx) * (1 - fy);
      hmap[idx + 1] -= erodeAmount * fx * (1 - fy);
      hmap[idx + W] -= erodeAmount * (1 - fx) * fy;
      hmap[idx + W + 1] -= erodeAmount * fx * fy;
    }

    // update speed and position
    speed = Math.sqrt(Math.max(0, speed * speed + heightDiff * gravity));
    x = newX;
    y = newY;
    water *= (1 - evapRate);
  }
}

That's a lot of code but every line is doing something physical. The gradient computation uses bilinear interpolation -- the droplet isn't locked to grid cells, it flows smoothly between them. The direction blends inertia (keep going where you were going) with gravity (go downhill). This prevents the droplet from instantly snapping to the steepest direction -- real water has momentum.

The sediment capacity formula is the key insight: slope * speed * water * capacity. Steep slopes with fast-moving water can carry lots of sediment. Flat areas with slow water can't carry much, so sediment gets deposited. This single formula produces the entire erosion/deposition cycle. Valleys get carved (steep + fast = erosion) and plains get built (flat + slow = deposition).

The bilinear deposit/erode means we're modifying the 4 surrounding grid cells proportionally to the droplet's sub-pixel position. This prevents the terracing artifacts you'd get if you only modified the nearest cell. Smooth erosion from sub-pixel precision.

Running the erosion

Now we just throw a lot of droplets at the terrain:

function erode(hmap, dropletCount) {
  for (let i = 0; i < dropletCount; i++) {
    const x = 1 + Math.random() * (W - 3);
    const y = 1 + Math.random() * (H - 3);
    erodeDroplet(hmap, x, y);
  }
}

// copy the original for comparison
const original = new Float32Array(heightmap);

// erode with 50,000 droplets
erode(heightmap, 50000);

// render the result
renderHeightmap(heightmap);

50,000 droplets is a good starting point for a 512x512 map. You can see the effect clearly -- valleys form where water naturally converges, ridges sharpen, flat sediment deposits appear at the base of slopes. Run 200,000 droplets and the terrain looks genuinely geological. The river networks branch like real drainage basins, with tributaries feeding into main channels.

The before/after comparison is the payoff. Random noise heightmap on the left. Same heightmap after 100,000 droplets on the right. The noise becomes terrain. It's one of the most satisfying transformations in procedural generation.

Thermal erosion: crumbling slopes

Hydraulic erosion carves valleys with water. Thermal erosion crumbles steep slopes. When the height difference between two neighboring cells exceeds a threshold (the "talus angle"), material slides downhill. Think rockslides, scree slopes, cliff faces crumbling into rubble at the base.

function thermalErode(hmap, iterations, talusThreshold, transferRate) {
  for (let iter = 0; iter < iterations; iter++) {
    for (let y = 1; y < H - 1; y++) {
      for (let x = 1; x < W - 1; x++) {
        const idx = y * W + x;
        const h = hmap[idx];

        // check 4 neighbors
        const neighbors = [
          { dx: 1, dy: 0 },
          { dx: -1, dy: 0 },
          { dx: 0, dy: 1 },
          { dx: 0, dy: -1 }
        ];

        let maxDiff = 0;
        let maxNeighbor = -1;

        for (let n = 0; n < neighbors.length; n++) {
          const nx = x + neighbors[n].dx;
          const ny = y + neighbors[n].dy;
          const nh = hmap[ny * W + nx];
          const diff = h - nh;

          if (diff > maxDiff) {
            maxDiff = diff;
            maxNeighbor = ny * W + nx;
          }
        }

        // if steepest slope exceeds talus angle, transfer material
        if (maxDiff > talusThreshold && maxNeighbor >= 0) {
          const amount = maxDiff * transferRate * 0.5;
          hmap[idx] -= amount;
          hmap[maxNeighbor] += amount;
        }
      }
    }
  }
}

// apply thermal erosion: 10 passes, talus threshold 0.01, transfer rate 0.5
thermalErode(heightmap, 10, 0.01, 0.5);

Thermal erosion is simpler than hydraulic -- no droplet tracking, no sediment capacity. Just: for each cell, if the slope to the steepest neighbor exceeds a threshold, move some material downhill. After several passes, steep cliffs get rounded, talus slopes accumulate at the base of ridges, and the overall terrain becomes smoother.

The talusThreshold controls how steep slopes can be before material starts sliding. Low threshold (0.005) means even gentle slopes erode -- everything gets smoothed out. High threshold (0.05) means only steep cliffs crumble -- ridges stay sharp. For realistic results, combine hydraulic erosion first (carve the valleys) then thermal erosion (smooth the rough edges).

Rendering with shaded relief

Flat coloring is fine but shaded relief makes terrain pop off the screen. Compute a surface normal from the heightmap gradient and dot it with a light direction:

function renderShaded(hmap) {
  const imgData = ctx.createImageData(W, H);

  // light direction (from upper-left, the cartographic standard)
  const lx = -1;
  const ly = -1;
  const lz = 2;
  const llen = Math.sqrt(lx * lx + ly * ly + lz * lz);
  const lightX = lx / llen;
  const lightY = ly / llen;
  const lightZ = lz / llen;

  for (let y = 1; y < H - 1; y++) {
    for (let x = 1; x < W - 1; x++) {
      const idx = y * W + x;
      const h = hmap[idx];

      // compute normal from neighbors
      const dhdx = (hmap[idx + 1] - hmap[idx - 1]) * 0.5;
      const dhdy = (hmap[idx + W] - hmap[idx - W]) * 0.5;

      // normal = (-dhdx, -dhdy, 1) normalized
      const scale = 3.0;  // exaggerate height for visibility
      const nx = -dhdx * scale;
      const ny = -dhdy * scale;
      const nz = 1;
      const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz);

      // dot product with light
      const shade = Math.max(0,
        (nx * lightX + ny * lightY + nz * lightZ) / nlen
      );

      // base color from elevation
      let r, g, b;
      if (h < 0.25) {
        r = 50; g = 100; b = 70;
      } else if (h < 0.55) {
        r = 90; g = 140; b = 60;
      } else {
        r = 160; g = 155; b = 140;
      }

      // apply shading
      r = Math.floor(r * (0.3 + shade * 0.7));
      g = Math.floor(g * (0.3 + shade * 0.7));
      b = Math.floor(b * (0.3 + shade * 0.7));

      imgData.data[idx * 4 + 0] = r;
      imgData.data[idx * 4 + 1] = g;
      imgData.data[idx * 4 + 2] = b;
      imgData.data[idx * 4 + 3] = 255;
    }
  }

  ctx.putImageData(imgData, 0, 0);
}

The surface normal is computed from the height differences to neighboring cells. The light comes from the upper-left (standard cartographic convention -- maps have been shaded this way since the 1800s). The dot product between normal and light direction gives a brightness value. Slopes facing the light are bright. Slopes facing away are dark. This hillshading transforms a flat image into something with obvious 3D structure. Valleys read as deep, ridges as prominent, slopes as inclined.

The scale = 3.0 exaggerates the height differences. Without it, the terrain would look almost flat because our noise values only range from 0 to 1. Exaggerating the vertical makes the hillshading more dramatic. Real cartographers do the same thing -- USGS maps typically use 2-5x vertical exaggeration in their shaded relief.

Contour lines

Add contour lines on top of the shaded relief for a proper topographic map look:

function drawContours(hmap, ctx, interval, color) {
  ctx.strokeStyle = color;
  ctx.lineWidth = 0.5;

  for (let y = 0; y < H - 1; y++) {
    for (let x = 0; x < W - 1; x++) {
      const idx = y * W + x;
      const h = hmap[idx];

      // check if a contour line crosses this cell
      const level = Math.floor(h / interval);
      const right = Math.floor(hmap[idx + 1] / interval);
      const below = Math.floor(hmap[idx + W] / interval);

      if (level !== right || level !== below) {
        ctx.fillStyle = color;
        ctx.fillRect(x, y, 1, 1);
      }
    }
  }
}

// render terrain first, then overlay contours
renderShaded(heightmap);
drawContours(heightmap, ctx, 0.05, 'rgba(40, 30, 20, 0.3)');

The contour detection is crude but effective -- if the height quantized to the contour interval changes between a cell and its neighbors, there's a contour crossing. For a 512x512 map this produces thin single-pixel contour lines. An interval of 0.05 gives about 20 contour levels across the 0-1 range.

For prettier contours you'd want marching squares (proper isoline extraction) but the quick-and-dirty approach above works fine for creative coding and runs fast. The contour lines make river valleys and drainage patterns even more visible -- you can trace the V-shaped contours pointing upstream, which is how real geographers read topographic maps.

DLA growth: building from nothing

Episode 56 covered DLA (Diffusion-Limited Aggregation) in detail. Here's the connection to erosion: DLA is anti-erosion. Instead of water removing material from high places and depositing it in low places, DLA adds material wherever random walkers happen to stick. The result is branching crystal growth instead of branching river networks. Same fractal branching, opposite physical process.

We can grow DLA structures on top of our eroded terrain:

function growDLA(hmap, seedX, seedY, particles, growthHeight) {
  const structure = new Uint8Array(W * H);
  structure[seedY * W + seedX] = 1;

  function isAdjacentToStructure(x, y) {
    for (let dy = -1; dy <= 1; dy++) {
      for (let dx = -1; dx <= 1; dx++) {
        if (dx === 0 && dy === 0) continue;
        const nx = x + dx;
        const ny = y + dy;
        if (nx >= 0 && nx < W && ny >= 0 && ny < H) {
          if (structure[ny * W + nx] === 1) return true;
        }
      }
    }
    return false;
  }

  for (let i = 0; i < particles; i++) {
    // random start on edge
    let x = Math.floor(Math.random() * W);
    let y = Math.floor(Math.random() * H);
    let steps = 0;

    while (steps < 20000) {
      x += Math.floor(Math.random() * 3) - 1;
      y += Math.floor(Math.random() * 3) - 1;

      if (x < 0 || x >= W || y < 0 || y >= H) break;

      if (isAdjacentToStructure(x, y)) {
        structure[y * W + x] = 1;
        hmap[y * W + x] += growthHeight;
        break;
      }
      steps++;
    }
  }

  return structure;
}

// grow crystals from a point, adding height to the terrain
growDLA(heightmap, W / 2, H / 2, 5000, 0.15);

Each DLA particle that sticks raises the heightmap at that point. The branching DLA structure becomes a raised fractal formation growing out of the terrain -- like mineral deposits, coral growth, or lichen spreading across rock. Render it with the shaded relief and the crystal structure casts shadows and catches light. Erosion carves down, DLA builds up, and both produce branching patterns. The terrain becomes a battlefield between destruction and creation.

Lichen growth: expanding frontiers

Lichen spreads across surfaces as an expanding frontier. Cells at the edge of the colony can grow into empty neighbors based on local conditions. We can model this as a cellular automaton layered on top of the heightmap:

function growLichen(hmap, seedX, seedY, steps) {
  const colony = new Float32Array(W * H);  // 0 = empty, >0 = lichen age
  colony[seedY * W + seedX] = 1;

  for (let step = 0; step < steps; step++) {
    const newColony = new Float32Array(colony);

    for (let y = 1; y < H - 1; y++) {
      for (let x = 1; x < W - 1; x++) {
        if (colony[y * W + x] > 0) continue;  // already colonized

        // check if any neighbor is colonized
        let neighborCount = 0;
        for (let dy = -1; dy <= 1; dy++) {
          for (let dx = -1; dx <= 1; dx++) {
            if (dx === 0 && dy === 0) continue;
            if (colony[(y + dy) * W + (x + dx)] > 0) neighborCount++;
          }
        }

        if (neighborCount > 0) {
          // growth probability depends on terrain conditions
          const h = hmap[y * W + x];
          // lichen prefers mid-elevation, exposed surfaces
          const nutrient = 1 - Math.abs(h - 0.5) * 2;
          const growProb = 0.05 * nutrient * neighborCount;

          if (Math.random() < growProb) {
            newColony[y * W + x] = step + 1;
          }
        }
      }
    }

    colony.set(newColony);
  }

  return colony;
}

The growth probability depends on the heightmap value -- lichen prefers mid-elevation where there's moisture but not too much runoff. The neighborCount factor means growth accelerates along the frontier where many colonized cells are adjacent. This produces irregular, organic boundary shapes that look like actual lichen patches when rendered.

To render it, overlay the colony on the terrain with a different color:

function renderWithLichen(hmap, colony) {
  renderShaded(hmap);  // base terrain
  const imgData = ctx.getImageData(0, 0, W, H);

  for (let i = 0; i < W * H; i++) {
    if (colony[i] > 0) {
      const age = colony[i];
      const t = Math.min(1, age / 200);
      // blend lichen color (pale green-gray) over terrain
      imgData.data[i * 4 + 0] = Math.floor(imgData.data[i * 4 + 0] * 0.4 + (140 + t * 40) * 0.6);
      imgData.data[i * 4 + 1] = Math.floor(imgData.data[i * 4 + 1] * 0.4 + (160 + t * 30) * 0.6);
      imgData.data[i * 4 + 2] = Math.floor(imgData.data[i * 4 + 2] * 0.4 + (100 + t * 20) * 0.6);
    }
  }

  ctx.putImageData(imgData, 0, 0);
}

Old lichen (high age) is slightly different color from young lichen (low age). The blending with the underlying terrain color means the lichen takes on the character of the surface it's growing on -- darker on shadowed slopes, lighter on exposed ridges. Tiny detail but it sells the illusion.

Combining erosion and growth

The creative exercise: apply all three processes to the same heightmap. First generate noise terrain. Then hydraulic erosion carves the valleys. Then thermal erosion smooths the cliffs. Then DLA grows mineral deposits in the low areas. Then lichen spreads across the mid-elevations. Layer by layer, the terrain develops history.

// 1. generate terrain
for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    heightmap[y * W + x] = fbm(x, y, 6);
  }
}

// 2. hydraulic erosion - carve rivers
erode(heightmap, 80000);

// 3. thermal erosion - smooth cliffs
thermalErode(heightmap, 15, 0.008, 0.4);

// 4. DLA crystal growth in a low valley
// find lowest point
let minH = 1;
let minIdx = 0;
for (let i = 0; i < W * H; i++) {
  if (heightmap[i] < minH) {
    minH = heightmap[i];
    minIdx = i;
  }
}
const crystalX = minIdx % W;
const crystalY = Math.floor(minIdx / W);
growDLA(heightmap, crystalX, crystalY, 3000, 0.08);

// 5. lichen on exposed mid-altitude rock
const lichen = growLichen(heightmap, W / 2, H / 4, 150);

// 6. render everything
renderWithLichen(heightmap, lichen);
drawContours(heightmap, ctx, 0.04, 'rgba(30, 20, 10, 0.2)');

Five processes, one terrain. Noise makes the raw material. Water carves the structure. Gravity smooths the rough edges. Crystals grow in the wet low areas. Lichen colonizes the dry midlands. Each process is simple on its own -- a few dozen lines of code at most. Together they produce a landscape with visual depth that looks like it has a geological history. Because in a way it does -- each process leaves its marks and the next process responds to what came before.

Aging and weathering existing art

Here's a creative twist. Take any image -- a clean geometric design, a text rendering, a logo -- and treat it as a heightmap. Then erode it. The crisp edges become rounded. The flat areas develop texture. The geometric becomes organic. Digital entropy.

function imageToHeightmap(imageData) {
  const hmap = new Float32Array(W * H);
  for (let i = 0; i < W * H; i++) {
    // luminance as height
    const r = imageData.data[i * 4 + 0];
    const g = imageData.data[i * 4 + 1];
    const b = imageData.data[i * 4 + 2];
    hmap[i] = (r * 0.299 + g * 0.587 + b * 0.114) / 255;
  }
  return hmap;
}

// draw some clean geometry
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#fff';
ctx.font = 'bold 200px monospace';
ctx.fillText('ERODE', 40, 320);

// convert to heightmap
const textImage = ctx.getImageData(0, 0, W, H);
const textHeight = imageToHeightmap(textImage);

// erode the text
erode(textHeight, 30000);
thermalErode(textHeight, 20, 0.015, 0.3);

// render as terrain
renderShaded(textHeight);

The word "ERODE" starts as sharp white text on black background. After 30,000 droplets of hydraulic erosion plus thermal smoothing, the letters develop worn edges, sediment pools in the counter spaces of the letters, valleys form along the straight strokes. It looks like text carved into stone that's been weathering for centuries. The irony of the word "ERODE" being eroded is a nice conceptual touch for generative art.

You can control the degree of weathering by adjusting the droplet count. 5,000 droplets: slight softening, the text is still clearly readable. 50,000 droplets: heavy erosion, text is degraded but recognizable. 200,000 droplets: the original form is almost gone, just ghost traces in the terrain. It's a slider between "freshly carved" and "ancient ruin."

Connecting to what we've built

Erosion simulation pulls together tools from across the series. The noise heightmap is from episode 12 (Perlin noise) and episode 35 (noise on the GPU). The DLA growth is from episode 56. The shaded relief rendering uses surface normals and dot products from the shader episodes (ep033, ep040). The cellular automaton growth model for lichen is a direct relative of Game of Life from episode 48.

And it connects forward. Swarm intelligence systems -- ants finding efficient paths, slime molds building networks -- interact with terrain in similar ways. Water finds optimal downhill paths just like ants find optimal food paths. The principles of local agents following gradients and leaving marks apply to all of these simulations. Different agents, same underlying dance between local rules and emergent global patterns.

't Komt erop neer...

  • Hydraulic erosion simulates rain droplets flowing downhill across a heightmap. Each droplet picks up sediment on steep fast slopes (erosion) and drops sediment on flat slow areas (deposition). The sediment capacity formula -- slope times speed times water volume -- drives the whole system. Thousands of droplets carve realistic river networks from random noise terrain
  • The droplet model uses bilinear interpolation for smooth gradient computation and sub-pixel sediment modification. Inertia blends the old direction with the downhill gradient so water has momentum -- it doesn't instantly snap to the steepest slope. Evaporation gradually kills each droplet
  • Thermal erosion is simpler: if the height difference between neighboring cells exceeds a threshold (the talus angle), material slides downhill. This rounds off steep cliffs, builds talus slopes at the base of ridges, and smooths the terrain that hydraulic erosion roughened
  • Shaded relief rendering computes surface normals from the heightmap gradient and dots them with a light direction. Standard cartographic technique since the 1800s -- slopes facing the light are bright, slopes facing away are dark. Vertical exaggeration (2-5x) makes the 3D structure readable
  • DLA growth and lichen spreading are erosion's creative opposites -- they build structure instead of removing it. DLA raises the heightmap where particles stick, creating mineral-like crystal formations. Lichen expands its frontier based on terrain conditions, preferring mid-elevation exposed surfaces
  • Combining multiple processes on one terrain creates layered geological history. Noise generates raw material, water carves valleys, gravity smooths cliffs, crystals grow in wet lows, lichen colonizes dry mids. Each process is simple alone but together they produce terrain with convincing depth
  • Treating any image as a heightmap and eroding it turns geometric designs into weathered, organic forms. Clean text becomes ancient carved stone. The erosion amount controls the age -- from fresh to ruin

Sallukes! Thanks for reading.

X

@femdev