Урок · Середній рівень · ~50 хв
JavaScript · Three.js · Обчислювальна геометрія

3D-діаграми Вороного та Делоне у браузері

Діаграма Вороного розбиває простір так, що кожна область містить саме ті точки, які найближчі до одного зерна. Її двійник — тріангуляція Делоне — це меш без малих кутів. Разом вони лежать в основі процедурних мап, просторового індексування та генеративного мистецтва. Цей урок будує обидві з нуля та рендерить їх у Three.js.

1Наївний Вороний на 2D-полотні

Найпростіший алгоритм: для кожного пікселя полотна знаходимо найближче зерно. O(W×H×N) — жахливо повільно для великих полотен, але наочно:

const W = 400, H = 400, N = 32; const canvas = document.getElementById('voronoi'); const ctx = canvas.getContext('2d'); const imgData = ctx.createImageData(W, H); // Генеруємо N випадкових зерен із випадковими кольорами 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);
Для рендерингу в реальному часі перейдіть на алгоритм jump flooding (JFA) на текстурі WebGL — він виконується за O(log N) проходів і дружній до GPU.

2Інкрементний Делоне за Бойєром–Ватсоном

Вставляємо точки одну за одною. Кожна вставка знаходить усі трикутники, описане коло яких містить нову точку, видаляє їх і повторно тріангулює утворений полігональний отвір:

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; // вироджений 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) { // Супертрикутник, що містить усі точки 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); } // Граничний полігон поганих трикутників (ребра, що не є спільними) 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 }); } } // Видаляємо трикутники, що мають спільні вершини із супертрикутником 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)) )); }

3Виділення комірок Вороного з Делоне

Діаграма Вороного — це двійник тріангуляції Делоне: з'єднайте центри описаних кіл суміжних трикутників. Для кожного зерна зберіть усі трикутники, що містять його як вершину, а потім відсортуйте їхні центри описаних кіл за кутом:

function voronoiCells(seeds, triangles) { const cells = new Map(seeds.map(s => [s, []])); for (const tri of triangles) { const { x, y } = tri.cc; // центр описаного кола = вершина Вороного for (const seed of tri.verts) { if (cells.has(seed)) cells.get(seed).push({ x, y }); } } // Сортуємо вершини кожної комірки у послідовному напрямку обходу 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: зерно → впорядковані вершини полігона }

4Видавлювання комірок у 3D-призми

Надайте кожному полігону Вороного випадкову висоту, а потім видавіть його за допомогою ExtrudeGeometry з Three.js:

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); // Розташовуємо комірку у площині XZ mesh.rotation.x = -Math.PI / 2; scene.add(mesh); }
Форма ExtrudeGeometry задається в (x,y) та видавлюється вздовж z. Ми обертаємо меш на −90° по X, щоб він лежав плазом на площині землі.

5Релаксація Ллойда для однорідних комірок

Ітерування зерно → центроїд комірки → повторна тріангуляція поступово робить області рівнішими за розміром:

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); // Обмежуємо межами обмежувального прямокутника 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; }

6Розфарбовування за висотою та анімація

// Анімація: повільно обертаємо мапу const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); scene.rotation.y = clock.getElapsedTime() * 0.1; renderer.render(scene, camera); } animate(); // Розфарбовуємо комірки за глибиною видавлювання (у тонах ландшафту) function elevationColor(depth, maxDepth) { const t = depth / maxDepth; if (t < 0.15) return new THREE.Color(0x1a6aa0); // глибока вода if (t < 0.25) return new THREE.Color(0x3a9ad9); // мілка вода if (t < 0.40) return new THREE.Color(0xd4b483); // пляж if (t < 0.70) return new THREE.Color(0x4a7c40); // трава if (t < 0.85) return new THREE.Color(0x7a6a55); // скеля return new THREE.Color(0xfafafa); // сніг }
Для відшліфованого результату застосуйте м'яке спрямоване світло згори зліва та ненав'язливе розсіяне світло. Розфарбовування за висотою перетворює діаграму на процедурну тектонічну мапу.