Урок · Просунутий рівень · ~90 хв
Three.js · BufferGeometry · Відсікання граней

Створіть 3D воксельний рушій на WebGL

Воксельні світи беруть свій початок від 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 граней. Якщо сусідній воксель у заданому напрямку суцільний, ця грань повністю прихована й не повинна генеруватися:

const FACES = [ { dir: [1,0,0], corners: [[1,0,0],[1,1,0],[1,1,1],[1,0,1]], normal: [ 1,0,0] }, { dir: [-1,0,0], corners: [[0,0,1],[0,1,1],[0,1,0],[0,0,0]], normal: [-1,0,0] }, { dir: [0,1,0], corners: [[0,1,0],[0,1,1],[1,1,1],[1,1,0]], normal: [ 0,1,0] }, { dir: [0,-1,0], corners: [[1,0,0],[1,0,1],[0,0,1],[0,0,0]], normal: [ 0,-1,0] }, { dir: [0,0,1], corners: [[1,0,1],[1,1,1],[0,1,1],[0,0,1]], normal: [ 0,0,1] }, { dir: [0,0,-1], corners: [[0,0,0],[0,1,0],[1,1,0],[1,0,0]], normal: [ 0,0,-1] }, ]; function isSolid(chunk, world, x, y, z) { // перевіряємо власний чанк, потім сусідні чанки для прикордонних вокселів if (x >= 0 && x < CX && z >= 0 && z < CZ) return chunk.get(x, y, z) !== 0; const ncx = chunk.cx + (x < 0 ? -1 : x >= CX ? 1 : 0); const ncz = chunk.cz + (z < 0 ? -1 : z >= CZ ? 1 : 0); const neighbour = world.get(`${ncx},${ncz}`); if (!neighbour) return true; // вважаємо відсутні чанки суцільними (запобігає прогалинам) return neighbour.get(((x % CX) + CX) % CX, y, ((z % CZ) + CZ) % CZ) !== 0; }

3Greedy meshing

Наївне формування меша генерує по одному квадрату на видиму грань — до 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Керування чанками та потокове завантаження

Завантажуйте та рендеріть лише ті чанки, що в межах налаштовуваної дистанції рендерингу від гравця. Перебудовуйте «брудні» чанки по одному на кадр, щоб уникнути ривків:

Радіус рендерингу 17×17 чанків (дистанція огляду 16 чанків) означає щонайбільше 289 активних чанків. З greedy meshing кожен чанк у середньому має ~2 000 трикутників — загалом близько 578 тис. трикутників, що цілком у межах можливостей настільних GPU.