Three.js From Zero · Article s5-06
GPU Culling & Nanite-Style Geometry
GPU Culling & Nanite-Style Geometry is Article s5-06 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.
Stop drawing what the camera can't see. Stop drawing what's behind other things. Stop drawing every triangle at full detail regardless of screen size. Modern: all of this on the GPU, at meshlet granularity.
1. The culling hierarchy
- Frustum culling: is the object inside the view frustum?
- Occlusion culling: is it hidden behind something else?
- LOD selection: which detail level matches screen size?
- Backface culling: faces pointing away (per-triangle, GPU handles it).
2. CPU frustum (Three.js default)
Every frame, Three.js computes the 6 planes of the camera frustum. For each mesh, check its bounding sphere. Outside → skip.
// What Three.js does
for each mesh:
if (!frustum.intersectsBox(mesh.boundingBox)) skip;
Good enough for thousands of meshes. Breaks at millions.
3. GPU frustum culling
Move the check to a compute shader.
// Compute pass: test each instance against frustum planes
[[compute]]
fn cull(@builtin(global_invocation_id) id: vec3u) {
let inst = instances[id.x];
let inFrustum = test(inst.bbox, frustumPlanes);
if (inFrustum) {
let slot = atomicAdd(&drawCount, 1);
drawIndirect[slot] = inst.drawArgs;
}
}
Output: a compacted indirect-draw buffer. Then drawIndirect() the survivors in one call. Millions of instances, 60fps.
4. Occlusion culling — Hi-Z
Render the depth buffer from last frame. Build a mip chain where each level stores the max of its 2×2 source pixels (Hi-Z = hierarchical depth).
For each instance, project its bounding box, pick the mip level matching its screen size, sample the Hi-Z. If the bbox's nearest z is farther than the Hi-Z's farthest z → occluded, skip.
5. Nanite (the Unreal 5 thing)
Meshes are pre-split into meshlets — clusters of ~128 triangles. Meshlets arranged in a DAG of LODs.
Per frame:
- GPU traversal of the DAG picks meshlets at screen-appropriate detail.
- Per-meshlet frustum + Hi-Z culling.
- Rasterizer draws survivors.
- If meshlet is under 1 screen pixel, it writes pixel directly (software raster).
Net: movie-quality geometry (billions of triangles) at 60fps with constant overdraw.
6. Live demo — frustum culling on 50k instances
50 000 animated instances over a wide plane. CPU frustum cull toggle. Watch draw-count drop when culling is on.
7. LOD selection
Compute screen-space size: project bounding sphere, compare to pixel threshold. Pick LOD level. Three.js has LOD object — auto-switches meshes by distance.
const lod = new THREE.LOD();
lod.addLevel(highMesh, 0);
lod.addLevel(medMesh, 30);
lod.addLevel(lowMesh, 100);
scene.add(lod);
8. What to do in Three.js today
- InstancedMesh: one draw call per unique mesh × material. Always.
- BatchedMesh (r167+): multi-geometry multi-material in one draw call.
- Frustum culling: per-instance isn't automatic. Check position vs Three's
Frustumin a worker or per-frame loop. - LOD: the
THREE.LODobject for 2-3 levels. - Nanite-like: not stock. Roll your own with WebGPU compute + meshlet format.
9. Takeaways
- Cull early, cull cheap. Frustum first, occlusion next, LOD last.
- CPU culling: scales to ~10k. GPU culling: scales to millions.
- Hi-Z depth pyramid is the occlusion-culling workhorse.
- Nanite = meshlet DAG + GPU traversal + software raster for sub-pixel triangles.
- In Three.js: Instanced/Batched + LOD + custom compute = 95% of the way.