From 929a7bfd2ccfe38fc374d728af71bc0565ce13d7 Mon Sep 17 00:00:00 2001 From: Tom Conder Date: Sun, 20 Sep 2026 13:05:30 -0500 Subject: [PATCH 1/3] Add hardware occlusion culling for camera-view passes 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. --- assets/manifest.yaml | 2 + assets/shaders/slang/occlusionbox.slang | 27 +++++ game/src/layer/mazelayer.cpp | 68 ++++++++++-- game/src/layer/mazelayer.hpp | 13 +++ sponge/src/platform/opengl/scene/cube.cpp | 32 +----- .../platform/opengl/scene/occlusionculler.cpp | 103 ++++++++++++++++++ .../platform/opengl/scene/occlusionculler.hpp | 67 ++++++++++++ sponge/src/platform/opengl/scene/unitcube.hpp | 35 ++++++ 8 files changed, 312 insertions(+), 35 deletions(-) create mode 100644 assets/shaders/slang/occlusionbox.slang create mode 100644 sponge/src/platform/opengl/scene/occlusionculler.cpp create mode 100644 sponge/src/platform/opengl/scene/occlusionculler.hpp create mode 100644 sponge/src/platform/opengl/scene/unitcube.hpp diff --git a/assets/manifest.yaml b/assets/manifest.yaml index 8bab0597..c7cfedc4 100644 --- a/assets/manifest.yaml +++ b/assets/manifest.yaml @@ -82,4 +82,6 @@ shaders: cube.vert: shaders/slang/cube.slang:vertMain depthprepass.vert: shaders/slang/depthprepass.slang:vertMain depthprepass.frag: shaders/slang/depthprepass.slang:fragMain + occlusionbox.vert: shaders/slang/occlusionbox.slang:vertMain + occlusionbox.frag: shaders/slang/occlusionbox.slang:fragMain cluster_assign.comp: shaders/slang/cluster_assign.slang:csMain diff --git a/assets/shaders/slang/occlusionbox.slang b/assets/shaders/slang/occlusionbox.slang new file mode 100644 index 00000000..f3a7c023 --- /dev/null +++ b/assets/shaders/slang/occlusionbox.slang @@ -0,0 +1,27 @@ +// occlusionbox.slang: depth-only AABB proxy for hardware occlusion queries. +// Colour writes are masked off by the caller — the fragment output is never +// read, it only needs to exist for the query's depth test to run. +uniform float4x4 mvp; + +struct VSInput { + float3 position : POSITION; +}; +struct VSOutput { + float4 pos : SV_Position; +}; + +// clang-format off +[shader("vertex")] +VSOutput vertMain(VSInput input) { + // clang-format on + VSOutput o; + o.pos = mul(mvp, float4(input.position, 1.0)); + return o; +}; + +// clang-format off +[shader("fragment")] +float4 fragMain(VSOutput input) : SV_Target { + // clang-format on + return float4(0.0); +}; diff --git a/game/src/layer/mazelayer.cpp b/game/src/layer/mazelayer.cpp index 83ea4c56..fd629426 100644 --- a/game/src/layer/mazelayer.cpp +++ b/game/src/layer/mazelayer.cpp @@ -60,6 +60,7 @@ using sponge::platform::opengl::scene::FXAA; using sponge::platform::opengl::scene::Mesh; using sponge::platform::opengl::scene::Model; using sponge::platform::opengl::scene::ModelCreateInfo; +using sponge::platform::opengl::scene::OcclusionCuller; using sponge::platform::opengl::scene::SceneTarget; using sponge::platform::opengl::scene::ShadowMap; using sponge::platform::opengl::scene::TAA; @@ -107,20 +108,26 @@ void MazeLayer::finishLoading(std::vector> builtModels) { std::vector meshBounds; meshBounds.reserve(model->getMeshCount()); + sponge::scene::AABB objectBounds{ + glm::vec3(std::numeric_limits::max()), + glm::vec3(std::numeric_limits::lowest()) + }; for (size_t m = 0; m < model->getMeshCount(); m++) { - meshBounds.push_back( - sponge::scene::transform(model->getMeshBounds(m), modelMatrix)); + const auto worldBounds = + sponge::scene::transform(model->getMeshBounds(m), modelMatrix); + objectBounds.min = glm::min(objectBounds.min, worldBounds.min); + objectBounds.max = glm::max(objectBounds.max, worldBounds.max); + meshBounds.push_back(worldBounds); } objectMeshWorldBounds.push_back(std::move(meshBounds)); + objectWorldBounds.push_back(objectBounds); } sceneBounds = { glm::vec3(std::numeric_limits::max()), glm::vec3(std::numeric_limits::lowest()) }; - for (const auto& bounds : objectMeshWorldBounds) { - for (const auto& box : bounds) { - sceneBounds.min = glm::min(sceneBounds.min, box.min); - sceneBounds.max = glm::max(sceneBounds.max, box.max); - } + for (const auto& box : objectWorldBounds) { + sceneBounds.min = glm::min(sceneBounds.min, box.min); + sceneBounds.max = glm::max(sceneBounds.max, box.max); } const auto gameCameraCreateInfo = @@ -163,6 +170,7 @@ void MazeLayer::finishLoading(std::vector> builtModels) { shadowMap = std::make_unique(directionalLight.shadowMapRes); cube = std::make_unique(); + occlusionCuller = std::make_unique(objectModels.size()); fxaa = std::make_unique(Maze::get().getWindow()->getWidth(), Maze::get().getWindow()->getHeight()); @@ -485,6 +493,12 @@ void MazeLayer::onRender() { const auto& frame = renderFrames[renderReadIndex.load(std::memory_order_acquire)]; + // Pick up whichever occlusion queries resolved since last frame, before + // any pass decides what's visible. + if (occlusionCuller) { + occlusionCuller->pollResults(); + } + // Phase 1: shadow map if (frame.shadowEnabled && frame.shadowCastShadow) { renderSceneToDepthMap(frame); @@ -493,6 +507,10 @@ void MazeLayer::onRender() { // Phase 2: depth prepass renderDepthPrepass(frame); + // Phase 2.5: occlusion queries against the depth just rasterized — + // results feed next frame's occlusion skip, not this one's. + renderOcclusionQueries(frame); + // Phase 3: light culling if (clusteredLights && frame.numLights > 0) { clusteredLights->update(frame.lightPositions.data(), @@ -796,6 +814,10 @@ void MazeLayer::renderGameObjects(const thread::MazeRenderFrame& frame) const { const auto submitStart = std::chrono::steady_clock::now(); for (size_t i = 0; i < frame.objectModels.size(); i++) { + if (occlusionCuller && !occlusionCuller->isVisible(i)) { + continue; + } + const auto& modelMatrix = frame.objectModelMatrices[i]; shader->setMat4("mvp", frame.cameraMVP * modelMatrix); @@ -882,6 +904,10 @@ void MazeLayer::renderDepthPrepass(const thread::MazeRenderFrame& frame) const { depthPrepassShader->bind(); for (size_t i = 0; i < frame.objectModels.size(); ++i) { + if (occlusionCuller && !occlusionCuller->isVisible(i)) { + continue; + } + // Rasterization uses the jittered matrix so prepass depth lines up with // the scene pass; motion is measured unjittered, or the jitter itself // would read as movement. @@ -921,6 +947,34 @@ void MazeLayer::renderDepthPrepass(const thread::MazeRenderFrame& frame) const { glBindFramebuffer(GL_FRAMEBUFFER, 0); } +void MazeLayer::renderOcclusionQueries( + const thread::MazeRenderFrame& frame) const { + if (!occlusionCuller) { + return; + } + + // Test every object's AABB against the depth prepass just rasterized. + // Depth-test only: no colour, no depth write, so this can't perturb the + // buffer the opaque pass is about to blit and depth-test against. + glBindFramebuffer(GL_FRAMEBUFFER, depthPrepassFbo); + glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); + glDepthMask(GL_FALSE); + glDepthFunc(GL_LEQUAL); + // Global back-face culling (RendererAPI) would cull every face of a box + // the camera is inside — true for any large object's AABB, e.g. sponza's + // — leaving the query with nothing to rasterize and the object stuck + // permanently "occluded". The proxy has no back faces to hide anyway. + glDisable(GL_CULL_FACE); + + occlusionCuller->query(objectWorldBounds, frame.cameraMVP, frame.cameraPos); + + glEnable(GL_CULL_FACE); + glDepthMask(GL_TRUE); + glDepthFunc(GL_LESS); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + void MazeLayer::blitDepthToCurrentFbo(const int w, const int h) const { GLint drawFbo = 0; glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &drawFbo); diff --git a/game/src/layer/mazelayer.hpp b/game/src/layer/mazelayer.hpp index ba71588c..075d94fd 100644 --- a/game/src/layer/mazelayer.hpp +++ b/game/src/layer/mazelayer.hpp @@ -11,6 +11,7 @@ #include "platform/opengl/scene/cube.hpp" #include "platform/opengl/scene/fxaa.hpp" #include "platform/opengl/scene/model.hpp" +#include "platform/opengl/scene/occlusionculler.hpp" #include "platform/opengl/scene/scenetarget.hpp" #include "platform/opengl/scene/shadowmap.hpp" #include "platform/opengl/scene/taa.hpp" @@ -154,9 +155,19 @@ class MazeLayer final : public sponge::layer::Layer { // Objects never move after finishLoading(), so this is computed once // there rather than every frame like the visibility test that reads it. std::vector> objectMeshWorldBounds; + // Per-object union of objectMeshWorldBounds, index-locked with + // objectModels — the box occlusionCuller tests each object against. + // Same one-time computation, same reason: objects never move. + std::vector objectWorldBounds; // Union of objectMeshWorldBounds, for fitting the shadow frustum to the // scene. Same one-time computation as above, same reason. sponge::scene::AABB sceneBounds; + // Hardware occlusion queries against the depth prepass, gating the + // camera-view passes (depth prepass, opaque) only — not the shadow pass, + // which has its own light-frustum visibility. One frame of latency; see + // occlusionculler.hpp. + std::unique_ptr + occlusionCuller; std::unique_ptr clusteredLights; std::shared_ptr @@ -264,6 +275,8 @@ class MazeLayer final : public sponge::layer::Layer { void renderDepthPrepass(const thread::MazeRenderFrame& frame) const; + void renderOcclusionQueries(const thread::MazeRenderFrame& frame) const; + void blitDepthToCurrentFbo(int w, int h) const; void renderGameObjects(const thread::MazeRenderFrame& frame) const; diff --git a/sponge/src/platform/opengl/scene/cube.cpp b/sponge/src/platform/opengl/scene/cube.cpp index 6972ac6a..7fc33f53 100644 --- a/sponge/src/platform/opengl/scene/cube.cpp +++ b/sponge/src/platform/opengl/scene/cube.cpp @@ -2,36 +2,12 @@ #include "logging/log.hpp" #include "platform/opengl/renderer/assetmanager.hpp" +#include "platform/opengl/scene/unitcube.hpp" #include -#include #include #include -namespace { -constexpr std::array vertices = { - glm::vec3{ -0.5, 0.5, -0.5 }, glm::vec3{ -0.5, 0.5, 0.5 }, - glm::vec3{ 0.5, 0.5, 0.5 }, glm::vec3{ -0.5, 0.5, -0.5 }, - glm::vec3{ 0.5, 0.5, 0.5 }, glm::vec3{ 0.5, 0.5, -0.5 }, - glm::vec3{ -0.5, 0.5, -0.5 }, glm::vec3{ -0.5, -0.5, -0.5 }, - glm::vec3{ -0.5, -0.5, 0.5 }, glm::vec3{ -0.5, 0.5, -0.5 }, - glm::vec3{ -0.5, -0.5, 0.5 }, glm::vec3{ -0.5, 0.5, 0.5 }, - glm::vec3{ 0.5, 0.5, 0.5 }, glm::vec3{ 0.5, -0.5, 0.5 }, - glm::vec3{ 0.5, -0.5, -0.5 }, glm::vec3{ 0.5, 0.5, 0.5 }, - glm::vec3{ 0.5, -0.5, -0.5 }, glm::vec3{ 0.5, 0.5, -0.5 }, - glm::vec3{ 0.5, 0.5, -0.5 }, glm::vec3{ 0.5, -0.5, -0.5 }, - glm::vec3{ -0.5, -0.5, -0.5 }, glm::vec3{ 0.5, 0.5, -0.5 }, - glm::vec3{ -0.5, -0.5, -0.5 }, glm::vec3{ -0.5, 0.5, -0.5 }, - glm::vec3{ -0.5, 0.5, 0.5 }, glm::vec3{ -0.5, -0.5, 0.5 }, - glm::vec3{ 0.5, -0.5, 0.5 }, glm::vec3{ -0.5, 0.5, 0.5 }, - glm::vec3{ 0.5, -0.5, 0.5 }, glm::vec3{ 0.5, 0.5, 0.5 }, - glm::vec3{ -0.5, -0.5, 0.5 }, glm::vec3{ -0.5, -0.5, -0.5 }, - glm::vec3{ 0.5, -0.5, -0.5 }, glm::vec3{ -0.5, -0.5, 0.5 }, - glm::vec3{ 0.5, -0.5, -0.5 }, glm::vec3{ 0.5, -0.5, 0.5 }, -}; -constexpr uint32_t vertexCount = 36; -} // namespace - namespace sponge::platform::opengl::scene { using renderer::AssetManager; @@ -47,8 +23,8 @@ Cube::Cube() { vao = std::make_unique(); vao->bind(); - vbo = std::make_unique(vertices.data(), - sizeof(vertices)); + vbo = std::make_unique(unitCubeVertices.data(), + sizeof(unitCubeVertices)); vbo->bind(); constexpr uint32_t positionLoc = 0; @@ -62,7 +38,7 @@ Cube::Cube() { void Cube::render() const { vao->bind(); - glDrawArrays(GL_TRIANGLES, 0, vertexCount); + glDrawArrays(GL_TRIANGLES, 0, unitCubeVertexCount); vao->unbind(); } diff --git a/sponge/src/platform/opengl/scene/occlusionculler.cpp b/sponge/src/platform/opengl/scene/occlusionculler.cpp new file mode 100644 index 00000000..35732d92 --- /dev/null +++ b/sponge/src/platform/opengl/scene/occlusionculler.cpp @@ -0,0 +1,103 @@ +#include "platform/opengl/scene/occlusionculler.hpp" + +#include "platform/opengl/renderer/assetmanager.hpp" +#include "platform/opengl/renderer/gl.hpp" +#include "platform/opengl/scene/unitcube.hpp" + +#include + +namespace sponge::platform::opengl::scene { +using renderer::AssetManager; + +OcclusionCuller::OcclusionCuller(const size_t objectCount) : + queries(objectCount, 0), visible(objectCount, 1), issued(objectCount, 0) { + if (objectCount == 0) { + return; + } + + glGenQueries(static_cast(objectCount), queries.data()); + + shader = AssetManager::createShader({ + .name = "occlusionbox", + .vertexShader = "occlusionbox.vert", + .fragmentShader = "occlusionbox.frag", + }); + shader->bind(); + + vao = std::make_unique(); + vao->bind(); + + vbo = std::make_unique(unitCubeVertices.data(), + sizeof(unitCubeVertices)); + vbo->bind(); + + constexpr uint32_t positionLoc = 0; + glEnableVertexAttribArray(positionLoc); + glVertexAttribPointer(positionLoc, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), + reinterpret_cast(0)); + + shader->unbind(); + vao->unbind(); +} + +OcclusionCuller::~OcclusionCuller() { + if (!queries.empty()) { + glDeleteQueries(static_cast(queries.size()), queries.data()); + } +} + +void OcclusionCuller::query(const std::vector& worldBounds, + const glm::mat4& viewProj, + const glm::vec3& cameraPos) { + shader->bind(); + vao->bind(); + + for (size_t i = 0; i < worldBounds.size(); i++) { + const auto& box = worldBounds[i]; + + const bool cameraInside = + cameraPos.x >= box.min.x && cameraPos.x <= box.max.x && + cameraPos.y >= box.min.y && cameraPos.y <= box.max.y && + cameraPos.z >= box.min.z && cameraPos.z <= box.max.z; + if (cameraInside) { + visible[i] = 1; + // Leave unissued: pollResults() then skips it until a query + // fired after the camera leaves the box gives a real answer. + issued[i] = 0; + continue; + } + + const auto center = (box.min + box.max) * 0.5F; + const auto size = box.max - box.min; + const auto boxModel = glm::translate(glm::mat4(1.F), center) * + glm::scale(glm::mat4(1.F), size); + shader->setMat4("mvp", viewProj * boxModel); + + glBeginQuery(GL_ANY_SAMPLES_PASSED, queries[i]); + glDrawArrays(GL_TRIANGLES, 0, unitCubeVertexCount); + glEndQuery(GL_ANY_SAMPLES_PASSED); + issued[i] = 1; + } + + vao->unbind(); + shader->unbind(); +} + +void OcclusionCuller::pollResults() { + for (size_t i = 0; i < queries.size(); i++) { + if (!issued[i]) { + continue; + } + + GLuint available = 0; + glGetQueryObjectuiv(queries[i], GL_QUERY_RESULT_AVAILABLE, &available); + if (available == GL_FALSE) { + continue; + } + GLuint anyPassed = 0; + glGetQueryObjectuiv(queries[i], GL_QUERY_RESULT, &anyPassed); + visible[i] = anyPassed != 0U ? 1 : 0; + } +} + +} // namespace sponge::platform::opengl::scene diff --git a/sponge/src/platform/opengl/scene/occlusionculler.hpp b/sponge/src/platform/opengl/scene/occlusionculler.hpp new file mode 100644 index 00000000..5161b91b --- /dev/null +++ b/sponge/src/platform/opengl/scene/occlusionculler.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "platform/opengl/renderer/shader.hpp" +#include "platform/opengl/renderer/vertexarray.hpp" +#include "platform/opengl/renderer/vertexbuffer.hpp" +#include "scene/frustum.hpp" + +#include + +#include +#include +#include +#include + +namespace sponge::platform::opengl::scene { + +// Hardware occlusion culling: one GL_ANY_SAMPLES_PASSED query per object, +// its world AABB drawn depth-test-only against whatever is already in the +// bound depth buffer. Results lag one frame: call pollResults() at the +// start of a frame to pick up last frame's answers before deciding what to +// draw, then query() once this frame's real depth is rasterized, so the +// result is ready for next frame. An object stays visible until its first +// result arrives, so nothing is hidden before there's data to hide it with. +class OcclusionCuller { +public: + explicit OcclusionCuller(size_t objectCount); + ~OcclusionCuller(); + + OcclusionCuller(const OcclusionCuller&) = delete; + OcclusionCuller& operator=(const OcclusionCuller&) = delete; + + // Caller must already have the target depth buffer bound, with depth + // test on (GL_LEQUAL) and colour/depth writes off — this only reads + // depth. worldBounds must be objectCount long, index-locked with the + // caller's own object list. + // + // An object whose box contains cameraPos is skipped and left visible: a + // camera inside a convex box never sees its near face (it's behind the + // camera, clipped away), so only the far face rasterizes — always + // farther than the object's own nearby geometry already in the depth + // buffer, so the query would always "fail" against itself. True for any + // object large enough to enclose the camera (e.g. the whole level). + void query(const std::vector& worldBounds, + const glm::mat4& viewProj, const glm::vec3& cameraPos); + + // Non-blocking: pulls in whichever queries already have a result. + // Objects with no result yet keep their last known visibility. + void pollResults(); + + bool isVisible(size_t index) const { + return visible[index] != 0; + } + +private: + std::shared_ptr shader; + std::unique_ptr vao; + std::unique_ptr vbo; + std::vector queries; + std::vector visible; + // A name from glGenQueries isn't a valid query object until it's been + // through one glBeginQuery/glEndQuery pair — polling it before that is a + // GL_INVALID_OPERATION ("query object not found"), so pollResults() + // skips any index query() hasn't issued yet. + std::vector issued; +}; + +} // namespace sponge::platform::opengl::scene diff --git a/sponge/src/platform/opengl/scene/unitcube.hpp b/sponge/src/platform/opengl/scene/unitcube.hpp new file mode 100644 index 00000000..66ff66ce --- /dev/null +++ b/sponge/src/platform/opengl/scene/unitcube.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include +#include + +namespace sponge::platform::opengl::scene { + +// Unit cube centred at the origin, extent [-0.5, 0.5] per axis. Shared by +// anything that just needs a cheap box proxy: light-position debug cubes, +// occlusion-query AABB proxies. +constexpr std::array unitCubeVertices = { + glm::vec3{ -0.5, 0.5, -0.5 }, glm::vec3{ -0.5, 0.5, 0.5 }, + glm::vec3{ 0.5, 0.5, 0.5 }, glm::vec3{ -0.5, 0.5, -0.5 }, + glm::vec3{ 0.5, 0.5, 0.5 }, glm::vec3{ 0.5, 0.5, -0.5 }, + glm::vec3{ -0.5, 0.5, -0.5 }, glm::vec3{ -0.5, -0.5, -0.5 }, + glm::vec3{ -0.5, -0.5, 0.5 }, glm::vec3{ -0.5, 0.5, -0.5 }, + glm::vec3{ -0.5, -0.5, 0.5 }, glm::vec3{ -0.5, 0.5, 0.5 }, + glm::vec3{ 0.5, 0.5, 0.5 }, glm::vec3{ 0.5, -0.5, 0.5 }, + glm::vec3{ 0.5, -0.5, -0.5 }, glm::vec3{ 0.5, 0.5, 0.5 }, + glm::vec3{ 0.5, -0.5, -0.5 }, glm::vec3{ 0.5, 0.5, -0.5 }, + glm::vec3{ 0.5, 0.5, -0.5 }, glm::vec3{ 0.5, -0.5, -0.5 }, + glm::vec3{ -0.5, -0.5, -0.5 }, glm::vec3{ 0.5, 0.5, -0.5 }, + glm::vec3{ -0.5, -0.5, -0.5 }, glm::vec3{ -0.5, 0.5, -0.5 }, + glm::vec3{ -0.5, 0.5, 0.5 }, glm::vec3{ -0.5, -0.5, 0.5 }, + glm::vec3{ 0.5, -0.5, 0.5 }, glm::vec3{ -0.5, 0.5, 0.5 }, + glm::vec3{ 0.5, -0.5, 0.5 }, glm::vec3{ 0.5, 0.5, 0.5 }, + glm::vec3{ -0.5, -0.5, 0.5 }, glm::vec3{ -0.5, -0.5, -0.5 }, + glm::vec3{ 0.5, -0.5, -0.5 }, glm::vec3{ -0.5, -0.5, 0.5 }, + glm::vec3{ 0.5, -0.5, -0.5 }, glm::vec3{ 0.5, -0.5, 0.5 }, +}; +constexpr uint32_t unitCubeVertexCount = 36; + +} // namespace sponge::platform::opengl::scene From 03b1157d30633a45dfb893d9ac299eaacb4c4c72 Mon Sep 17 00:00:00 2001 From: Tom Conder Date: Sun, 20 Sep 2026 16:06:40 -0500 Subject: [PATCH 2/3] Show per-frame occlusion-culling stats in the debug UI 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. --- game/src/layer/imgui/imguilayer.cpp | 28 +++++++++++++++++++++++----- game/src/layer/imgui/imguilayer.hpp | 6 ++++++ game/src/layer/mazelayer.cpp | 7 ++++++- game/src/layer/mazelayer.hpp | 12 ++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/game/src/layer/imgui/imguilayer.cpp b/game/src/layer/imgui/imguilayer.cpp index b9438e56..768c362c 100644 --- a/game/src/layer/imgui/imguilayer.cpp +++ b/game/src/layer/imgui/imguilayer.cpp @@ -57,6 +57,9 @@ bool ImGuiLayer::hasLogMenu = true; std::vector ImGuiLayer::levelNames; std::vector ImGuiLayer::categoryNames; +std::array ImGuiLayer::meshVisibleHistory{}; +int ImGuiLayer::historyOffset = 0; + using sponge::layer::Layer; using sponge::layer::LayerStack; using sponge::platform::opengl::renderer::AssetManager; @@ -132,19 +135,34 @@ void ImGuiLayer::showInfoSection() { ImGui::Text("%s", resolution.c_str()); const auto mazeLayer = Maze::get().getMazeLayer(); + + const auto meshVisible = mazeLayer->getVisibleMeshCount(); + const auto meshTotal = mazeLayer->getTotalMeshCount(); + + // Ring buffer: write the current value, then advance so + // historyOffset points at the oldest sample — PlotLines' own + // values_offset scrolls the plot from there. + meshVisibleHistory[static_cast(historyOffset)] = + static_cast(meshVisible); + historyOffset = (historyOffset + 1) % historyLength; + ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::Text("Meshes"); ImGui::TableNextColumn(); - ImGui::Text("%u / %u visible", mazeLayer->getVisibleMeshCount(), - mazeLayer->getTotalMeshCount()); + const auto meshOverlay = fmt::format("{} / {}", meshVisible, meshTotal); + ImGui::PlotLines("##MeshesGraph", meshVisibleHistory.data(), + historyLength, historyOffset, meshOverlay.c_str(), 0.F, + static_cast(std::max(meshTotal, 1U)), + ImVec2(0, 30)); ImGui::TableNextRow(); ImGui::TableNextColumn(); - ImGui::Text("Cull / submit"); + ImGui::Text("Objects"); ImGui::TableNextColumn(); - ImGui::Text("%u us / %u us", mazeLayer->getCullMicros(), - mazeLayer->getSubmitMicros()); + ImGui::Text("%u / %u occlusion-visible", + mazeLayer->getOcclusionVisibleCount(), + mazeLayer->getOcclusionTotalCount()); ImGui::EndTable(); } diff --git a/game/src/layer/imgui/imguilayer.hpp b/game/src/layer/imgui/imguilayer.hpp index 2658d257..641ece93 100644 --- a/game/src/layer/imgui/imguilayer.hpp +++ b/game/src/layer/imgui/imguilayer.hpp @@ -5,6 +5,7 @@ #include "layer/layerstack.hpp" #include "logging/log.hpp" +#include #include #include #include @@ -29,6 +30,11 @@ class ImGuiLayer final : public sponge::layer::Layer { static std::vector levelNames; static std::vector categoryNames; + // Rolling per-frame history for the Info section's Meshes graph. + static constexpr int historyLength = 100; + static std::array meshVisibleHistory; + static int historyOffset; + // Main sections static void showInfoSection(); static void showAppInfoWindow(float width); diff --git a/game/src/layer/mazelayer.cpp b/game/src/layer/mazelayer.cpp index fd629426..d55b103d 100644 --- a/game/src/layer/mazelayer.cpp +++ b/game/src/layer/mazelayer.cpp @@ -812,11 +812,13 @@ void MazeLayer::renderGameObjects(const thread::MazeRenderFrame& frame) const { shadowMap->activateAndBindShadowTexture(1); } - const auto submitStart = std::chrono::steady_clock::now(); + const auto submitStart = std::chrono::steady_clock::now(); + uint32_t occlusionVisible = 0; for (size_t i = 0; i < frame.objectModels.size(); i++) { if (occlusionCuller && !occlusionCuller->isVisible(i)) { continue; } + occlusionVisible++; const auto& modelMatrix = frame.objectModelMatrices[i]; @@ -834,6 +836,9 @@ void MazeLayer::renderGameObjects(const thread::MazeRenderFrame& frame) const { .count(); submitMicros.store(static_cast(submitUs), std::memory_order_relaxed); + occlusionVisibleCount.store(occlusionVisible, std::memory_order_relaxed); + occlusionTotalCount.store(static_cast(frame.objectModels.size()), + std::memory_order_relaxed); shader->unbind(); } diff --git a/game/src/layer/mazelayer.hpp b/game/src/layer/mazelayer.hpp index 075d94fd..962645cb 100644 --- a/game/src/layer/mazelayer.hpp +++ b/game/src/layer/mazelayer.hpp @@ -124,6 +124,16 @@ class MazeLayer final : public sponge::layer::Layer { return submitMicros.load(std::memory_order_relaxed); } + // This frame's occlusion-culling stats, for the debug UI. Written from + // renderGameObjects() (render thread), read from the render thread too — + // atomic only so the type matches the frustum counters above. + uint32_t getOcclusionVisibleCount() const { + return occlusionVisibleCount.load(std::memory_order_relaxed); + } + uint32_t getOcclusionTotalCount() const { + return occlusionTotalCount.load(std::memory_order_relaxed); + } + // True once finishLoading() has run; LoadingLayer skips reloading if set. bool isLoaded() const { return resourcesReady.load(std::memory_order_acquire); @@ -242,6 +252,8 @@ class MazeLayer final : public sponge::layer::Layer { // Set from renderGameObjects(), which is const (render-thread methods // are const throughout this class). mutable std::atomic submitMicros{ 0 }; + mutable std::atomic occlusionVisibleCount{ 0 }; + mutable std::atomic occlusionTotalCount{ 0 }; float ambientStrength = .25F; float ao = .25F; int32_t attenuationIndex = 4; From abc747aba39f24b0d3829f1d075eac4e7b63afde Mon Sep 17 00:00:00 2001 From: Tom Conder Date: Sun, 20 Sep 2026 16:06:46 -0500 Subject: [PATCH 3/3] Extend occlusion culling to the shadow pass 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. --- game/src/layer/mazelayer.cpp | 26 +++++++++++++++++++ game/src/layer/mazelayer.hpp | 14 +++++++--- .../platform/opengl/scene/occlusionculler.cpp | 13 +++++----- .../platform/opengl/scene/occlusionculler.hpp | 19 ++++++++------ .../src/platform/opengl/scene/shadowmap.cpp | 1 + .../src/platform/opengl/scene/shadowmap.hpp | 7 +++++ 6 files changed, 61 insertions(+), 19 deletions(-) diff --git a/game/src/layer/mazelayer.cpp b/game/src/layer/mazelayer.cpp index d55b103d..34f39f71 100644 --- a/game/src/layer/mazelayer.cpp +++ b/game/src/layer/mazelayer.cpp @@ -171,6 +171,8 @@ void MazeLayer::finishLoading(std::vector> builtModels) { shadowMap = std::make_unique(directionalLight.shadowMapRes); cube = std::make_unique(); occlusionCuller = std::make_unique(objectModels.size()); + shadowOcclusionCuller = + std::make_unique(objectModels.size()); fxaa = std::make_unique(Maze::get().getWindow()->getWidth(), Maze::get().getWindow()->getHeight()); @@ -498,6 +500,9 @@ void MazeLayer::onRender() { if (occlusionCuller) { occlusionCuller->pollResults(); } + if (shadowOcclusionCuller) { + shadowOcclusionCuller->pollResults(); + } // Phase 1: shadow map if (frame.shadowEnabled && frame.shadowCastShadow) { @@ -1018,12 +1023,33 @@ void MazeLayer::renderSceneToDepthMap( shader->setMat4("lightSpaceMatrix", frame.lightSpaceMatrix); for (size_t i = 0; i < frame.objectModels.size(); i++) { + if (shadowOcclusionCuller && !shadowOcclusionCuller->isVisible(i)) { + continue; + } shader->setMat4("model", frame.objectModelMatrices[i]); frame.objectModels[i]->render(shader, frame.objectMeshVisibleLight[i]); } shader->unbind(); + // Test every object's AABB against the shadow depth just rasterized, + // same technique as renderOcclusionQueries() but against the light's own + // depth instead of the camera's — still bound, so no FBO switch needed. + if (shadowOcclusionCuller) { + glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); + glDepthMask(GL_FALSE); + glDepthFunc(GL_LEQUAL); + glDisable(GL_CULL_FACE); + + shadowOcclusionCuller->query(objectWorldBounds, frame.lightSpaceMatrix, + shadowMap->getEyePosition()); + + glEnable(GL_CULL_FACE); + glDepthMask(GL_TRUE); + glDepthFunc(GL_LESS); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + } + shadowMap->unbind(); } diff --git a/game/src/layer/mazelayer.hpp b/game/src/layer/mazelayer.hpp index 962645cb..037e67d3 100644 --- a/game/src/layer/mazelayer.hpp +++ b/game/src/layer/mazelayer.hpp @@ -166,18 +166,24 @@ class MazeLayer final : public sponge::layer::Layer { // there rather than every frame like the visibility test that reads it. std::vector> objectMeshWorldBounds; // Per-object union of objectMeshWorldBounds, index-locked with - // objectModels — the box occlusionCuller tests each object against. - // Same one-time computation, same reason: objects never move. + // objectModels — the box occlusionCuller and shadowOcclusionCuller test + // each object against. Same one-time computation, same reason: objects + // never move. std::vector objectWorldBounds; // Union of objectMeshWorldBounds, for fitting the shadow frustum to the // scene. Same one-time computation as above, same reason. sponge::scene::AABB sceneBounds; // Hardware occlusion queries against the depth prepass, gating the - // camera-view passes (depth prepass, opaque) only — not the shadow pass, - // which has its own light-frustum visibility. One frame of latency; see + // camera-view passes (depth prepass, opaque). One frame of latency; see // occlusionculler.hpp. std::unique_ptr occlusionCuller; + // Same technique against the shadow map's own depth: an object can be + // light-occluded independently of camera-occluded, so this is a separate + // result from occlusionCuller, alongside the light-frustum mask + // (objectMeshVisibleLight). + std::unique_ptr + shadowOcclusionCuller; std::unique_ptr clusteredLights; std::shared_ptr diff --git a/sponge/src/platform/opengl/scene/occlusionculler.cpp b/sponge/src/platform/opengl/scene/occlusionculler.cpp index 35732d92..df63b00a 100644 --- a/sponge/src/platform/opengl/scene/occlusionculler.cpp +++ b/sponge/src/platform/opengl/scene/occlusionculler.cpp @@ -48,21 +48,20 @@ OcclusionCuller::~OcclusionCuller() { void OcclusionCuller::query(const std::vector& worldBounds, const glm::mat4& viewProj, - const glm::vec3& cameraPos) { + const glm::vec3& eyePos) { shader->bind(); vao->bind(); for (size_t i = 0; i < worldBounds.size(); i++) { const auto& box = worldBounds[i]; - const bool cameraInside = - cameraPos.x >= box.min.x && cameraPos.x <= box.max.x && - cameraPos.y >= box.min.y && cameraPos.y <= box.max.y && - cameraPos.z >= box.min.z && cameraPos.z <= box.max.z; - if (cameraInside) { + const bool eyeInside = eyePos.x >= box.min.x && eyePos.x <= box.max.x && + eyePos.y >= box.min.y && eyePos.y <= box.max.y && + eyePos.z >= box.min.z && eyePos.z <= box.max.z; + if (eyeInside) { visible[i] = 1; // Leave unissued: pollResults() then skips it until a query - // fired after the camera leaves the box gives a real answer. + // fired after the eye leaves the box gives a real answer. issued[i] = 0; continue; } diff --git a/sponge/src/platform/opengl/scene/occlusionculler.hpp b/sponge/src/platform/opengl/scene/occlusionculler.hpp index 5161b91b..559a25a0 100644 --- a/sponge/src/platform/opengl/scene/occlusionculler.hpp +++ b/sponge/src/platform/opengl/scene/occlusionculler.hpp @@ -32,16 +32,19 @@ class OcclusionCuller { // Caller must already have the target depth buffer bound, with depth // test on (GL_LEQUAL) and colour/depth writes off — this only reads // depth. worldBounds must be objectCount long, index-locked with the - // caller's own object list. + // caller's own object list. viewProj/eyePos are whatever's being tested + // against — a perspective camera or an orthographic light. // - // An object whose box contains cameraPos is skipped and left visible: a - // camera inside a convex box never sees its near face (it's behind the - // camera, clipped away), so only the far face rasterizes — always - // farther than the object's own nearby geometry already in the depth - // buffer, so the query would always "fail" against itself. True for any - // object large enough to enclose the camera (e.g. the whole level). + // An object whose box contains eyePos is skipped and left visible. This + // matters for a perspective eye inside a convex box: only the far face + // rasterizes (the near face is behind the eye, clipped away), and + // that's always farther than the object's own nearby geometry already + // in the depth buffer, so the query would always "fail" against itself + // — true for any object large enough to enclose the eye (e.g. the whole + // level). An orthographic eye (e.g. a directional light) doesn't clip + // this way, so there the guard is just a harmless no-op. void query(const std::vector& worldBounds, - const glm::mat4& viewProj, const glm::vec3& cameraPos); + const glm::mat4& viewProj, const glm::vec3& eyePos); // Non-blocking: pulls in whichever queries already have a result. // Objects with no result yet keep their last known visibility. diff --git a/sponge/src/platform/opengl/scene/shadowmap.cpp b/sponge/src/platform/opengl/scene/shadowmap.cpp index 1c4bc924..5ab80238 100644 --- a/sponge/src/platform/opengl/scene/shadowmap.cpp +++ b/sponge/src/platform/opengl/scene/shadowmap.cpp @@ -213,6 +213,7 @@ void ShadowMap::updateLightSpaceMatrix(const glm::vec3& lightDirection, glm::vec3(0.F, 1.F, 0.F); const auto eye = center - lightDirection * radius * 2.F; const auto lightView = glm::lookAt(eye, center, up); + eyePosition = eye; // Fit the ortho box to the scene's bounds as seen from the light, so the // frustum always exactly covers the static scene, at whatever size it diff --git a/sponge/src/platform/opengl/scene/shadowmap.hpp b/sponge/src/platform/opengl/scene/shadowmap.hpp index b29ee0e9..3968eade 100644 --- a/sponge/src/platform/opengl/scene/shadowmap.hpp +++ b/sponge/src/platform/opengl/scene/shadowmap.hpp @@ -36,6 +36,12 @@ class ShadowMap { const glm::mat4& getLightSpaceMatrix() const; + // World-space eye used to build the light's view matrix, for occlusion + // queries run against this map's own depth (see OcclusionCuller). + const glm::vec3& getEyePosition() const { + return eyePosition; + } + // Fits the ortho box and near/far to sceneBounds (world space) each call, // so the frustum always exactly covers the static scene regardless of // its size, instead of a fixed box sized for whatever scene existed when @@ -66,6 +72,7 @@ class ShadowMap { uint32_t shadowWidth; glm::mat4 lightSpaceMatrix{ 1.0f }; + glm::vec3 eyePosition{ 0.0f }; void initialize(); void applyBlur() const;