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.