Soft Shadows

September 7th, 2025
Unreleased Project

Motivation

In the never-ending quest for compelling lighting in video games, I recently delved into rendering soft shadows. Hard shadows are actually rare in real life, since they are only visible when the light source is very small compared to the occluder. However, in video games we usually treat lights as point lights that lack any surface area, but that often isn't what we want to represent artistically. In this article, I will explain how I solved this problem and offered artistic-driven parameters to my team.

Off On
Soft Shadows Off vs On

Implementation

By default, Unity URP renders soft shadows using Percentage Closer Filtering (PCF). While it hides aliased edges and provides more pleasant results than hard shadows, softness can't be configured. Therefore, light area can't be configured or represented. Another way of rendering soft shadows is using Percentage Closer Soft Shadows (PCSS). In that technique, a sampling step is performed before filtering the shadow map to identify the average distance to the occluder and adjust the sampling pattern radius for filtering. You can read more on it here.

My implementation follows a similar structure to the one used in PCSS:


        static float2 _PoissonDiskShadows[24] = 
        {
            float2(-0.07733, -0.67920),
            float2(-0.71161, -0.26784),
            float2(-0.52051, -0.11176),
            float2(-0.26109, 0.01776),
            float2(-0.03004, -0.34879),
            float2(-0.48658, 0.50171),
            float2(-0.74085, 0.37335),
            float2(-0.05425, 0.04487),
            float2(-0.52625, 0.12483),
            float2(-0.18583, -0.17481),
            float2(0.33716, 0.18172),
            float2(-0.10199, 0.57954),
            float2(0.53478, 0.66744),
            float2(0.66503, 0.44478),
            float2(0.41769, 0.44275),
            float2(0.08899, 0.35702),
            float2(0.38512, -0.56217),
            float2(0.47295, -0.72390),
            float2(0.16913, -0.18960),
            float2(0.15327, -0.04767),
            float2(-0.57868, 0.81125),
            float2(0.37330, 0.78119),
            float2(0.86544, -0.01258),
            float2(0.18936, -0.95787),
        };
        

Dealing with aliasing

This would provide perfect smooth shadows if occlusion was a nice contiguous analytical function. However, it is a numerical representation stored in a texture. Even using high resolution shadow maps, there are cases where we will want to represent shadows with such hardness that the sampling area will only cover very few pixels, or even less than one pixel.

This is especially troublesome in shadows near occluders when the shadow maps are set to a low resolution. So, instead of trying to represent a level of fidelity that simply is not present in the shadow map, I decided to clamp the sampling radius so that its minimum area covers at least 3x3 pixels. That way, hardness may be lost in some cases in exchange for softer shadows, but that is still preferable compared to aliased edges near occluders.

Besides, screen space shadows can be introduced to recover hardness and detail near occluders, but I'll cover that in a future article.

Cascaded shadow maps

In the case of directional lights, we are currently using cascaded shadow maps. Before, we used PCF filtering with varying radius depending only on shadow quality (3x3, 5x5, etc). However, that made transitions between different cascades very apparent, because their perceived softness varied substantially, especially when using low resolution shadow maps.

Using the new soft shadows implementation, that problem is mostly solved, because the softness radius in world space units does not depend on shadow map resolution anymore. Some differences can still be appreciated between cascades of course, as details are lost in further cascades, but that is a separate problem.

PCF 5x5 Dithered
PCF 5x5 vs Dithered

Parametrization

Softness is parametrized per light:

This is not physically based at all, but offers very intuitive controls and results.

Results

Off On
Soft Shadows Off vs On
Off On
Soft Shadows Off vs On
Off On
Soft Shadows Off vs On
Off On
Soft Shadows Off vs On

Conclusion

This implementation not only offers the capacity to render soft shadows, but also to scale softness as the occluder-occludee distance grows. The exposed parameters for each light are highly intuitive for artists and have been well received by the team.

It must be said that rendering shadows like this is more expensive than doing traditional PCF filtering, because more samples are required and their distribution cause more cache misses. However, the improved fidelity and realism are worth it in my opinion. It is not only about hiding aliased edges in shadows or achieving more realistic graphics, these shadows also provide a sense of scale and better visual cues to easily understand the 3D environment.

Going Forward

We currently support directional, point and spot lights with shadows. In the future, I would like to add support for other light shapes such as rectangle area lights. It will be interesting to see how this approach to soft shadows fares in those scenarios, with lights that cover larger areas.

Additionally, it would be interesting to implement spatial denoising. Without it, we need a high amount of samples per pixel to prevent ghosting in the TAA pass. Using a compute shader could allow us to calculate shadows for every light and applying spatial denoising in a single compute pass. Potentially, that would allow us to use less samples per pixel, reduce ghosting and improve overall performance. But for now, what's been achieved is already quite good both visually and performance-wise.