From 9a85ae71428ef6ff68b672f1fcd88f26829214d5 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Wed, 6 May 2026 12:37:42 +0200 Subject: [PATCH] Add soft `PCSS` shadows on Ultra quality Add new `SourceAngle` property to Directional light that controls PCSS penumbra size --- Source/Engine/Graphics/Graphics.h | 2 +- .../Engine/Level/Actors/DirectionalLight.cpp | 3 + Source/Engine/Level/Actors/DirectionalLight.h | 6 + Source/Engine/Renderer/RenderList.cpp | 2 +- Source/Engine/Renderer/RenderList.h | 1 + Source/Shaders/Noise.hlsl | 8 +- Source/Shaders/Random.hlsl | 4 + Source/Shaders/ShadowsSampling.hlsl | 169 +++++++++++++++++- 8 files changed, 187 insertions(+), 8 deletions(-) diff --git a/Source/Engine/Graphics/Graphics.h b/Source/Engine/Graphics/Graphics.h index 393fcd164..5fbf1d454 100644 --- a/Source/Engine/Graphics/Graphics.h +++ b/Source/Engine/Graphics/Graphics.h @@ -39,7 +39,7 @@ public: API_FIELD() static Quality VolumetricFogQuality; /// - /// The shadows quality. + /// The shadows filtering quality (sampling). /// API_FIELD() static Quality ShadowsQuality; diff --git a/Source/Engine/Level/Actors/DirectionalLight.cpp b/Source/Engine/Level/Actors/DirectionalLight.cpp index 8ab2b2dad..d828b619d 100644 --- a/Source/Engine/Level/Actors/DirectionalLight.cpp +++ b/Source/Engine/Level/Actors/DirectionalLight.cpp @@ -49,6 +49,7 @@ void DirectionalLight::Draw(RenderContext& renderContext) data.Cascade3Spacing = Cascade3Spacing; data.Cascade4Spacing = Cascade4Spacing; data.CascadeBlendSize = CascadeBlendSize; + data.SourceAngle = Math::Tan(SourceAngle * DegreesToRadians * 0.5f); data.PartitionMode = PartitionMode; data.ContactShadowsLength = ContactShadowsLength; data.StaticFlags = GetStaticFlags(); @@ -65,6 +66,7 @@ void DirectionalLight::Serialize(SerializeStream& stream, const void* otherObj) SERIALIZE_GET_OTHER_OBJ(DirectionalLight); + SERIALIZE(SourceAngle); SERIALIZE(CascadeCount); SERIALIZE(Cascade1Spacing); SERIALIZE(Cascade2Spacing); @@ -79,6 +81,7 @@ void DirectionalLight::Deserialize(DeserializeStream& stream, ISerializeModifier // Base LightWithShadow::Deserialize(stream, modifier); + DESERIALIZE(SourceAngle); DESERIALIZE(CascadeCount); DESERIALIZE(Cascade1Spacing); DESERIALIZE(Cascade2Spacing); diff --git a/Source/Engine/Level/Actors/DirectionalLight.h b/Source/Engine/Level/Actors/DirectionalLight.h index 538243468..fc00a31a1 100644 --- a/Source/Engine/Level/Actors/DirectionalLight.h +++ b/Source/Engine/Level/Actors/DirectionalLight.h @@ -12,6 +12,12 @@ class FLAXENGINE_API DirectionalLight : public LightWithShadow { DECLARE_SCENE_OBJECT(DirectionalLight); public: + /// + /// Light source angle (in degrees) that defines its angular diameter. Higher values produce softer shadows. The default value is 0.5357 degrees, which is the angular diameter of the Sun as seen from Earth. Set this to 0 to produce hard shadows with sharp edges. + /// + API_FIELD(Attributes = "EditorOrder(41), EditorDisplay(\"Light\"), Limit(0, 4, 0.001f), ValueCategory(Utils.ValueCategory.Angle)") + float SourceAngle = 0.5332f; + /// /// The partitioning mode for the shadow cascades. /// diff --git a/Source/Engine/Renderer/RenderList.cpp b/Source/Engine/Renderer/RenderList.cpp index ee2a307b1..7d00a1a6b 100644 --- a/Source/Engine/Renderer/RenderList.cpp +++ b/Source/Engine/Renderer/RenderList.cpp @@ -113,7 +113,7 @@ void RenderDirectionalLightData::SetShaderData(ShaderLightData& data, bool useSh { data.SpotAngles.X = -2.0f; data.SpotAngles.Y = 1.0f; - data.SourceRadius = 0; + data.SourceRadius = SourceAngle; data.SourceLength = 0; data.Color = Color; data.MinRoughness = Math::Max(MinRoughness, MIN_ROUGHNESS); diff --git a/Source/Engine/Renderer/RenderList.h b/Source/Engine/Renderer/RenderList.h index 6133924a3..3fb203dfe 100644 --- a/Source/Engine/Renderer/RenderList.h +++ b/Source/Engine/Renderer/RenderList.h @@ -80,6 +80,7 @@ struct RenderDirectionalLightData : RenderLightData PartitionMode PartitionMode; int32 CascadeCount; float CascadeBlendSize; + float SourceAngle; RenderDirectionalLightData() { diff --git a/Source/Shaders/Noise.hlsl b/Source/Shaders/Noise.hlsl index df5a041fa..54dbae565 100644 --- a/Source/Shaders/Noise.hlsl +++ b/Source/Shaders/Noise.hlsl @@ -56,12 +56,16 @@ float2 PerlinNoiseFade(float2 t) // "Next Generation Post Processing in Call of Duty: Advanced Warfare" // http://advances.realtimerendering.com/s2014/index.html +float InterleavedGradientNoise(float2 uv) +{ + float3 magic = float3(0.06711056f, 0.00583715f, 52.9829189f); + return frac(magic.z * frac(dot(uv, magic.xy))); +} float InterleavedGradientNoise(float2 uv, uint frameCount) { const float2 magicFrameScale = float2(47, 17) * 0.695; uv += frameCount * magicFrameScale; - const float3 magic = float3(0.06711056, 0.00583715, 52.9829189); - return frac(magic.z * frac(dot(uv, magic.xy))); + return InterleavedGradientNoise(uv); } // Removes error from the color to properly store it in lower precision formats (error = 2^(-mantissaBits)) diff --git a/Source/Shaders/Random.hlsl b/Source/Shaders/Random.hlsl index fa97f436a..ea0f469ab 100644 --- a/Source/Shaders/Random.hlsl +++ b/Source/Shaders/Random.hlsl @@ -14,6 +14,10 @@ float RandN1(float n) { return frac(sin(n) * 43758.5453123); } +float RandN1(float2 n) +{ + return RandN1(dot(n, float2(12.9898, 78.233))); +} // Generic noise (2-components) float2 RandN2(float2 n) diff --git a/Source/Shaders/ShadowsSampling.hlsl b/Source/Shaders/ShadowsSampling.hlsl index ef4e3a4d8..b401844a3 100644 --- a/Source/Shaders/ShadowsSampling.hlsl +++ b/Source/Shaders/ShadowsSampling.hlsl @@ -9,11 +9,14 @@ #ifndef SHADOWS_CSM_DITHERING #define SHADOWS_CSM_DITHERING 0 #endif +#ifndef SHADOWS_PCSS +#define SHADOWS_PCSS (SHADOWS_QUALITY >= 3) +#endif #include "./Flax/ShadowsCommon.hlsl" #include "./Flax/GBufferCommon.hlsl" #include "./Flax/LightingCommon.hlsl" -#if SHADOWS_CSM_DITHERING +#if SHADOWS_CSM_DITHERING || SHADOWS_PCSS #include "./Flax/Random.hlsl" #endif @@ -207,6 +210,148 @@ float SampleShadowMapOptimizedPCF(Texture2D shadowMap, float2 shadowMapUV #endif } +#if SHADOWS_PCSS + +// "Vogel disk" sampling pattern: https://github.com/corporateshark/poisson-disk-generator +#define SHADOWS_PCSS_SAMPLES 32 +static const half2 VogelPoints[SHADOWS_PCSS_SAMPLES] = { +#if SHADOWS_PCSS_SAMPLES == 4 + // 4 samples + half2(0.353553, 0.000000), + half2(-0.451560, 0.413635), + half2(0.069174, -0.787537), + half2(0.569060, 0.742409), +#elif SHADOWS_PCSS_SAMPLES == 8 + // 8 samples + half2(0.25000000, 0.00000000), + half2(-0.31930089, 0.29248416), + half2(0.04891348, -0.55687296), + half2(0.40238643, 0.52496207), + half2(-0.73851585, -0.13074535), + half2(0.69968677, -0.44490278), + half2(-0.23419666, 0.87043202), + half2(-0.44604915, -0.85938364), +#elif SHADOWS_PCSS_SAMPLES == 16 + // 16 samples + half2(0.17677665, 0.00000000), + half2(-0.22577983, 0.20681751), + half2(0.03458714, -0.39376867), + half2(0.28453016, 0.37120426), + half2(-0.52220953, -0.09245092), + half2(0.49475324, -0.31459379), + half2(-0.16560209, 0.61548841), + half2(-0.31540442, -0.60767603), + half2(0.68456841, 0.25023210), + half2(-0.71235347, 0.29377294), + half2(0.34362423, -0.73360229), + half2(0.25340176, 0.80903494), + half2(-0.76454973, -0.44352412), + half2(0.89722824, -0.19680285), + half2(-0.54790950, 0.77848911), + half2(-0.12594837, -0.97615927), +#elif SHADOWS_PCSS_SAMPLES == 32 + // 32 samples + half2(0.12500000, 0.00000000), + half2(-0.15965044, 0.14624202), + half2(0.02445674, -0.27843648), + half2(0.20119321, 0.26248097), + half2(-0.36925793, -0.06537271), + half2(0.34984338, -0.22245139), + half2(-0.11709833, 0.43521595), + half2(-0.22302461, -0.42969179), + half2(0.48406291, 0.17694080), + half2(-0.50370997, 0.20772886), + half2(0.24297905, -0.51873517), + half2(0.17918217, 0.57207417), + half2(-0.54061830, -0.31361890), + half2(0.63443625, -0.13916063), + half2(-0.38743055, 0.55047488), + half2(-0.08905894, -0.69024885), + half2(0.54879880, 0.46308208), + half2(-0.73889750, 0.03009081), + half2(0.53931046, -0.53597510), + half2(-0.03660476, 0.77976608), + half2(-0.51236552, -0.61490381), + half2(0.81227481, 0.10993028), + half2(-0.68869931, 0.47834957), + half2(0.18879366, -0.83590192), + half2(0.43436146, 0.75957572), + half2(-0.85019755, -0.27210116), + half2(0.82646751, -0.38088906), + half2(-0.35873961, 0.85479879), + half2(-0.31848884, -0.88836360), + half2(0.84942913, 0.44759941), + half2(-0.94430852, 0.24780297), + half2(0.53754807, -0.83391672), +#endif +}; + +float2 SampleShadowPCSSRotate(float2 pos, float2 rotation) +{ + return float2(pos.x * rotation.x - pos.y * rotation.y, pos.y * rotation.x + pos.x * rotation.y); +} + +// [Percentage-Closer Soft Shadows, Randima Fernando, NVIDIA] +float SampleShadowMapPCSS(Texture2D shadowMap, float2 shadowMapUV, float sceneDepth, float4 shadowToAtlas, float sourceAngle) +{ + // Scale samples to shadow map tile + float2 shadowMapSize; + shadowMap.GetDimensions(shadowMapSize.x, shadowMapSize.y); + float resolution = shadowMapSize.x * shadowToAtlas.x; + float minRadius = 0.3f / resolution; + float2 uvMin = shadowToAtlas.zw; + float2 uvMax = shadowToAtlas.xy + shadowToAtlas.zw; + + // Fix penumbra size to be consistent across different shadow map resolutions + sourceAngle *= resolution / 1024.0; + + // Rotate the sampling pattern based on the pixel position to reduce banding artifacts (use shadow-space position for stability) + float rotationAngle = RandN1(shadowMapUV) * PI; + float2 rotation; + sincos(rotationAngle, rotation.x, rotation.y); + + // Search blockers + float searchRadius = sourceAngle * saturate(sceneDepth - 0.02f) / sceneDepth; + searchRadius = max(searchRadius, minRadius); + uint blockers = 0; + float avgBlockerDistance = 0.0f; + for (uint i = 0; i < SHADOWS_PCSS_SAMPLES; i++) + { + float2 offset = VogelPoints[i] * searchRadius; + offset = SampleShadowPCSSRotate(offset, rotation); + offset = shadowMapUV + offset; + offset = clamp(offset, uvMin, uvMax); + float shadowMapDepth = LOAD_SHADOW_MAP(shadowMap, offset); + if (shadowMapDepth < sceneDepth) + { + blockers++; + avgBlockerDistance += shadowMapDepth; + } + } + if (blockers < 1) + return 1; // No blockers, fully lit + avgBlockerDistance /= blockers; + + // Calculate penumbra size + float penumbra = max(sceneDepth - avgBlockerDistance, 0.0); + float filterRadius = penumbra * sourceAngle; + filterRadius = max(filterRadius, minRadius); // Don't use too small filter near blockers to avoid jagged edges + + // Filter shadowmap + float shadow = 0.0f; + for (uint i = 0; i < SHADOWS_PCSS_SAMPLES; i++) + { + float2 offset = VogelPoints[i] * filterRadius; + offset = SampleShadowPCSSRotate(offset, rotation); + offset = shadowMapUV + offset; + offset = clamp(offset, uvMin, uvMax); + shadow += LOAD_SHADOW_MAP(shadowMap, offset) > sceneDepth; + } + return shadow / (float)SHADOWS_PCSS_SAMPLES; +} + +#endif + // Samples the shadow cascade for the given directional light on the material surface (supports subsurface shadowing) ShadowSample SampleDirectionalLightShadowCascade(LightData light, Buffer shadowsBuffer, Texture2D shadowMap, GBufferSample gBuffer, ShadowData shadow, float3 samplePosition, uint cascadeIndex) { @@ -218,7 +363,12 @@ ShadowSample SampleDirectionalLightShadowCascade(LightData light, Buffer float2 shadowMapUV = GetLightShadowAtlasUV(shadow, shadowTile, samplePosition, shadowPosition); // Sample shadow map +#if SHADOWS_PCSS + float sourceAngle = light.SourceRadius; // tan(SourceAngle * DegreesToRadians * 0.5); + result.SurfaceShadow = SampleShadowMapPCSS(shadowMap, shadowMapUV, shadowPosition.z, shadowTile.ShadowToAtlas, sourceAngle); +#else result.SurfaceShadow = SampleShadowMapOptimizedPCF(shadowMap, shadowMapUV, shadowPosition.z); +#endif // Increase the sharpness for higher cascades to match the filter radius const float SharpnessScale[MaxNumCascades] = { 1.0f, 1.5f, 3.0f, 3.5f }; @@ -322,7 +472,7 @@ ShadowSample SampleDirectionalLightShadow(LightData light, Buffer shadow } // Samples the shadow for the given local light on the material surface (supports subsurface shadowing) -ShadowSample SampleLocalLightShadow(LightData light, Buffer shadowsBuffer, Texture2D shadowMap, GBufferSample gBuffer, float3 L, float toLightLength, uint tileIndex) +ShadowSample SampleLocalLightShadow(LightData light, Buffer shadowsBuffer, Texture2D shadowMap, GBufferSample gBuffer, float3 L, float toLightLength, uint tileIndex, float sourceAngle = 0) { #if !LIGHTING_NO_DIRECTIONAL // Skip if surface is in a full shadow @@ -362,7 +512,11 @@ ShadowSample SampleLocalLightShadow(LightData light, Buffer shadowsBuffe float2 shadowMapUV = GetLightShadowAtlasUV(shadow, shadowTile, samplePosition, shadowPosition); // Sample shadow map +#if SHADOWS_PCSS + result.SurfaceShadow = SampleShadowMapPCSS(shadowMap, shadowMapUV, shadowPosition.z, shadowTile.ShadowToAtlas, sourceAngle); +#else result.SurfaceShadow = SampleShadowMapOptimizedPCF(shadowMap, shadowMapUV, shadowPosition.z); +#endif #if defined(USE_GBUFFER_CUSTOM_DATA) // Subsurface shadowing @@ -387,7 +541,11 @@ ShadowSample SampleSpotLightShadow(LightData light, Buffer shadowsBuffer float3 toLight = light.Position - gBuffer.WorldPos; float toLightLength = length(toLight); float3 L = toLight / toLightLength; - return SampleLocalLightShadow(light, shadowsBuffer, shadowMap, gBuffer, L, toLightLength, 0); + + // TODO: make it physical-based (need to compare with path tracing) + float sourceAngle = 0.02 * light.SourceRadius * dot(-light.Direction, light.Position - gBuffer.WorldPos) / light.Radius; + + return SampleLocalLightShadow(light, shadowsBuffer, shadowMap, gBuffer, L, toLightLength, 0, sourceAngle); } // Samples the shadow for the given point light on the material surface (supports subsurface shadowing) @@ -399,8 +557,11 @@ ShadowSample SamplePointLightShadow(LightData light, Buffer shadowsBuffe // Figure out which cube face we're sampling from uint cubeFaceIndex = GetCubeFaceIndex(-L); + + // TODO: make it physical-based (need to compare with path tracing) + float sourceAngle = 0.02 * light.SourceRadius * length(light.Position - gBuffer.WorldPos) / light.Radius; - return SampleLocalLightShadow(light, shadowsBuffer, shadowMap, gBuffer, L, toLightLength, cubeFaceIndex); + return SampleLocalLightShadow(light, shadowsBuffer, shadowMap, gBuffer, L, toLightLength, cubeFaceIndex, sourceAngle); } GBufferSample GetDummyGBufferSample(float3 worldPosition)