Урок · Просунутий рівень · ~60 хв
Three.js · Marching Cubes · Шум Перліна · BufferGeometry

Ландшафт із Marching Cubes та шумом Перліна

Алгоритм Marching Cubes виділяє гладку триангульовану поверхню зі скалярного поля. Поєднайте його з 3D-шумом Перліна — і отримаєте органічні печери, навіси та плаваючі острови, недосяжні для карт висот. Цей урок реалізує MC з нуля та створює анімований ландшафт у реальному часі.

1Скалярне поле 3D-шуму Перліна

// Покращений шум Кена Перліна (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 для природнішого ландшафту 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; }

2Таблиця випадків Marching Cubes

Кожен куб має 8 кутів — кожен або всередині, або поза ізоповерхнею, що дає 256 можливих конфігурацій. Попередньо обчислена таблиця ребер відображає кожну конфігурацію в набір ребер, які перетинає поверхня:

// Повні edgeTable на 256 записів і triTable 256×16 є великими. // Імпортуємо компактну версію: const { edgeTable, triTable } = await import('./mc-tables.js'); // Або вбудовуємо — поширений підхід: включити таблицю ~4 КБ безпосередньо. // Зсуви кутів куба (локальні координати 0,1 на вісь) 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], ]; // 12 ребер: пари індексів кутів 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], ];
Таблиці випадків і трикутників MC опублікували Лоренсен і Клайн у 1987 році. Вони у вільному доступі й зазвичай включаються як JS-константа ~4 КБ. Пропуск їх відтворення тут дозволяє зосередитися на логіці алгоритму.

3Інтерполяція вершин на ребрах

const ISO = 0; // рівень ізоповерхні // Лінійна інтерполяція вздовж ребра між двома кутами 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) { // Будуємо індекс випадку з бітів знаків кутів let caseIdx = 0; for (let i = 0; i < 8; i++) { if (values[i] < ISO) caseIdx |= (1 << i); } if (edgeTable[caseIdx] === 0) return []; // немає перетину // Інтерполюємо позиції вершин на перетнутих ребрах 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)); // Виправлено: застосовуємо зсуви по кожній осі 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]); } } // Видаємо трикутники 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; }

4Побудова меша через BufferGeometry

const RES = 40; // роздільність сітки 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++) { // Семплюємо поле густини в усіх 8 кутах 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; // густина: відʼємна всередині, додатна зовні 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; }

5Обчислення гладких нормалей через градієнт

// Аналітичний градієнт поля шуму (скінченні різниці) 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(); } // Для різкіших, але все ще гладких нормалей: обчислюйте градієнт шуму в кожній // позиції вершини і використовуйте його напряму як нормаль. Це уникає артефактів // усереднення computeVertexNormals() на гострих згинах.

6Анімація через прокручування поля

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; // перебудова на 10 fps для економії CPU function animate() { requestAnimationFrame(animate); const t = clock.getElapsedTime(); rebuild += clock.getDelta(); if (rebuild >= REBUILD_INTERVAL) { rebuild = 0; // Перебудова: звільняємо стару геометрію, створюємо нову mesh.geometry.dispose(); mesh.geometry = buildMesh(t * 0.1); } renderer.render(scene, camera); }
Перебудова геометрії на CPU щокадру є дорогою. Для продакшну перенесіть цикл MC у Web Worker або реалізуйте його в обчислювальному шейдері WebGL / проході transform feedback.