Урок · Просунутий рівень · ~60 хв
Ландшафт із 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.