INTERACTIVE STUDY / 02 — CPU + WEBGL

Terrain Lab.

From equations to landscapes. Explore and compare five procedural terrain algorithms.

The embedded demo currently has Japanese controls. Algorithm names and technical indicators are shown in English.

Read all five algorithms, equations & source ↓

IMPLEMENTATION / EQUATIONS & SOURCE

Follow the inputs through the code.

Read each section as purpose → equation → actual source. Snippets are excerpts of the working implementation, not standalone programs. Follow the source link for surrounding context.

01 / From seed to height: the shared pipeline

The seed string becomes a 32-bit integer that determines random sequences, permutations, and feature points. Except for Diamond-Square, the generator samples K layers, doubling frequency and multiplying amplitude by persistence p. fBm describes this composition, rather than a separate base noise.

Equation
F(x,z) = [Σₖ₌₀ᴷ⁻¹ pᵏ N(qₖ)] / [Σₖ₌₀ᴷ⁻¹ pᵏ]
qₖ = (s · 2ᵏ · x/n + oₓ, s · 2ᵏ · z/n + o𝓏)

n: subdivisions; s: scale; K: octaves; p: persistence. Offsets are (13.7, 9.3) for Perlin/Simplex/Ridged and (0, 0) for Worley. Increasing scale fits more terrain features into the same area.

public/demos/terrain-lab/terrain.js : 165Current build source
        for (let o = 0; o < p.octaves; o++) {
          let v =
            p.algorithm === "simplex"
              ? noise.simplex((x / n) * freq + 13.7, (z / n) * freq + 9.3)
              : p.algorithm === "worley"
                ? noise.worley((x / n) * freq, (z / n) * freq)
                : noise.perlin((x / n) * freq + 13.7, (z / n) * freq + 9.3);
          if (p.algorithm === "ridged") v = (1 - Math.abs(v)) ** 2;
          value += v * amplitude;
          total += amplitude;
          amplitude *= p.persistence;
          freq *= 2;
        }
Read this source on GitHub ↗

02 / Perlin: interpolate gradient contributions

At each of four corners, compute a dot product between a seeded gradient g and displacement d. Interpolate along x, then z. A quintic fade curve smooths the transitions at cell boundaries.

Equation
fade(t) = 6t⁵ − 15t⁴ + 10t³
lerp(a,b,t) = a + t(b−a)
N(u,v) = lerp(lerp(g₀₀·d₀₀,g₁₀·d₁₀,fade(u)),
              lerp(g₀₁·d₀₁,g₁₁·d₁₁,fade(u)),fade(v))

u,v: fractional cell coordinates. The permutation table selects from eight gradients; diagonal gradients are not unit-normalized in this implementation.

public/demos/terrain-lab/terrain.js : 40Current build source
    perlin(x, y) {
      const ix = Math.floor(x),
        iy = Math.floor(y),
        X = ix & 255,
        Y = iy & 255;
      x -= ix;
      y -= iy;
      return lerp(
        lerp(
          grad(perm[X + perm[Y]], x, y),
          grad(perm[X + 1 + perm[Y]], x - 1, y),
          fade(x),
        ),
        lerp(
          grad(perm[X + perm[Y + 1]], x, y - 1),
          grad(perm[X + 1 + perm[Y + 1]], x - 1, y - 1),
          fade(x),
        ),
        fade(y),
      );
    },
Read this source on GitHub ↗

03 / Simplex: use three triangle vertices

Skew the coordinates to identify a cell, then unskew to recover local coordinates. The local position selects one of two triangles. Sum three vertex contributions, each attenuated by distance.

Equation
F₂ = (√3−1)/2, G₂ = (3−√3)/6
aⱼ = max(0, 0.5 − dⱼₓ² − dⱼ𝓏²)
N(x,z) = 70 · Σⱼ₌₀² aⱼ⁴ (gⱼ · dⱼ)

dⱼ: displacement from vertex j; gⱼ: its gradient. 70 is the implementation’s amplitude factor. The local variable z in the excerpt is attenuation, not the terrain z coordinate.

public/demos/terrain-lab/terrain.js : 72Current build source
      let n = 0;
      for (const [dx, dy, xx, yy] of [
        [0, 0, x0, y0],
        [i1, j1, x0 - i1 + g, y0 - j1 + g],
        [1, 1, x0 - 1 + 2 * g, y0 - 1 + 2 * g],
      ]) {
        let z = 0.5 - xx * xx - yy * yy;
        if (z > 0) {
          z *= z;
          n +=
            z * z * grad(perm[((i + dx) & 255) + perm[(j + dy) & 255]], xx, yy);
        }
      }
      return n * 70;
    },
Read this source on GitHub ↗

04 / Ridged: turn zero crossings into ridges

Apply absolute value, inversion, and squaring to Perlin samples before the shared fBm sum. Values near zero become ridges. This variant is ridged fBm without cross-octave weight feedback.

Equation
R(x,z) = (1 − |Nₚₑᵣₗᵢₙ(x,z)|)²
public/demos/terrain-lab/terrain.js : 172Current build source
          if (p.algorithm === "ridged") v = (1 - Math.abs(v)) ** 2;
          value += v * amplitude;
          total += amplitude;
          amplitude *= p.persistence;
          freq *= 2;
        }
Read this source on GitHub ↗

05 / Diamond-Square: recursive averages with displacement

