vidar.games

Meshlet cluster culling

2026-11-01 ยท Discuss on X

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:

  1. One thread per object tests its bounding sphere against the camera frustum.
  2. The same thread picks the level of detail: the simplest level whose error, projected to the screen, stays under 1 px.
  3. 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).
  4. 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

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.

New breakdowns go out on X first: @vidar_0208. If this one helped, you can buy me a coffee.

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.