Three.js From Zero · Article s5-06
S5-06 GPU Culling & Nanite-Style
GPU Culling & Nanite-Style Geometry
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.