Shadow Quality (2)

This time, let’s look at how we can add some smoothness to our shadows. There exist a variety techniques to do so, the most popular being percentage closer filterig (PCF). The idea is simple: instead of sampling the shadow map only once and getting a binary result (shadow or no shadow), we also sample the surrounding shadow map texels and average the resulting shadow/no shadow decisions like so:

const int halfkernelWidth = 2;

float result = 0;
for(int y=-halfkernelWidth; y<=halfkernelWidth; ++y)
{
    for(int x=-halfkernelWidth; x<=halfkernelWidth; ++x)
    {
        float texCoords = float4(splitInfo.TexCoords + float2(x,y) / ShadowMapSize, 0, 0);
        result += (splitInfo.LightSpaceDepth <  tex2Dlod( ShadowMapSampler, texCoords ).r);
    }
}
result /= ((halfkernelWidth*2+1)*(halfkernelWidth*2+1));

Note that there exist a couple of more efficient and better looking sampling patterns but for the sake of simplicity I’m sticking with the above pattern. In any case, the averageing of yes-no decisions yields a result that can only take on a set of discrete values (25 for the above example), which means that our shadow levels will be more or less heavily quantized. Check out the image below: On the left the shadowed scene and on the right a closeup of a shadow border with the above PCF filter kernel and the corresponding pixel value histogram. You can clearly see the shadow value quantization in the image as well as in the pixel color histogram.

While this method produces good (with some tweaking excellent) results, it is rather sample hungry: In the above case we need a total of 25 samples for each shadow lookup, which is just too much for less powerful graphics hardware. So let’s look at the alternatives: Instead of using a regular grid sampling pattern, let’s switch to a stochastic sampling pattern: Pick a set of sampling positions from a disk with center at the current pixel and sample only those. A commonly used sampling pattern in this context is the poisson disk: its sampling positions are chosen uniformly at random but with a distance constraint. It can be shown that this sampling pattern has some highly desirable properties like most of it’s energy concentrated in the high frequencies. In the images below you can see the filter kernel on the left (12 taps) and the resulting shadow border on the right.

Notice how the shadow border became much more concentrated and somewhat less blocky. Still not quite satisfying though. So let’s apply another approach, proposed by [Mittring07]: Let’s apply a random rotation to the filter kernel before sampling. This will change up the sampling positions from pixel to pixel, reducing artifacts on neighbouring pixels. Note that randomness in shaders is a tricky issue: Each pixel is shaded independently of the surrounding pixels so traditional random number generators that depend on the result of the previously drawn random number cannot be used here. And usually, it is a requirement that the generated random number be stable with respect to the camera: You want to avoid changing your random number and with it your shading result when the camera stands still otherwise you might perceive flickering even in still scenes. Ideally your random number should not change even when the camera moves. I chose to precalculate a set of random numbers at application start and upload them via a texture. The texture is then indexed via the fragment’s world space position, which guarantees stability with a still standing camera. Since each random number represents a rotation angle \phi and all we do is rotate the filter kernel, I directly store cos(\phi) and sin(\phi) in the texture so we ony need to do a multiply-add in the fragment shader.

// generate a volume texture for our random numbers
mRandomTexture3D = new Texture3D(mGraphicsDevice, 32, 32, 32, false, SurfaceFormat.Rg32);

// fill with cos/sin of random rotation angles
Func<int, IEnumerable<UInt16> > randomRotations = (count) =>
    {
        return Enumerable
            .Range(0,count)
            .Select(i => (float)(random.NextDouble() * Math.PI * 2))
            .SelectMany(r => new[]{ Math.Cos(r), Math.Sin(r) })
            .Select( v => (UInt16)((v*0.5+0.5) * UInt16.MaxValue));
    };

mRandomTexture3D.SetData(randomRotations(mRandomTexture3D.Width * mRandomTexture3D.Height * mRandomTexture3D.Depth).ToArray());

The fragment shader then looks up the rotation values based on the fragment’s world position and rotates the poisson disk before doing the shadow map look-ups:

// get random kernel rotation (cos, sin) from texure, based on fragment world position
float4 randomTexCoord3D = float4(shadowData.WorldPosition.xyz*100, 0);
float2 randomValues = tex3Dlod(RandomSampler3D, randomTexCoord3D).rg;
float2 rotation = randomValues * 2 - 1;

float result = 0;
for(int s=0; s<numSamples; ++s)
{
    // compute rotated sample position
    float2 poissonOffset = float2(
        rotation.x * PoissonKernel[s].x - rotation.y * PoissonKernel[s].y,
        rotation.y * PoissonKernel[s].x + rotation.x * PoissonKernel[s].y
    );

    // perform shadow map look up and add binary shadow/no shadow decision to result
    const float4 randomizedTexCoords = float4(splitInfo.TexCoords + poissonOffset * PoissonKernelScale[splitInfo.SplitIndex], 0, 0);
    result += splitInfo.LightSpaceDepth <  tex2Dlod( ShadowMapSampler, randomizedTexCoords).r;
}

// normalize yes/no decisions and combine with ndotl term
float shadowFactor = result / numSamples * t;

And here are the results:

To the eye, the transition between shadow/no shadow looks much smoother than with the 5×5 block kernel – even though the shadow levels are even more quantized: check the histogram, there are only 12 different shadow levels. The scene itself doesn’t necessarily look better in total but once the textures and lighting are added things change drastically: the ‘noise’ disappears mostly in the surface detail and is quite less noticable except on very uniformly shaded surfaces.

Note that I held back some detail: how do we calculate the ndotl based term: t? Up to now we set t = (ndotl > 0) but this doesn’t work well together with the stochastic sampling approach shown above: (ndotl > 0) produces hard shadow edges whereas the rotated poisson disk gives us a dithered looking fall-off. Combining those two looks quite weird as shown below. So we need to dither ndotl as well:

float l = saturate(smoothstep(0, 0.2, ndotl));
float t = smoothstep(randomValues.x * 0.5, 1.0f, l);

So instead of creating a hard edge by computing ndotl > 0, we use the smoothstep function to create a smooth transition between 0 and 1 when ndotl lies in the range [0, \dots, 0.2] and store it in the variable l. We then use our random value randomValue.x and l and feed them into the smoothstep function again. If l lies in the range [\text{randomValue.x}, \dots, 1] it will be smoothly interpolated between 0 and 1, based on how close it is to either randomValue.x or 1. But since l represents a smooth transition between 0 (shadow) and 1 (no shadow) and randomValue.x (ideally) follows a uniform distribution, this means that the further l moves away from shadow , the more likely t will receive the value 1 (no shadow) too. Conversely, the closer l gets to the shadow border, the more likely t will be 0 (shadow). But there is still a random factor in there which can cause t to be 1 even if l is 0.. but very unlikely. The two images below illustrate the effect dithering on ndotl: On the left the scene is rendered without, and on the right with ndotl dithering.

Stay tuned for some more images and videos in the next post!

Links

Leave a Reply

Your email address will not be published. Required fields are marked *