Воксельні світи беруть свій початок від Minecraft. Цей урок будує
воксельний рушій на основі чанків з нуля: модель даних світу, greedy meshing
для зменшення кількості трикутників у 10×, відсікання граней, щоб внутрішні грані
ніколи не малювалися, та генератор світу на основі шуму Перліна.
1Структура даних чанка
Розділіть нескінченний світ на чанки фіксованого розміру. Кожен чанк володіє
плоским Uint8Array ідентифікаторів вокселів (0 = повітря, 1+ = тип
суцільного блоку). Використання чанків 16×256×16 (x, y, z), як у
Minecraft, тримає дані компактними, а перебудову меша — швидкою:
const CX = 16, CY = 256, CZ = 16; // розміри чанка class Chunk {
constructor(cx, cz) { this.cx = cx; this.cz = cz; // координати чанка
(world / CX, world / CZ) this.data = new Uint8Array(CX * CY * CZ); //
спочатку все повітря this.mesh = null; } get(x, y, z) { if (x < 0||x >=
CX||y < 0||y >= CY||z < 0||z >= CZ) return 0; return this.data[y * CX
* CZ + z * CX + x]; } set(x, y, z, id) { this.data[y * CX * CZ + z *
CX + x] = id; } } // Світ зіставляє рядки "cx,cz" → Chunk const world =
new Map();
2Відсікання граней — малюємо лише видимі грані
Воксельний куб має 6 граней. Якщо сусідній воксель у заданому напрямку
суцільний, ця грань повністю прихована й не повинна генеруватися:
Наївне формування меша генерує по одному квадрату на видиму грань — до 6 квадратів на
воксель. Greedy meshing об'єднує суміжні грані одного типу
в один більший квадрат, зменшуючи кількість трикутників у 5–15× для типового
ландшафту. Алгоритм проходить кожну вісь зріз за зрізом:
// Спрощений greedy-мешер лише для грані +Y (верхньої) function
greedyMeshTopFaces(chunk, world) { const quads = []; for (let y = 0; y
< CY; y++) { // Будуємо 2D-маску видимих зверху граней на цьому рівні Y
const mask = new Int16Array(CX * CZ); // +voxelID, якщо грань видима,
інакше 0 for (let z = 0; z < CZ; z++) for (let x = 0; x < CX; x++) {
const voxel = chunk.get(x, y, z); if (voxel !== 0 && !isSolid(chunk,
world, x, y+1, z)) mask[z * CX + x] = voxel; } // Жадібно об'єднуємо смуги
for (let z = 0; z < CZ; z++) for (let x = 0; x < CX; ) { const id =
mask[z * CX + x]; if (!id) { x++; continue; } // Розширюємо в напрямку x
let w = 1; while (x + w < CX && mask[z * CX + x + w] === id) w++; //
Розширюємо в напрямку z let h = 1; outer: while (z + h < CZ) { for (let
dx = 0; dx < w; dx++) if (mask[(z+h) * CX + x + dx] !== id) break
outer; h++; } quads.push({ x, y, z, w, h, id }); // Очищаємо об'єднану область
for (let dz = 0; dz < h; dz++) for (let dx = 0; dx < w; dx++)
mask[(z+dz) * CX + x + dx] = 0; x += w; } } return quads; }
Застосовуйте greedy meshing незалежно для кожного з 6 напрямків граней.
Ключовий інваріант: дві суміжні грані можна об'єднати, лише якщо вони мають
однаковий тип вокселя, однаковий напрямок грані та обидві видимі.
4Побудова BufferGeometry з даних меша
function buildChunkMesh(chunk, world) { const positions = [], normals
= [], indices = [], uvs = []; let vertexIndex = 0; for (const { dir,
corners, normal } of FACES) { for (let y = 0; y < CY; y++) for (let z
= 0; z < CZ; z++) for (let x = 0; x < CX; x++) { if (!chunk.get(x, y,
z)) continue; const nx = x + dir[0], ny = y + dir[1], nz = z + dir[2];
if (isSolid(chunk, world, nx, ny, nz)) continue; // грань прихована //
Генеруємо 4 вершини for (const [cx, cy, cz] of corners) {
positions.push(x+cx, y+cy, z+cz); normals.push(...normal); }
uvs.push(0,0, 0,1, 1,1, 1,0); // Два трикутники (обхід за годинниковою стрілкою)
indices.push( vertexIndex, vertexIndex+1, vertexIndex+2, vertexIndex,
vertexIndex+2, vertexIndex+3); vertexIndex += 4; } } const geo = new
THREE.BufferGeometry(); geo.setAttribute('position', new
THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals,
3)); geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setIndex(indices); return new THREE.Mesh(geo, new
THREE.MeshLambertMaterial({ color: 0x88aa55 })); }
5Процедурна генерація світу
// Простий 2D fBm-генератор висоти function generateChunk(chunk) { for
(let z = 0; z < CZ; z++) for (let x = 0; x < CX; x++) { const wx =
chunk.cx * CX + x; // світовий X const wz = chunk.cz * CZ + z; // світовий Z
const h = Math.floor(fbm(wx / 80, wz / 80) * 60) + 60; for (let y = 0;
y <= h; y++) { if (y === h) chunk.set(x, y, z, 1); // трава else if (y
> h - 4) chunk.set(x, y, z, 2); // земля else chunk.set(x, y, z, 3); //
камінь } // Заповнюємо водою нижче y=70 for (let y = h + 1; y <= 70; y++)
chunk.set(x, y, z, 4); } } function loadChunk(cx, cz) { const key =
`${cx},${cz}`; if (world.has(key)) return world.get(key); const chunk
= new Chunk(cx, cz); generateChunk(chunk); world.set(key, chunk); //
Плануємо перебудову меша на наступний кадр, щоб уникнути просідань кадрів
dirtyChunks.add(key); return chunk; }
6Керування чанками та потокове завантаження
Завантажуйте та рендеріть лише ті чанки, що в межах налаштовуваної дистанції рендерингу від
гравця. Перебудовуйте «брудні» чанки по одному на кадр, щоб уникнути ривків:
Дистанція рендерингу: завантажуйте чанки в межах ±8 чанків по X
та Z. Вивантажуйте чанки далі за 12.
Множина «брудних»: коли воксель змінено, додайте його чанк
та всі 4 суміжні по гранях чанки до множини dirtyChunks.
Обробляйте по одному «брудному» чанку за такт кадру з цієї множини.
Web Workers: перенесіть generateChunk та
buildChunkMesh у воркер. Передавайте сирі масиви
назад через transferables і викликайте
geo.setAttribute у головному потоці.
Пулінг об'єктів: замість створення та знищення
об'єктів THREE.Mesh, підтримуйте пул і міняйте
геометрію/матеріали, коли слот чанка повторно використовується.
Радіус рендерингу 17×17 чанків (дистанція огляду 16 чанків) означає щонайбільше 289
активних чанків. З greedy meshing кожен чанк у середньому має ~2 000
трикутників — загалом близько 578 тис. трикутників, що цілком у межах
можливостей настільних GPU.