Initialize four corners randomly. The diamond step averages four corners to create a center; the square step averages known cardinal neighbors. Halve the spacing and multiply displacement amplitude by p. Boundaries average only available neighbors, without wrapping.

Equation
Hnew = (Σᵢ₌₁ᵐ Hᵢ)/m + (U−0.5)Aₗ
A₀ = 0.8, Aₗ₊₁ = pAₗ, U ∈ [0,1)

m: available neighbors; l: subdivision level. The grid has (n+1)² points with n=2ᵏ. The UI offers 64/128/256 subdivisions; scale and octave controls do not apply.

public/demos/terrain-lab/terrain.js : 129Current build source
    for (let y = 0; y <= n; y += h)
      for (let x = (y / h) % 2 === 0 ? h : 0; x <= n; x += step) {
        let v = 0,
          c = 0;
        for (const [dx, dy] of [
          [-h, 0],
          [h, 0],
          [0, -h],
          [0, h],
        ])
          if (x + dx >= 0 && x + dx <= n && y + dy >= 0 && y + dy <= n) {
            v += at(x + dx, y + dy);
            c++;
          }
        set(x, y, v / c + (r() - 0.5) * amp);
      }
    amp *= rough;
  }
Read this source on GitHub ↗

06 / Worley: nearest-feature distance

Generate one seeded feature point per integer cell. Search the containing cell and eight neighbors, then use the nearest Euclidean distance. Feature points become depressions and cell boundaries form ridges. This demo limits the search to 3×3 cells rather than performing an exhaustive global nearest-point search.

Equation
D(x,z) = minⱼ∈₃×₃ √((x−qⱼₓ)² + (z−qⱼ𝓏)²)
N(x,z) = min(D(x,z), 1.4) / 1.4

qⱼ: feature points. Distances are clipped at 1.4, scaled, then octave-combined. A closer feature outside the 3×3 window can be missed.

public/demos/terrain-lab/terrain.js : 87Current build source
    worley(x, y) {
      let d = 9;
      const ix = Math.floor(x),
        iy = Math.floor(y);
      for (let a = -1; a <= 1; a++)
        for (let b = -1; b <= 1; b++) {
          const cx = ix + a,
            cy = iy + b,
            h = seedHash(`${seed}:${cx}:${cy}`),
            r = rng(h),
            dx = cx + r() - x,
            dy = cy + r() - y;
          d = Math.min(d, Math.hypot(dx, dy));
        }
      return Math.min(d, 1.4) / 1.4;
    },
Read this source on GitHub ↗

07 / Normalization, islands, mesh, and PNG

Normalize using the field’s min/max, then subtract a center-distance falloff. The 3D mesh scales vertex heights by A and recomputes normals. The heightmap stores unscaled height as 8-bit grayscale, rather than exporting the mesh.

Equation
h₀ = (F−Fmin)/(Fmax−Fmin)  [分母0 / zero denominator → 1]
d = √((2x/n−1)² + (2z/n−1)²)
h = max(0, h₀ − 0.5d^(2.4))  [島ON / island on]
y₃ᴅ = Ah, ywater = A · sea, gray = round(255h)

With island off, h=h₀. Per-field normalization removes absolute-amplitude differences between generators. 128 subdivisions produce 129²=16,641 vertices and 2×128²=32,768 triangles.

public/demos/terrain-lab/terrain.js : 181Current build source
  let lo = Infinity,
    hi = -Infinity;
  for (const h of a) {
    lo = Math.min(lo, h);
    hi = Math.max(hi, h);
  }
  for (let z = 0; z <= n; z++)
    for (let x = 0; x <= n; x++) {
      const i = z * (n + 1) + x;
      let h = (a[i] - lo) / (hi - lo || 1);
      if (p.island) {
        const d = Math.sqrt(((x / n) * 2 - 1) ** 2 + ((z / n) * 2 - 1) ** 2);
        h = Math.max(0, h - Math.pow(d, 0.0 + 2.4) * 0.5);
      }
      a[i] = h;
    }
Read this source on GitHub ↗

08 / Separate CPU generation from GPU rendering

The Worker computes heights and transfers the Float32Array buffer. The main thread updates Three.js positions, colors, and normals; WebGL renders the result. Generation time excludes messaging, mesh construction, and GPU rendering.

public/demos/terrain-lab/worker.js : 1Current build source
import { generateTerrain } from "./terrain.js";
onmessage = ({ data: { id, params } }) => {
  try {
    const result = generateTerrain(params);
    postMessage({ id, ...result }, [result.heights.buffer]);
  } catch (e) {
    postMessage({ id, error: e.message });
  }
};
Read this source on GitHub ↗

METHOD / HOW TO EXPLORE

Fix the seed. Explore the differences.

Perlin and Simplex use fBm. Ridged combines inverted, squared absolute Perlin noise. Diamond-Square uses recursive subdivision; Worley uses nearest-feature distance (F1). Each height field is normalized to 0–1 for comparing shapes.

Identical seeds and settings reproduce the same terrain. Adjust elevation, sea level, and island falloff; save the 3D view or an 8-bit heightmap as PNG. Diamond-Square does not use scale or octave controls.

Generation time covers height-field computation in the Worker, excluding mesh construction and rendering. FPS is a rendering reference. Both depend on the device, browser, and settings; these are not measurements of my C++ projects or games.

Demo source code ↗