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.
Frequently Asked Questions
What will I learn in this tutorial?
Generate smooth isosurface terrain using the Marching Cubes algorithm and 3D Perlin noise, rendered in Three.js with normals and lighting.
What topics are covered in this tutorial?
This tutorial covers: 3D Perlin noise scalar field, Marching Cubes case table, Interpolate edge vertices, Build the mesh via BufferGeometry, Compute smooth normals, Animate by shifting the field.
What tools and technologies does this tutorial use?
This tutorial uses Three.js, Marching Cubes, Perlin Noise, BufferGeometry.
How long does this tutorial take?
This tutorial takes approximately 60 minutes to complete.
What prerequisites do I need before starting?
This is a Advanced-level tutorial — no special preparation beyond basic JavaScript is assumed.