Phase 3C: Hierarchical-Z occlusion culling (CPU-side v1)

After the main draw, blit the MSAA default-framebuffer depth to a
single-sample 256×128 depth texture, read it back, and build a CPU
max-reduced mip pyramid.  Next frame's cullAndUploadVisible projects
each BVH node / instance AABB through the previous frame's VP and
compares the AABB's nearest depth against the pyramid's deepest value
at the matching mip level; strictly-beyond AABBs are rejected.

Conservative direction (aabb_near > hiz_max) — never wrongly rejects a
visible instance, so no flicker.  BVH subtree-level test lets a single
8-corner projection reject up to a leaf's worth of instances.

Tuning knobs: IFC_NO_HIZ=1 disables; IFC_HIZ_SIZE overrides base width.
New stats counter hiz_rej shows rejects/frame.

Measured: big win on interior views (GPU-bound), roughly zero net
effect on exterior overviews (CPU-bound on cull traversal, so the
saved GPU work is masked).  Tried a 3-deep PBO ring for async readback
and reverted — the extra frame of staleness produced visible flicker
on fast orbit, and the synchronous readback wasn't actually a measured
bottleneck at 256×128.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-13 23:25:33 +10:00
parent c78e16eafb
commit 8596d53a4a
3 changed files with 420 additions and 13 deletions
+117 -12
View File
@@ -565,15 +565,120 @@ typical cost is 100600 ms per model, folded into the already-visible
"finalizing" step. Cached into the sidecar afterwards, so subsequent
opens skip it entirely.
#### 3C. Hierarchical-Z occlusion culling (longer-term)
#### 3C. Hierarchical-Z occlusion culling — ✅ done (v1, CPU-side)
Render large occluders first, build a depth pyramid, test instance
AABBs against it. In dense BIM most geometry is behind other geometry
from any given interior viewpoint; historically a 310× reduction in
drawn instances. Most valuable *after* 3A+3B, which together handle
the far-away and small-detail cases. Pairs naturally with GPU-side
culling (a compute shader doing the HiZ test and writing the visible
list + indirect buffer in place).
Reject frustum-visible instances whose AABB is fully behind something
already drawn. The last drawn frame's depth buffer is the oracle — if a
region's deepest rasterised fragment is closer than an AABB's nearest
point, nothing in that AABB can win the depth test.
In dense BIM this matters most on interior views: standing inside a
building, 8095 % of the model sits behind the walls of the current
room and contributes nothing to the frame. Phase 3A drops the
*distant-and-small* geometry, 3B drops its triangle count when kept,
and 3C drops the *close-and-big-but-hidden* bulk that neither of those
can touch. On an outdoor overview shot (nothing is occluded) 3C does
almost nothing — which is fine, 3A+3B already cover that case.
##### Pipeline (v1: CPU-side, 1-frame stale)
```
render():
draw main scene into MSAA default fb
axis gizmo
buildHizPyramid(): <-- new
glBlitFramebuffer MSAA depth → single-sample depth tex (256×128)
glReadPixels depth tex → CPU
max-reduce mip chain on CPU (89 levels)
store the VP that produced this frame
swapBuffers
cullAndUploadVisible():
per BVH node: frustum ∧ contribution ∧ hiz (subtree early-out)
per instance: frustum ∧ contribution ∧ hiz
```
The pyramid is always the *previous* frame's depth. On a newly loaded
scene or after a camera jump the cull is conservatively too permissive
for a frame or two (draws the occluded stuff by accident) and then
settles. No flicker because we never *wrongly reject* a visible
instance — the comparison is `aabb_near_depth > hiz_max`, so the
worst case is a kept instance that was actually occluded.
##### Why CPU-side?
Because the readback is cheap at this resolution (~128 KB / frame,
single glReadPixels ≈ 0.5 ms on PCIe) and the test itself is trivial
— ~100 k AABBs × 8 corners × a small mip lookup is well under a
millisecond on one thread. Phase 3D will port the cull to a compute
shader reading the pyramid as a texture, eliminating the readback; but
Phase 3C's CPU implementation was small enough to do first and
measure.
No MSAA complication on the write side: we just blit the default
framebuffer's multi-sample depth into a single-sample texture (GL
handles the resolve). No separate occluder pass either — we use the
previous completed frame's depth buffer directly, which is what a
temporal-reprojection HiZ reduces to when the "occluder set" is
"everything visible last frame".
##### The test
```cpp
project 8 AABB corners through hiz_vp → NDC rect + min z
if any corner has w ≤ 0: return false // crosses near plane
if rect is outside [-1, 1]²: return false
pick mip level where rect ≤ 2×2 texels
hiz_max = max(pyramid[mip][covered texels])
return aabb_near_depth > hiz_max
```
Comparing the AABB's *closest* point against the pyramid's *deepest*
value is the conservative direction — it only rejects when the AABB
is strictly beyond everything we already drew in that region. We pick
the mip at which the rect covers ≲ 2 texels on each axis so the lookup
is O(1) regardless of AABB size.
##### BVH integration
The same test runs on interior BVH node AABBs before leaf expansion,
so an occluded subtree skips all its instances in one shot. This is
where most of the per-frame cost savings show up on interior shots —
rejecting a 500-instance BVH subtree costs one 8-corner projection.
##### Tuning knobs
| Var | Default | Effect |
|-----|---------|--------|
| `IFC_NO_HIZ` | unset | `1` disables HiZ entirely (forces the Phase-3B-only path). |
| `IFC_HIZ_SIZE` | `256` | Base pyramid width in texels; height tracks viewport aspect. Raise for more accurate near-silhouette occlusion, lower to shrink readback. |
The stats overlay gains one counter, `hiz_rej`, showing how many
instances per frame the HiZ test rejected. On outdoor overview shots
it hovers near zero; on indoor shots it climbs into the hundreds of
thousands and the frame time drops accordingly.
##### Known caveats
- **1 frame stale.** The pyramid is aligned to last frame's view, so
when you whip the camera across the scene we may draw one frame of
stuff that the new view would have occluded. Invisible in practice
at 60 fps. We tried a 3-deep PBO ring for async readback (2-frame
stale) and it produced visible flicker on fast orbits — reverted.
- **Readback syncs the GPU.** `glGetTextureImage` is blocking.
Measured cost is well under a millisecond at 256×128; not a
bottleneck on the machines tested. Phase 3D's compute-shader cull
removes it entirely.
- **Doesn't move the needle on overview shots.** Those scenes are
CPU-bound on the cull traversal itself, not GPU-bound on drawing,
so cutting the drawn-triangle count in half is invisible in the
frame time. `hiz_rej` still rises modestly on overviews (the frustum
hull contains everything behind visible walls) but saved GPU work
is masked by CPU cost. HiZ pays off on interior views, where the
GPU *was* the bottleneck. If a project never leaves overview,
`IFC_NO_HIZ=1` shaves the ~1 ms of HiZ cost.
- **Transparent geometry would need special handling**, but the
current renderer doesn't have any, so no-op for now.
#### 3D. GPU-side culling via compute (longer-term)
@@ -598,7 +703,7 @@ Scene size Bottleneck Fix
100k500k CPU cull scan BVH (Phase 2) — done
500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull
+ Phase 3B LOD (done)
multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion
multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback)
```
## Roadmap
@@ -615,10 +720,10 @@ multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion
- [x] Reflection-aware two-pass draw for mirrored placements
- [x] Backface culling (user-toggleable, default on)
- [x] `reorient-shells` enabled in iterator
- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`)
- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`)
- [x] Phase 3A — screen-space contribution culling
- [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`)
- [ ] **Phase 3C — Hierarchical-Z occlusion culling** (next)
- [ ] Phase 3D — GPU-side compute-shader culling
- [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback)
- [ ] **Phase 3D — GPU-side compute-shader culling** (next; replaces the readback)
- [ ] Vulkan/MoltenVK backend for macOS
- [ ] Embedded Python scripting console