Add soft PCSS shadows on Ultra quality

Add new `SourceAngle` property to Directional light that controls PCSS penumbra size
This commit is contained in:
2026-05-06 12:37:42 +02:00
parent fad0f7a345
commit 9a85ae7142
8 changed files with 187 additions and 8 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ public:
API_FIELD() static Quality VolumetricFogQuality;
/// <summary>
/// The shadows quality.
/// The shadows filtering quality (sampling).
/// </summary>
API_FIELD() static Quality ShadowsQuality;
@@ -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);
@@ -12,6 +12,12 @@ class FLAXENGINE_API DirectionalLight : public LightWithShadow
{
DECLARE_SCENE_OBJECT(DirectionalLight);
public:
/// <summary>
/// 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.
/// </summary>
API_FIELD(Attributes = "EditorOrder(41), EditorDisplay(\"Light\"), Limit(0, 4, 0.001f), ValueCategory(Utils.ValueCategory.Angle)")
float SourceAngle = 0.5332f;
/// <summary>
/// The partitioning mode for the shadow cascades.
/// </summary>
+1 -1
View File
@@ -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);
+1
View File
@@ -80,6 +80,7 @@ struct RenderDirectionalLightData : RenderLightData
PartitionMode PartitionMode;
int32 CascadeCount;
float CascadeBlendSize;
float SourceAngle;
RenderDirectionalLightData()
{
+6 -2
View File
@@ -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))
+4
View File
@@ -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)
+165 -4
View File
@@ -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<float> 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<float> 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<float4> shadowsBuffer, Texture2D<float> shadowMap, GBufferSample gBuffer, ShadowData shadow, float3 samplePosition, uint cascadeIndex)
{
@@ -218,7 +363,12 @@ ShadowSample SampleDirectionalLightShadowCascade(LightData light, Buffer<float4>
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<float4> shadow
}
// Samples the shadow for the given local light on the material surface (supports subsurface shadowing)
ShadowSample SampleLocalLightShadow(LightData light, Buffer<float4> shadowsBuffer, Texture2D<float> shadowMap, GBufferSample gBuffer, float3 L, float toLightLength, uint tileIndex)
ShadowSample SampleLocalLightShadow(LightData light, Buffer<float4> shadowsBuffer, Texture2D<float> 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<float4> 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<float4> 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<float4> 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)