Tutorial · Intermediate · ~50 min
JavaScript · Three.js · Computational Geometry

3D Voronoi & Delaunay in the Browser

A Voronoi diagram partitions space so every region contains exactly the points closest to one seed. Its dual — the Delaunay triangulation — is the mesh with no small angles. Together they power procedural maps, spatial indexing, and generative art. This tutorial builds both from scratch and renders them in Three.js.

1Naïve Voronoi on a 2D canvas

The simplest algorithm: for every canvas pixel, find the nearest seed. O(W×H×N) — terribly slow for large canvases, but illustrative:

const W = 400, H = 400, N = 32; const canvas = document.getElementById('voronoi'); const ctx = canvas.getContext('2d'); const imgData = ctx.createImageData(W, H); // Generate N random seeds with random colours const seeds = Array.from({ length: N }, () => ({ x: Math.random() * W, y: Math.random() * H, r: Math.random() * 200 | 0, g: Math.random() * 200 | 0, b: Math.random() * 200 | 0, })); for (let py = 0; py < H; py++) { for (let px = 0; px < W; px++) { let minDist = Infinity, closest = 0; for (let i = 0; i < seeds.length; i++) { const dx = px - seeds[i].x, dy = py - seeds[i].y; const d2 = dx * dx + dy * dy; if (d2 < minDist) { minDist = d2; closest = i; } } const { r, g, b } = seeds[closest]; const idx = (py * W + px) * 4; imgData.data[idx] = r; imgData.data[idx+1] = g; imgData.data[idx+2] = b; imgData.data[idx+3] = 255; } } ctx.putImageData(imgData, 0, 0);
For realtime rendering switch to jump flooding algorithm (JFA) on a WebGL texture — it runs in O(log N) passes and is GPU-friendly.

2Bowyer–Watson incremental Delaunay

Insert points one by one. Each insertion finds all triangles whose circumcircle contains the new point, removes them, and re-triangulates the resulting polygon hole:

