Meshlet cluster culling
49 million triangles of rocks, drawn twice.
Left: three.js draws every triangle. About 11 s a frame on a CPU renderer.
Right: the new meshlet pipeline culls 64-triangle clusters on the GPU. About 1 s.
The problem
A field of 1,021 rocks at 48,020 triangles each is 49.0 M triangles. A normal draw call sends all of them every frame, including rocks behind the camera, faces turned away and rocks so far away that each triangle is smaller than a pixel. Instancing (one draw for many copies) saves draw calls, but every triangle still goes through the vertex stage.
How it works
three.js now ships a MeshletDrawingPipeline (WebGPU) in its examples. Each mesh is split into meshlets of 64 triangles, plus up to 6 simplified levels of detail built with meshoptimizer. Then every frame, in compute shaders:
- One thread per object tests its bounding sphere against the camera frustum.
- The same thread picks the level of detail: the simplest level whose error, projected to the screen, stays under 1 px.
- One thread per meshlet then tests 3 things: is it inside the frustum, does every triangle in it face away (normal cone test), and was it hidden behind last frame's depth (occlusion culling).
- Surviving meshlets go into a queue, and the GPU draws them with indirect draw calls. The CPU never sees the list.
From the overview shot that leaves about 15,500 meshlets, at most 1.0 M triangles, out of 49.0 M.
What I hit
Headless Chromium gave me a WebGPU device that died after the first frame. It only stayed alive with SwiftShader Vulkan and the Vulkan surface disabled.
The level of detail is chosen per object. A huge rock that is half near and half far gets one level for the whole thing. Engines that pick detail per cluster group avoid that, at the cost of a more complex hierarchy.
The cone test needs a uniform, positive scale per instance, so I scaled every rock evenly.
The cluster counter is read back from the GPU every 30 frames, so the number on screen lags by about 1 s.
My build
three.js dev branch, WebGPURenderer with the repo's pipeline and its defaults (1 px LOD threshold, occlusion culling on). 3 rock meshes, each a subdivided icosahedron with ridged noise and flat cuts, 1,021 instances. Same camera on both halves. Frame times are real measurements, but on SwiftShader, a CPU software renderer. A GPU will be far faster on both sides, so read the ratio, not the seconds.
Trade-offs
- Cost follows what's on screen, not what's in the scene
- Level of detail comes for free once the mesh is processed
- Needs WebGPU, compute shaders and indirect draws
- Meshlet and LOD building takes time at load
- Some material features (custom vertex output, alpha test and more) are not supported
When to use it
Scenes with lots of dense, mostly static geometry: scanned props, rock fields, cities. For a few simple meshes, the standard path is fine.
Source and credits
Repo: github.com/mrdoob/three.js (MIT license), dev branch at commit cb0a902
Code: I used the repo's MeshletDrawingPipeline (examples/jsm/pipelines/MeshletDrawingPipeline.js) and MeshletGeometryUtils (examples/jsm/utils/, with the meshoptimizer modules it imports) unchanged, set up like examples/webgpu_meshlets.html, with the pipeline's default 1 px LOD threshold and occlusion culling on. The rock meshes, materials, scene, camera and overlay are my own; no models or textures from the repo.
Topic inspired by @dangreenheck's three.js meshlet pipeline demo.