From c93fc2eab898462bc9bcd64ea6bd64ba6a181df1 Mon Sep 17 00:00:00 2001 From: Tom Conder Date: Sun, 20 Sep 2026 17:54:20 -0500 Subject: [PATCH] Add screen-space ambient occlusion Hemisphere-kernel SSAO against the depth prepass's depth and a new view-space normal target, depth-aware blurred and multiplied into ambient. The depth prepass gains an MRT normal output for it, and the old flat ambient-occlusion scalar becomes an optional multiplier on top of the real per-pixel result instead of a stand-in for it. Blending is left on globally by the renderer; the SSAO passes render to alpha-less R16F targets, so leaving it enabled here silently blended every write away to zero. Disable it for the pass like the depth prepass already does for its own MRT writes. --- assets/manifest.yaml | 2 + assets/scenes/maze.yaml | 3 +- assets/shaders/slang/depthprepass.slang | 35 ++++- assets/shaders/slang/include/uniforms.slang | 1 + assets/shaders/slang/pbr.slang | 4 + assets/shaders/slang/ssao.slang | 87 ++++++++++++ assets/shaders/slang/ssaoblur.slang | 36 +++++ game/src/layer/imgui/imguilayer.cpp | 42 ++++-- game/src/layer/imgui/imguilayer.hpp | 1 + game/src/layer/mazelayer.cpp | 116 +++++++++++---- game/src/layer/mazelayer.hpp | 26 ++-- game/src/scene/scenefile.hpp | 5 +- game/src/thread/mazeframe.hpp | 3 + sponge/src/platform/opengl/scene/ssao.cpp | 147 ++++++++++++++++++++ sponge/src/platform/opengl/scene/ssao.hpp | 62 +++++++++ 15 files changed, 517 insertions(+), 53 deletions(-) create mode 100644 assets/shaders/slang/ssao.slang create mode 100644 assets/shaders/slang/ssaoblur.slang create mode 100644 sponge/src/platform/opengl/scene/ssao.cpp create mode 100644 sponge/src/platform/opengl/scene/ssao.hpp diff --git a/assets/manifest.yaml b/assets/manifest.yaml index c7cfedc4..5f8292d0 100644 --- a/assets/manifest.yaml +++ b/assets/manifest.yaml @@ -84,4 +84,6 @@ shaders: depthprepass.frag: shaders/slang/depthprepass.slang:fragMain occlusionbox.vert: shaders/slang/occlusionbox.slang:vertMain occlusionbox.frag: shaders/slang/occlusionbox.slang:fragMain + ssao.frag: shaders/slang/ssao.slang:fragMain + ssao_blur.frag: shaders/slang/ssaoblur.slang:fragMain cluster_assign.comp: shaders/slang/cluster_assign.slang:csMain diff --git a/assets/scenes/maze.yaml b/assets/scenes/maze.yaml index 4ac3c7ad..ca45db08 100644 --- a/assets/scenes/maze.yaml +++ b/assets/scenes/maze.yaml @@ -13,7 +13,8 @@ camera: lighting: ambient: strength: 0.25 - occlusion: 0.25 + # 1 = no extra attenuation; SSAO now supplies real per-pixel occlusion. + occlusion: 1.0 directional: enabled: true diff --git a/assets/shaders/slang/depthprepass.slang b/assets/shaders/slang/depthprepass.slang index f7229bac..0eff0b0c 100644 --- a/assets/shaders/slang/depthprepass.slang +++ b/assets/shaders/slang/depthprepass.slang @@ -1,11 +1,22 @@ // depthprepass.slang: camera-space depth-only prepass, plus the screen-space -// velocity buffer TAA resolves through. +// velocity buffer TAA resolves through and the view-space normal buffer SSAO +// reads. uniform float4x4 mvp; // jittered; must match the scene pass uniform float4x4 mvpNoJitter; // this frame, unjittered uniform float4x4 prevMvpNoJitter; // last frame, unjittered +uniform float4x4 normalMatrix; // transpose(inverse(view * model)) +// Same VSInput layout as pbr.slang (position/texCoord/normal/tangent), so +// slangc assigns the same attribute locations the mesh VAO was built +// against. texCoord/tangent go unused here. Light cubes draw through this +// shader too but their VAO only has position bound (see cube.cpp), so their +// normal reads the GL default (0,0,0) — filtered out downstream by the +// zero-length check in ssao.slang. struct VSInput { float3 position : POSITION; + float2 texCoord : TEXCOORD0; + float3 normal : NORMAL; + float4 tangent : TANGENT; }; struct VSOutput { float4 pos : SV_Position; @@ -13,6 +24,11 @@ struct VSOutput { // the sub-pixel jitter must not enter the difference. float4 currClip : TEXCOORD0; float4 prevClip : TEXCOORD1; + float3 viewNormal : TEXCOORD2; +}; +struct FSOutput { + float2 velocity : SV_Target0; + float4 normal : SV_Target1; }; // clang-format off @@ -24,14 +40,23 @@ VSOutput vertMain(VSInput input) { o.pos = mul(mvp, position); o.currClip = mul(mvpNoJitter, position); o.prevClip = mul(prevMvpNoJitter, position); + o.viewNormal = mul((float3x3)normalMatrix, input.normal); return o; }; // clang-format off [shader("fragment")] -float2 fragMain(VSOutput input) : SV_Target { +FSOutput fragMain(VSOutput input) { // clang-format on - float2 currUV = input.currClip.xy / input.currClip.w * 0.5 + 0.5; - float2 prevUV = input.prevClip.xy / input.prevClip.w * 0.5 + 0.5; - return currUV - prevUV; + FSOutput o; + float2 currUV = input.currClip.xy / input.currClip.w * 0.5 + 0.5; + float2 prevUV = input.prevClip.xy / input.prevClip.w * 0.5 + 0.5; + o.velocity = currUV - prevUV; + + // Guard against normalizing a true-zero normal (light cubes / geometry + // with no NORMAL attribute bound): 0/0 is NaN, and a NaN sentinel breaks + // the "no surface here" length check ssao.slang relies on. + float len = length(input.viewNormal); + o.normal = float4(len > 1e-4 ? input.viewNormal / len : float3(0.0), 0.0); + return o; }; diff --git a/assets/shaders/slang/include/uniforms.slang b/assets/shaders/slang/include/uniforms.slang index 673e3b06..340809be 100644 --- a/assets/shaders/slang/include/uniforms.slang +++ b/assets/shaders/slang/include/uniforms.slang @@ -26,6 +26,7 @@ struct PbrParams { float metallicFactor; float roughnessFactor; float ao; + bool ssaoEnabled; float ambientStrength; float evsmBleedThreshold; bool hasNoTexture; diff --git a/assets/shaders/slang/pbr.slang b/assets/shaders/slang/pbr.slang index 0f9c5407..f2a56de6 100644 --- a/assets/shaders/slang/pbr.slang +++ b/assets/shaders/slang/pbr.slang @@ -51,6 +51,7 @@ VSOutput vertMain(VSInput input) { [[vk::binding(7)]] Sampler2D occlusionMap; [[vk::binding(8)]] Sampler2D emissiveMap; [[vk::binding(9)]] Sampler2D metallicRoughnessMap; +[[vk::binding(10)]] Sampler2D ssaoTexture; // KHR_texture_transform: t.xy = offset, t.zw = scale (no rotation support) float2 applyUVTransform(float2 uv, float4 t) { @@ -85,6 +86,9 @@ float4 fragMain(VSOutput input, float4 fragCoord : SV_Position) : SV_Target { float2 occlusionUV = applyUVTransform(input.texCoords, params.occlusionUVTransform); ao *= occlusionMap.Sample(occlusionUV).r; } + if (params.ssaoEnabled) { + ao *= ssaoTexture.Sample(fragCoord.xy / params.screenSize).r; + } float3 emissiveTexColor = float3(0.0); if (params.hasEmissiveMap) { diff --git a/assets/shaders/slang/ssao.slang b/assets/shaders/slang/ssao.slang new file mode 100644 index 00000000..62a6fd1a --- /dev/null +++ b/assets/shaders/slang/ssao.slang @@ -0,0 +1,87 @@ +// ssao.slang: screen-space ambient occlusion, hemisphere-kernel sampling +// against the depth prepass's depth + view-space normal targets. Technique +// follows the classic Crytek/Rendu approach (github.com/kosua20/Rendu, +// resources/common/shaders/screens/ssao.frag): reconstruct view-space +// position from depth, orient a hemisphere kernel with a random per-pixel +// rotation, and count how many kernel samples land behind real geometry. +struct FSInput { + float2 texCoords : TEXCOORD0; +}; + +[[vk::binding(0)]] Sampler2D depthTex; +[[vk::binding(1)]] Sampler2D normalTex; // view-space normal; (0,0,0) = no surface (background/light cubes) +[[vk::binding(2)]] Sampler2D noiseTex; // 4x4 tiling rotation vectors + +uniform float4x4 projection; +uniform float4x4 invProjection; +uniform float2 noiseScale; // screenSize / 4, tiles noiseTex across the screen +uniform float radius; + +// Without this, a flat unoccluded surface still darkens by roughly half: +// depth-buffer/normal quantization noise makes about half the kernel samples +// read back as "behind" the very surface they were cast from. The bias +// demands the occluder sit meaningfully closer than the sample point before +// it counts. +static const float bias = 0.025; + +// 16 hemisphere sample offsets, cosine-weighted and biased toward the +// origin (more samples close to the surface, where occlusion detail is), +// precomputed offline with a fixed seed. Scaled by `radius` below. +static const float3 kernel[16] = { + float3(0.031343, 0.008854, 0.048690), + float3(-0.054242, 0.052629, 0.031128), + float3(0.014520, -0.006623, 0.017081), + float3(0.037989, 0.100225, 0.013283), + float3(-0.009715, -0.041701, 0.033541), + float3(-0.023567, 0.073727, 0.097785), + float3(0.173635, 0.049325, 0.120350), + float3(0.149972, -0.094466, 0.175101), + float3(-0.003614, -0.019836, 0.008477), + float3(0.001372, 0.006399, 0.015564), + float3(-0.078911, 0.130415, 0.264265), + float3(0.088872, -0.199457, 0.093786), + float3(0.374838, 0.268386, 0.141412), + float3(0.101602, -0.062204, 0.362429), + float3(0.431340, 0.086597, 0.504608), + float3(-0.219564, 0.363803, 0.202840), +}; + +float3 viewPositionFromDepth(float2 uv, float depth) { + float4 clip = float4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + float4 view = mul(invProjection, clip); + return view.xyz / view.w; +} + +// clang-format off +[shader("fragment")] +float fragMain(FSInput input) : SV_Target { + // clang-format on + float3 n = normalTex.Sample(input.texCoords).xyz; + if (length(n) < 0.1) { + return 1.0; + } + + float3 randomVec = normalize(float3(noiseTex.Sample(input.texCoords * noiseScale).xy, 0.0)); + float3 t = normalize(randomVec - n * dot(randomVec, n)); + float3 b = cross(n, t); + + float depth = depthTex.Sample(input.texCoords).r; + float3 position = viewPositionFromDepth(input.texCoords, depth); + + float occlusion = 0.0; + for (int i = 0; i < 16; ++i) { + float3 offset = t * kernel[i].x + b * kernel[i].y + n * kernel[i].z; + float3 samplePos = position + radius * offset; + + float4 sampleClip = mul(projection, float4(samplePos, 1.0)); + float2 sampleUV = (sampleClip.xy / sampleClip.w) * 0.5 + 0.5; + + float sampleDepth = depthTex.Sample(sampleUV).r; + float3 sampledPos = viewPositionFromDepth(sampleUV, sampleDepth); + + float isValid = abs(position.z - sampledPos.z) < radius ? 1.0 : 0.0; + occlusion += (sampledPos.z >= samplePos.z + bias) ? isValid : 0.0; + } + + return 1.0 - occlusion / 16.0; +}; diff --git a/assets/shaders/slang/ssaoblur.slang b/assets/shaders/slang/ssaoblur.slang new file mode 100644 index 00000000..05c26ef2 --- /dev/null +++ b/assets/shaders/slang/ssaoblur.slang @@ -0,0 +1,36 @@ +// ssaoblur.slang: depth-aware box blur over the raw SSAO pass. Plain box +// blur would smear occlusion across depth discontinuities (an object's edge +// bleeding onto the background behind it); weighting each tap by how close +// its depth is to the center pixel's keeps edges sharp. +struct FSInput { + float2 texCoords : TEXCOORD0; +}; + +[[vk::binding(0)]] Sampler2D aoTex; +[[vk::binding(1)]] Sampler2D depthTex; + +uniform float2 texelSize; // 1 / ssao target resolution + +// clang-format off +[shader("fragment")] +float fragMain(FSInput input) : SV_Target { + // clang-format on + float centerDepth = depthTex.Sample(input.texCoords).r; + + float total = 0.0; + float weight = 0.0; + for (int y = -1; y <= 1; ++y) { + for (int x = -1; x <= 1; ++x) { + float2 uv = input.texCoords + float2(x, y) * texelSize; + float depth = depthTex.Sample(uv).r; + // Depth is nonlinear (raw buffer values), but the tap footprint + // is only 3x3 texels, so the resulting depth-difference bias + // stays negligible without linearizing. + float w = 1.0 / (1e-5 + abs(depth - centerDepth)); + total += aoTex.Sample(uv).r * w; + weight += w; + } + } + + return total / weight; +}; diff --git a/game/src/layer/imgui/imguilayer.cpp b/game/src/layer/imgui/imguilayer.cpp index 768c362c..94920434 100644 --- a/game/src/layer/imgui/imguilayer.cpp +++ b/game/src/layer/imgui/imguilayer.cpp @@ -206,6 +206,10 @@ void ImGuiLayer::showLightsSection() { showBloomControls(); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("SSAO##Tab")) { + showSsaoControls(); + ImGui::EndTabItem(); + } ImGui::EndTabBar(); } } @@ -255,6 +259,34 @@ void ImGuiLayer::showBloomControls() { } } +void ImGuiLayer::showSsaoControls() { + if (ImGui::BeginTable("Ssao##Table", 2, tableFlags)) { + const auto mazeLayer = Maze::get().getMazeLayer(); + + auto ssaoEnabled = mazeLayer->isSsaoEnabled(); + showTableRow([&] { + ImGui::Text("Enabled"); + ImGui::TableNextColumn(); + if (ImGui::Checkbox("##ssaoenabled", &ssaoEnabled)) { + mazeLayer->setSsaoEnabled(ssaoEnabled); + } + }); + + auto radius = mazeLayer->getSsaoRadius(); + showTableRow([&] { + ImGui::Text("Radius"); + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + if (ImGui::SliderFloat("##ssaoradius", &radius, 0.05F, 2.F, "%.2f", + ImGuiSliderFlags_AlwaysClamp)) { + mazeLayer->setSsaoRadius(radius); + } + }); + + ImGui::EndTable(); + } +} + void ImGuiLayer::showDirectionalLightControls() { if (ImGui::BeginTable("DirectionalLights##Table", 2, tableFlags)) { const auto mazeLayer = Maze::get().getMazeLayer(); @@ -364,8 +396,7 @@ void ImGuiLayer::showPointLightControls() { if (ImGui::BeginTable("PointLights##Table", 1, ImGuiTableFlags_NoPadInnerX | ImGuiTableFlags_NoPadOuterX)) { - auto ambientStrength = mazeLayer->getAmbientStrength(); - auto ambientOcclusion = mazeLayer->getAmbientOcclusion(); + auto ambientStrength = mazeLayer->getAmbientStrength(); showTableRow([&] { if (ImGui::SliderFloat("Ambient Strength", &ambientStrength, 0.F, @@ -374,13 +405,6 @@ void ImGuiLayer::showPointLightControls() { } }); - showTableRow([&] { - if (ImGui::SliderFloat("Ambient Occlusion", &ambientOcclusion, 0.F, - 1.F, "%.3f", ImGuiSliderFlags_AlwaysClamp)) { - mazeLayer->setAmbientOcclusion(ambientOcclusion); - } - }); - ImGui::EndTable(); } } diff --git a/game/src/layer/imgui/imguilayer.hpp b/game/src/layer/imgui/imguilayer.hpp index 641ece93..e9268f6d 100644 --- a/game/src/layer/imgui/imguilayer.hpp +++ b/game/src/layer/imgui/imguilayer.hpp @@ -42,6 +42,7 @@ class ImGuiLayer final : public sponge::layer::Layer { static void showMenu(); // App info window sections static void showLightsSection(); static void showBloomControls(); + static void showSsaoControls(); static void showDirectionalLightControls(); static void showPointLightControls(); static void showAttenuationSlider(int32_t& attenuationIndex); diff --git a/game/src/layer/mazelayer.cpp b/game/src/layer/mazelayer.cpp index 34f39f71..2e8f9601 100644 --- a/game/src/layer/mazelayer.cpp +++ b/game/src/layer/mazelayer.cpp @@ -63,6 +63,7 @@ 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::Ssao; using sponge::platform::opengl::scene::TAA; using thread::AntiAliasing; @@ -143,6 +144,7 @@ void MazeLayer::finishLoading(std::vector> builtModels) { shader->bind(); shader->setFloat("ao", ao); + shader->setBoolean("ssaoEnabled", ssaoEnabled); shader->setFloat("ambientStrength", ambientStrength); @@ -183,6 +185,9 @@ void MazeLayer::finishLoading(std::vector> builtModels) { bloom = std::make_unique(Maze::get().getWindow()->getWidth(), Maze::get().getWindow()->getHeight()); + ssao = std::make_unique(Maze::get().getWindow()->getWidth(), + Maze::get().getWindow()->getHeight()); + sceneTarget = std::make_unique(Maze::get().getWindow()->getWidth(), Maze::get().getWindow()->getHeight()); @@ -441,6 +446,9 @@ void MazeLayer::captureRenderFrame(const uint32_t slotIndex) { frame.bloomEnabled = bloomEnabled; frame.bloomThreshold = bloomThreshold; frame.bloomIntensity = bloomIntensity; + + frame.ssaoEnabled = ssaoEnabled; + frame.ssaoRadius = ssaoRadius; } // Publication happens in onFrameSync() on the main thread, while both @@ -478,6 +486,9 @@ void MazeLayer::onRender() { if (bloom) { bloom->resize(w, h); } + if (ssao) { + ssao->resize(w, h); + } sceneTarget->resize(w, h); screenWidth = static_cast(w); screenHeight = static_cast(h); @@ -524,6 +535,16 @@ void MazeLayer::onRender() { frame.cameraView, frame.cameraProjection); } + // Phase 3.5: SSAO, against the depth prepass's depth + view-space normal. + // Skipped when disabled: renderGameObjects() also gates the shader's read + // of the texture on frame.ssaoEnabled, so a stale/unrun texture is never + // sampled. + if (frame.ssaoEnabled && ssao) { + ssao->process(depthPrepassTexture, normalPrepassTexture, + frame.cameraProjection, + glm::inverse(frame.cameraProjection), frame.ssaoRadius); + } + // Phase 4: opaque pass, into the linear HDR scene target. const bool fxaaActive = frame.antiAliasing == AntiAliasing::Fxaa && fxaa; const bool taaActive = frame.antiAliasing == AntiAliasing::Taa && taa; @@ -587,19 +608,6 @@ void MazeLayer::onRender() { glDepthFunc(GL_LEQUAL); } -float MazeLayer::getAmbientOcclusion() const { - return ao; -} - -void MazeLayer::setAmbientOcclusion(const float val) { - ao = val; - - const auto shader = Mesh::getShader(); - shader->bind(); - shader->setFloat("ao", ao); - shader->unbind(); -} - float MazeLayer::getAmbientStrength() const { return ambientStrength; } @@ -817,6 +825,12 @@ void MazeLayer::renderGameObjects(const thread::MazeRenderFrame& frame) const { shadowMap->activateAndBindShadowTexture(1); } + if (ssao) { + glActiveTexture(GL_TEXTURE10); + glBindTexture(GL_TEXTURE_2D, ssao->getTexture()); + glActiveTexture(GL_TEXTURE0); + } + const auto submitStart = std::chrono::steady_clock::now(); uint32_t occlusionVisible = 0; for (size_t i = 0; i < frame.objectModels.size(); i++) { @@ -855,6 +869,9 @@ void MazeLayer::createDepthPrepassFbo(const int w, const int h) { if (velocityTexture != 0) { glDeleteTextures(1, &velocityTexture); } + if (normalPrepassTexture != 0) { + glDeleteTextures(1, &normalPrepassTexture); + } if (depthPrepassFbo != 0) { glDeleteFramebuffers(1, &depthPrepassFbo); } @@ -870,13 +887,23 @@ void MazeLayer::createDepthPrepassFbo(const int w, const int h) { createRenderTarget(static_cast(w), static_cast(h), GL_RG16F, GL_RG, GL_FLOAT, GL_NEAREST); + // View-space normal, consumed by SSAO. RGB16F carries signed unit + // components directly, no [0,1] encode/decode needed. + normalPrepassTexture = + createRenderTarget(static_cast(w), static_cast(h), + GL_RGBA16F, GL_RGBA, GL_FLOAT, GL_NEAREST); + glGenFramebuffers(1, &depthPrepassFbo); glBindFramebuffer(GL_FRAMEBUFFER, depthPrepassFbo); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthPrepassTexture, 0); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, velocityTexture, 0); - glDrawBuffer(GL_COLOR_ATTACHMENT0); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, + normalPrepassTexture, 0); + constexpr std::array drawBuffers = { GL_COLOR_ATTACHMENT0, + GL_COLOR_ATTACHMENT1 }; + glDrawBuffers(2, drawBuffers.data()); glReadBuffer(GL_NONE); if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { SPONGE_GL_CRITICAL("Depth prepass framebuffer is not complete!"); @@ -893,24 +920,31 @@ void MazeLayer::renderDepthPrepass(const thread::MazeRenderFrame& frame) const { const bool writeVelocity = frame.antiAliasing == AntiAliasing::Taa; glBindFramebuffer(GL_FRAMEBUFFER, depthPrepassFbo); - glColorMask(writeVelocity ? GL_TRUE : GL_FALSE, - writeVelocity ? GL_TRUE : GL_FALSE, GL_FALSE, GL_FALSE); + // Indexed mask: attachment 0 (velocity) follows writeVelocity, attachment + // 1 (normal) is always live — SSAO reads it every frame regardless of AA + // mode. + glColorMaski(0, writeVelocity ? GL_TRUE : GL_FALSE, + writeVelocity ? GL_TRUE : GL_FALSE, GL_FALSE, GL_FALSE); + glColorMaski(1, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); glDepthMask(GL_TRUE); glDepthFunc(GL_LESS); glClear(GL_DEPTH_BUFFER_BIT); + // Blending is enabled globally (RendererAPI) and never turned off, so the + // restore below is unconditional. Without this, the shader's writes blend + // against the clear colour instead of landing untouched — wrong for + // motion vectors and for normals alike. + glDisable(GL_BLEND); if (writeVelocity) { - // Blending is enabled globally (RendererAPI) and never turned off, so - // the restore below is unconditional. Without this, the shader writes - // no alpha and the motion vectors get blended against the clear colour - // and never reach the texture. Motion is not a colour; there is - // nothing here to blend. - glDisable(GL_BLEND); // Explicit zero rather than glClear, which would use the global grey // clear colour and read back as ~22 pixels of bogus motion. constexpr std::array noMotion = { 0.F, 0.F, 0.F, 0.F }; glClearBufferfv(GL_COLOR, 0, noMotion.data()); } + // Zero normal reads as "no surface here" (see ssao.slang), which is + // exactly right for pixels the loop below never draws to. + constexpr std::array noNormal = { 0.F, 0.F, 0.F, 0.F }; + glClearBufferfv(GL_COLOR, 1, noNormal.data()); depthPrepassShader->bind(); for (size_t i = 0; i < frame.objectModels.size(); ++i) { @@ -921,10 +955,13 @@ void MazeLayer::renderDepthPrepass(const thread::MazeRenderFrame& frame) const { // 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. - depthPrepassShader->setMat4("mvp", frame.cameraMVP * - frame.objectModelMatrices[i]); + const auto& modelMatrix = frame.objectModelMatrices[i]; + depthPrepassShader->setMat4("mvp", frame.cameraMVP * modelMatrix); depthPrepassShader->setMat4( - "mvpNoJitter", frame.cameraViewProj * frame.objectModelMatrices[i]); + "normalMatrix", glm::mat4(glm::transpose(glm::inverse( + glm::mat3(frame.cameraView * modelMatrix))))); + depthPrepassShader->setMat4("mvpNoJitter", + frame.cameraViewProj * modelMatrix); depthPrepassShader->setMat4("prevMvpNoJitter", frame.prevCameraViewProj * frame.prevObjectModelMatrices[i]); @@ -950,9 +987,7 @@ void MazeLayer::renderDepthPrepass(const thread::MazeRenderFrame& frame) const { depthPrepassShader->unbind(); - if (writeVelocity) { - glEnable(GL_BLEND); - } + glEnable(GL_BLEND); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); glBindFramebuffer(GL_FRAMEBUFFER, 0); } @@ -1123,6 +1158,31 @@ void MazeLayer::setBloomIntensity(const float val) { bloomIntensity = val; } +bool MazeLayer::isSsaoEnabled() const { + return ssaoEnabled; +} + +void MazeLayer::setSsaoEnabled(const bool val) { + { + std::scoped_lock lock(settingsMutex); + ssaoEnabled = val; + } + + const auto shader = Mesh::getShader(); + shader->bind(); + shader->setBoolean("ssaoEnabled", val); + shader->unbind(); +} + +float MazeLayer::getSsaoRadius() const { + return ssaoRadius; +} + +void MazeLayer::setSsaoRadius(const float val) { + std::scoped_lock lock(settingsMutex); + ssaoRadius = val; +} + bool MazeLayer::isImguiActive() const { return isImguiOpen; } diff --git a/game/src/layer/mazelayer.hpp b/game/src/layer/mazelayer.hpp index 037e67d3..506cbe71 100644 --- a/game/src/layer/mazelayer.hpp +++ b/game/src/layer/mazelayer.hpp @@ -14,6 +14,7 @@ #include "platform/opengl/scene/occlusionculler.hpp" #include "platform/opengl/scene/scenetarget.hpp" #include "platform/opengl/scene/shadowmap.hpp" +#include "platform/opengl/scene/ssao.hpp" #include "platform/opengl/scene/taa.hpp" #include "scene/frustum.hpp" #include "scene/gamecamera.hpp" @@ -54,10 +55,6 @@ class MazeLayer final : public sponge::layer::Layer { // completed onUpdate() so render[N] always reads update[N-1]'s frame. void onFrameSync() override; - float getAmbientOcclusion() const; - - void setAmbientOcclusion(float val); - float getAmbientStrength() const; void setAmbientStrength(float val); @@ -103,6 +100,11 @@ class MazeLayer final : public sponge::layer::Layer { float getBloomIntensity() const; void setBloomIntensity(float val); + bool isSsaoEnabled() const; + void setSsaoEnabled(bool val); + float getSsaoRadius() const; + void setSsaoRadius(float val); + bool isImguiActive() const; // This frame's frustum-culling stats, for the debug UI. Updated on the @@ -192,11 +194,15 @@ class MazeLayer final : public sponge::layer::Layer { uint32_t depthPrepassTexture{ 0 }; // Screen-space motion (RG16F, current UV minus previous UV) written by the // depth prepass and consumed by TAA. Shares the prepass FBO. - uint32_t velocityTexture{ 0 }; - std::unique_ptr cube; - std::unique_ptr fxaa; - std::unique_ptr taa; + uint32_t velocityTexture{ 0 }; + // View-space normal (RGB16F), written by the depth prepass and consumed + // by SSAO. Shares the prepass FBO. + uint32_t normalPrepassTexture{ 0 }; + std::unique_ptr cube; + std::unique_ptr fxaa; + std::unique_ptr taa; std::unique_ptr bloom; + std::unique_ptr ssao; std::unique_ptr sceneTarget; std::unique_ptr shadowMap; @@ -261,7 +267,7 @@ class MazeLayer final : public sponge::layer::Layer { mutable std::atomic occlusionVisibleCount{ 0 }; mutable std::atomic occlusionTotalCount{ 0 }; float ambientStrength = .25F; - float ao = .25F; + float ao = 1.F; int32_t attenuationIndex = 4; thread::AntiAliasing antiAliasing = thread::AntiAliasing::Taa; bool bloomEnabled = true; @@ -273,6 +279,8 @@ class MazeLayer final : public sponge::layer::Layer { // for the same look. Re-derive it against a measurement, never by // scaling the old number. float bloomIntensity = 0.08F; + bool ssaoEnabled = true; + float ssaoRadius = 0.5F; bool mouseButtonPressed = false; int32_t numLights = 0; bool isImguiOpen = true; diff --git a/game/src/scene/scenefile.hpp b/game/src/scene/scenefile.hpp index 12a94c06..a0115ff3 100644 --- a/game/src/scene/scenefile.hpp +++ b/game/src/scene/scenefile.hpp @@ -32,7 +32,10 @@ struct SceneCamera { struct SceneAmbient { float strength{ .25F }; - float occlusion{ .25F }; + // 1 = no extra attenuation. SSAO now supplies real per-pixel occlusion; + // this is a flat multiplier on top of it, for an artist to darken ambient + // further without touching SSAO's radius/bias. + float occlusion{ 1.F }; }; struct SceneDirectionalLight { diff --git a/game/src/thread/mazeframe.hpp b/game/src/thread/mazeframe.hpp index 40cda1b3..942f127d 100644 --- a/game/src/thread/mazeframe.hpp +++ b/game/src/thread/mazeframe.hpp @@ -91,6 +91,9 @@ struct MazeRenderFrame { bool bloomEnabled{ false }; float bloomThreshold{ 0.8F }; float bloomIntensity{ 0.08F }; + + bool ssaoEnabled{ true }; + float ssaoRadius{ 0.5F }; }; } // namespace game::thread diff --git a/sponge/src/platform/opengl/scene/ssao.cpp b/sponge/src/platform/opengl/scene/ssao.cpp new file mode 100644 index 00000000..3985831a --- /dev/null +++ b/sponge/src/platform/opengl/scene/ssao.cpp @@ -0,0 +1,147 @@ +#include "platform/opengl/scene/ssao.hpp" + +#include "logging/log.hpp" +#include "platform/opengl/renderer/assetmanager.hpp" +#include "platform/opengl/renderer/gl.hpp" + +#include +#include +#include + +namespace sponge::platform::opengl::scene { +using renderer::AssetManager; + +Ssao::Ssao(const uint32_t width, const uint32_t height) : + width(width), height(height) { + initialize(); +} + +Ssao::~Ssao() { + destroyFramebuffers(); + glDeleteTextures(1, &noiseTexture); +} + +void Ssao::initialize() { + ssaoShader = AssetManager::createShader(renderer::ShaderCreateInfo{ + .name = "ssao", + .vertexShader = "screenquad.vert", + .fragmentShader = "ssao.frag", + }); + blurShader = AssetManager::createShader(renderer::ShaderCreateInfo{ + .name = "ssao_blur", + .vertexShader = "screenquad.vert", + .fragmentShader = "ssao_blur.frag", + }); + + createNoiseTexture(); + createFramebuffers(); +} + +void Ssao::createNoiseTexture() { + // Random rotation vectors around the tangent-space Z axis (z left at 0, + // as in the reference technique). Fixed seed: deterministic across runs, + // no need for the noise pattern itself to vary. + // NOLINTNEXTLINE(bugprone-random-generator-seed) fixed layout + std::mt19937 rng(1337U); + std::uniform_real_distribution dist(-1.F, 1.F); + + std::array noise{}; + for (auto& v : noise) { + v = dist(rng); + } + + glGenTextures(1, &noiseTexture); + glBindTexture(GL_TEXTURE_2D, noiseTexture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RG16F, 4, 4, 0, GL_RG, GL_FLOAT, + noise.data()); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + glBindTexture(GL_TEXTURE_2D, 0); +} + +void Ssao::createFramebuffers() { + auto makeAoFbo = [this](uint32_t& fbo, uint32_t& tex) { + tex = renderer::createRenderTarget(width, height, GL_R16F, GL_RED, + GL_FLOAT, GL_LINEAR); + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, tex, 0); + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != + GL_FRAMEBUFFER_COMPLETE) { + SPONGE_GL_CRITICAL("SSAO framebuffer is not complete!"); + } + }; + + makeAoFbo(rawFbo, rawTexture); + makeAoFbo(blurFbo, blurTexture); + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + +void Ssao::destroyFramebuffers() { + glDeleteFramebuffers(1, &rawFbo); + glDeleteTextures(1, &rawTexture); + glDeleteFramebuffers(1, &blurFbo); + glDeleteTextures(1, &blurTexture); + rawFbo = rawTexture = blurFbo = blurTexture = 0; +} + +void Ssao::process(const uint32_t depthTexId, const uint32_t normalTexId, + const glm::mat4& projection, const glm::mat4& invProjection, + const float radius) const { + glDisable(GL_DEPTH_TEST); + // The renderer leaves blending on globally, and these targets are + // single-channel (GL_R16F, no alpha) with undefined shader alpha output: + // left enabled, every write blended toward the framebuffer's zeroed + // initial contents instead of landing. + glDisable(GL_BLEND); + glViewport(0, 0, static_cast(width), static_cast(height)); + + glBindFramebuffer(GL_FRAMEBUFFER, rawFbo); + ssaoShader->bind(); + ssaoShader->setMat4("projection", projection); + ssaoShader->setMat4("invProjection", invProjection); + ssaoShader->setFloat2( + "noiseScale", + glm::vec2(static_cast(width), static_cast(height)) / 4.F); + ssaoShader->setFloat("radius", radius); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, depthTexId); + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, normalTexId); + glActiveTexture(GL_TEXTURE2); + glBindTexture(GL_TEXTURE_2D, noiseTexture); + quad.draw(); + ssaoShader->unbind(); + + glBindFramebuffer(GL_FRAMEBUFFER, blurFbo); + blurShader->bind(); + blurShader->setFloat2( + "texelSize", + 1.F / glm::vec2(static_cast(width), static_cast(height))); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, rawTexture); + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, depthTexId); + quad.draw(); + blurShader->unbind(); + + glActiveTexture(GL_TEXTURE0); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glEnable(GL_DEPTH_TEST); + glEnable(GL_BLEND); +} + +void Ssao::resize(const uint32_t newWidth, const uint32_t newHeight) { + if (width == newWidth && height == newHeight) { + return; + } + width = newWidth; + height = newHeight; + destroyFramebuffers(); + createFramebuffers(); +} + +} // namespace sponge::platform::opengl::scene diff --git a/sponge/src/platform/opengl/scene/ssao.hpp b/sponge/src/platform/opengl/scene/ssao.hpp new file mode 100644 index 00000000..4ba3eb47 --- /dev/null +++ b/sponge/src/platform/opengl/scene/ssao.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "platform/opengl/renderer/shader.hpp" +#include "platform/opengl/scene/screenquad.hpp" + +#include + +#include +#include + +namespace sponge::platform::opengl::scene { + +// Screen-space ambient occlusion: hemisphere-kernel sampling against the +// depth prepass's depth + view-space normal targets, followed by a +// depth-aware blur to remove the per-pixel noise the random kernel rotation +// introduces. See ssao.slang for the technique. +class Ssao { +public: + Ssao() = delete; + Ssao(uint32_t width, uint32_t height); + ~Ssao(); + + Ssao(const Ssao&) = delete; + Ssao& operator=(const Ssao&) = delete; + + // depthTexId/normalTexId are the depth prepass's targets; projection and + // its inverse must be the same camera projection the prepass rasterized + // with, or the reconstructed positions won't line up with the depth. + void process(uint32_t depthTexId, uint32_t normalTexId, + const glm::mat4& projection, const glm::mat4& invProjection, + float radius) const; + + uint32_t getTexture() const { + return blurTexture; + } + + void resize(uint32_t newWidth, uint32_t newHeight); + +private: + std::shared_ptr ssaoShader; + std::shared_ptr blurShader; + ScreenQuad quad; + + // 4x4 tiling texture of random rotation vectors, breaking up the kernel's + // sampling pattern so the blur removes noise instead of banding. + uint32_t noiseTexture = 0; + + uint32_t rawFbo = 0; + uint32_t rawTexture = 0; + uint32_t blurFbo = 0; + uint32_t blurTexture = 0; + + uint32_t width = 0; + uint32_t height = 0; + + void initialize(); + void createNoiseTexture(); + void createFramebuffers(); + void destroyFramebuffers(); +}; + +} // namespace sponge::platform::opengl::scene