Tutorial · Advanced · ~60 min
Terrain with Marching Cubes & Perlin Noise
The Marching Cubes algorithm extracts a smooth triangulated surface from a scalar field. Combine it with 3D Perlin noise and you get organic caves, overhangs, and floating islands impossible to achieve with heightmaps. This tutorial implements MC from scratch and builds a real-time animated terrain.
13D Perlin noise scalar field
// Ken Perlin's improved noise (3D) const PERM = new Uint8Array(512);
const P = [...Array(256).keys()]; for (let i = 255; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); [P[i], P[j]] = [P[j],
P[i]]; } for (let i = 0; i < 512; i++) PERM[i] = P[i & 255]; const
fade = t => t * t * t * (t * (t * 6 - 15) + 10); const lerp = (a, b,
t) => a + t * (b - a); function grad(hash, x, y, z) { const h = hash &
15; const u = h < 8 ? x : y; const v = h < 4 ? y : h === 12 || h
=== 14 ? x : z; return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); }
function noise3(x, y, z) { const X = Math.floor(x) & 255, Y =
Math.floor(y) & 255, Z = Math.floor(z) & 255; x -= Math.floor(x); y -=
Math.floor(y); z -= Math.floor(z); const u = fade(x), v = fade(y), w =
fade(z); const A = PERM[X]+Y, AA = PERM[A]+Z, AB = PERM[A+1]+Z; const
B = PERM[X+1]+Y, BA = PERM[B]+Z, BB = PERM[B+1]+Z; return lerp(
lerp(lerp(grad(PERM[AA],x,y,z), grad(PERM[BA],x-1,y,z),u),
lerp(grad(PERM[AB],x,y-1,z), grad(PERM[BB],x-1,y-1,z),u), v),
lerp(lerp(grad(PERM[AA+1],x,y,z-1), grad(PERM[BA+1],x-1,y,z-1),u),
lerp(grad(PERM[AB+1],x,y-1,z-1), grad(PERM[BB+1],x-1,y-1,z-1),u), v),
w); } // fBm for more natural terrain function fbm3(x, y, z, octaves =
4) { let val = 0, amp = 0.5, freq = 1; for (let i = 0; i < octaves;
i++) { val += noise3(x*freq, y*freq, z*freq) * amp; amp *= 0.5; freq
*= 2; } return val; }
2Marching Cubes case table
Each cube has 8 corners — each either inside or outside the isosurface, giving 256 possible configurations. A precomputed edge table maps each configuration to the set of edges that the surface crosses:
// The full 256-entry edgeTable and 256×16 triTable are large. //
Import a compact version: const { edgeTable, triTable } = await
import('./mc-tables.js'); // Or inline — a common approach is to
bundle the ~4 KB table directly. // Cube corner offsets (local
coordinates 0,1 per axis) const CORNERS = [
[0,0,0],[1,0,0],[1,1,0],[0,1,0], [0,0,1],[1,0,1],[1,1,1],[0,1,1], ];
// The 12 edges: pairs of corner indices const EDGES = [
[0,1],[1,2],[2,3],[3,0], [4,5],[5,6],[6,7],[7,4],
[0,4],[1,5],[2,6],[3,7], ];
The MC case and triangle tables were published by Lorensen & Cline
in 1987. They are freely available and typically bundled as a ~4 KB JS
constant. Skipping reproduction here keeps focus on the algorithm
logic.
3Interpolate edge vertices
const ISO = 0; // isosurface level // Linear interpolation along an
edge between two corners function interp(p1, v1, p2, v2) { if
(Math.abs(ISO - v1) < 1e-5) return [...p1]; if (Math.abs(ISO - v2)
< 1e-5) return [...p2]; if (Math.abs(v1 - v2) < 1e-5) return
[...p1]; const t = (ISO - v1) / (v2 - v1); return [ p1[0] + t * (p2[0]
- p1[0]), p1[1] + t * (p2[1] - p1[1]), p1[2] + t * (p2[2] - p1[2]), ];
} function marchCube(ox, oy, oz, values, scale) { // Build case index
from corner sign bits let caseIdx = 0; for (let i = 0; i < 8; i++)
{ if (values[i] < ISO) caseIdx |= (1 << i); } if
(edgeTable[caseIdx] === 0) return []; // no intersection //
Interpolate vertex positions on intersected edges const verts = new
Array(12); for (let e = 0; e < 12; e++) { if (edgeTable[caseIdx] &
(1 << e)) { const [c0, c1] = EDGES[e]; const p0 =
CORNERS[c0].map((v, i) => (ox + v) * scale[i]); const p1 =
CORNERS[c1].map((v, i) => (ox + v) * scale[i] + (i===0?0:i===1?0:0));
// Corrected: apply offsets per axis const pos0 =
[(ox+CORNERS[c0][0])*scale[0], (oy+CORNERS[c0][1])*scale[1],
(oz+CORNERS[c0][2])*scale[2]]; const pos1 =
[(ox+CORNERS[c1][0])*scale[0], (oy+CORNERS[c1][1])*scale[1],
(oz+CORNERS[c1][2])*scale[2]]; verts[e] = interp(pos0, values[c0],
pos1, values[c1]); } } // Emit triangles const tris = []; for (let t =
0; triTable[caseIdx][t] !== -1; t += 3) {
tris.push(verts[triTable[caseIdx][t]], verts[triTable[caseIdx][t+1]],
verts[triTable[caseIdx][t+2]]); } return tris; }
4Build the mesh via BufferGeometry
const RES = 40; // grid resolution const SCALE = [2/RES, 2/RES,
2/RES]; function buildMesh(t = 0) { const positions = []; for (let z =
0; z < RES; z++) { for (let y = 0; y < RES; y++) { for (let x =
0; x < RES; x++) { // Sample density field at all 8 corners const
values = CORNERS.map(([dx, dy, dz]) => { const fx = (x + dx) / RES *
3; const fy = (y + dy) / RES * 3; const fz = (z + dz) / RES * 3 + t;
// density: negative inside, positive outside return fbm3(fx, fy, fz)
- 0.3 + fy * 0.4; }); const tris = marchCube(x, y, z, values, SCALE);
for (const v of tris) positions.push(...v); } } } const geo = new
THREE.BufferGeometry(); geo.setAttribute('position', new
THREE.Float32BufferAttribute(positions, 3));
geo.computeVertexNormals(); return geo; }
5Compute smooth normals via gradient
// Analytical gradient of the noise field (finite differences) const
EPS = 0.01; function gradient(x, y, z) { const f = (dx, dy, dz) =>
fbm3(x+dx, y+dy, z+dz); return new THREE.Vector3( (f(EPS,0,0) -
f(-EPS,0,0)) / (2 * EPS), (f(0,EPS,0) - f(0,-EPS,0)) / (2 * EPS),
(f(0,0,EPS) - f(0,0,-EPS)) / (2 * EPS), ).normalize(); } // For
sharper but still smooth normals: compute the noise gradient at each
// vertex position and use it directly as the normal. This avoids the
averaging // artifacts of computeVertexNormals() at sharp creases.
6Animate by scrolling the field
import * as THREE from
'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js';
const mat = new THREE.MeshStandardMaterial({ color: 0x7c9d6f,
roughness: .7, metalness: .1, side: THREE.DoubleSide }); let mesh =
new THREE.Mesh(buildMesh(0), mat); scene.add(mesh); const clock = new
THREE.Clock(); let rebuild = 0; const REBUILD_INTERVAL = 1 / 10; //
rebuild at 10 fps to save CPU function animate() {
requestAnimationFrame(animate); const t = clock.getElapsedTime();
rebuild += clock.getDelta(); if (rebuild >= REBUILD_INTERVAL) {
rebuild = 0; // Rebuild: dispose old geometry, create new one
mesh.geometry.dispose(); mesh.geometry = buildMesh(t * 0.1); }
renderer.render(scene, camera); }
Rebuilding geometry on the CPU every frame is expensive. For
production, move the MC loop to a Web Worker or
implement it in a WebGL compute shader / transform feedback pass.