Optimizing 3D Voxel Meshing in WebGL & Three.js
Techniques for rendering procedural voxel worlds at 60 FPS in browsers: chunking, face culling, raycasting, and memory pooling.
The Draw Call Bottleneck
In Three.js, instantiating a separate `Mesh` for every block in a 16x16x64 chunk produces over 16,000 individual draw calls per chunk. Even with GPU instancing, invisible internal faces between touching blocks waste precious rasterizer bandwidth.
1. Hidden Face Culling During Mesh Generation
Before adding a quad to a chunk's geometry buffer, inspect the voxel grid to see if an adjacent block already occupies the neighboring coordinate. If neighbor block is opaque, skip generating the face entirely.
function buildChunkGeometry(chunk, world) {
const positions = [];
const normals = [];
const uvs = [];
for (let x = 0; x < 16; x++) {
for (let y = 0; y < 64; y++) {
for (let z = 0; z < 16; z++) {
const block = chunk.getBlock(x, y, z);
if (!block.isSolid) continue;
// Check 6 cardinal neighbors
for (const face of FACES) {
const neighbor = world.getBlock(x + face.dir[0], y + face.dir[1], z + face.dir[2]);
if (!neighbor || !neighbor.isSolid) {
// Neighbor is transparent or air — emit 2 triangles for this face
emitFaceVertices(positions, normals, uvs, face, x, y, z, block.type);
}
}
}
}
}
return new BufferGeometry(/* ... */);
}2. Procedural Heightmaps with Multi-Octave Noise
Terrain elevation is evaluated by combining high-frequency low-amplitude noise (surface roughness) with low-frequency high-amplitude noise (hills and continental forms).
Face culling reduces voxel vertex count by up to 85%, transforming an unplayable slide-show into a consistent 60 FPS experience.