Hi-Z occlusion and chunk culling
40,000 small objects in a city of grey blocks. Left draws everything inside the view: 12,364. Right tests chunks, then objects, against a Hi-Z depth pyramid: 1,953.
The problem
Frustum culling keeps everything inside the view cone, including everything behind the next wall. Walking down a street, most of that is invisible, but it is still sent to the GPU and still costs vertices.
How it works
The source (a Unity URP renderer feature) does it in three steps:
- Copy the depth buffer into a 512 texture and build mips. Each texel of the next mip keeps the farthest of the 2x2 below it, so one texel answers "what is the farthest thing covering this area?"
- A compute shader projects every instance, picks a mip, and reads the Hi-Z there. If the instance is farther than the farthest occluder, it is hidden. Off-screen instances go too.
- Survivors are appended to a buffer and drawn with one indirect instanced draw.
I added a chunk level: the map is cut into 1,024 cells of 10 m. Chunk boxes are tested first, and objects in a rejected chunk are never tested at all.
What I hit
WebGL2 has no compute shaders and no indirect draw. My tests run in fragment passes, one pixel per chunk or object. I read the results back and pack the survivors into instanced meshes on the CPU.
The source tests one centre point per instance with a distance-based mip. A centre point can hide an object that still peeks out, so I test the whole bounding box and pick the mip from its screen size.
My Hi-Z is 512 x 256 from a 960 x 1080 view. The first level takes the farthest depth over each texel's whole footprint, so it never claims more cover than the full-res depth shows.
My build
three.js (r187), WebGL2. 254 buildings are the only occluders in the Hi-Z. 40,000 objects of 3 kinds: 80, 32 and 14 triangles.
At 3 s: left draws 12,364 objects, 600,872 triangles. Right keeps 132 of 1,024 chunks (216 rejected by Hi-Z), tests 5,474 objects and draws 1,953, 93,480 triangles.
Trade-offs
- Big cuts wherever walls hide most of the view
- The chunk test skips most per-object tests
- Occluders must be in depth before the test
- Reading results back stalls the GPU; the source avoids that with indirect draws
- Small objects never hide anything
- I rendered on a software GPU, so counts only, no timings
When to use it
Cities, interiors and dense props behind big occluders. In open fields frustum culling already does most of the work.
Source and credits
Technique source: RenderFeature_HizCull by SoSoReally, https://github.com/SoSoReally/RenderFeature_HizCull, MIT license, commit 1ba6b6fecc4376ad88a50fbc640a7e5d4f1f79b0. Ported to three.js and WebGL2: the Hi-Z pyramid (farthest depth of each 2x2 block, from GenerateMipmap), the per-instance test against the Hi-Z and the frustum (from HizCullMask), and the depth copy, mip, cull and draw order (from HizCullRendererFeature). No files were copied unchanged.
Render framework: three.js by mrdoob and contributors, https://github.com/mrdoob/three.js, MIT license, branch dev at commit fa01dd6ad164c48554f1bb9456f6500db052ec4f. Used for rendering, render targets and instanced meshes. It is not the source of the culling technique.
My own code: the chunk level (1,024 cells tested before objects), bounding-box tests instead of centre points, the conservative first Hi-Z level, the fragment-pass test with read-back and CPU packing (WebGL2 has no compute or indirect draw), the procedural city and 40,000 objects, the top view, counters and video. No external models or assets. No IA model.