Voxel worlds trace their DNA to Minecraft. This tutorial builds a
chunk-based voxel engine from scratch: world data model, greedy meshing
to reduce triangle count by 10×, face-culling so internal faces are
never drawn, and a Perlin-driven world generator.
1Chunk data structure
Divide the infinite world into fixed-size chunks. Each chunk owns a
flat Uint8Array of voxel IDs (0 = air, 1+ = solid block
type). Using 16×256×16 (x, y, z) chunks like
Minecraft keeps data small and re-meshing fast:
const CX = 16, CY = 256, CZ = 16; // chunk dimensions class Chunk {
constructor(cx, cz) { this.cx = cx; this.cz = cz; // chunk coordinates
(world / CX, world / CZ) this.data = new Uint8Array(CX * CY * CZ); //
all air initially 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; } } // World maps "cx,cz" strings → Chunk const world =
new Map();
2Face culling — only draw visible faces
A voxel cube has 6 faces. If the adjacent voxel in a given direction
is solid, that face is completely hidden and should not be emitted:
Naive meshing emits one quad per visible face — up to 6 quads per
voxel. Greedy meshing merges adjacent same-type faces
into a single lager quad, reducing triangle count by 5–15× for typical
terrain. The algorithm sweeps each axis slice-by-slice:
// Simplified greedy mesher for the +Y (top) face only function
greedyMeshTopFaces(chunk, world) { const quads = []; for (let y = 0; y
< CY; y++) { // Build a 2D mask of top-visible faces at this Y level
const mask = new Int16Array(CX * CZ); // +voxelID if face visible,
else 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; } // Greedily merge runs
for (let z = 0; z < CZ; z++) for (let x = 0; x < CX; ) { const id =
mask[z * CX + x]; if (!id) { x++; continue; } // Extend in x direction
let w = 1; while (x + w < CX && mask[z * CX + x + w] === id) w++; //
Extend in z direction 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 }); // Clear merged area
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; }
Apply greedy meshing independently for each of the 6 face directions.
The key invariant: two adjacent faces can be merged only if they have
the same voxel type, the same face direction, and are both visible.
4Build BufferGeometry from mesh data
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; // face hidden //
Emit 4 vertices 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); // Two triangles (CW winding)
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 })); }
5Procedural world generation
// Simple 2D fBm height generator function generateChunk(chunk) { for
(let z = 0; z < CZ; z++) for (let x = 0; x < CX; x++) { const wx =
chunk.cx * CX + x; // world X const wz = chunk.cz * CZ + z; // world 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); // grass else if (y
> h - 4) chunk.set(x, y, z, 2); // dirt else chunk.set(x, y, z, 3); //
stone } // Fill water below 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); //
Schedule re-mesh on next frame to avoid frame drops
dirtyChunks.add(key); return chunk; }
6Chunk management and streaming
Only load and render chunks within a configurable render distance of
the player. Re-mesh dirty chunks one per frame to avoid hitches:
Render distance: Load chunks within ±8 chunks on X
and Z. Unload chunks farther than 12.
Dirty set: When a voxel is edited, add its chunk
and all 4 face-adjacent chunks to a dirtyChunks Set.
Process one dirty chunk per frame tick from the set.
Web Workers: Move generateChunk and
buildChunkMesh to a worker. Transfer the raw arrays
back with transferables and call
geo.setAttribute on the main thread.
Object pooling: Instead of creating and destroying
THREE.Mesh objects, maintain a pool and swap
geometry/materials when a chunk slot is reused.
A 17×17 chunk render radius (16-chunk view distance) means at most 289
active chunks. With greedy meshing each chunk averages ~2,000
triangles — around 578k triangles total, well within desktop GPU
limits.