Skip to content

Add hardware occlusion culling for camera-view passes - #500

Merged
tomconder merged 3 commits into
mainfrom
feature/occlusion-culling
Sep 20, 2026
Merged

tomconder merged 3 commits into
mainfrom
feature/occlusion-culling

Conversation

@tomconder

@tomconder tomconder commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add per-object GL_ANY_SAMPLES_PASSED occlusion queries, each testing an object's world AABB against the depth prepass. Objects with no visible pixels are skipped in the depth prepass and opaque pass.
  • Extend the same technique to the shadow pass: a second, independent OcclusionCuller instance tests each object's AABB against the shadow map's own depth (light view/projection, not the camera's), alongside the existing light-frustum mask. An object can be light-occluded (hidden behind a closer shadow caster) without being camera-occluded, or vice versa, so this is a separate result, not a reuse of the camera-view one.
  • Results lag one frame in both passes: pollResults() picks up last frame's answers before deciding what to draw, query() runs after this frame's real depth is rasterized so the answer is ready for next frame. For the shadow pass, the query runs right after the shadow depth renders, while its FBO is still bound.
  • An object whose AABB contains the query's eye position skips the query and stays visible. From inside, a box only shows its far face (the near face is behind the eye, clipped away), which is always farther than the object's own nearby geometry already in the depth buffer — so the query would fail against itself every frame. Sponza is exactly this case for the camera: its AABB is the whole level, and the camera never leaves it. ShadowMap now exposes the light's view eye position so the shadow-pass query can apply the same guard. The orthographic light projection doesn't clip this way, so there the guard is just a harmless no-op.
  • Query draws also need GL_CULL_FACE off: the renderer's global back-face culling would cull every face of a box the eye is standing inside, leaving nothing for the query to rasterize.
  • New OcclusionCuller class in sponge/src/platform/opengl/scene/, following the same one-class-per-GPU-resource pattern as ShadowMap/ClusteredLights/Cube. Its query() takes a generic eyePos (not cameraPos) since it's shared by both the camera and the light. Shared the unit-cube geometry between Cube and the new query proxy via unitcube.hpp.
  • Debug UI: "Meshes" now plots a 100-frame rolling graph of frustum-visible mesh count (ImGui::PlotLines, overlay shows current/total). "Objects" stays a plain X / Y occlusion-visible text row.

Benchmark

Measured on the current maze.yaml scene (release build, ENABLE_IMGUI=ON), standing in the main hall:

Metric Value
Objects 3 / 3 occlusion-visible
Meshes (frustum) 66 / 105 visible
Cull / submit 2-3 us / 258-266 us
Frame ~3.0-3.1 ms (320-330 FPS)

This scene has only 3 top-level objects: Sponza (one object holding all 105 meshes), the helmet, and the debug cube. Sponza's own AABB is the whole level, so per the eye-inside rule above it's permanently exempt from testing against both the camera and the light — meaning the 105 meshes that dominate this scene's cost are never reachable by either pass. The other two objects are both near the spawn point and stay in view/lit together. Object-granularity occlusion culling is a no-op on this scene today, camera and shadow alike, same conclusion as #499 for the light-frustum shadow cull: the win shows up once the scene has separate objects that can actually occlude each other (a real maze layout with separate wall/room objects), not on a single-mesh backdrop.

  • Query overhead itself is invisible at this object count: submit time (which includes the skip check) is unchanged within noise from a plain frustum-only baseline.

Test plan

  • cmake --build out/build/ci-windows-release --target game succeeds
  • pre-commit passes
  • Launched the built game, confirmed Sponza, the helmet, and the debug cube all render correctly and stay correct after moving the camera
  • Confirmed shadows still render correctly with shadow-pass occlusion culling enabled
  • Confirmed no GL_INVALID_OPERATION or other driver errors over an extended run (caught and fixed one: polling a query before its first glBeginQuery/glEndQuery)
  • Confirmed the Meshes graph renders and scrolls correctly in the debug UI

Each object gets a GL_ANY_SAMPLES_PASSED query, its world AABB drawn
against the depth prepass. Results lag one frame: this frame's poll
gates the depth prepass and opaque pass, then this frame's real depth
feeds next frame's queries. The shadow pass is untouched, since it has
its own light-frustum visibility.

An object whose AABB contains the camera skips the query and stays
visible. From inside, a box shows only its far face, always farther
than the object's own nearby geometry already in the depth buffer, so
the query would fail against itself. Sponza is one such object: its
AABB is the whole level, and the camera is always inside it.

Query draws also need face culling off: global back-face culling
would cull every face of a box the camera is inside, leaving nothing
for the query to test.
@github-actions github-actions Bot added sponge Sponge engine changes game Game changes feature Feature branch changes labels Sep 20, 2026
@tomconder
tomconder force-pushed the feature/occlusion-culling branch from 39c6d5e to 6e788bc Compare September 20, 2026 21:04
Adds an "Objects" row (plain "X / Y occlusion-visible" text) next to
the existing mesh and timing rows, same pattern as the existing
frustum-cull mesh counter. The "Meshes" row is upgraded from a static
"X / Y" text to a 100-frame rolling ImGui::PlotLines graph (overlay
text keeps the current/total counts), giving a rolling view of frustum
visibility instead of a single instantaneous number.
Adds a second OcclusionCuller instance tested against the shadow map's
own depth instead of the camera's, alongside the existing light-frustum
mask. An object can be light-occluded (hidden behind a closer shadow
caster) independently of whether it's camera-occluded, so this is a
separate result from the camera-view culler, not a reuse of it.

ShadowMap now exposes the light's view eye position, needed to run the
same query. Queries are issued against this frame's shadow depth right
after the shadow pass renders, while its FBO is still bound, and polled
at the same point as the camera-view queries.

OcclusionCuller::query() took cameraPos but is now called for the
light's eye too; renamed to eyePos and rewrote the skip-guard comment,
which described perspective near-plane clipping that doesn't apply to
this orthographic case. Also fixed a stale comment on objectWorldBounds
that still named only one of the two cullers reading it.
@tomconder
tomconder force-pushed the feature/occlusion-culling branch from 6e788bc to abc747a Compare September 20, 2026 21:07
@tomconder
tomconder merged commit 5a800b1 into main Sep 20, 2026
12 checks passed
@tomconder
tomconder deleted the feature/occlusion-culling branch September 20, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Feature branch changes game Game changes sponge Sponge engine changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant