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.
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.
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;
}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.
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.
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),
);
},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.
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.
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;
},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.
R(x,z) = (1 − |Nₚₑᵣₗᵢₙ(x,z)|)² if (p.algorithm === "ridged") v = (1 - Math.abs(v)) ** 2;
value += v * amplitude;
total += amplitude;
amplitude *= p.persistence;
freq *= 2;
}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.
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.
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;
}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.
D(x,z) = minⱼ∈₃×₃ √((x−qⱼₓ)² + (z−qⱼ𝓏)²)
N(x,z) = min(D(x,z), 1.4) / 1.4qⱼ: feature points. Distances are clipped at 1.4, scaled, then octave-combined. A closer feature outside the 3×3 window can be missed.
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;
},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.
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.
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;
}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.
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 });
}
};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 ↗