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.

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.