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.

Frequently Asked Questions

What will I learn in this tutorial?

Generate Voronoi diagrams and Delaunay triangulations in the browser using Fortune's algorithm, then lift them into 3D with Three.js.

What topics are covered in this tutorial?

This tutorial covers: Naïve Voronoi on a 2D canvas, Incremental Bowyer–Watson for Delaunay, Extract Voronoi cells from Delaunay, Extrude cells into 3D prisms, Lloyd relaxation for uniform cells, Colour and animate.

What tools and technologies does this tutorial use?

This tutorial uses JavaScript, Three.js, Computational Geometry.

How long does this tutorial take?

This tutorial takes approximately 50 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.