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