function circumcircle(a, b, c) { const ax = a.x - c.x, ay = a.y - c.y; const bx = b.x - c.x, by = b.y - c.y; const D = 2 * (ax * by - ay * bx); if (Math.abs(D) < 1e-10) return null; // degenerate const ux = (by * (ax*ax + ay*ay) - ay * (bx*bx + by*by)) / D; const uy = (ax * (bx*bx + by*by) - bx * (ax*ax + ay*ay)) / D; const cx2 = ux + c.x, cy2 = uy + c.y; const r2 = ux*ux + uy*uy; return { x: cx2, y: cy2, r2 }; } function bowyer(points) { // Super triangle containing all points const M = 1e6; const st = [ { x: 0, y: -M*3 }, { x: -M*3, y: M*3 }, { x: M*3, y: M*3 }, ]; let triangles = [{ verts: [...st], cc: circumcircle(...st) }]; for (const p of points) { const bad = []; const good = []; for (const tri of triangles) { const { x, y, r2 } = tri.cc; const dx = p.x - x, dy = p.y - y; if (dx*dx + dy*dy < r2) bad.push(tri); else good.push(tri); } // Boundary polygon of bad triangles (edges not shared) const edgeCount = new Map(); for (const tri of bad) { const [a, b, c] = tri.verts; for (const edge of [[a,b],[b,c],[c,a]]) { const key = [edge[0],edge[1]].sort((x,y)=>x.x-y.x||x.y-y.y) .map(v=>`${v.x},${v.y}`).join('|'); edgeCount.set(key, (edgeCount.get(key)||0)+1); } } const boundary = [...edgeCount.entries()] .filter(([,n])=>n===1).map(([k])=>{ const [a,b] = k.split('|').map(s=>{const[x,y]=s.split(',');return{x:+x,y:+y};}); return [a,b]; }); triangles = good; for (const [a,b] of boundary) { const verts = [a, b, p]; const cc = circumcircle(a, b, p); if (cc) triangles.push({ verts, cc }); } } // Remove triangles sharing super-triangle vertices return triangles.filter(t => t.verts.every(v => st.every(s => !(Math.abs(v.x-s.x)<1 && Math.abs(v.y-s.y)<1)) )); }

3Extract Voronoi cells from Delaunay

The Voronoi diagram is the dual of the Delaunay triangulation: connect circumcentres of adjacent triangles. For each seed point, collect all triangles that contain it as a vertex, then sort their circumcentres angularly:

function voronoiCells(seeds, triangles) { const cells = new Map(seeds.map(s => [s, []])); for (const tri of triangles) { const { x, y } = tri.cc; // circumcentre = Voronoi vertex for (const seed of tri.verts) { if (cells.has(seed)) cells.get(seed).push({ x, y }); } } // Sort each cell's vertices in a consistent winding order for (const [seed, pts] of cells) { pts.sort((a, b) => Math.atan2(a.y - seed.y, a.x - seed.x) - Math.atan2(b.y - seed.y, b.x - seed.x)); } return cells; // Map: seed → ordered polygon vertices }

4Extrude cells into 3D prisms

Give each Voronoi polygon a random height, then extrude it with Three.js ExtrudeGeometry:

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, innerWidth/innerHeight, 0.1, 2000); camera.position.set(0, 200, 400); function buildCell(polygon, depth) { const shape = new THREE.Shape(); shape.moveTo(polygon[0].x, polygon[0].y); for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i].x, polygon[i].y); shape.closePath(); const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false, }); const hue = Math.random(); const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color().setHSL(hue, .7, .45), roughness: .4, metalness: .1, }); return new THREE.Mesh(geo, mat); } const cells = voronoiCells(seeds, triangles); for (const [, polygon] of cells) { if (polygon.length < 3) continue; const mesh = buildCell(polygon, 10 + Math.random() * 60); // Centre the cell in XZ plane mesh.rotation.x = -Math.PI / 2; scene.add(mesh); }
The ExtrudeGeometry shape is specified in (x,y) and extruded along z. We rotate the mesh −90° on X so it lies flat on the ground plane.

5Lloyd relaxation for uniform cells

Iterating seed → centroid of cell → re-triangulate gradually makes regions more equal in size:

function centroid(polygon) { let ax = 0, ay = 0, area = 0; const n = polygon.length; for (let i = 0, j = n - 1; i < n; j = i++) { const cross = polygon[j].x * polygon[i].y - polygon[i].x * polygon[j].y; ax += (polygon[j].x + polygon[i].x) * cross; ay += (polygon[j].y + polygon[i].y) * cross; area += cross; } area /= 2; return { x: ax / (6 * area), y: ay / (6 * area) }; } function lloyd(seeds, bounds, iterations = 5) { for (let iter = 0; iter < iterations; iter++) { const triangles = bowyer(seeds); const cells = voronoiCells(seeds, triangles); seeds = seeds.map(s => { const cell = cells.get(s); if (!cell || cell.length < 3) return s; const c = centroid(cell); // Clamp to bounding box return { x: Math.max(bounds.x0, Math.min(bounds.x1, c.x)), y: Math.max(bounds.y0, Math.min(bounds.y1, c.y)), }; }); } return seeds; }

6Colour by elevation and animate

// Animate: slowly rotate the map const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); scene.rotation.y = clock.getElapsedTime() * 0.1; renderer.render(scene, camera); } animate(); // Colour cells by their extrusion depth (terrain-toned) function elevationColor(depth, maxDepth) { const t = depth / maxDepth; if (t < 0.15) return new THREE.Color(0x1a6aa0); // deep water if (t < 0.25) return new THREE.Color(0x3a9ad9); // shallow water if (t < 0.40) return new THREE.Color(0xd4b483); // beach if (t < 0.70) return new THREE.Color(0x4a7c40); // grass if (t < 0.85) return new THREE.Color(0x7a6a55); // rock return new THREE.Color(0xfafafa); // snow }
For a polished result, apply a soft directional light from the upper-left and a subtle ambient light. The elevation colouring turns the diagram into a procedural tectonic map.