From afef417dfc4cd6ed1d2ae306f89ececfad64dd1f Mon Sep 17 00:00:00 2001 From: Canberk Pitirli Date: Sun, 4 Oct 2026 22:10:51 +0300 Subject: [PATCH] feat: cascaded shadow maps for the main directional light The sun now renders 1-4 cascades into one depth array on Vulkan and OpenGL. Each cascade is a sphere around the camera with practical-split radii and a texel-snapped ortho fit. Cascade count, split lambda and max distance come from the GraphicsConfig presets, and the shadow raster bias now scales with shadowBias. The last cascade is still exposed as the old single map so existing shaders and the DX backends keep working. --- assets/shaders/default_shader.frag | 65 ++- assets/shaders/default_shader.frag.spv | Bin 15492 -> 19424 bytes assets/shaders/default_shader_gl.frag | 60 ++- assets/shaders/lighting_pass.frag | 62 ++- assets/shaders/lighting_pass.frag.spv | Bin 26696 -> 29156 bytes assets/shaders/skinned_shader.frag | 72 ++- assets/shaders/skinned_shader.frag.spv | Bin 12924 -> 17116 bytes .../Graphics/Common/ConstantBuffer.hpp | 27 +- include/private/Graphics/Common/Renderer.hpp | 43 +- .../Graphics/OpenGL/OpenGLRenderer.hpp | 20 +- .../Graphics/Vulkan/VulkanRenderer.hpp | 26 +- include/public/Core/GraphicsConfig.hpp | 8 + include/public/Lighting/DirectionalLight.hpp | 20 + src/Core/Application.cpp | 2 + src/Graphics/Common/GraphicsConfig.cpp | 21 +- src/Graphics/OpenGL/OpenGLRenderer.cpp | 131 ++++- src/Graphics/Vulkan/VulkanDeferred.cpp | 4 +- src/Graphics/Vulkan/VulkanDescriptors.cpp | 26 +- src/Graphics/Vulkan/VulkanPipelines.cpp | 17 +- src/Graphics/Vulkan/VulkanRenderer.cpp | 244 +-------- src/Graphics/Vulkan/VulkanShadow.cpp | 497 +++++++++++------- src/Lighting/LightManager.cpp | 184 ++++--- 22 files changed, 899 insertions(+), 630 deletions(-) diff --git a/assets/shaders/default_shader.frag b/assets/shaders/default_shader.frag index b3a6bb8e..11b81999 100644 --- a/assets/shaders/default_shader.frag +++ b/assets/shaders/default_shader.frag @@ -36,10 +36,18 @@ layout(set = 2, binding = 0) uniform ShadowLightUBO { float uHeightFogDensity; float uHeightFogFalloff; float uHeightFogEnabled; + vec4 uExtraDir[3]; + vec4 uExtraColor[3]; + uint uNumExtraLights; + mat4 uNdcToShadow; + mat4 uCascadeVP[4]; + vec4 uCascadeSplits; // cascade radius around uCameraPos + uint uCascadeCount; + float uCascadeBlend; // blend band, fraction of the radius }; -layout(set = 3, binding = 0) uniform sampler2DShadow shadowMap; // hardware PCF -layout(set = 3, binding = 1) uniform sampler2D shadowMapRaw; // blocker search +layout(set = 3, binding = 2) uniform sampler2DArrayShadow shadowMap; // cascades, PCF +layout(set = 3, binding = 3) uniform sampler2DArray shadowMapRaw; // blocker search float InterleavedGradientNoise(vec2 screenPos) { vec3 magic = vec3(0.06711056, 0.00583715, 52.9829189); @@ -58,12 +66,13 @@ vec2 VogelDisk(int i, int count, float phi) { const int BLOCKER_SAMPLES = 16; const int PCF_SAMPLES = 48; -float FindAvgBlockerDepth(vec2 uv, float zRef, float searchRadius, float phi) { +float FindAvgBlockerDepth(vec2 uv, float layer, float zRef, + float searchRadius, float phi) { float sum = 0.0; int count = 0; for (int i = 0; i < BLOCKER_SAMPLES; ++i) { vec2 off = VogelDisk(i, BLOCKER_SAMPLES, phi) * searchRadius; - float d = texture(shadowMapRaw, uv + off).r; + float d = texture(shadowMapRaw, vec3(uv + off, layer)).r; if (d < zRef) { sum += d; count++; @@ -72,11 +81,12 @@ float FindAvgBlockerDepth(vec2 uv, float zRef, float searchRadius, float phi) { return (count > 0) ? sum / float(count) : -1.0; } -float PCFFilter(vec2 uv, float zRef, float filterRadius, float phi) { +float PCFFilter(vec2 uv, float layer, float zRef, float filterRadius, + float phi) { float shadow = 0.0; for (int i = 0; i < PCF_SAMPLES; ++i) { vec2 off = VogelDisk(i, PCF_SAMPLES, phi) * filterRadius; - shadow += texture(shadowMap, vec3(uv + off, zRef)); + shadow += texture(shadowMap, vec4(uv + off, layer, zRef)); } return shadow / float(PCF_SAMPLES); } @@ -87,7 +97,7 @@ float PCFFilter(vec2 uv, float zRef, float filterRadius, float phi) { // - Penumbra is linear in the blocker-receiver depth gap; no perspective // divide (that formula only applies to perspective/point lights). // - uLightSize acts as a softness multiplier — larger → wider penumbra. -float CalcShadow(vec4 sc) { +float CalcShadow(vec4 sc, float layer) { vec3 projCoords = sc.xyz / sc.w; projCoords.xy = projCoords.xy * 0.5 + 0.5; @@ -96,11 +106,8 @@ float CalcShadow(vec4 sc) { projCoords.z < 0.0 || projCoords.z > 1.0) return 1.0; - vec2 fadeCoord = smoothstep(vec2(0.0), vec2(0.05), projCoords.xy) - * smoothstep(vec2(0.0), vec2(0.05), vec2(1.0) - projCoords.xy); - float zFade = smoothstep(0.0, 0.05, projCoords.z) + float edgeFade = smoothstep(0.0, 0.05, projCoords.z) * smoothstep(0.0, 0.1, 1.0 - projCoords.z); - float edgeFade = fadeCoord.x * fadeCoord.y * zFade; float zRef = projCoords.z - uShadowBias; float phi = InterleavedGradientNoise(gl_FragCoord.xy) * 6.283185; @@ -108,7 +115,8 @@ float CalcShadow(vec4 sc) { // Fixed 30-texel blocker search: detects blockers ~0.7m above receiver // on a 28m/4096 map. Decoupled from uLightSize for stability. float searchRadius = uShadowTexelSize * 30.0; - float avgBlocker = FindAvgBlockerDepth(projCoords.xy, zRef, searchRadius, phi); + float avgBlocker = FindAvgBlockerDepth(projCoords.xy, layer, zRef, + searchRadius, phi); if (avgBlocker < 0.0) return 1.0; @@ -119,10 +127,39 @@ float CalcShadow(vec4 sc) { uShadowTexelSize * 0.5, // floor: contact-hard uShadowTexelSize * 20.0); // cap: floating objects - float shadow = PCFFilter(projCoords.xy, zRef, filterRadius, phi); + float shadow = PCFFilter(projCoords.xy, layer, zRef, filterRadius, phi); return mix(1.0, shadow, uShadowStrength * edgeFade); } +// Normal offset scales with the cascade radius so it stays the same number +// of texels in every cascade. +vec4 CascadeCoord(uint c, vec3 worldPos, vec3 N) { + float normalBias = uLightDir.w * uCascadeSplits[c] / + uCascadeSplits[uCascadeCount - 1u]; + return uCascadeVP[c] * vec4(worldPos + N * normalBias, 1.0); +} + +// Picks the cascade by distance from the camera and blends into the next one +// (or out to unshadowed after the last) across the band at its outer edge. +float CalcCascadedShadow(vec3 worldPos, vec3 N) { + if (uCascadeCount == 0u) return 1.0; + float dist = length(worldPos - uCameraPos.xyz); + uint c = 0u; + while (c + 1u < uCascadeCount && dist > uCascadeSplits[c]) ++c; + + float outer = uCascadeSplits[c]; + if (dist >= outer) return 1.0; + float t = smoothstep(outer * (1.0 - uCascadeBlend), outer, dist); + float shadow = CalcShadow(CascadeCoord(c, worldPos, N), float(c)); + if (t > 0.0) { + float next = (c + 1u < uCascadeCount) + ? CalcShadow(CascadeCoord(c + 1u, worldPos, N), float(c + 1u)) + : 1.0; + shadow = mix(shadow, next, t); + } + return shadow; +} + // ACES film tone mapping (Stephen Hill fit) vec3 ACESFilm(vec3 x) { return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3(0.0), vec3(1.0)); @@ -171,7 +208,7 @@ void main() { // Wrap diffuse: softens shadow terminator, side faces get ~15% sun float NdotL = clamp(dot(N, -lightDir) * 0.85 + 0.15, 0.0, 1.0); - float shadow = CalcShadow(fragShadowCoord); + float shadow = CalcCascadedShadow(fragWorldPos, N); vec3 diffuse = lightColor * lightIntens * NdotL * shadow; // Compose: AO only on ambient (direct light ignores occlusion) diff --git a/assets/shaders/default_shader.frag.spv b/assets/shaders/default_shader.frag.spv index 4773f1dfcf989709eceed027802a5c885b339f87..b1de49d4d748683b39bdacd11861250cf10fa45c 100644 GIT binary patch literal 19424 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 literal 15492 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 1.0 || proj.y < 0.0 || proj.y > 1.0 || proj.z < 0.0 || proj.z > 1.0) return 1.0; - vec2 fadeCoord = smoothstep(vec2(0.0), vec2(0.05), proj.xy) - * smoothstep(vec2(0.0), vec2(0.05), vec2(1.0) - proj.xy); - float edgeFade = fadeCoord.x * fadeCoord.y; - float biasedDepth = proj.z - ShadowBias; float shadow; if (PCSSEnabled != 0u) { float searchRadius = ShadowTexelSize * ShadowLightSize * 40.0; - float blockerDepth = FindBlockerDepthGL(proj.xy, biasedDepth, searchRadius); + float blockerDepth = FindBlockerDepthGL(proj.xy, layer, biasedDepth, + searchRadius); if (blockerDepth < 0.0) return 1.0; @@ -260,7 +265,7 @@ float CalcShadowPCSSGL(vec4 sc) { float filterRadius = max(penumbra * ShadowTexelSize * 80.0, ShadowTexelSize * 8.0); filterRadius = min(filterRadius, ShadowTexelSize * ShadowLightSize * 30.0); - shadow = PCSSFilterGL(proj.xy, biasedDepth, filterRadius); + shadow = PCSSFilterGL(proj.xy, layer, biasedDepth, filterRadius); } else { // Standard 3x3 PCF shadow = 0.0; @@ -268,13 +273,36 @@ float CalcShadowPCSSGL(vec4 sc) { for (int x = -1; x <= 1; x++) { for (int y = -1; y <= 1; y++) { vec2 offset = vec2(float(x), float(y)) * radius; - shadow += texture(shadowMapSampler, vec3(proj.xy + offset, biasedDepth)); + shadow += texture(shadowMapSampler, + vec4(proj.xy + offset, layer, biasedDepth)); } } shadow /= 9.0; } - return mix(1.0, shadow, ShadowStrength * edgeFade); + return mix(1.0, shadow, ShadowStrength); +} + +// Picks the cascade by distance from the camera and blends into the next one +// (or out to unshadowed after the last) across the band at its outer edge. +float CalcCascadedShadowGL(vec3 worldPos) { + if (ShadowMapEnabled == 0u || CascadeCount == 0u) return 1.0; + float dist = length(worldPos - CameraPos); + uint c = 0u; + while (c + 1u < CascadeCount && dist > CascadeSplits[c]) ++c; + + float outer = CascadeSplits[c]; + if (dist >= outer) return 1.0; + float t = smoothstep(outer * (1.0 - CascadeBlend), outer, dist); + float shadow = CalcShadowPCSSGL(CascadeVP[c] * vec4(worldPos, 1.0), float(c)); + if (t > 0.0) { + float next = 1.0; + if (c + 1u < CascadeCount) + next = CalcShadowPCSSGL(CascadeVP[c + 1u] * vec4(worldPos, 1.0), + float(c + 1u)); + shadow = mix(shadow, next, t); + } + return shadow; } // ---- End PCSS Shadow Functions ---- @@ -372,7 +400,7 @@ void main() { L = normalize(-light.Direction); // Apply PCSS shadow to first directional light if (i == 0u) { - float shadow = CalcShadowPCSSGL(fragShadowCoord); + float shadow = CalcCascadedShadowGL(fragWorldPos); attenuation *= shadow; } } diff --git a/assets/shaders/lighting_pass.frag b/assets/shaders/lighting_pass.frag index 06da48bb..4a1e9ba6 100644 --- a/assets/shaders/lighting_pass.frag +++ b/assets/shaders/lighting_pass.frag @@ -16,7 +16,7 @@ layout(set = 0, binding = 0) uniform sampler2D gAlbedoAO; // albedo.rgb + layout(set = 0, binding = 1) uniform sampler2D gNormalRough; // normal.xyz (encoded) + roughness.a layout(set = 0, binding = 2) uniform sampler2D gMetalEmit; // metallic.r + emissive.gba layout(set = 0, binding = 3) uniform sampler2D gDepth; // world pos reconstructed from this -layout(set = 0, binding = 4) uniform sampler2DShadow gShadow; // hardware PCF +layout(set = 0, binding = 4) uniform sampler2DArrayShadow gShadow; // cascades, hardware PCF layout(set = 0, binding = 5) uniform sampler2D gShadowRaw; // PCSS blocker search layout(set = 0, binding = 6) uniform sampler2D gSSAO; // screen-space AO (R8, 1=clear, 0=occluded) @@ -56,6 +56,10 @@ layout(set = 2, binding = 0) uniform ShadowLightUBO { // no pad member: std140 aligns the mat4 to 320, matching the C++ struct // (a vec3 pad here pushed uNdcToShadow to 336 -> garbage shadow matrix) mat4 uNdcToShadow; // NDC -> shadow clip, CPU-composed (no shimmer) + mat4 uCascadeVP[4]; + vec4 uCascadeSplits; // cascade radius around uCameraPos + uint uCascadeCount; + float uCascadeBlend; // blend band, fraction of the radius }; // -- Set 3: IBL ----------------------------------------------------- @@ -92,43 +96,61 @@ vec2 VogelDisk(int i, int count, float phi) { // (gShadow) does a 2x2 lerp per tap, so 12 rotated taps read smooth and crisp. const int PCF_SAMPLES = 16; -float PCFFilter(vec2 uv, float zRef, float filterRadius, float phi) { +float PCFFilter(vec2 uv, float layer, float zRef, float filterRadius, + float phi) { float shadow = 0.0; for (int i = 0; i < PCF_SAMPLES; ++i) { vec2 off = VogelDisk(i, PCF_SAMPLES, phi) * filterRadius; - shadow += texture(gShadow, vec3(uv + off, zRef)); + shadow += texture(gShadow, vec4(uv + off, layer, zRef)); } return shadow / float(PCF_SAMPLES); } -// World-space shadow projection — same math as the GL lighting pass and the -// water shader (uLightVP is texel-snapped; the NdcToShadow composition -// shimmered under camera motion from float error at large world coords). -float CalcShadow(vec3 worldPos, vec3 N) { - float normalBias = uLightDir.w; - vec4 sc = uLightVP * vec4(worldPos + N * normalBias, 1.0); - vec3 projCoords = sc.xyz / sc.w; - projCoords.xy = projCoords.xy * 0.5 + 0.5; +// World-space shadow projection into one cascade. The normal offset scales +// with the cascade radius so it stays the same number of texels everywhere. +float CascadeShadow(uint c, vec3 worldPos, vec3 N) { + float normalBias = uLightDir.w * uCascadeSplits[c] / + uCascadeSplits[uCascadeCount - 1u]; + vec4 sc = uCascadeVP[c] * vec4(worldPos + N * normalBias, 1.0); + vec3 projCoords = sc.xyz / sc.w; + projCoords.xy = projCoords.xy * 0.5 + 0.5; if (any(lessThan(projCoords, vec3(0.0))) || any(greaterThan(projCoords, vec3(1.0)))) return 1.0; - vec2 fadeCoord = smoothstep(vec2(0.0), vec2(0.05), projCoords.xy) - * smoothstep(vec2(0.0), vec2(0.05), vec2(1.0) - projCoords.xy); - float zFade = smoothstep(0.0, 0.05, projCoords.z) - * smoothstep(0.0, 0.1, 1.0 - projCoords.z); - float edgeFade = fadeCoord.x * fadeCoord.y * zFade; + float zFade = smoothstep(0.0, 0.05, projCoords.z) + * smoothstep(0.0, 0.1, 1.0 - projCoords.z); float zRef = projCoords.z - uShadowBias; // Dither in shadow-texel space — world-stable, no boiling under motion float phi = InterleavedGradientNoise(projCoords.xy / uShadowTexelSize) * 6.283185; - // ~5 texels (≈0.4m at 3072/256m): world-space penumbra wide enough that - // camera motion cannot flicker the edge — the stability-critical knob + // ~5 texels of penumbra keep the edge from flickering under camera + // motion; this is the stability-critical knob float filterRadius = uShadowTexelSize * 5.0; - float shadow = PCFFilter(projCoords.xy, zRef, filterRadius, phi); - return mix(1.0, shadow, uShadowStrength * edgeFade); + float shadow = PCFFilter(projCoords.xy, float(c), zRef, filterRadius, phi); + return mix(1.0, shadow, uShadowStrength * zFade); +} + +// Picks the cascade by distance from the camera and blends into the next one +// (or out to unshadowed after the last) across the band at its outer edge. +float CalcShadow(vec3 worldPos, vec3 N) { + if (uCascadeCount == 0u) return 1.0; + float dist = length(worldPos - uCameraPos.xyz); + uint c = 0u; + while (c + 1u < uCascadeCount && dist > uCascadeSplits[c]) ++c; + + float outer = uCascadeSplits[c]; + if (dist >= outer) return 1.0; + float t = smoothstep(outer * (1.0 - uCascadeBlend), outer, dist); + float shadow = CascadeShadow(c, worldPos, N); + if (t > 0.0) { + float next = (c + 1u < uCascadeCount) + ? CascadeShadow(c + 1u, worldPos, N) : 1.0; + shadow = mix(shadow, next, t); + } + return shadow; } // ================================================================== diff --git a/assets/shaders/lighting_pass.frag.spv b/assets/shaders/lighting_pass.frag.spv index 8ed6ac244fa9337dd9f2b35ff29b90258c2f0bc6..89fcbd7690d7df2df1e4880cd1994b4ed4756bc9 100644 GIT binary patch literal 29156 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 literal 26696 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 diff --git a/assets/shaders/skinned_shader.frag b/assets/shaders/skinned_shader.frag index ca9a7021..a230317e 100644 --- a/assets/shaders/skinned_shader.frag +++ b/assets/shaders/skinned_shader.frag @@ -31,10 +31,23 @@ layout(set = 2, binding = 0) uniform ShadowLightUBO { float uFogStart; float uFogEnd; vec2 _fogPad; + vec4 uFogZenithColor; + float uHeightFogTop; + float uHeightFogDensity; + float uHeightFogFalloff; + float uHeightFogEnabled; + vec4 uExtraDir[3]; + vec4 uExtraColor[3]; + uint uNumExtraLights; + mat4 uNdcToShadow; + mat4 uCascadeVP[4]; + vec4 uCascadeSplits; // cascade radius around uCameraPos + uint uCascadeCount; + float uCascadeBlend; // blend band, fraction of the radius }; -layout(set = 3, binding = 0) uniform sampler2DShadow shadowMap; // hardware PCF -layout(set = 3, binding = 1) uniform sampler2D shadowMapRaw; // blocker search +layout(set = 3, binding = 2) uniform sampler2DArrayShadow shadowMap; // cascades, PCF +layout(set = 3, binding = 3) uniform sampler2DArray shadowMapRaw; // blocker search // ------------------------------------------------------------ // Sampling helpers @@ -68,12 +81,13 @@ vec2 VogelDisk(int i, int count, float phi) { const int BLOCKER_SAMPLES = 16; const int PCF_SAMPLES = 48; -float FindAvgBlockerDepth(vec2 uv, float zRef, float searchRadius, float phi) { +float FindAvgBlockerDepth(vec2 uv, float layer, float zRef, + float searchRadius, float phi) { float sum = 0.0; int count = 0; for (int i = 0; i < BLOCKER_SAMPLES; ++i) { vec2 off = VogelDisk(i, BLOCKER_SAMPLES, phi) * searchRadius; - float d = texture(shadowMapRaw, uv + off).r; + float d = texture(shadowMapRaw, vec3(uv + off, layer)).r; if (d < zRef) { sum += d; count++; @@ -82,11 +96,12 @@ float FindAvgBlockerDepth(vec2 uv, float zRef, float searchRadius, float phi) { return (count > 0) ? sum / float(count) : -1.0; } -float PCFFilter(vec2 uv, float zRef, float filterRadius, float phi) { +float PCFFilter(vec2 uv, float layer, float zRef, float filterRadius, + float phi) { float shadow = 0.0; for (int i = 0; i < PCF_SAMPLES; ++i) { vec2 off = VogelDisk(i, PCF_SAMPLES, phi) * filterRadius; - shadow += texture(shadowMap, vec3(uv + off, zRef)); + shadow += texture(shadowMap, vec4(uv + off, layer, zRef)); } return shadow / float(PCF_SAMPLES); } @@ -98,7 +113,7 @@ float PCFFilter(vec2 uv, float zRef, float filterRadius, float phi) { // - Penumbra is linear in the blocker-receiver depth gap; no perspective // divide (that formula only applies to perspective/point lights). // - uLightSize acts as a softness multiplier — larger → wider penumbra. -float CalcShadow(vec4 sc) { +float CalcShadow(vec4 sc, float layer) { vec3 projCoords = sc.xyz / sc.w; projCoords.xy = projCoords.xy * 0.5 + 0.5; @@ -107,11 +122,8 @@ float CalcShadow(vec4 sc) { projCoords.z < 0.0 || projCoords.z > 1.0) return 1.0; - vec2 fadeXY = smoothstep(vec2(0.0), vec2(0.04), projCoords.xy) - * smoothstep(vec2(0.0), vec2(0.04), vec2(1.0) - projCoords.xy); - float fadeZ = smoothstep(0.0, 0.05, projCoords.z) - * smoothstep(0.0, 0.08, 1.0 - projCoords.z); - float edgeFade = fadeXY.x * fadeXY.y * fadeZ; + float edgeFade = smoothstep(0.0, 0.05, projCoords.z) + * smoothstep(0.0, 0.08, 1.0 - projCoords.z); float zRef = projCoords.z - uShadowBias; float phi = InterleavedGradientNoise(gl_FragCoord.xy) * 6.283185; @@ -120,7 +132,8 @@ float CalcShadow(vec4 sc) { // above receiver on a 28m/4096 map — decoupled from uLightSize so the // blocker search stays reliable at all softness settings. float searchRadius = uShadowTexelSize * 30.0; - float avgBlocker = FindAvgBlockerDepth(projCoords.xy, zRef, searchRadius, phi); + float avgBlocker = FindAvgBlockerDepth(projCoords.xy, layer, zRef, + searchRadius, phi); if (avgBlocker < 0.0) return 1.0; @@ -133,10 +146,39 @@ float CalcShadow(vec4 sc) { uShadowTexelSize * 0.5, // floor: contact-hard uShadowTexelSize * 20.0); // cap: floating objects - float shadow = PCFFilter(projCoords.xy, zRef, filterRadius, phi); + float shadow = PCFFilter(projCoords.xy, layer, zRef, filterRadius, phi); return mix(1.0, shadow, uShadowStrength * edgeFade); } +// Normal offset scales with the cascade radius so it stays the same number +// of texels in every cascade. +vec4 CascadeCoord(uint c, vec3 worldPos, vec3 N) { + float normalBias = uLightDir.w * uCascadeSplits[c] / + uCascadeSplits[uCascadeCount - 1u]; + return uCascadeVP[c] * vec4(worldPos + N * normalBias, 1.0); +} + +// Picks the cascade by distance from the camera and blends into the next one +// (or out to unshadowed after the last) across the band at its outer edge. +float CalcCascadedShadow(vec3 worldPos, vec3 N) { + if (uCascadeCount == 0u) return 1.0; + float dist = length(worldPos - uCameraPos.xyz); + uint c = 0u; + while (c + 1u < uCascadeCount && dist > uCascadeSplits[c]) ++c; + + float outer = uCascadeSplits[c]; + if (dist >= outer) return 1.0; + float t = smoothstep(outer * (1.0 - uCascadeBlend), outer, dist); + float shadow = CalcShadow(CascadeCoord(c, worldPos, N), float(c)); + if (t > 0.0) { + float next = (c + 1u < uCascadeCount) + ? CalcShadow(CascadeCoord(c + 1u, worldPos, N), float(c + 1u)) + : 1.0; + shadow = mix(shadow, next, t); + } + return shadow; +} + void main() { vec4 texColor = texture(diffuseTexture, fragUV); vec4 baseColor = texColor * fragColor; @@ -162,7 +204,7 @@ void main() { float spec = normFactor * pow(max(dot(N, halfDir), 0.0), shininess); vec3 specular = lightColor * spec * 0.5 * max(NdotL, 0.0); - float shadow = CalcShadow(fragShadowCoord); + float shadow = CalcCascadedShadow(fragWorldPos, N); // Output linear HDR — tonemapping (ACES + gamma) is applied in the // bloom composite pass. Doing Reinhard here would cause double-tonemapping. diff --git a/assets/shaders/skinned_shader.frag.spv b/assets/shaders/skinned_shader.frag.spv index 639554d17cacbbceeeb60041b3067c6a983ac637..0f900d8e896ba86ec147fc3cfdec6293c833db05 100644 GIT binary patch literal 17116 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 literal 12924 zcmZXZ34B~t*~Twqp_J0Hl%=c*El?K0ve$lvq=g1Fw1u*Zh9NUa2GUGOCI#w33q=7D z5&hUyEQ^W=DvAn-A0vnYvbiF-vA8dY%KowV|KGW9ay#D6`#aC`yzhC>d(S=R%$>Ap z{h1pi$$H5~$;Qc>>m}*6X|g^_g4UY0UERyN_ZqDh_u6m&w;M1uX-eaanFed7)s`2^ zLppZHaVGYDY&G^V>>2C@>~-u7Y!hKQB#m3oThky3B9yrN{>q+DEisCRIzm=9W);FJaB*o3iBHHResanWpU5g=cld1o01B=n8vmyPTFW%`;mp=qYGz3(;SzN>-CzifXCQt!7=u=;QUzE zH}D(Q;VsF{@bU4$uCtmkBbCz@Rw^S!3i2LaN9!vTODCLI=Nqyo`!XqV6@8%7>PH7NP5^>kBJ`m637XeL2>-{8Tpz za9sBU7+Y9aUK%N+nPTs*zdC?#QtI<; z$od>pI;%9eth_S4=NM~}{`6kzp3pZ`UsUO@-^oPH?rZgB)xwB3)5`2QC!P!HhYc0S zwJB&leU<*Dg(6W+$xhkaiD!djA9&XF5B4k?DKH$5k#6ZT*~6Iewb`1?f&cfOZAlJ9 zFN~iD^}YwK)3-U9*TCmD@I%(&O-UELzc5T4tw}fdzxQ%;(t}=KkM%K6Stmzx(%ZmG z4ZMFH-ja;MtEIDI%G0r{XvK10-`Hr0=T{vY;pw@?{ch-E!j3&o-jb|@pIR6#MQ1g? zJB*ponv!$S7N_&untre7KfW?DSX^8gshU@1ya) z^&=j=CHX}3%?360iqQPx7#b~C&$i~)?s2{o z*z-rf=c<0M;1m9)bbot>9Zp4Y{>@CICp2@bZIX+#k6K$!b4)c~f(~bF^LgHRwncuq z=T<(Kn*BPkh_jE{s+{IosJ14jnM>`0oaVWwc5zPg%u~BGr+MC~t<7nkb!s={G|xJ< z8!_k5v#uTN72G*>PUN0>=9!jp_jx{EZoHTA?swlYyl1(4TkgEbS7)3ikeCCFTD*jD6oj^+B=;Lfj_)(&@W^{a0I-WefHs+J1Xu_AySM_wL$n!@TS# zpMyDfk*5R9bx80dAa7kcqa!%4Ht8jI&feX?d*RO6bg~v{XYd4uX}yQ_u>2K#Tb}6| zIE?fD(}N7GHZS9zC0>qe-5llV6?LqEnJ?Xx_2Ezke&rNXS ztmjKQxzwyjezO8sjCWVcIu^(s=U&X`8T?UjzP=~nNwVsYJ=s}4>o+jxR*Rf(f+s&d zbAH6U1y|ETYa*$+wCFb(?cs;I$JfeK_~gg0pT9M=?FgPpY%{h4tF|rUYGKAWKY7y^ z+stYqI-Y*H=b>8E>vc0WL@4owv@6XS{`}%en=bwjP zebo%sRs9_J0^B~v>5DuMfVtB3>is`d=jpu2AA#RD?Sam?PtS2fypMkatl9g}FZW!} z--qWqpYeI{T$dlfYH$u*e_r-^1;sUDu35CckH(zKea8&$+334up08=(7d3F-4I|!j z+V{fX=JS0p&#%jI<9!Fr`+ffluK%_M?)zW(&3{(|zbD7-@4H{b%O7muzVn6Oe7^Gq zH~#4ycfNeD3%~Kc*U9HIzn-aMaM!t9{VcHS`gop#&-84tG3p2NgnS-q#_N9{SS|Pm z!0v4Q2pP!oX?u~Q7?u#ke8a)rpCpj$h*~e$B-bqgP&4;j^Fu!kN+z)5^ z5_Y_KFMz9QUN)|^80SI+HODd5aa=p7p@+)Tn<*#ylkw+e6D-_oy_wr z+AFchcP}y4>t4Sq^V!F}A&(k9o%Qh?iuw2qSUqaE2CSxe*;qr2`&k6XHAjBjwQzlx zWbi7!Sv4VdUY+W(Ntzi2s&DQtVz-nPrvi16PG(PG3nK{j4tYdn{ z=#MeJ0rp;ke-rH5Jtx~!-vX)OaUYZ3-uV^YCpjpJXz~+gV?}Cj}cMQ)*wHV`GaQ@!D2UiRKePH*B z&&zf2eXud=vA=!*=9BI({f=`Ft-APr+6Su=*pIIUb}{ ziyS`!t3{nZ2IrsIPvF)N&*-OMW7Ok0{0z(|eGdA44w3)oaBGa%$H9D(Z7}=kk9GYE zu=N;Yt&h>FMQu-l^Uv{@aAV>b{R(W1dT74}t9RsT`VBn4j(!X0lh&`#`lJ6-aB~=A zO;6COS(9hm@4(MwzGrFWbF;bsJ$P4Q)b0B;ty=W`BX}hiXVRa*YMPggXHwk%pAp<+ za@YS~z~&79U%{@+sQqtXHREF4{T*x`eQ_o|2X<_Ik^3LueD3Gr-h1T!Cs@t6$o(&{ zx%7Fzeut|?eJ_I5I+-8Ox|hJ-fBt-Z8Loa*Hixf(?dKKRtC^-8+H0BSd@bdC@Y(!7 zn*EK}Z*J>)fmYue%XEk# zoUi3@xLVBNJHcv4VD_=TcVXUl)Y}as0ozyqQs&v`b8L!}M4#(G9_zsS^#2dN{JT-?hpjT~ zbN2rU)x|S%*t6X=u^nx!0sp@()`a(YQUmw@+`{j^>&tQD`x|&U#~uH)23~347iHY% zV=bQXV(akzTx<>ESE>CywBCd5OFZ`;+EXwu{RLY6wy~B@1)Jkm=C?>&!qj@P*T^A% zFJ>9>=0(W zy1AThHRI2qtzx4YH+~HB+3T0D!0h85ke`VeqaMG>&H|f9U!2n`!RlU)>vK|zI^PFY z3;ura*4XXYoPGdojQY#;KZn-K`PKJ9OwIWfN1j#SeBRY?*Fo&zb5ooot7xrDpPK#M zOXp#(l~vRsKR?sf&>F8#&9xEZ+saR&wI18Z_aU&)JMw)PtQPt7spZ#V4?Ox`0Cvn< zvU#}>tmgW4UM{Bfa$fX(1p7E<9padmkAl6|$a@Lcym8Kc46GLC?4@A)sC(b;D>dtM zO!GTV_%3Vk853iF0&IWJ9&!HcX@`5|&z|k!ao+5Z!#n118`v6lqK&iXa&riSnE7)2r&X2ExjZu#=Zw33DKHE4SZU?Iw@BQji^I7^_ zeIB;<-TLu-G1YW9z{;&YAhzYTVb*mvIntKEgUU*!4yvMbzcXUsh^ zi*^^xx@Ob5_IIbPVa~tYwf}mKyS6uEBEv7A-oUqO;Inhwe0$`${pU3B19RN?gL2&X zc@2Di13#pJcQx?t2Hun7j(19qJ6>-CFE#M~jQd<>68~Myd5zy}_hg#77vaqK#P5Oc zfsMZxi)VWuSUuM3_rYpmKJ(q+Kfuhd-!)UCRWpzN`>`4pzZ)LNG<7d)iT(N@Sbql= z?|(ncxLWMPhrq__>!c3n=wYmeosGp_dIW5&m$ezA7JVKA*RV&i=<_46eXPqq#;8S~ zAA@U{wzWxkWi}{K?wo%t#z>aOL#ydvT^;fWd>-sybmvyQC4O6o&an$u3xQ5NaV!r+X zR*(659;_Dg6?trdfQnAf_DjWff|SA)lyv7VbTEQ9{7 zcLVVH=(@azH?X%b<0DryxP}(FTHtD$myQ3QV6Ik36J|{0-vG>Ko&3i6o7Fy(z}8@# z=Y`)@YB9$ffooa+c=l7!)E%Q0-^O4*=@>r$$@;L!F%4YH{E=f5G<9n?$EINCQa$o+ z2Ie!KcPhlV_`SV3*s+bXcE4BEV%#mj-p|=sjJqY8dc(TJwz?)-4|@V`(RP;eqi;; zaR69tf6N@dtEd^j8{^Fd*Jz{Ocfi#&FB|*$4x=x|`5&(jR#ifYsvsTL@Nj{d;yC2KMso(03@N=2;=m zpB?Iv_i(WH75oUWUS<&n+0%X(nz{aS@+zo^Ir2jAJS?xH+xt8_$Tj@Vgds(0W diff --git a/include/private/Graphics/Common/ConstantBuffer.hpp b/include/private/Graphics/Common/ConstantBuffer.hpp index d96c6ebb..66d99136 100644 --- a/include/private/Graphics/Common/ConstantBuffer.hpp +++ b/include/private/Graphics/Common/ConstantBuffer.hpp @@ -1,8 +1,9 @@ #ifndef _CONSTANTBUFFER_H_ #define _CONSTANTBUFFER_H_ -#include #include +#include +#include #include namespace Sleak { @@ -43,6 +44,9 @@ namespace Sleak { // Lights[]) still read a valid prefix. Don't reorder. static constexpr uint32_t MAX_LIGHTS = 16; + /// Upper bound on directional shadow cascades (array layers). + static constexpr uint32_t MAX_SHADOW_CASCADES = 4; + /// Full per-frame lighting UBO: camera, ambient, fog, and the packed light array. struct alignas(16) LightCBData { // Header Row 0: CameraPos.xyz + NumActiveLights @@ -204,7 +208,18 @@ namespace Sleak { // Shadow coords must use this — a per-fragment round trip // through reconstructed world position shimmers under rotation. float NdcToShadow[16]; + + // Cascades. LightVP above is the last cascade so shaders that + // only know the single map keep working. + float CascadeVP[MAX_SHADOW_CASCADES][16]; + float CascadeSplits[MAX_SHADOW_CASCADES]; // radius around camera + uint32_t CascadeCount; + float CascadeBlend; // blend band, fraction of the cascade radius + float _cascadePad[2]; }; + static_assert(offsetof(ShadowLightUBO, CascadeVP) == 384 && + offsetof(ShadowLightUBO, CascadeCount) == 656, + "ShadowLightUBO must match the std140 shader block"); // GPU-aligned POD struct for post-process settings constant buffer. // Total: 16 bytes (1 x 16-byte row). @@ -263,7 +278,17 @@ namespace Sleak { // Row 6-9: NDC -> shadow clip (see ShadowLightUBO::NdcToShadow) float NdcToShadow[16]; + + // Rows 10-27: cascades (see ShadowLightUBO) + float CascadeVP[MAX_SHADOW_CASCADES][16]; + float CascadeSplits[MAX_SHADOW_CASCADES]; + uint32_t CascadeCount; + float CascadeBlend; + float _cascadePad[2]; }; + static_assert(offsetof(PCSSShadowGPUData, CascadeVP) == 160 && + offsetof(PCSSShadowGPUData, CascadeCount) == 432, + "PCSSShadowGPUData must match the std140 shader block"); // GPU-aligned POD struct for SSAO composite settings (used in PBR shader). // Total: 16 bytes (1 x 16-byte row). diff --git a/include/private/Graphics/Common/Renderer.hpp b/include/private/Graphics/Common/Renderer.hpp index 56bb17f9..e4248696 100644 --- a/include/private/Graphics/Common/Renderer.hpp +++ b/include/private/Graphics/Common/Renderer.hpp @@ -1,10 +1,11 @@ #pragma once - -#include "RenderContext.hpp" #include #include +#include "ConstantBuffer.hpp" +#include "RenderContext.hpp" + namespace Sleak { namespace RenderEngine { @@ -134,6 +135,11 @@ class ENGINE_API Renderer { // Shadow mapping support (overridden by VulkanRenderer) virtual void UpdateShadowLightUBO(const void* data, uint32_t size) { (void)data; (void)size; } virtual void SetLightVP(const float* lightVP) { (void)lightVP; } + /// Stages one light view-projection per cascade for the next shadow pass. + virtual void SetShadowCascades(const float* viewProj, uint32_t count) { + (void)viewProj; + (void)count; + } void SetShadowPassEnabled(bool enabled) { m_shadowPassEnabled = enabled; } bool IsShadowPassEnabled() const { return m_shadowPassEnabled; } @@ -193,15 +199,31 @@ class ENGINE_API Renderer { /// Requests a new shadow map size, clamped to [256, 8192] and queued if resources already exist. void SetShadowMapResolution(uint32_t res) { if (res < 256 || res > 8192) return; - if (res == m_shadowMapResolution) return; if (m_shadowResourcesCreated) { + QueueShadowResourceChange(); m_pendingShadowMapResolution = res; - m_shadowResChangeRequested = true; return; } m_shadowMapResolution = res; } uint32_t GetShadowMapResolution() const { return m_shadowMapResolution; } + + /// Requests a shadow cascade (array layer) count, queued like the size. + void SetShadowCascadeCount(uint32_t count) { + if (count < 1 || count > MAX_SHADOW_CASCADES) return; + if (m_shadowResourcesCreated) { + QueueShadowResourceChange(); + m_pendingShadowCascadeCount = count; + return; + } + m_shadowCascadeCount = count; + } + uint32_t GetShadowCascadeCount() const { return m_shadowCascadeCount; } + + /// Scales each backend's tuned shadow-pass raster depth bias. + void SetShadowDepthBiasScale(float scale) { + m_shadowDepthBiasScale = scale > 0.0f ? scale : 0.0f; + } virtual void ApplyShadowResolutionChange() {} // Deferred rendering mode (default: enabled) @@ -307,11 +329,22 @@ class ENGINE_API Renderer { // PCSS bool m_pcssEnabled = true; - // Shadow map resolution + // Shadow map resolution and cascade (layer) count uint32_t m_shadowMapResolution = 2048; uint32_t m_pendingShadowMapResolution = 2048; + uint32_t m_shadowCascadeCount = 1; + uint32_t m_pendingShadowCascadeCount = 1; bool m_shadowResourcesCreated = false; bool m_shadowResChangeRequested = false; + float m_shadowDepthBiasScale = 1.0f; + + /// Seeds the pending shadow settings from the live ones. + void QueueShadowResourceChange() { + if (m_shadowResChangeRequested) return; + m_pendingShadowMapResolution = m_shadowMapResolution; + m_pendingShadowCascadeCount = m_shadowCascadeCount; + m_shadowResChangeRequested = true; + } // Deferred rendering bool m_deferredEnabled = true; diff --git a/include/private/Graphics/OpenGL/OpenGLRenderer.hpp b/include/private/Graphics/OpenGL/OpenGLRenderer.hpp index f58c8631..a7610b32 100644 --- a/include/private/Graphics/OpenGL/OpenGLRenderer.hpp +++ b/include/private/Graphics/OpenGL/OpenGLRenderer.hpp @@ -107,9 +107,17 @@ class ENGINE_API OpenGLRenderer : public Renderer, public RenderContext { /// Configures the shared VAO's vertex attribute layout for the standard mesh format. void SetupVertexLayout(); - // Shadow mapping + // Shadow mapping: one depth array layer per cascade, plus a 2D view of + // the last cascade for shaders that only know the single map. GLuint m_shadowFBO = 0; GLuint m_shadowDepthTex = 0; + GLuint m_shadowLegacyView = 0; + GLuint m_shadowCompareSampler = 0; + GLuint m_shadowRawSampler = 0; + float m_cascadeVP[MAX_SHADOW_CASCADES][16] = {}; + float m_pendingCascadeVP[MAX_SHADOW_CASCADES][16] = {}; + uint32_t m_cascadeCount = 0; + uint32_t m_pendingCascadeCount = 0; GLuint m_shadowUBO = 0; bool m_shadowMapCreated = false; bool m_shadowUBOCreated = false; @@ -119,9 +127,17 @@ class ENGINE_API OpenGLRenderer : public Renderer, public RenderContext { bool m_inShadowPass = false; GLuint m_shadowTransformUBO = 0; void SetLightVP(const float* mat) override; + /// Stages the per-cascade light view-projections for the next BeginRender. + void SetShadowCascades(const float* viewProj, uint32_t count) override; void UpdateShadowLightUBO(const void* data, uint32_t size) override; - /// Allocates the shadow-pass depth FBO and texture at the configured resolution. + /// Drops the shadow map so it is rebuilt with the queued settings. + void ApplyShadowResolutionChange() override; + /// Allocates the shadow-pass depth FBO and cascade array texture. bool CreateShadowMapResources(); + /// Deletes the shadow array texture, its view, samplers, and FBO. + void DestroyShadowMapResources(); + /// Binds the last cascade at unit 3 and the whole array at 17/18. + void BindShadowMapTextures(); /// Allocates the UBO backing ShadowLightUBO. bool CreateShadowUBO(); /// Replays cached shadow-caster draws into the depth-only shadow FBO. diff --git a/include/private/Graphics/Vulkan/VulkanRenderer.hpp b/include/private/Graphics/Vulkan/VulkanRenderer.hpp index 053adb78..7941745e 100644 --- a/include/private/Graphics/Vulkan/VulkanRenderer.hpp +++ b/include/private/Graphics/Vulkan/VulkanRenderer.hpp @@ -182,6 +182,8 @@ class ENGINE_API VulkanRenderer : public Renderer, public RenderContext { void UpdateShadowLightUBO(const void* data, uint32_t size) override; /// Stages the light view-projection matrix for commit at the next BeginRender. void SetLightVP(const float* lightVP) override; + /// Stages the per-cascade light view-projections for the next BeginRender. + void SetShadowCascades(const float* viewProj, uint32_t count) override; // Deferred rendering overrides virtual bool IsDeferredEnabled() const override { return m_deferredEnabled && m_gbufferResourcesCreated; } @@ -206,7 +208,7 @@ class ENGINE_API VulkanRenderer : public Renderer, public RenderContext { void ApplyMSAAChange() override; /// Recreates the swapchain to apply a queued VSync toggle. void ApplyVSyncChange() override; - /// Recreates the extent-dependent shadow map objects at the queued resolution. + /// Recreates the shadow map objects with the queued size and cascades. void ApplyShadowResolutionChange() override; private: @@ -261,6 +263,16 @@ class ENGINE_API VulkanRenderer : public Renderer, public RenderContext { bool CreateShadowLightUBOResources(); /// Destroys the shadow map image, pipeline, render pass, and light UBO resources. void CleanupShadowResources(); + /// Creates the layered shadow image and its array and per-layer views. + bool CreateShadowMapImage(); + /// Creates one framebuffer per cascade layer for the shadow render pass. + bool CreateShadowFramebuffers(); + /// Destroys what CreateShadowMapImage made. + void DestroyShadowMapImage(); + /// Points the set-3 shadow descriptors at the current shadow views. + void WriteShadowSamplerDescriptors(); + /// Renders the cached shadow casters into every cascade layer. + void RecordShadowPass(); /// Creates the Vulkan instance with validation layers when available. bool InitVulkan(); /// Creates the SDL-backed Vulkan presentation surface. @@ -481,11 +493,13 @@ class ENGINE_API VulkanRenderer : public Renderer, public RenderContext { // Shadow mapping resources VkImage m_shadowImage = VK_NULL_HANDLE; VkDeviceMemory m_shadowImageMemory = VK_NULL_HANDLE; - VkImageView m_shadowImageView = VK_NULL_HANDLE; + VkImageView m_shadowImageView = VK_NULL_HANDLE; // last cascade, 2D + VkImageView m_shadowArrayView = VK_NULL_HANDLE; // all cascades + std::array m_shadowLayerViews = {}; VkSampler m_shadowSampler = VK_NULL_HANDLE; // compare sampler (hardware PCF) VkSampler m_shadowRawSampler = VK_NULL_HANDLE; // non-compare sampler (PCSS blocker search) VkRenderPass m_shadowRenderPass = VK_NULL_HANDLE; - VkFramebuffer m_shadowFramebuffer = VK_NULL_HANDLE; + std::array m_shadowFramebuffers = {}; VkPipeline m_shadowPipeline = VK_NULL_HANDLE; VulkanShader* m_shadowShader = nullptr; bool m_shadowPassActive = false; @@ -507,6 +521,12 @@ class ENGINE_API VulkanRenderer : public Renderer, public RenderContext { float m_pendingLightVP[16] = {}; bool m_hasPendingLightVP = false; + // Per-cascade light VPs, staged and committed the same way as m_lightVP + float m_cascadeVP[MAX_SHADOW_CASCADES][16] = {}; + float m_pendingCascadeVP[MAX_SHADOW_CASCADES][16] = {}; + uint32_t m_cascadeCount = 0; + uint32_t m_pendingCascadeCount = 0; + // Light/Shadow UBO (set 2, binding 0) VkDescriptorSetLayout m_lightUBODescriptorSetLayout = VK_NULL_HANDLE; VkDescriptorPool m_lightUBODescriptorPool = VK_NULL_HANDLE; diff --git a/include/public/Core/GraphicsConfig.hpp b/include/public/Core/GraphicsConfig.hpp index 3cb65314..d7129f7e 100644 --- a/include/public/Core/GraphicsConfig.hpp +++ b/include/public/Core/GraphicsConfig.hpp @@ -16,6 +16,9 @@ enum class GraphicsQuality { Off, Low, Medium, High, Ultra }; /// Application::ApplyGraphicsConfig(). Game owns and persists this. /// @ingroup core struct ENGINE_API GraphicsConfig { + /// shadowBias value the backends' raster depth bias is tuned against. + static constexpr float kDefaultShadowBias = 0.002f; + // Post-FX (deferred path) bool ssaoEnabled = false; float ssaoRadius = 0.5f; @@ -36,6 +39,11 @@ struct ENGINE_API GraphicsConfig { float shadowBias = 0.002f; float shadowStrength = 1.0f; + // Shadow cascades + uint32_t shadowCascadeCount = 3; + float shadowSplitLambda = 0.6f; // 0 uniform, 1 logarithmic + float shadowMaxDistance = 128.0f; // radius the cascades cover + // Culling bool frustumCullingEnabled = true; bool occlusionCullingEnabled = true; diff --git a/include/public/Lighting/DirectionalLight.hpp b/include/public/Lighting/DirectionalLight.hpp index 33908b1b..43726bf8 100644 --- a/include/public/Lighting/DirectionalLight.hpp +++ b/include/public/Lighting/DirectionalLight.hpp @@ -23,6 +23,7 @@ namespace Sleak { /// player can actually see multiplies your effective texel density. /// The default covers a very large area, which is right for open /// terrain and far too coarse for a single figure on a small floor. + /// /// @code{.cpp} /// auto* key = new Sleak::DirectionalLight("KeyLight"); @@ -62,6 +63,22 @@ namespace Sleak { } Math::Vector3D GetDirection() const { return m_direction; } + /// Number of shadow cascades, clamped to 1-4 when the shadow is built. + void SetShadowCascadeCount(uint32_t count) { + m_shadowCascadeCount = count; + } + uint32_t GetShadowCascadeCount() const { return m_shadowCascadeCount; } + + /// Split scheme blend: 0 is uniform, 1 is logarithmic. + void SetShadowSplitLambda(float lambda) { + m_shadowSplitLambda = lambda; + } + float GetShadowSplitLambda() const { return m_shadowSplitLambda; } + + /// Radius the cascades cover around the camera; 0 uses frustum size. + void SetShadowMaxDistance(float dist) { m_shadowMaxDistance = dist; } + float GetShadowMaxDistance() const { return m_shadowMaxDistance; } + // Shadow frustum configuration void SetShadowFrustumSize(float size) { m_shadowFrustumSize = size; } float GetShadowFrustumSize() const { return m_shadowFrustumSize; } @@ -81,6 +98,9 @@ namespace Sleak { float m_shadowDistance = 160.0f; float m_shadowNear = 0.1f; float m_shadowFar = 500.0f; + uint32_t m_shadowCascadeCount = 1; + float m_shadowSplitLambda = 0.6f; + float m_shadowMaxDistance = 0.0f; }; } // namespace Sleak diff --git a/src/Core/Application.cpp b/src/Core/Application.cpp index 83c0eae5..76808da3 100644 --- a/src/Core/Application.cpp +++ b/src/Core/Application.cpp @@ -422,6 +422,8 @@ namespace Sleak { renderer->SetIBLIntensity(cfg.iblIntensity); renderer->SetTAAEnabled(cfg.taaEnabled); renderer->SetShadowMapResolution(cfg.shadowMapResolution); + renderer->SetShadowDepthBiasScale(cfg.shadowBias / + GraphicsConfig::kDefaultShadowBias); renderer->SetMSAASampleCount(cfg.msaaSamples); renderer->SetTonemappingEnabled(cfg.tonemapEnabled); renderer->SetExposure(cfg.exposure); diff --git a/src/Graphics/Common/GraphicsConfig.cpp b/src/Graphics/Common/GraphicsConfig.cpp index 89007578..b14e61b9 100644 --- a/src/Graphics/Common/GraphicsConfig.cpp +++ b/src/Graphics/Common/GraphicsConfig.cpp @@ -17,6 +17,7 @@ GraphicsConfig GraphicsConfig::Preset(GraphicsQuality q) { cfg.lightShaftEnabled = false; cfg.shadowEnabled = false; cfg.pcssEnabled = false; + cfg.shadowCascadeCount = 1; cfg.occlusionCullingEnabled = false; cfg.occlusionBufferWidth = 160; cfg.occlusionBufferHeight = 90; @@ -34,6 +35,9 @@ GraphicsConfig GraphicsConfig::Preset(GraphicsQuality q) { cfg.pcssEnabled = false; cfg.shadowMapResolution = 1024; cfg.shadowFrustumSize = 96.0f; + cfg.shadowCascadeCount = 2; + cfg.shadowSplitLambda = 0.5f; + cfg.shadowMaxDistance = 96.0f; cfg.shadowCasterDistance = 64.0f; cfg.occlusionCullingEnabled = false; cfg.occlusionBufferWidth = 160; @@ -50,6 +54,9 @@ GraphicsConfig GraphicsConfig::Preset(GraphicsQuality q) { cfg.lightShaftEnabled = false; cfg.shadowEnabled = true; cfg.pcssEnabled = true; + cfg.shadowCascadeCount = 3; + cfg.shadowSplitLambda = 0.6f; + cfg.shadowMaxDistance = 128.0f; cfg.occlusionCullingEnabled = true; cfg.occlusionBufferWidth = 256; cfg.occlusionBufferHeight = 144; @@ -65,13 +72,16 @@ GraphicsConfig GraphicsConfig::Preset(GraphicsQuality q) { cfg.lightShaftEnabled = true; cfg.shadowEnabled = true; cfg.pcssEnabled = true; + cfg.shadowCascadeCount = 4; + cfg.shadowSplitLambda = 0.7f; + cfg.shadowMaxDistance = 160.0f; cfg.occlusionCullingEnabled = true; cfg.occlusionBufferWidth = 256; cfg.occlusionBufferHeight = 144; break; } case GraphicsQuality::Ultra: { - // High + larger shadow range + TAA. + // High + more shadow range in the same cascades + TAA. cfg.ssaoEnabled = true; cfg.ssrEnabled = true; cfg.bloomEnabled = true; @@ -82,8 +92,10 @@ GraphicsConfig GraphicsConfig::Preset(GraphicsQuality q) { cfg.pcssEnabled = true; cfg.shadowMapResolution = 3072; cfg.shadowDistance = 256.0f; - cfg.shadowFrustumSize = 160.0f; - cfg.shadowCasterDistance = 160.0f; + cfg.shadowCascadeCount = 4; + cfg.shadowSplitLambda = 0.75f; + cfg.shadowMaxDistance = 256.0f; + cfg.shadowCasterDistance = 256.0f; cfg.occlusionCullingEnabled = true; cfg.occlusionBufferWidth = 320; cfg.occlusionBufferHeight = 180; @@ -101,6 +113,9 @@ void GraphicsConfig::ApplyShadows(Sleak::DirectionalLight& light) const { light.SetShadowDistance(shadowDistance); light.SetShadowBias(shadowBias); light.SetShadowStrength(shadowStrength); + light.SetShadowCascadeCount(shadowCascadeCount); + light.SetShadowSplitLambda(shadowSplitLambda); + light.SetShadowMaxDistance(shadowMaxDistance); } } // namespace Sleak diff --git a/src/Graphics/OpenGL/OpenGLRenderer.cpp b/src/Graphics/OpenGL/OpenGLRenderer.cpp index 58da1d67..65fa6334 100644 --- a/src/Graphics/OpenGL/OpenGLRenderer.cpp +++ b/src/Graphics/OpenGL/OpenGLRenderer.cpp @@ -121,11 +121,14 @@ void OpenGLRenderer::BeginRender() { ApplyVSyncChange(); if (m_msaaChangeRequested) ApplyMSAAChange(); + if (m_shadowResChangeRequested) ApplyShadowResolutionChange(); // Commit staged lightVP before shadow pass so shadow + main agree. if (m_hasPendingLightVP) { memcpy(m_lightVP, m_pendingLightVP, sizeof(m_lightVP)); } + memcpy(m_cascadeVP, m_pendingCascadeVP, sizeof(m_cascadeVP)); + m_cascadeCount = m_pendingCascadeCount; // Shadow pass before main rendering if (m_shadowPassEnabled) { @@ -192,9 +195,7 @@ void OpenGLRenderer::Cleanup() { CleanupGBufferResources(); if (m_shadowTransformUBO) { glDeleteBuffers(1, &m_shadowTransformUBO); m_shadowTransformUBO = 0; } if (m_shadowUBO) { glDeleteBuffers(1, &m_shadowUBO); m_shadowUBO = 0; } - if (m_shadowDepthTex) { glDeleteTextures(1, &m_shadowDepthTex); m_shadowDepthTex = 0; } - if (m_shadowFBO) { glDeleteFramebuffers(1, &m_shadowFBO); m_shadowFBO = 0; } - m_shadowMapCreated = false; + DestroyShadowMapResources(); m_shadowUBOCreated = false; CleanupMSAAFramebuffer(); if (bImInitialized) { @@ -652,6 +653,23 @@ void OpenGLRenderer::SetLightVP(const float* mat) { } } +void OpenGLRenderer::SetShadowCascades(const float* viewProj, uint32_t count) { + count = std::min(count, MAX_SHADOW_CASCADES); + if (count > 0 && viewProj) + memcpy(m_pendingCascadeVP, viewProj, sizeof(float) * 16 * count); + m_pendingCascadeCount = count; +} + +void OpenGLRenderer::ApplyShadowResolutionChange() { + m_shadowResChangeRequested = false; + if (m_pendingShadowMapResolution == m_shadowMapResolution && + m_pendingShadowCascadeCount == m_shadowCascadeCount) + return; + DestroyShadowMapResources(); + m_shadowMapResolution = m_pendingShadowMapResolution; + m_shadowCascadeCount = m_pendingShadowCascadeCount; +} + bool OpenGLRenderer::CreateShadowUBO() { if (m_shadowUBOCreated) return true; glGenBuffers(1, &m_shadowUBO); @@ -677,6 +695,11 @@ void OpenGLRenderer::UpdateShadowLightUBO(const void* data, uint32_t size) { shadowData.ShadowLightSize = ubo->LightSize; shadowData.PCSSEnabled = m_pcssEnabled ? 1 : 0; shadowData.ShadowMapEnabled = m_shadowPassEnabled ? 1 : 0; + memcpy(shadowData.CascadeVP, ubo->CascadeVP, sizeof(shadowData.CascadeVP)); + memcpy(shadowData.CascadeSplits, ubo->CascadeSplits, + sizeof(shadowData.CascadeSplits)); + shadowData.CascadeCount = ubo->CascadeCount; + shadowData.CascadeBlend = ubo->CascadeBlend; glBindBuffer(GL_UNIFORM_BUFFER, m_shadowUBO); glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(shadowData), &shadowData); @@ -689,41 +712,99 @@ void OpenGLRenderer::UpdateShadowLightUBO(const void* data, uint32_t size) { bool OpenGLRenderer::CreateShadowMapResources() { if (m_shadowMapCreated) return true; - // Create depth texture + const GLsizei layers = static_cast(m_shadowCascadeCount); + const float borderColor[] = {1.0f, 1.0f, 1.0f, 1.0f}; + glGenTextures(1, &m_shadowDepthTex); - glBindTexture(GL_TEXTURE_2D, m_shadowDepthTex); - glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32F, - m_shadowMapResolution, m_shadowMapResolution, 0, - GL_DEPTH_COMPONENT, GL_FLOAT, nullptr); + glBindTexture(GL_TEXTURE_2D_ARRAY, m_shadowDepthTex); + glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_DEPTH_COMPONENT32F, + m_shadowMapResolution, m_shadowMapResolution, layers); + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + + // 2D view of the last cascade, compared in hardware, for shaders that + // still declare a single sampler2DShadow at unit 3. + glGenTextures(1, &m_shadowLegacyView); + glTextureView(m_shadowLegacyView, GL_TEXTURE_2D, m_shadowDepthTex, + GL_DEPTH_COMPONENT32F, 0, 1, layers - 1, 1); + glBindTexture(GL_TEXTURE_2D, m_shadowLegacyView); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); - float borderColor[] = {1.0f, 1.0f, 1.0f, 1.0f}; glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL); glBindTexture(GL_TEXTURE_2D, 0); - // Create FBO + // The array is read through sampler objects: one compares (PCF), the + // other returns raw depth for the PCSS blocker search. + glGenSamplers(1, &m_shadowCompareSampler); + glGenSamplers(1, &m_shadowRawSampler); + for (GLuint sampler : {m_shadowCompareSampler, m_shadowRawSampler}) { + glSamplerParameteri(sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); + glSamplerParameteri(sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); + glSamplerParameterfv(sampler, GL_TEXTURE_BORDER_COLOR, borderColor); + } + const GLuint cmp = m_shadowCompareSampler; + glSamplerParameteri(cmp, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glSamplerParameteri(cmp, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glSamplerParameteri(cmp, GL_TEXTURE_COMPARE_MODE, + GL_COMPARE_REF_TO_TEXTURE); + glSamplerParameteri(cmp, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL); + const GLuint raw = m_shadowRawSampler; + glSamplerParameteri(raw, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glSamplerParameteri(raw, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glSamplerParameteri(raw, GL_TEXTURE_COMPARE_MODE, GL_NONE); + glGenFramebuffers(1, &m_shadowFBO); glBindFramebuffer(GL_FRAMEBUFFER, m_shadowFBO); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, m_shadowDepthTex, 0); + glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + m_shadowDepthTex, 0, 0); glDrawBuffer(GL_NONE); glReadBuffer(GL_NONE); if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { SLEAK_ERROR("OpenGL shadow FBO is not complete!"); glBindFramebuffer(GL_FRAMEBUFFER, 0); + DestroyShadowMapResources(); return false; } glBindFramebuffer(GL_FRAMEBUFFER, 0); m_shadowMapCreated = true; m_shadowResourcesCreated = true; - SLEAK_INFO("OpenGL shadow map resources created ({}x{})", m_shadowMapResolution, m_shadowMapResolution); + SLEAK_INFO("OpenGL shadow map resources created ({}x{}, {} cascades)", + m_shadowMapResolution, m_shadowMapResolution, layers); return true; } +void OpenGLRenderer::DestroyShadowMapResources() { + for (GLuint* tex : {&m_shadowLegacyView, &m_shadowDepthTex}) { + if (*tex) glDeleteTextures(1, tex); + *tex = 0; + } + for (GLuint* sampler : {&m_shadowCompareSampler, &m_shadowRawSampler}) { + if (*sampler) glDeleteSamplers(1, sampler); + *sampler = 0; + } + if (m_shadowFBO) glDeleteFramebuffers(1, &m_shadowFBO); + m_shadowFBO = 0; + m_shadowMapCreated = false; + m_shadowResourcesCreated = false; +} + +void OpenGLRenderer::BindShadowMapTextures() { + if (!m_shadowMapCreated) return; + glActiveTexture(GL_TEXTURE3); + glBindTexture(GL_TEXTURE_2D, m_shadowLegacyView); + glActiveTexture(GL_TEXTURE17); + glBindTexture(GL_TEXTURE_2D_ARRAY, m_shadowDepthTex); + glBindSampler(17, m_shadowCompareSampler); + glActiveTexture(GL_TEXTURE18); + glBindTexture(GL_TEXTURE_2D_ARRAY, m_shadowDepthTex); + glBindSampler(18, m_shadowRawSampler); + glActiveTexture(GL_TEXTURE0); +} + void OpenGLRenderer::RenderShadowPass() { // Skip if no cached draws — preserve previous frame's shadow map auto* queue = RenderCommandQueue::GetInstance(); @@ -737,14 +818,13 @@ void OpenGLRenderer::RenderShadowPass() { GLint savedViewport[4]; glGetIntegerv(GL_VIEWPORT, savedViewport); - // Bind shadow FBO glBindFramebuffer(GL_FRAMEBUFFER, m_shadowFBO); glViewport(0, 0, m_shadowMapResolution, m_shadowMapResolution); - glClear(GL_DEPTH_BUFFER_BIT); // Enable polygon offset for slope-scale depth bias glEnable(GL_POLYGON_OFFSET_FILL); - glPolygonOffset(2.0f, 4.0f); + glPolygonOffset(2.0f * m_shadowDepthBiasScale, + 4.0f * m_shadowDepthBiasScale); // Back-face cull: closed solid volumes always have the light-facing // surface win — same depth result, half the raster. @@ -755,9 +835,15 @@ void OpenGLRenderer::RenderShadowPass() { // Ensure VAO is bound for shadow pass draws glBindVertexArray(m_VAO); - // Execute shadow draw commands + // One layer per cascade; BindConstantBuffer builds the caster transform + // from m_lightVP, so it carries the cascade being drawn. + const uint32_t cascades = std::min(m_cascadeCount, m_shadowCascadeCount); m_inShadowPass = true; - if (queue) { + for (uint32_t c = 0; c < cascades; ++c) { + glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + m_shadowDepthTex, 0, static_cast(c)); + glClear(GL_DEPTH_BUFFER_BIT); + memcpy(m_lightVP, m_cascadeVP[c], sizeof(m_lightVP)); queue->ExecuteShadowPass(this); } m_inShadowPass = false; @@ -775,10 +861,7 @@ void OpenGLRenderer::RenderShadowPass() { glViewport(savedViewport[0], savedViewport[1], savedViewport[2], savedViewport[3]); - // Bind shadow map texture at unit 3 - glActiveTexture(GL_TEXTURE3); - glBindTexture(GL_TEXTURE_2D, m_shadowDepthTex); - glActiveTexture(GL_TEXTURE0); + BindShadowMapTextures(); } // ============================================================ @@ -1087,11 +1170,7 @@ void OpenGLRenderer::ExecuteDeferredLightingPass() { // Rebind shadow map at unit 3 — BindGBufferShader cleared it to avoid // sampler2DShadow/sampler2D conflict during the geometry pass. - if (m_shadowMapCreated) { - glActiveTexture(GL_TEXTURE3); - glBindTexture(GL_TEXTURE_2D, m_shadowDepthTex); - glActiveTexture(GL_TEXTURE0); - } + BindShadowMapTextures(); // IBL — bind irradiance/prefilter/BRDF at units 14/15/16 and upload the // settings UBO at slot 10. Disabled if precompute hasn't completed. diff --git a/src/Graphics/Vulkan/VulkanDeferred.cpp b/src/Graphics/Vulkan/VulkanDeferred.cpp index 201676ce..fed7e7ed 100644 --- a/src/Graphics/Vulkan/VulkanDeferred.cpp +++ b/src/Graphics/Vulkan/VulkanDeferred.cpp @@ -775,9 +775,9 @@ void VulkanRenderer::UpdateGBufferDescriptors() { // Depth images must use DEPTH_STENCIL_READ_ONLY_OPTIMAL (not SHADER_READ_ONLY_OPTIMAL) // when accessed as a sampler; using the wrong layout causes VK_ERROR_DEVICE_LOST. // Fall back to the default 1x1 white texture when no shadow map is available. - if (m_shadowImageView) { + if (m_shadowArrayView) { imageInfos[4].imageLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; - imageInfos[4].imageView = m_shadowImageView; + imageInfos[4].imageView = m_shadowArrayView; imageInfos[4].sampler = m_shadowSampler ? m_shadowSampler : (m_defaultTexture ? m_defaultTexture->GetSampler() : VK_NULL_HANDLE); } else if (m_defaultTexture) { diff --git a/src/Graphics/Vulkan/VulkanDescriptors.cpp b/src/Graphics/Vulkan/VulkanDescriptors.cpp index 5094c00c..997a857a 100644 --- a/src/Graphics/Vulkan/VulkanDescriptors.cpp +++ b/src/Graphics/Vulkan/VulkanDescriptors.cpp @@ -66,20 +66,18 @@ bool VulkanRenderer::CreateDescriptorSetLayout() { &m_lightUBODescriptorSetLayout) != VK_SUCCESS) SLEAK_RETURN_ERR("Failed to create light UBO descriptor set layout!"); - // Set 3: shadow map samplers — binding 0 = compare sampler (PCF), - // binding 1 = raw sampler (PCSS blocker search) - std::array shadowSamplerBindings{}; - shadowSamplerBindings[0].binding = 0; - shadowSamplerBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - shadowSamplerBindings[0].descriptorCount = 1; - shadowSamplerBindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; - shadowSamplerBindings[0].pImmutableSamplers = nullptr; - - shadowSamplerBindings[1].binding = 1; - shadowSamplerBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - shadowSamplerBindings[1].descriptorCount = 1; - shadowSamplerBindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; - shadowSamplerBindings[1].pImmutableSamplers = nullptr; + // Set 3: shadow map samplers. Bindings 0/1 = last cascade as a 2D map + // (compare for PCF, raw for the PCSS blocker search), 2/3 = all cascades + // as an array with the same two samplers. + std::array shadowSamplerBindings{}; + for (uint32_t b = 0; b < shadowSamplerBindings.size(); ++b) { + shadowSamplerBindings[b].binding = b; + shadowSamplerBindings[b].descriptorType = + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + shadowSamplerBindings[b].descriptorCount = 1; + shadowSamplerBindings[b].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + shadowSamplerBindings[b].pImmutableSamplers = nullptr; + } VkDescriptorSetLayoutCreateInfo shadowSamplerLayoutInfo{}; shadowSamplerLayoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; diff --git a/src/Graphics/Vulkan/VulkanPipelines.cpp b/src/Graphics/Vulkan/VulkanPipelines.cpp index 01c3e353..6b367bd7 100644 --- a/src/Graphics/Vulkan/VulkanPipelines.cpp +++ b/src/Graphics/Vulkan/VulkanPipelines.cpp @@ -897,6 +897,16 @@ bool VulkanRenderer::CreateCustomFormatPipelines(VertexFormatHandle format) { } else { VkPipelineShaderStageCreateInfo shaderStage = shadowShader->GetVertexInfo(); + const std::array shadowDynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR, + VK_DYNAMIC_STATE_DEPTH_BIAS}; + VkPipelineDynamicStateCreateInfo shadowDynamicState{}; + shadowDynamicState.sType = + VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + shadowDynamicState.dynamicStateCount = + static_cast(shadowDynamicStates.size()); + shadowDynamicState.pDynamicStates = shadowDynamicStates.data(); + VkPipelineInputAssemblyStateCreateInfo inputAssembly{}; inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; @@ -915,10 +925,7 @@ bool VulkanRenderer::CreateCustomFormatPipelines(VertexFormatHandle format) { rasterizer.lineWidth = 1.0f; rasterizer.cullMode = VK_CULL_MODE_BACK_BIT; rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; - rasterizer.depthBiasEnable = VK_TRUE; - rasterizer.depthBiasConstantFactor = 1.25f; - rasterizer.depthBiasSlopeFactor = 1.75f; - rasterizer.depthBiasClamp = 0.0f; + rasterizer.depthBiasEnable = VK_TRUE; // factors set per pass VkPipelineMultisampleStateCreateInfo msaa{}; msaa.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; @@ -949,7 +956,7 @@ bool VulkanRenderer::CreateCustomFormatPipelines(VertexFormatHandle format) { pipelineInfo.pMultisampleState = &msaa; pipelineInfo.pDepthStencilState = &depthStencil; pipelineInfo.pColorBlendState = &colorBlendInfo; - pipelineInfo.pDynamicState = &dynamicState; + pipelineInfo.pDynamicState = &shadowDynamicState; pipelineInfo.layout = pipelineLay; pipelineInfo.renderPass = m_shadowRenderPass; pipelineInfo.subpass = 0; diff --git a/src/Graphics/Vulkan/VulkanRenderer.cpp b/src/Graphics/Vulkan/VulkanRenderer.cpp index 1b3cfe12..717e32bc 100644 --- a/src/Graphics/Vulkan/VulkanRenderer.cpp +++ b/src/Graphics/Vulkan/VulkanRenderer.cpp @@ -175,6 +175,8 @@ void VulkanRenderer::BeginRender() { if (m_hasPendingLightVP) { memcpy(m_lightVP, m_pendingLightVP, sizeof(m_lightVP)); } + memcpy(m_cascadeVP, m_pendingCascadeVP, sizeof(m_cascadeVP)); + m_cascadeCount = m_pendingCascadeCount; // Apply pending changes between frames if (m_vsyncChangeRequested) @@ -260,60 +262,8 @@ void VulkanRenderer::BeginRender() { bFrameStarted = true; m_pbrMaterialSlot[currentFrame] = 0; // reset PBR material ring for this frame - // Skip shadow pass if no cached draws — preserve previous frame's shadow map - auto* shadowQueue = RenderCommandQueue::GetInstance(); - bool hasShadowDraws = shadowQueue && shadowQueue->HasCachedShadowDraws(); - - if (m_shadowResourcesCreated && m_shadowPassEnabled && hasShadowDraws) { - VkClearValue shadowClear{}; - shadowClear.depthStencil = {1.0f, 0}; - - VkRenderPassBeginInfo shadowPassInfo{}; - shadowPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; - shadowPassInfo.renderPass = m_shadowRenderPass; - shadowPassInfo.framebuffer = m_shadowFramebuffer; - shadowPassInfo.renderArea.offset = {0, 0}; - shadowPassInfo.renderArea.extent = {m_shadowMapResolution, m_shadowMapResolution}; - shadowPassInfo.clearValueCount = 1; - shadowPassInfo.pClearValues = &shadowClear; - - vkCmdBeginRenderPass(command, &shadowPassInfo, VK_SUBPASS_CONTENTS_INLINE); - vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, m_shadowPipeline); - - // shadow_depth.vert statically declares `layout(set = 1, binding = 0) uniform BoneUBO` - // (skinning conditioned on boneWeights). The shader must have set 1 bound even for - // non-skinned casters, otherwise vkCmdDrawIndexed fires VUID-vkCmdDrawIndexed-None-08600. - // Bind the bone UBO once at pass start so all shadow draws (skinned or static) are legal. - if (m_boneUBOCreated) { - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipelineLay, 1, 1, - &boneDescriptorSets[currentFrame], 0, nullptr); - } - - VkViewport shadowViewport{}; - shadowViewport.x = 0.0f; - shadowViewport.y = 0.0f; - shadowViewport.width = static_cast(m_shadowMapResolution); - shadowViewport.height = static_cast(m_shadowMapResolution); - shadowViewport.minDepth = 0.0f; - shadowViewport.maxDepth = 1.0f; - vkCmdSetViewport(command, 0, 1, &shadowViewport); - - VkRect2D shadowScissor{}; - shadowScissor.offset = {0, 0}; - shadowScissor.extent = {m_shadowMapResolution, m_shadowMapResolution}; - vkCmdSetScissor(command, 0, 1, &shadowScissor); - - m_shadowPassActive = true; - m_shadowPCCacheValid = false; - auto* queue = RenderCommandQueue::GetInstance(); - if (queue) { - queue->ExecuteShadowPass(this); - } - m_shadowPassActive = false; - - vkCmdEndRenderPass(command); - } + // No cached draws keeps the previous frame's shadow map + RecordShadowPass(); // ---- Deferred path: begin GBuffer render pass ---- if (m_gbufferResourcesCreated && m_deferredEnabled) { @@ -1494,190 +1444,32 @@ bool VulkanRenderer::CreateSyncObjects() { return true; } -/// Recreates the extent-dependent shadow map objects (image, view, -/// framebuffer) at the queued resolution. Keep in sync with -/// CreateShadowResources; samplers, render pass, and pipeline are extent-independent. +/// Recreates the shadow image, views, and framebuffers at the queued +/// resolution and cascade count; samplers, render pass, and pipeline stay. void VulkanRenderer::ApplyShadowResolutionChange() { if (!m_shadowResChangeRequested) return; m_shadowResChangeRequested = false; if (!m_shadowResourcesCreated) return; - if (m_pendingShadowMapResolution == m_shadowMapResolution) return; + if (m_pendingShadowMapResolution == m_shadowMapResolution && + m_pendingShadowCascadeCount == m_shadowCascadeCount) + return; vkDeviceWaitIdle(device); - - vkDestroyFramebuffer(device, m_shadowFramebuffer, nullptr); - m_shadowFramebuffer = VK_NULL_HANDLE; - vkDestroyImageView(device, m_shadowImageView, nullptr); - m_shadowImageView = VK_NULL_HANDLE; - vkDestroyImage(device, m_shadowImage, nullptr); - m_shadowImage = VK_NULL_HANDLE; - vkFreeMemory(device, m_shadowImageMemory, nullptr); - m_shadowImageMemory = VK_NULL_HANDLE; + DestroyShadowMapImage(); m_shadowMapResolution = m_pendingShadowMapResolution; - - VkImageCreateInfo imageInfo{}; - imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; - imageInfo.imageType = VK_IMAGE_TYPE_2D; - imageInfo.extent = {m_shadowMapResolution, m_shadowMapResolution, 1}; - imageInfo.mipLevels = 1; - imageInfo.arrayLayers = 1; - imageInfo.format = VK_FORMAT_D32_SFLOAT; - imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL; - imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; - imageInfo.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | - VK_IMAGE_USAGE_SAMPLED_BIT; - imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; - imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; - - if (vkCreateImage(device, &imageInfo, nullptr, &m_shadowImage) != - VK_SUCCESS) { - SLEAK_ERROR("Shadow resolution change: image creation failed"); - m_shadowResourcesCreated = false; - return; - } - - VkMemoryRequirements memReqs; - vkGetImageMemoryRequirements(device, m_shadowImage, &memReqs); - - VkMemoryAllocateInfo allocInfo{}; - allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; - allocInfo.allocationSize = memReqs.size; - allocInfo.memoryTypeIndex = FindMemoryType( - memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); - - if (vkAllocateMemory(device, &allocInfo, nullptr, &m_shadowImageMemory) != - VK_SUCCESS) { - SLEAK_ERROR("Shadow resolution change: memory allocation failed"); - m_shadowResourcesCreated = false; - return; - } - vkBindImageMemory(device, m_shadowImage, m_shadowImageMemory, 0); - - VkImageViewCreateInfo viewInfo{}; - viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; - viewInfo.image = m_shadowImage; - viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; - viewInfo.format = VK_FORMAT_D32_SFLOAT; - viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; - viewInfo.subresourceRange.baseMipLevel = 0; - viewInfo.subresourceRange.levelCount = 1; - viewInfo.subresourceRange.baseArrayLayer = 0; - viewInfo.subresourceRange.layerCount = 1; - - if (vkCreateImageView(device, &viewInfo, nullptr, &m_shadowImageView) != - VK_SUCCESS) { - SLEAK_ERROR("Shadow resolution change: image view creation failed"); + m_shadowCascadeCount = m_pendingShadowCascadeCount; + if (!CreateShadowMapImage() || !CreateShadowFramebuffers()) { + SLEAK_ERROR("Shadow map recreation failed"); + DestroyShadowMapImage(); m_shadowResourcesCreated = false; return; } + WriteShadowSamplerDescriptors(); - VkFramebufferCreateInfo fbInfo{}; - fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; - fbInfo.renderPass = m_shadowRenderPass; - fbInfo.attachmentCount = 1; - fbInfo.pAttachments = &m_shadowImageView; - fbInfo.width = m_shadowMapResolution; - fbInfo.height = m_shadowMapResolution; - fbInfo.layers = 1; - - if (vkCreateFramebuffer(device, &fbInfo, nullptr, &m_shadowFramebuffer) != - VK_SUCCESS) { - SLEAK_ERROR("Shadow resolution change: framebuffer creation failed"); - m_shadowResourcesCreated = false; - return; - } - - // Initial layout transition — descriptor must be valid pre-first-pass - { - VkCommandBufferAllocateInfo cmdAllocInfo{}; - cmdAllocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; - cmdAllocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; - cmdAllocInfo.commandPool = commands; - cmdAllocInfo.commandBufferCount = 1; - - VkCommandBuffer cmdBuf; - vkAllocateCommandBuffers(device, &cmdAllocInfo, &cmdBuf); - - VkCommandBufferBeginInfo cmdBeginInfo{}; - cmdBeginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - cmdBeginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - vkBeginCommandBuffer(cmdBuf, &cmdBeginInfo); - - VkImageMemoryBarrier barrier{}; - barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; - barrier.newLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; - barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.image = m_shadowImage; - barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; - barrier.subresourceRange.baseMipLevel = 0; - barrier.subresourceRange.levelCount = 1; - barrier.subresourceRange.baseArrayLayer = 0; - barrier.subresourceRange.layerCount = 1; - barrier.srcAccessMask = 0; - barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; - - vkCmdPipelineBarrier(cmdBuf, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, - VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, - nullptr, 0, nullptr, 1, &barrier); - - vkEndCommandBuffer(cmdBuf); - - VkSubmitInfo layoutSubmit{}; - layoutSubmit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - layoutSubmit.commandBufferCount = 1; - layoutSubmit.pCommandBuffers = &cmdBuf; - - VkFenceCreateInfo layoutFenceInfo{}; - layoutFenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; - VkFence layoutFence; - vkCreateFence(device, &layoutFenceInfo, nullptr, &layoutFence); - vkQueueSubmit(graphicsQueue, 1, &layoutSubmit, layoutFence); - vkWaitForFences(device, 1, &layoutFence, VK_TRUE, UINT64_MAX); - vkDestroyFence(device, layoutFence, nullptr); - vkFreeCommandBuffers(device, commands, 1, &cmdBuf); - } - - // Point set-3 descriptors at the new image view - if (m_lightUBOCreated && m_shadowImageView && m_shadowSampler && - m_shadowRawSampler) { - for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { - VkDescriptorImageInfo compareInfo{}; - compareInfo.imageLayout = - VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; - compareInfo.imageView = m_shadowImageView; - compareInfo.sampler = m_shadowSampler; - - VkDescriptorImageInfo rawInfo{}; - rawInfo.imageLayout = - VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; - rawInfo.imageView = m_shadowImageView; - rawInfo.sampler = m_shadowRawSampler; - - std::array writes{}; - writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[0].dstSet = m_shadowSamplerDescriptorSets[i]; - writes[0].dstBinding = 0; - writes[0].dstArrayElement = 0; - writes[0].descriptorType = - VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - writes[0].descriptorCount = 1; - writes[0].pImageInfo = &compareInfo; - - writes[1] = writes[0]; - writes[1].dstBinding = 1; - writes[1].pImageInfo = &rawInfo; - - vkUpdateDescriptorSets(device, - static_cast(writes.size()), - writes.data(), 0, nullptr); - } - } - - SLEAK_INFO("VulkanRenderer: Shadow map resized to {}x{}", - m_shadowMapResolution, m_shadowMapResolution); + SLEAK_INFO("VulkanRenderer: Shadow map resized to {}x{}, {} cascades", + m_shadowMapResolution, m_shadowMapResolution, + m_shadowCascadeCount); } /// Top-level orchestrator for deferred rendering: creates the GBuffer color diff --git a/src/Graphics/Vulkan/VulkanShadow.cpp b/src/Graphics/Vulkan/VulkanShadow.cpp index 3ff9a4d6..0d4cbcec 100644 --- a/src/Graphics/Vulkan/VulkanShadow.cpp +++ b/src/Graphics/Vulkan/VulkanShadow.cpp @@ -14,57 +14,7 @@ namespace Sleak { /// Creates the shadow depth image, sampler, render pass, and framebuffer. bool VulkanRenderer::CreateShadowResources() { // 1. Create shadow depth image (2048x2048, D32_SFLOAT) - VkImageCreateInfo imageInfo{}; - imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; - imageInfo.imageType = VK_IMAGE_TYPE_2D; - imageInfo.extent = {m_shadowMapResolution, m_shadowMapResolution, 1}; - imageInfo.mipLevels = 1; - imageInfo.arrayLayers = 1; - imageInfo.format = VK_FORMAT_D32_SFLOAT; - imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL; - imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; - imageInfo.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | - VK_IMAGE_USAGE_SAMPLED_BIT; - imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; - imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; - - if (vkCreateImage(device, &imageInfo, nullptr, &m_shadowImage) != VK_SUCCESS) { - SLEAK_ERROR("Failed to create shadow map image!"); - return false; - } - - VkMemoryRequirements memReqs; - vkGetImageMemoryRequirements(device, m_shadowImage, &memReqs); - - VkMemoryAllocateInfo allocInfo{}; - allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; - allocInfo.allocationSize = memReqs.size; - allocInfo.memoryTypeIndex = FindMemoryType(memReqs.memoryTypeBits, - VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); - - if (vkAllocateMemory(device, &allocInfo, nullptr, &m_shadowImageMemory) != VK_SUCCESS) { - SLEAK_ERROR("Failed to allocate shadow map memory!"); - return false; - } - - vkBindImageMemory(device, m_shadowImage, m_shadowImageMemory, 0); - - // 2. Create image view (DEPTH aspect) - VkImageViewCreateInfo viewInfo{}; - viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; - viewInfo.image = m_shadowImage; - viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; - viewInfo.format = VK_FORMAT_D32_SFLOAT; - viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; - viewInfo.subresourceRange.baseMipLevel = 0; - viewInfo.subresourceRange.levelCount = 1; - viewInfo.subresourceRange.baseArrayLayer = 0; - viewInfo.subresourceRange.layerCount = 1; - - if (vkCreateImageView(device, &viewInfo, nullptr, &m_shadowImageView) != VK_SUCCESS) { - SLEAK_ERROR("Failed to create shadow map image view!"); - return false; - } + if (!CreateShadowMapImage()) return false; // 3. Create comparison sampler with bilinear filtering for smooth PCF VkSamplerCreateInfo samplerInfo{}; @@ -153,76 +103,7 @@ bool VulkanRenderer::CreateShadowResources() { return false; } - // 5. Create framebuffer - VkFramebufferCreateInfo fbInfo{}; - fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; - fbInfo.renderPass = m_shadowRenderPass; - fbInfo.attachmentCount = 1; - fbInfo.pAttachments = &m_shadowImageView; - fbInfo.width = m_shadowMapResolution; - fbInfo.height = m_shadowMapResolution; - fbInfo.layers = 1; - - if (vkCreateFramebuffer(device, &fbInfo, nullptr, &m_shadowFramebuffer) != VK_SUCCESS) { - SLEAK_ERROR("Failed to create shadow framebuffer!"); - return false; - } - - // 6. Transition shadow image to DEPTH_STENCIL_READ_ONLY_OPTIMAL so the - // descriptor is valid even before the first shadow pass runs. - // Depth images must use this layout (not SHADER_READ_ONLY_OPTIMAL) - // for sampler access; the wrong layout causes VK_ERROR_DEVICE_LOST. - { - VkCommandBufferAllocateInfo cmdAllocInfo{}; - cmdAllocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; - cmdAllocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; - cmdAllocInfo.commandPool = commands; - cmdAllocInfo.commandBufferCount = 1; - - VkCommandBuffer cmdBuf; - vkAllocateCommandBuffers(device, &cmdAllocInfo, &cmdBuf); - - VkCommandBufferBeginInfo cmdBeginInfo{}; - cmdBeginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - cmdBeginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - vkBeginCommandBuffer(cmdBuf, &cmdBeginInfo); - - VkImageMemoryBarrier barrier{}; - barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; - barrier.newLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; - barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.image = m_shadowImage; - barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; - barrier.subresourceRange.baseMipLevel = 0; - barrier.subresourceRange.levelCount = 1; - barrier.subresourceRange.baseArrayLayer = 0; - barrier.subresourceRange.layerCount = 1; - barrier.srcAccessMask = 0; - barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; - - vkCmdPipelineBarrier(cmdBuf, - VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, - VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, - 0, 0, nullptr, 0, nullptr, 1, &barrier); - - vkEndCommandBuffer(cmdBuf); - - VkSubmitInfo layoutSubmit{}; - layoutSubmit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - layoutSubmit.commandBufferCount = 1; - layoutSubmit.pCommandBuffers = &cmdBuf; - - VkFenceCreateInfo layoutFenceInfo{}; - layoutFenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; - VkFence layoutFence; - vkCreateFence(device, &layoutFenceInfo, nullptr, &layoutFence); - vkQueueSubmit(graphicsQueue, 1, &layoutSubmit, layoutFence); - vkWaitForFences(device, 1, &layoutFence, VK_TRUE, UINT64_MAX); - vkDestroyFence(device, layoutFence, nullptr); - vkFreeCommandBuffers(device, commands, 1, &cmdBuf); - } + if (!CreateShadowFramebuffers()) return false; // 7. Create shadow pipeline if (!CreateShadowPipeline()) { @@ -230,43 +111,12 @@ bool VulkanRenderer::CreateShadowResources() { return false; } - // Write shadow sampler to set 3 descriptors (UBO resources already created). - // Binding 0 uses the compare sampler for hardware PCF; binding 1 uses the - // raw sampler so the PCSS blocker search can read un-compared depth values. - if (m_lightUBOCreated && m_shadowImageView && m_shadowSampler && m_shadowRawSampler) { - for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { - // Depth images must use DEPTH_STENCIL_READ_ONLY_OPTIMAL for sampler access. - VkDescriptorImageInfo compareInfo{}; - compareInfo.imageLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; - compareInfo.imageView = m_shadowImageView; - compareInfo.sampler = m_shadowSampler; - - VkDescriptorImageInfo rawInfo{}; - rawInfo.imageLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; - rawInfo.imageView = m_shadowImageView; - rawInfo.sampler = m_shadowRawSampler; - - std::array writes{}; - writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[0].dstSet = m_shadowSamplerDescriptorSets[i]; - writes[0].dstBinding = 0; - writes[0].dstArrayElement = 0; - writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - writes[0].descriptorCount = 1; - writes[0].pImageInfo = &compareInfo; - - writes[1] = writes[0]; - writes[1].dstBinding = 1; - writes[1].pImageInfo = &rawInfo; - - vkUpdateDescriptorSets(device, static_cast(writes.size()), - writes.data(), 0, nullptr); - } - } + WriteShadowSamplerDescriptors(); m_shadowResourcesCreated = true; - SLEAK_INFO("VulkanRenderer: Shadow mapping resources created ({}x{} shadow map)", - m_shadowMapResolution, m_shadowMapResolution); + SLEAK_INFO( + "VulkanRenderer: Shadow mapping resources created ({}x{}, {} cascades)", + m_shadowMapResolution, m_shadowMapResolution, m_shadowCascadeCount); return true; } @@ -283,8 +133,9 @@ bool VulkanRenderer::CreateShadowPipeline() { // Vertex-only pipeline (no fragment shader) VkPipelineShaderStageCreateInfo shaderStage = m_shadowShader->GetVertexInfo(); - std::vector dynamicStates = { - VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + std::vector dynamicStates = {VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR, + VK_DYNAMIC_STATE_DEPTH_BIAS}; VkPipelineDynamicStateCreateInfo dynamicState{}; dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; @@ -332,10 +183,7 @@ bool VulkanRenderer::CreateShadowPipeline() { rasterizer.lineWidth = 1.0f; rasterizer.cullMode = VK_CULL_MODE_BACK_BIT; // same winding as main pass rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; - rasterizer.depthBiasEnable = VK_TRUE; - rasterizer.depthBiasConstantFactor = 1.25f; - rasterizer.depthBiasSlopeFactor = 1.75f; - rasterizer.depthBiasClamp = 0.0f; + rasterizer.depthBiasEnable = VK_TRUE; // factors set per pass VkPipelineMultisampleStateCreateInfo msaa{}; msaa.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; @@ -421,13 +269,13 @@ bool VulkanRenderer::CreateShadowLightUBOResources() { memset(m_lightUBOMapped[i], 0, uboSize); } - // Create descriptor pool for light UBO + shadow samplers. - // Two shadow samplers per frame now (compare + raw for PCSS blocker search). + // Create descriptor pool for light UBO + shadow samplers: compare + raw + // for the last cascade (2D) and for all cascades (array). std::array poolSizes{}; poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; poolSizes[0].descriptorCount = MAX_FRAMES_IN_FLIGHT; poolSizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - poolSizes[1].descriptorCount = MAX_FRAMES_IN_FLIGHT * 2; + poolSizes[1].descriptorCount = MAX_FRAMES_IN_FLIGHT * 4; VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; @@ -489,10 +337,9 @@ bool VulkanRenderer::CreateShadowLightUBOResources() { return false; } - // Write default shadow sampler descriptors (using default texture as placeholder) - // These will be overwritten with actual shadow map when shadow resources are created. - // Both binding=0 (compare) and binding=1 (raw) must be populated or the layout - // is incomplete and first-frame sampling reads undefined memory. + // Placeholder descriptors (the default texture) until the shadow map + // exists. Every binding must be populated or the layout is incomplete and + // first-frame sampling reads undefined memory. if (m_defaultTexture) { for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { VkDescriptorImageInfo imageInfo{}; @@ -500,17 +347,16 @@ bool VulkanRenderer::CreateShadowLightUBOResources() { imageInfo.imageView = m_defaultTexture->GetImageView(); imageInfo.sampler = m_defaultTexture->GetSampler(); - std::array writes{}; - writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[0].dstSet = m_shadowSamplerDescriptorSets[i]; - writes[0].dstBinding = 0; - writes[0].dstArrayElement = 0; - writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - writes[0].descriptorCount = 1; - writes[0].pImageInfo = &imageInfo; - - writes[1] = writes[0]; - writes[1].dstBinding = 1; + std::array writes{}; + for (uint32_t b = 0; b < writes.size(); ++b) { + writes[b].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[b].dstSet = m_shadowSamplerDescriptorSets[i]; + writes[b].dstBinding = b; + writes[b].descriptorType = + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[b].descriptorCount = 1; + writes[b].pImageInfo = &imageInfo; + } vkUpdateDescriptorSets(device, static_cast(writes.size()), writes.data(), 0, nullptr); @@ -531,10 +377,6 @@ void VulkanRenderer::CleanupShadowResources() { delete m_shadowShader; m_shadowShader = nullptr; - if (m_shadowFramebuffer) { - vkDestroyFramebuffer(device, m_shadowFramebuffer, nullptr); - m_shadowFramebuffer = VK_NULL_HANDLE; - } if (m_shadowRenderPass) { vkDestroyRenderPass(device, m_shadowRenderPass, nullptr); m_shadowRenderPass = VK_NULL_HANDLE; @@ -547,18 +389,7 @@ void VulkanRenderer::CleanupShadowResources() { vkDestroySampler(device, m_shadowRawSampler, nullptr); m_shadowRawSampler = VK_NULL_HANDLE; } - if (m_shadowImageView) { - vkDestroyImageView(device, m_shadowImageView, nullptr); - m_shadowImageView = VK_NULL_HANDLE; - } - if (m_shadowImage) { - vkDestroyImage(device, m_shadowImage, nullptr); - m_shadowImage = VK_NULL_HANDLE; - } - if (m_shadowImageMemory) { - vkFreeMemory(device, m_shadowImageMemory, nullptr); - m_shadowImageMemory = VK_NULL_HANDLE; - } + DestroyShadowMapImage(); // Cleanup light UBO resources if (m_lightUBOCreated) { @@ -618,5 +449,279 @@ void VulkanRenderer::SetLightVP(const float* lightVP) { } } +/// Stages the per-cascade light view-projections; BeginRender commits them. +void VulkanRenderer::SetShadowCascades(const float* viewProj, uint32_t count) { + count = std::min(count, MAX_SHADOW_CASCADES); + if (count > 0 && viewProj) + memcpy(m_pendingCascadeVP, viewProj, sizeof(float) * 16 * count); + m_pendingCascadeCount = count; +} + +/// Creates the layered shadow image and its array and per-layer views. +bool VulkanRenderer::CreateShadowMapImage() { + const uint32_t layers = m_shadowCascadeCount; + + VkImageCreateInfo imageInfo{}; + imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imageInfo.imageType = VK_IMAGE_TYPE_2D; + imageInfo.extent = {m_shadowMapResolution, m_shadowMapResolution, 1}; + imageInfo.mipLevels = 1; + imageInfo.arrayLayers = layers; + imageInfo.format = VK_FORMAT_D32_SFLOAT; + imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + imageInfo.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT; + imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; + imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + + if (vkCreateImage(device, &imageInfo, nullptr, &m_shadowImage) != + VK_SUCCESS) { + SLEAK_ERROR("Failed to create shadow map image!"); + return false; + } + + VkMemoryRequirements memReqs; + vkGetImageMemoryRequirements(device, m_shadowImage, &memReqs); + + VkMemoryAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.allocationSize = memReqs.size; + allocInfo.memoryTypeIndex = FindMemoryType( + memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + + if (vkAllocateMemory(device, &allocInfo, nullptr, &m_shadowImageMemory) != + VK_SUCCESS) { + SLEAK_ERROR("Failed to allocate shadow map memory!"); + return false; + } + vkBindImageMemory(device, m_shadowImage, m_shadowImageMemory, 0); + + VkImageViewCreateInfo viewInfo{}; + viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewInfo.image = m_shadowImage; + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D_ARRAY; + viewInfo.format = VK_FORMAT_D32_SFLOAT; + viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + viewInfo.subresourceRange.baseMipLevel = 0; + viewInfo.subresourceRange.levelCount = 1; + viewInfo.subresourceRange.baseArrayLayer = 0; + viewInfo.subresourceRange.layerCount = layers; + + if (vkCreateImageView(device, &viewInfo, nullptr, &m_shadowArrayView) != + VK_SUCCESS) { + SLEAK_ERROR("Failed to create shadow map array view!"); + return false; + } + + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewInfo.subresourceRange.layerCount = 1; + for (uint32_t i = 0; i < layers; ++i) { + viewInfo.subresourceRange.baseArrayLayer = i; + if (vkCreateImageView(device, &viewInfo, nullptr, + &m_shadowLayerViews[i]) != VK_SUCCESS) { + SLEAK_ERROR("Failed to create shadow map layer view!"); + return false; + } + } + // Shaders that only know one shadow map sample the last cascade. + m_shadowImageView = m_shadowLayerViews[layers - 1]; + + // Transition every layer to DEPTH_STENCIL_READ_ONLY_OPTIMAL so the + // descriptors are valid before the first shadow pass runs. Depth images + // must use this layout (not SHADER_READ_ONLY_OPTIMAL) for sampler access. + { + VkCommandBufferAllocateInfo cmdAllocInfo{}; + cmdAllocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + cmdAllocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + cmdAllocInfo.commandPool = commands; + cmdAllocInfo.commandBufferCount = 1; + + VkCommandBuffer cmdBuf; + vkAllocateCommandBuffers(device, &cmdAllocInfo, &cmdBuf); + + VkCommandBufferBeginInfo cmdBeginInfo{}; + cmdBeginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + cmdBeginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vkBeginCommandBuffer(cmdBuf, &cmdBeginInfo); + + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = m_shadowImage; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = layers; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + + vkCmdPipelineBarrier(cmdBuf, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, + nullptr, 0, nullptr, 1, &barrier); + + vkEndCommandBuffer(cmdBuf); + + VkSubmitInfo layoutSubmit{}; + layoutSubmit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + layoutSubmit.commandBufferCount = 1; + layoutSubmit.pCommandBuffers = &cmdBuf; + + VkFenceCreateInfo layoutFenceInfo{}; + layoutFenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + VkFence layoutFence; + vkCreateFence(device, &layoutFenceInfo, nullptr, &layoutFence); + vkQueueSubmit(graphicsQueue, 1, &layoutSubmit, layoutFence); + vkWaitForFences(device, 1, &layoutFence, VK_TRUE, UINT64_MAX); + vkDestroyFence(device, layoutFence, nullptr); + vkFreeCommandBuffers(device, commands, 1, &cmdBuf); + } + + return true; +} + +/// Creates one framebuffer per cascade layer for the shadow render pass. +bool VulkanRenderer::CreateShadowFramebuffers() { + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = m_shadowRenderPass; + fbInfo.attachmentCount = 1; + fbInfo.width = m_shadowMapResolution; + fbInfo.height = m_shadowMapResolution; + fbInfo.layers = 1; + + for (uint32_t i = 0; i < m_shadowCascadeCount; ++i) { + fbInfo.pAttachments = &m_shadowLayerViews[i]; + if (vkCreateFramebuffer(device, &fbInfo, nullptr, + &m_shadowFramebuffers[i]) != VK_SUCCESS) { + SLEAK_ERROR("Failed to create shadow framebuffer!"); + return false; + } + } + return true; +} + +/// Destroys the shadow image, its views, and the per-cascade framebuffers. +void VulkanRenderer::DestroyShadowMapImage() { + for (auto& fb : m_shadowFramebuffers) { + if (fb) vkDestroyFramebuffer(device, fb, nullptr); + fb = VK_NULL_HANDLE; + } + for (auto& view : m_shadowLayerViews) { + if (view) vkDestroyImageView(device, view, nullptr); + view = VK_NULL_HANDLE; + } + m_shadowImageView = VK_NULL_HANDLE; + if (m_shadowArrayView) { + vkDestroyImageView(device, m_shadowArrayView, nullptr); + m_shadowArrayView = VK_NULL_HANDLE; + } + if (m_shadowImage) { + vkDestroyImage(device, m_shadowImage, nullptr); + m_shadowImage = VK_NULL_HANDLE; + } + if (m_shadowImageMemory) { + vkFreeMemory(device, m_shadowImageMemory, nullptr); + m_shadowImageMemory = VK_NULL_HANDLE; + } +} + +/// Points set 3 at the shadow views: bindings 0/1 see the last cascade as a +/// plain 2D map, bindings 2/3 see every cascade as an array. +void VulkanRenderer::WriteShadowSamplerDescriptors() { + if (!m_lightUBOCreated || !m_shadowImageView || !m_shadowArrayView || + !m_shadowSampler || !m_shadowRawSampler) + return; + + for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { + std::array infos{}; + for (auto& info : infos) + info.imageLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL; + infos[0].imageView = m_shadowImageView; + infos[0].sampler = m_shadowSampler; + infos[1].imageView = m_shadowImageView; + infos[1].sampler = m_shadowRawSampler; + infos[2].imageView = m_shadowArrayView; + infos[2].sampler = m_shadowSampler; + infos[3].imageView = m_shadowArrayView; + infos[3].sampler = m_shadowRawSampler; + + std::array writes{}; + for (uint32_t b = 0; b < writes.size(); ++b) { + writes[b].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[b].dstSet = m_shadowSamplerDescriptorSets[i]; + writes[b].dstBinding = b; + writes[b].descriptorType = + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[b].descriptorCount = 1; + writes[b].pImageInfo = &infos[b]; + } + vkUpdateDescriptorSets(device, static_cast(writes.size()), + writes.data(), 0, nullptr); + } +} + +/// Renders the cached shadow casters once per cascade, each into its own +/// layer with that cascade's light VP behind the shadow push constants. +void VulkanRenderer::RecordShadowPass() { + auto* queue = RenderCommandQueue::GetInstance(); + if (!m_shadowResourcesCreated || !m_shadowPassEnabled || !queue || + !queue->HasCachedShadowDraws()) + return; + + const uint32_t cascades = std::min(m_cascadeCount, m_shadowCascadeCount); + + VkClearValue shadowClear{}; + shadowClear.depthStencil = {1.0f, 0}; + + VkViewport viewport{}; + viewport.width = static_cast(m_shadowMapResolution); + viewport.height = static_cast(m_shadowMapResolution); + viewport.maxDepth = 1.0f; + VkRect2D scissor{}; + scissor.extent = {m_shadowMapResolution, m_shadowMapResolution}; + + for (uint32_t c = 0; c < cascades; ++c) { + VkRenderPassBeginInfo passInfo{}; + passInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + passInfo.renderPass = m_shadowRenderPass; + passInfo.framebuffer = m_shadowFramebuffers[c]; + passInfo.renderArea.extent = scissor.extent; + passInfo.clearValueCount = 1; + passInfo.pClearValues = &shadowClear; + + vkCmdBeginRenderPass(command, &passInfo, VK_SUBPASS_CONTENTS_INLINE); + vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, + m_shadowPipeline); + m_activeCustomFormat = 0; + m_customFormatUnbound = false; + + // shadow_depth.vert statically declares the set-1 BoneUBO, so it must + // be bound even for static casters (VUID-vkCmdDrawIndexed-None-08600). + if (m_boneUBOCreated) { + vkCmdBindDescriptorSets( + command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLay, 1, 1, + &boneDescriptorSets[currentFrame], 0, nullptr); + } + + vkCmdSetViewport(command, 0, 1, &viewport); + vkCmdSetScissor(command, 0, 1, &scissor); + vkCmdSetDepthBias(command, 1.25f * m_shadowDepthBiasScale, 0.0f, + 1.75f * m_shadowDepthBiasScale); + + memcpy(m_lightVP, m_cascadeVP[c], sizeof(m_lightVP)); + m_shadowPassActive = true; + m_shadowPCCacheValid = false; + queue->ExecuteShadowPass(this); + m_shadowPassActive = false; + + vkCmdEndRenderPass(command); + } +} + } // namespace RenderEngine } // namespace Sleak diff --git a/src/Lighting/LightManager.cpp b/src/Lighting/LightManager.cpp index b4517184..70de20ff 100644 --- a/src/Lighting/LightManager.cpp +++ b/src/Lighting/LightManager.cpp @@ -51,6 +51,84 @@ static bool Invert4x4(const float m[16], float inv[16]) { return true; } + +constexpr float kCascadeBlend = 0.1f; +constexpr float kCascadePadTexels = 8.0f; + +/// Near plane of the main camera, read back from its projection matrix. +float MainCameraNear() { + const auto& P = Sleak::Camera::GetMainProjectionMatrix(); + if (P(2, 3) == 0.0f || P(2, 2) == 0.0f) return 0.1f; + float n = -P(3, 2) / P(2, 2); + return n > 0.0f ? n : 0.1f; +} + +/// Fits one sphere per cascade around the camera (practical split radii) and +/// builds a texel-snapped orthographic light view-projection for each. +void BuildShadowCascades(const Sleak::DirectionalLight& light, + const Sleak::Math::Vector3D& camPos, uint32_t count, + uint32_t resolution, float outVP[][16], + float outSplits[]) { + using namespace Sleak; + float maxDist = light.GetShadowMaxDistance(); + if (maxDist <= 0.0f) maxDist = light.GetShadowFrustumSize(); + const float nearDist = std::clamp(MainCameraNear(), 0.01f, maxDist * 0.5f); + const float lambda = std::clamp(light.GetShadowSplitLambda(), 0.0f, 1.0f); + + // Fixed light orientation with no translation: the cascades only move + // in light space, where they are snapped to whole texels. + const auto dir = light.GetDirection(); + Math::Vector ld({dir.GetX(), dir.GetY(), dir.GetZ()}); + Math::Vector up = + (std::fabs(dir.GetY()) > 0.999f) + ? Math::Vector({0.0f, 0.0f, 1.0f}) + : Math::Vector({0.0f, 1.0f, 0.0f}); + Math::Matrix4 lightView = Math::Matrix4::LookTo( + Math::Vector({0.0f, 0.0f, 0.0f}), ld, up); + + auto toLight = [&](int axis) { + return camPos.GetX() * lightView(0, axis) + + camPos.GetY() * lightView(1, axis) + + camPos.GetZ() * lightView(2, axis); + }; + const float cx = toLight(0), cy = toLight(1), cz = toLight(2); + + const float res = static_cast(resolution); + const float pad = res / (res - 2.0f * kCascadePadTexels); + + for (uint32_t i = 0; i < count; ++i) { + const float p = static_cast(i + 1) / static_cast(count); + const float logSplit = nearDist * std::pow(maxDist / nearDist, p); + const float uniSplit = nearDist + (maxDist - nearDist) * p; + const float split = + (i + 1 == count) ? maxDist + : lambda * logSplit + (1.0f - lambda) * uniSplit; + outSplits[i] = split; + + const float halfExtent = split * pad; + const float texel = 2.0f * halfExtent / res; + const float x = std::round(cx / texel) * texel; + const float y = std::round(cy / texel) * texel; + + const float pull = std::max(light.GetShadowDistance(), halfExtent); + const float zNear = cz - pull + light.GetShadowNearPlane(); + const float zFar = + cz - pull + std::max(light.GetShadowFarPlane(), pull + halfExtent); + + // Vulkan-style [0,1] depth; translations live in row 3 because the + // engine is row-major and GLSL reads the matrix transposed. + Math::Matrix4 proj = Math::Matrix4::Identity(); + proj(0, 0) = 1.0f / halfExtent; + proj(1, 1) = 1.0f / halfExtent; + proj(2, 2) = 1.0f / (zFar - zNear); + proj(3, 0) = -x / halfExtent; + proj(3, 1) = -y / halfExtent; + proj(3, 2) = -zNear / (zFar - zNear); + + Math::Matrix4 vp = lightView * proj; + std::memcpy(outVP[i], &vp(0, 0), sizeof(float) * 16); + } +} } // namespace namespace Sleak { @@ -212,107 +290,44 @@ void LightManager::UpdateShadowData() { float intensity = activeLight->GetIntensity(); Math::Matrix4 lightVP = Math::Matrix4::Identity(); + float cascadeVP[RenderEngine::MAX_SHADOW_CASCADES][16] = {}; + float cascadeSplits[RenderEngine::MAX_SHADOW_CASCADES] = {}; + uint32_t cascadeCount = 0; if (shadowLight) { - // Compute light view-projection matrix from shadow configuration - float frustumSize = shadowLight->GetShadowFrustumSize(); - float shadowDist = shadowLight->GetShadowDistance(); - float nearP = shadowLight->GetShadowNearPlane(); - float farP = shadowLight->GetShadowFarPlane(); - - // Light position: follow camera XZ but fix Y at world origin. - // Anchoring Y prevents the shadow frustum from shifting vertically - // when the player jumps/flies, which causes hard Z-plane cutoff flicker. - const auto& camPos = Camera::GetMainCameraPosition(); - Math::Vector3D lightPos = Math::Vector3D(camPos.GetX(), 0.0f, camPos.GetZ()) - + dir * (-shadowDist); - - // Convert to Vector for Matrix methods - Math::Vector lp({lightPos.GetX(), lightPos.GetY(), lightPos.GetZ()}); - Math::Vector ld({dir.GetX(), dir.GetY(), dir.GetZ()}); - - // Avoid degenerate LookTo when light direction is nearly vertical - // (cross product with (0,1,0) would be zero → NaN matrix) - Math::Vector up = (fabsf(dir.GetY()) > 0.999f) - ? Math::Vector({0.0f, 0.0f, 1.0f}) - : Math::Vector({0.0f, 1.0f, 0.0f}); - - Math::Matrix4 lightView = Math::Matrix4::LookTo(lp, ld, up); - - // Build Vulkan-compatible orthographic projection (LH, [0,1] depth range) - // Engine stores row-major, GLSL reads column-major (transposed) — - // translations go in ROW 3 so they end up in GLSL column 3. - float left = -frustumSize, right = frustumSize; - float bottom = -frustumSize, top = frustumSize; - Math::Matrix4 lightProj = Math::Matrix4::Identity(); - lightProj(0, 0) = 2.0f / (right - left); - lightProj(1, 1) = 2.0f / (top - bottom); - lightProj(2, 2) = 1.0f / (farP - nearP); - lightProj(3, 0) = -(right + left) / (right - left); - lightProj(3, 1) = -(top + bottom) / (top - bottom); - lightProj(3, 2) = -nearP / (farP - nearP); - - // ---- Texel snap (DirectX SDK standard technique) ---- - // Project world origin through the raw lightVP, measure its XY in - // shadow-map texel space, snap to nearest texel, apply the delta - // back to the projection matrix. This guarantees the sampling grid - // is aligned to world-space texel cells so sub-texel camera motion - // never shifts which texel a world point lands on → no shimmer. - // - // Using round() (not floor) — floor flips by a full texel when - // the fractional part crosses 0 due to float noise. - const float shadowMapSize = - static_cast(renderer->GetShadowMapResolution()); - const float halfShadow = shadowMapSize * 0.5f; - - Math::Matrix4 lightVP_raw = lightView * lightProj; - // Anchor the snap on the WORLD ORIGIN (fixed point). The camera is a - // constant offset from the light frustum (lightPos follows camXZ), so - // projecting the camera yields the SAME texel coords every frame — - // constant delta, snap no-ops, world texels crawl while moving. The - // origin's projected texel position drifts as the frustum follows the - // camera; rounding it quantizes frustum motion to whole texels. - // Row-vector convention: (0,0,0,1) * M = row 3. - float clipX = lightVP_raw(3, 0); - float clipY = lightVP_raw(3, 1); - float clipW = lightVP_raw(3, 3); - if (clipW != 0.0f) { - float ndcX = clipX / clipW; - float ndcY = clipY / clipW; - float texX = ndcX * halfShadow; - float texY = ndcY * halfShadow; - float roundedX = std::round(texX); - float roundedY = std::round(texY); - float offsetNdcX = (roundedX - texX) / halfShadow; - float offsetNdcY = (roundedY - texY) / halfShadow; - lightProj(3, 0) += offsetNdcX; - lightProj(3, 1) += offsetNdcY; - } - - // LightVP = View * Projection (row-major convention) - lightVP = lightView * lightProj; + cascadeCount = std::clamp(shadowLight->GetShadowCascadeCount(), 1u, + RenderEngine::MAX_SHADOW_CASCADES); + renderer->SetShadowCascadeCount(cascadeCount); + BuildShadowCascades(*shadowLight, Camera::GetMainCameraPosition(), + cascadeCount, renderer->GetShadowMapResolution(), + cascadeVP, cascadeSplits); } - // DIAG --shadowfreeze: latch the first lightVP forever. If shadows still + // DIAG --shadowfreeze: latch the first cascades forever. If shadows still // shimmer with a frozen frustum, the cause is screen-space, not the // frustum-follow chain. { static const bool s_freeze = CommandLine::HasFlag("--shadowfreeze"); static bool s_latched = false; - static float s_frozenVP[16]; + static float s_frozenVP[RenderEngine::MAX_SHADOW_CASCADES][16]; if (s_freeze && shadowLight) { if (!s_latched) { - std::memcpy(s_frozenVP, &lightVP(0, 0), sizeof(s_frozenVP)); + std::memcpy(s_frozenVP, cascadeVP, sizeof(s_frozenVP)); s_latched = true; SLEAK_WARN("shadowfreeze: light frustum latched"); } else { - std::memcpy(&lightVP(0, 0), s_frozenVP, sizeof(s_frozenVP)); + std::memcpy(cascadeVP, s_frozenVP, sizeof(s_frozenVP)); } } } - // Set the light VP matrix on the renderer + // The last cascade doubles as the single map for older shaders. + if (cascadeCount > 0) { + std::memcpy(&lightVP(0, 0), cascadeVP[cascadeCount - 1], + sizeof(float) * 16); + } renderer->SetLightVP(&lightVP(0, 0)); + renderer->SetShadowCascades(&cascadeVP[0][0], cascadeCount); // Build shadow light UBO const auto& camPos = Camera::GetMainCameraPosition(); @@ -376,6 +391,11 @@ void LightManager::UpdateShadowData() { 1.0f / static_cast(renderer->GetShadowMapResolution()); ubo.LightSize = shadowLight ? shadowLight->GetLightSize() : 0.0f; + std::memcpy(ubo.CascadeVP, cascadeVP, sizeof(ubo.CascadeVP)); + std::memcpy(ubo.CascadeSplits, cascadeSplits, sizeof(ubo.CascadeSplits)); + ubo.CascadeCount = cascadeCount; + ubo.CascadeBlend = kCascadeBlend; + // Fog — distance gradient (horizon + zenith) and exponential height fog if (m_fogEnabled) { ubo.FogColor[0] = m_fogR;