-
Notifications
You must be signed in to change notification settings - Fork 137
feat(volumetric-shadows): EVSM shadow mapping with global depth normalization #2493
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0a484d6
138829d
4397242
51629b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,40 +1,70 @@ | ||
| #ifndef __VOLUMETRIC_SHADOWS_HLSLI__ | ||
| #define __VOLUMETRIC_SHADOWS_HLSLI__ | ||
|
|
||
| // Variance Shadow Maps (VSM) | ||
| // Chebyshev's inequality on filtered depth moments | ||
|
|
||
| namespace VolumetricShadows | ||
| { | ||
| Texture2D<float2> SharedShadowMap : register(t18); | ||
|
|
||
| static const float VSM_MIN_VARIANCE = 0.00001; | ||
| static const float VSM_BLEEDING_REDUCTION = 0.2; | ||
| Texture2D<float4> SharedShadowMap : register(t18); | ||
|
|
||
| // EVSM exponents — must match values set in C++ (VolumetricShadows.h) | ||
| static const float EVSM_EXPONENT_POS = 40.0; | ||
| static const float EVSM_EXPONENT_NEG = 5.0; | ||
| static const float EVSM_VARIANCE_BIAS = 0.001; | ||
| static const float EVSM_LIGHT_BLEED_REDUCTION = 0.3; | ||
|
|
||
| // Convert orthographic shadow projection depth to globally-normalized [0,1]. | ||
| // positionLS.z from mul(ShadowProj, worldPos) is orthographic [0,1] within the cascade. | ||
| // We remap through world space to the same global range used during moment generation. | ||
| float NormalizeDepth(float depth, float cascadeNear, float cascadeFar, float globalNear, float globalFar) | ||
| { | ||
| float worldZ = cascadeNear + depth * (cascadeFar - cascadeNear); | ||
| return (worldZ - globalNear) / (globalFar - globalNear); | ||
| } | ||
|
|
||
| // Chebyshev upper bound on P(X >= t) | ||
| // moments.x = mean(z), moments.y = mean(z^2) | ||
| float ComputeVSM(float2 moments, float depth) | ||
| // Chebyshev upper bound: P(x >= t) <= variance / (variance + (t - mean)^2) | ||
| // Returns visibility [0,1] where 1 = fully lit | ||
| float ChebyshevUpperBound(float mean, float meanSq, float testValue) | ||
| { | ||
| float variance = max(moments.y - moments.x * moments.x, VSM_MIN_VARIANCE); | ||
| float d = depth - moments.x; | ||
| float variance = max(meanSq - mean * mean, EVSM_VARIANCE_BIAS); | ||
|
|
||
| float d = testValue - mean; | ||
| float pMax = variance / (variance + d * d); | ||
| return (depth <= moments.x) ? 1.0 : pMax; | ||
|
|
||
| // Reduce light bleeding by remapping [bleedReduction..1] -> [0..1] | ||
| pMax = saturate((pMax - EVSM_LIGHT_BLEED_REDUCTION) / (1.0 - EVSM_LIGHT_BLEED_REDUCTION)); | ||
|
|
||
| // If the test value is behind the mean, it's fully lit | ||
| return (testValue <= mean) ? 1.0 : pMax; | ||
| } | ||
|
|
||
| // Reduces light bleeding by remapping shadow values below a threshold to zero | ||
| float ReduceBleeding(float shadow, float amount) | ||
| // Compute EVSM shadow from stored moments | ||
| // moments = (E[e^cz], E[e^2cz], E[e^-cz], E[e^-2cz]) | ||
| float ComputeEVSM(float4 moments, float depth, float cascadeNear, float cascadeFar, float globalNear, float globalFar) | ||
| { | ||
| return saturate((shadow - amount) / (1.0 - amount)); | ||
| float d = NormalizeDepth(depth, cascadeNear, cascadeFar, globalNear, globalFar); | ||
| float posWarp = exp(EVSM_EXPONENT_POS * d); | ||
| float negWarp = exp(-EVSM_EXPONENT_NEG * d); | ||
|
|
||
| // Positive exponent test (standard front-face shadow) | ||
| float posShadow = ChebyshevUpperBound(moments.x, moments.y, posWarp); | ||
|
|
||
| // Negative exponent test (back-face light bleed suppression) | ||
| float negShadow = ChebyshevUpperBound(moments.z, moments.w, negWarp); | ||
|
|
||
| return min(posShadow, negShadow); | ||
| } | ||
|
Comment on lines
+39
to
54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update Lines 44-45 use the hardcoded compile-time constants 🤖 Prompt for AI Agents |
||
|
|
||
| // Sample a single cascade for VSM shadow | ||
| float SampleVSMCascade3D( | ||
| // Sample a single cascade for EVSM shadow (3D ray march) | ||
| float SampleEVSMCascade3D( | ||
| uint cascadeIndex, | ||
| float noise, | ||
| uint sampleCount, | ||
| float rcpSampleCount, | ||
| float3 startPositionLS, | ||
| float3 endPositionLS, | ||
| float cascadeNear, | ||
| float cascadeFar, | ||
| float globalNear, | ||
| float globalFar, | ||
| out float firstSample) | ||
| { | ||
| float shadow = 0.0; | ||
|
|
@@ -45,8 +75,8 @@ namespace VolumetricShadows | |
| float t = (float(k) + noise) * rcpSampleCount; | ||
| float3 samplePosLS = lerp(endPositionLS, startPositionLS, t); | ||
|
|
||
| float2 moments = SharedShadowMap.SampleLevel(LinearSampler, samplePosLS.xy, 1u - cascadeIndex); | ||
| float lit = ComputeVSM(moments, samplePosLS.z); | ||
| float4 moments = SharedShadowMap.SampleLevel(LinearSampler, samplePosLS.xy, 1u - cascadeIndex); | ||
| float lit = ComputeEVSM(moments, samplePosLS.z, cascadeNear, cascadeFar, globalNear, globalFar); | ||
|
|
||
| // Last to set firstSample is start position | ||
| firstSample = lit; | ||
|
|
@@ -88,16 +118,24 @@ namespace VolumetricShadows | |
| uint primaryCascade = uint(cascadeSelect); | ||
| bool needsBlending = (cascadeSelect > 0.0) && (cascadeSelect < 1.0); | ||
|
|
||
| float4 depthParams = directionalShadowLightData.CascadeDepthParams; | ||
| float4 globalParams = directionalShadowLightData.GlobalDepthParams; | ||
| float globalNear = globalParams.x; | ||
| float globalFar = globalParams.y; | ||
|
|
||
| // Transform ray to light space for primary cascade | ||
| float4x4 shadowProj = directionalShadowLightData.ShadowProj[primaryCascade]; | ||
| float3 startLS = mul(shadowProj, float4(startPosition, 1)).xyz; | ||
| float3 endLS = mul(shadowProj, float4(endPosition, 1)).xyz; | ||
| startLS.xy = saturate(startLS.xy); | ||
| endLS.xy = saturate(endLS.xy); | ||
|
|
||
| float primaryNear = primaryCascade == 0 ? depthParams.x : depthParams.z; | ||
| float primaryFar = primaryCascade == 0 ? depthParams.y : depthParams.w; | ||
|
|
||
| // Sample primary cascade | ||
| float primaryFirstSample; | ||
| float shadow = SampleVSMCascade3D(primaryCascade, noise, sampleCount, rcpSampleCount, startLS, endLS, primaryFirstSample); | ||
| float shadow = SampleEVSMCascade3D(primaryCascade, noise, sampleCount, rcpSampleCount, startLS, endLS, primaryNear, primaryFar, globalNear, globalFar, primaryFirstSample); | ||
| surfaceShadow = primaryFirstSample; | ||
|
|
||
| // Blend with secondary cascade if needed | ||
|
|
@@ -111,8 +149,11 @@ namespace VolumetricShadows | |
| startLS.xy = saturate(startLS.xy); | ||
| endLS.xy = saturate(endLS.xy); | ||
|
|
||
| float secondaryNear = secondaryCascade == 0 ? depthParams.x : depthParams.z; | ||
| float secondaryFar = secondaryCascade == 0 ? depthParams.y : depthParams.w; | ||
|
|
||
| float secondaryFirstSample; | ||
| float shadowBlend = SampleVSMCascade3D(secondaryCascade, noise, sampleCount, rcpSampleCount, startLS, endLS, secondaryFirstSample); | ||
| float shadowBlend = SampleEVSMCascade3D(secondaryCascade, noise, sampleCount, rcpSampleCount, startLS, endLS, secondaryNear, secondaryFar, globalNear, globalFar, secondaryFirstSample); | ||
| shadow = lerp(shadow, shadowBlend, cascadeSelect); | ||
| surfaceShadow = lerp(surfaceShadow, secondaryFirstSample, cascadeSelect); | ||
| } | ||
|
|
@@ -123,11 +164,11 @@ namespace VolumetricShadows | |
| return lerp(1.0, shadow, fadeFactor); | ||
| } | ||
|
|
||
| // Sample a single cascade for VSM shadow (2D point sample) | ||
| float SampleVSMCascade2D(uint cascadeIndex, float3 positionLS) | ||
| // Sample a single cascade for EVSM shadow (2D point sample) | ||
| float SampleEVSMCascade2D(uint cascadeIndex, float3 positionLS, float cascadeNear, float cascadeFar, float globalNear, float globalFar) | ||
| { | ||
| float2 moments = SharedShadowMap.SampleLevel(LinearSampler, positionLS.xy, 1u - cascadeIndex); | ||
| return ComputeVSM(moments, positionLS.z); | ||
| float4 moments = SharedShadowMap.SampleLevel(LinearSampler, positionLS.xy, 1u - cascadeIndex); | ||
| return ComputeEVSM(moments, positionLS.z, cascadeNear, cascadeFar, globalNear, globalFar); | ||
| } | ||
|
|
||
| float GetVSMShadow2D(float3 position, out float detailedShadow) | ||
|
|
@@ -155,12 +196,20 @@ namespace VolumetricShadows | |
| uint primaryCascade = uint(cascadeSelect); | ||
| bool needsBlending = (cascadeSelect > 0.0) && (cascadeSelect < 1.0); | ||
|
|
||
| float4 depthParams = directionalShadowLightData.CascadeDepthParams; | ||
| float4 globalParams = directionalShadowLightData.GlobalDepthParams; | ||
| float globalNear = globalParams.x; | ||
| float globalFar = globalParams.y; | ||
|
|
||
| // Transform position to light space for primary cascade | ||
| float3 positionLS = mul(directionalShadowLightData.ShadowProj[primaryCascade], float4(positionWS, 1)).xyz; | ||
| positionLS.xy = saturate(positionLS.xy); | ||
|
|
||
| float primaryNear = primaryCascade == 0 ? depthParams.x : depthParams.z; | ||
| float primaryFar = primaryCascade == 0 ? depthParams.y : depthParams.w; | ||
|
|
||
| // Sample primary cascade | ||
| float shadow = SampleVSMCascade2D(primaryCascade, positionLS); | ||
| float shadow = SampleEVSMCascade2D(primaryCascade, positionLS, primaryNear, primaryFar, globalNear, globalFar); | ||
|
|
||
| // Blend with secondary cascade if needed | ||
| [branch] if (needsBlending) | ||
|
|
@@ -170,14 +219,17 @@ namespace VolumetricShadows | |
| positionLS = mul(directionalShadowLightData.ShadowProj[secondaryCascade], float4(positionWS, 1)).xyz; | ||
| positionLS.xy = saturate(positionLS.xy); | ||
|
|
||
| float shadowBlend = SampleVSMCascade2D(secondaryCascade, positionLS); | ||
| float secondaryNear = secondaryCascade == 0 ? depthParams.x : depthParams.z; | ||
| float secondaryFar = secondaryCascade == 0 ? depthParams.y : depthParams.w; | ||
|
|
||
| float shadowBlend = SampleEVSMCascade2D(secondaryCascade, positionLS, secondaryNear, secondaryFar, globalNear, globalFar); | ||
| shadow = lerp(shadow, shadowBlend, cascadeSelect); | ||
| } | ||
|
|
||
| // Apply distance fade | ||
| float fadeFactor = 1.0 - pow(fade * fade, 8); | ||
| detailedShadow = lerp(1.0, ReduceBleeding(shadow, VSM_BLEEDING_REDUCTION), fadeFactor); | ||
| return lerp(1.0, shadow, fadeFactor); | ||
| detailedShadow = lerp(1.0, shadow, fadeFactor); | ||
| return detailedShadow; | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hardcoded EVSM exponents won't respect runtime settings.
The constants
EVSM_EXPONENT_POS = 40.0andEVSM_EXPONENT_NEG = 5.0are compile-time values that ignore the user's runtime settings (VolumetricShadows::Settings::ExponentPositiveandExponentNegative). The comment claims they "must match values set in C++," but the C++ values are runtime-configurable, not compile-time constants.More critically, these hardcoded values are used in
ComputeEVSM(lines 44-45) during shadow sampling, whileDownsampleShadowCS.hlsluses the runtime exponents fromEVSMLinearizeCBduring moment generation. If a user changes the exponent settings, moments will be generated with one exponent but sampled with another, producing completely incorrect shadow results.🐛 Recommended fix: read exponents from DirectionalShadowLightData
Since the exponents are already uploaded to the GPU via
EVSMLinearizeCBduring downsample and stored inDirectionalShadowLightDataduring deferred light upload, you can pass them through to the sampling functions.Option 1: Add exponent fields to
DirectionalShadowLightDataand pass them toComputeEVSM:In
ShadowSampling.hlsli:struct DirectionalShadowLightData { column_major float4x4 ShadowProj[2]; column_major float4x4 InvShadowProj[2]; float2 EndSplitDistances; float2 StartSplitDistances; float4 CascadeDepthParams; - float4 GlobalDepthParams; // x=globalNear, y=globalFar, zw=unused + float4 GlobalDepthParams; // x=globalNear, y=globalFar, z=ExponentPositive, w=ExponentNegative };In
VolumetricShadows.hlsli, updateComputeEVSM:Then thread the exponents through all call sites.
Option 2: Add a separate constant buffer for EVSM sampling parameters.
🤖 Prompt for AI Agents