Chapter 853: Voxel (Minecraft-like) Geometry (Manual)
Core Idea
Building a Minecraft-style voxel world naively (one cube mesh per voxel, or even one merged geometry with every voxel's full 6 faces) doesn't scale — the real solution is generating custom geometry that skips faces between two solid neighboring voxels, organized into fixed-size, lazily-allocated "cells."
Key Concepts
- One mesh per voxel fails fast: even a single 256×256 layer is 65,536 boxes — far too many draw calls to stay smooth, the same problem covered in the "optimize lots of objects" lessons.
- Naively merging all voxel geometry also fails: a merged geometry still includes every face of every voxel, including the faces between two touching voxels that can never actually be seen — wasted geometry that balloons both memory and triangle count, and a full 256³ volume runs out of memory this way.
- Skip interior faces: the correct approach builds geometry face-by-face, checking each voxel's neighbors (
getVoxel) and only emitting a face where the neighboring voxel is empty.
- Cells for lazy allocation: dividing the world into fixed-size cells (e.g. 32×32×32) and only allocating a cell's voxel array when something is actually placed in it avoids allocating memory for empty space; a
VoxelWorld class maps a global voxel coordinate to its owning cell (keyed by cell coordinates, e.g. "1,0,0") and the local offset within that cell.
- Texture atlas per voxel type: a texture atlas arranged one column per voxel type (side/top/bottom rows) lets each generated face look up the correct row/UVs for its voxel type and orientation.
Code Examples
class VoxelWorld {
constructor(cellSize) {
this.cellSize = cellSize;
this.cells = {};
}
getCellForVoxel(x, y, z) { /* looks up (or lazily creates) the owning cell by id */ }
setVoxel(x, y, z, v) {
const cell = this.getCellForVoxel(x, y, z);
const { cellSize } = this;
const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
cell[voxelY * cellSize * cellSize + voxelZ * cellSize + voxelX] = v;
}
getVoxel(x, y, z) { /* same offset math, returns 0 for an unallocated cell */ }
}
- What it demonstrates: mapping a global voxel coordinate to its owning cell and local offset, the foundation for both lazy cell allocation and neighbor lookups across cell boundaries.
Key Takeaways
- Neither one-mesh-per-voxel nor a naive full merge of all voxel geometry scales — both draw or store far more geometry than is ever actually visible.
- Real voxel-geometry generation must check each voxel's neighbors and skip faces between two solid voxels.
- Dividing the world into fixed-size cells, allocated only when non-empty, keeps memory bounded for large sparse worlds.
- A texture atlas organized by voxel type (with separate side/top/bottom rows) lets generated faces pick correct UVs per voxel type and orientation.
Connects To
- BufferGeometry: the underlying geometry type built up face-by-face for each cell.
- Optimize Lots of Objects (manual): the earlier lesson whose "too many draw calls" problem motivates the merged/cell-based approach here.