Tutorial · Advanced · ~60 min
Three.js · Marching Cubes · Perlin Noise · BufferGeometry

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.