Add hardware occlusion culling for camera-view passes - #500
Merged
Merged
Conversation
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.
tomconder
force-pushed
the
feature/occlusion-culling
branch
from
September 20, 2026 21:04
39c6d5e to
6e788bc
Compare
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
force-pushed
the
feature/occlusion-culling
branch
from
September 20, 2026 21:07
6e788bc to
abc747a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GL_ANY_SAMPLES_PASSEDocclusion 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.OcclusionCullerinstance 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.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.ShadowMapnow 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.GL_CULL_FACEoff: 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.OcclusionCullerclass insponge/src/platform/opengl/scene/, following the same one-class-per-GPU-resource pattern asShadowMap/ClusteredLights/Cube. Itsquery()takes a genericeyePos(notcameraPos) since it's shared by both the camera and the light. Shared the unit-cube geometry betweenCubeand the new query proxy viaunitcube.hpp.ImGui::PlotLines, overlay shows current/total). "Objects" stays a plainX / Y occlusion-visibletext row.Benchmark
Measured on the current
maze.yamlscene (release build,ENABLE_IMGUI=ON), standing in the main hall: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.
Test plan
cmake --build out/build/ci-windows-release --target gamesucceedsGL_INVALID_OPERATIONor other driver errors over an extended run (caught and fixed one: polling a query before its firstglBeginQuery/glEndQuery)