Improvements for shadow mapping in OpenGL and GLSL

This tutorial shows how to improve results of shadow mapping method, lists disadvantages of basic shadow mapping and possible solutions to those problems. Following improvements are considered: Variance Shadow Mapping (VSM), Exponential Shadow Maps (ESM), Percentage Closer Filtering (PCF), Stratified Poisson Sampling, Rotated Poisson Sampling and other. You can find more info about shadow mapping and shadow mapping for point light sources in previous tutorials.

Problems of shadow mapping

It is easy to implement shadow mapping, but it requires a lot of time and tweaking in order to improve quality. Also, parameters of shadow mapping should depend on a scene. There are following problems of shadow mapping:

Z-fighting – artifacts that arise on lit surfaces due to errors during comparison of depths from shadow map with depth of current fragment. In most cases this problem is resolved with additional offset for all depths in shadow map.

Aliasing – visible square-like aliasing on the borders between light and shadow when size of the shadow map is too small. It appears because multiple fragments visible through camera are mapped to the same texel in the shadow map. The problem can be solved by choosing optimal size of the shadow map and optimal light space frustum. Another aliasing problem appears in places where light rays are parallel to the surface.

Perspective aliasing – more aliasing near the end of perspective projection frustum. It appears because perspective projection has higher visible area to texel ratio near the far clipping plane.

Lost detail – when size of shadow map is large, and shadow casting objects are small in screen space (few fragments).

Banding – appears on the borders between light and shadow instead of smooth transition between light and shadow. This is issue of Percentage Closer Filtering and related mehtods.

Peter Panning – additional offset for depths in shadow map (to fix z-fighting) is too big and becomes visible. Such shifted shadows creates effect of “flying objects”.

Following sections shows how to solve these problems and how to improve quality of shadow mapping.

Surfaces that aren’t oriented to light are in shadow

One of the most simple optimizations for shadow mapping is automatic shadowing for fragments where directions of normals are opposite to direction of light rays. You can perform this check with dot product of normal (N) and vector from the light source (L). If value is less than zero, then fragment is in shadow. This optimization creates better shadows on surfaces parallel to light sources, and improves performance. Normals should be valid and well defined for correct work of this optimization. This optimization can decrease quality of shadows during percentage closer filtering and partially remove Peter Panning artifacts. Following code snippet shows how to implement orientation check:

// check for orientation. Check against 0, or smaller value.

// You can create smooth transition from light to shadow

if(dot(N, L) < 0)

{

shadowFactor = 0;

}

else{ … }

Additional offset to depth values in shadow map

Additional offset to depths in shadow map is used to minimize z-fighting. Let’s consider the following image. Surface of an object is depicted as green line, the shadow map’s projection plane – thick black line, red lines – borders between texels in the shadow map, black dots – depths stored in the shadow map. Whole surface on the left should be lit, but as shown (with the black line from the camera to the surface), part of the fragments that is projected to single texel in the shadow map is in the shadow, because distances from these fragments to the light source are bigger than depth stored in shadow map. Second part of the fragments that corresponds to the same texel in shadow map is lit. Situation is similar for next texels: half of the corresponding fragments is in shadow, another half is lit. This is the reason of z-fighting shown on one of the previous images. Image on the right side shows that additional offset is applied to depths in shadow map. Black dots show actual depths stored in the shadow map. Now all fragments of the green surface are lit.

The problem is to determine optimal offset for each depth in shadow map. If you apply not enough offset, z-fighting will be still present. If you apply very large offset then Peter Panning will become noticeable. Offset should depend on precision of the shadow map, and on slope of the surface relative to direction to light source.

Following code snippet shows how to add constant offset to distance from fragment to light source:

float distToLight = distance(fragmentWorldPosition, lightPosition) + epsilon;

This offset doesn’t take slope of the surface into account. Let’s consider the following image. The figure on the left shows distances in shadow map without any additional offset. This shadow map will produce z-fighting. The figure on the right shows initial depths and depths after additional offset. As you can see, offset for each depth is different. If the surface is perpendicular to light rays, then offset should be minimal (as for the left most texel). If the surface is parallel to lights rays, then use maximum offset (as for the right most texel). You can determine amount of additional offset with normal at the fragment and with direction to the light source. Check the following code:

// dot product returns cosine between N and L in [-1, 1] range

// then map the value to [0, 1], invert and use as offset

float offsetMod = 1.0 – clamp(dot(N, L), 0, 1)

float offset = minOffset + maxSlopeOffset * offsetMod;

// another method to calculate offset

// gives very large offset for surfaces parallel to light rays

float offsetMod2 = tan(acos(dot(N, L)))

float offset2 = minOffset + clamp(offsetMod2, 0, maxSlopeOffset);

OpenGL can automatically compute and add offset to values which are stored in Z-Buffer. You can setup offset with glPolygonOffset function. Two parameters are available: multiplier for offset that depends on slope of the surface, and value that determines amount of additional smallest possible offsets (depends on format of shadow map):

glEnable(GL_POLYGON_OFFSET_FILL);

glPolygonOffset(1.0f, 1.0f);

Linear filtering for shadow map

OpenGL has option that allows the fragment shader to sample shadow map with linear filtering. Linear filtering returns interpolated value that is based on four closest texels to sampling location instead of a value of closest texel. But shadow map has special built-in logic. Linear filter doesn’t interpolate between four depths, but automatically compares current distance from light to fragment with four values in shadow map. Each passed check adds 0.25 to result, and failed check doesn’t modify the result. So there are five possible values returned with sampling: 0.0, 0.25, 0.5, 0.75, 1.0.

In case if none of the checks have passed then 0 is returned. If all checks have passed then 1 is returned. So this value can be interpreted as shadowing factor. This is not linear filtration of depths but linear filtration of depth comparisons. To enable automatic comparisons you have to use samplerShadow sampler type in the fragment shader. Following code snippet shows how to initialize texture for automatic depth comparisons with linear filtering:

// bind shadow map

glBindTexture(GL_TEXTURE_2D, shadowMap);

// set linear filter

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);

// set automatic comparisson mode

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);

Shadowing factor with 5 possible values makes transition from light to shadow more smooth and decreases visible aliasing. Also, it may be much quicker to take single sample with linear filter than to take four separate samples and manually compare them with current distance from the fragment to the light source.

Percentage closer filtering

Percentage closer filtering (PCF) is the simplest method to create soft shadows with shadow mapping. Instead of taking one sample from the shadow map, it’s possible to perform multiple samples from shadow map around the location of the fragment. Shadowing factor is calculated as ratio of passed depth comparisons to total number of depth comparisons. Even more, PCF uses linear filtering to get results of 4 comparisons at once. So if PCF takes 4 samples, then in combination with linear filtering it gives 16 depth checks, and same number of intensity steps in transition between light and shadow. Such samples should be taken in such a way that all checks are done with unique texels from the shadow map. That is, linear filtering shouldn’t sample from same texels twice or more.

Quality of soft shadows in Percentage Closer Filtering directly depends on number of samples from shadow map. Small number of samples and large radius of PCF kernel lead to visible bands along transition between light and shadow. The more unique checks are performed, the better the quality of soft shadows. But more samples lead to bad performance. Big radius of PCF filter requires bigger offset to depth values in shadow map, or the lit surfaces may become partially shadowed. Following code snippet shows how to implement Percentage Closer Filtering:

// number of samples

const int numSamplingPositions = 4;

// offsets for rectangular PCF sampling

vec2 kernel[4] = vec2[]

(

vec2(1.0, 1.0), vec2(-1.0, 1.0), vec2(-1.0, -1.0), vec2(1.0, -1.0)

);

// performs sampling and accumulates shadowing factor

void sample(in vec3 coords, in vec2 offset,

inout float factor, inout float numSamplesUsed)

{

factor += texture(

u_textureShadowMap,

vec3(coords.xy + offset, coords.z)

);

numSamplesUsed += 1;

}

void main()

{

// …

// sample each PCF texel around current fragment

float shadowFactor = 0.0;

float numSamplesUsed = 0.0;

float PCFRadius = 1;

for(int i=0; i<numSamplingPositions; i++)

{

sample(projectedCoords,

kernel[i] * shadowMapStep * PCFRadius,

shadowFactor,

numSamplesUsed

);

}

// normalize shadowing factor

shadowFactor = shadowFactor/numSamplesUsed;

}

Poisson sampling kernel

Poisson disk of offsets allows PCF to sample with more irregular offsets than in standard PCF method. Offsets in Poisson disk are uniformly distributed in unit circle and distance between any two samples is not less than defined distance. This fact allows to reduce banding a bit and use less samples. To generate Poisson disk of offsets you can use Poisson Disk Generator app. You won’t get valid Poisson disk with simple random generation.

Comparing with regular PCF grid, sampling with Poisson kernel gives quite greater quality with smaller number of required samples. It reduces banding and preserves soft transitions between light and shadow. Following images show results of Poisson sampling and example of Poisson sampling kernel.

Rotated Poisson kernel

In any case, if you use constant grid of sampling positions for Percentage closer filtering, you will have to deal with insufficient softness of transition, banding and performance. Good quality of soft shadows and absence of banding require a lot of samples. And huge amount of samples is always the main performance bottleneck. The banding can be visible even with large number of samples because offsets of samples are same for all fragments, and they create patterns of intensity steps along boundaries of shadows.

You can try to randomly generate sampling kernel for each fragment. But as it was noted previously, random non uniform samples may sample from same texels of shadow map multiple times. You have to generate Poisson disk for each fragment, but all the math will decrease performance of the fragment shader even more than large number of samples. Instead, you can define one Poisson kernel in fragment shader, and then randomly rotate kernel in 2D for each fragment.

This gives us another problem – to generate random angle in the shader. But usually shaders doesn’t support built-in generation of random numbers. So you have to implement this functionality manually. In order to generate each time same random number for each fragment in the scene, generator of pseudo random numbers should depend on position of fragment in the scene. In other case generator would generate other numbers each time and the final image will flicker. You can write pseudo random number generator as some high frequency function in the shader, or provide texture with previously generated random numbers and sample different location for each fragment (using position of the fragment in the scene as texture coordinates). Following code snippet shows example of pseudo random number generator:

// generates pseudorandom number in [0, 1]

// seed – world space position of a fragemnt

// freq – modifier for seed. The bigger, the faster

// the pseudorandom numbers will change with change of world space position

float random(in vec3 seed, in float freq)

{

// project seed on random constant vector

float dt = dot(floor(seed * freq), vec3(53.1215, 21.1352, 9.1322));

// return only fractional part

return fract(sin(dt) * 2105.2354);

}

As we have to rotate Poisson disk with random angles, you can store cos(theta) and sin(theta) in a texture, and these values can be directly used in 2D rotation.

Following code snippet shows implementation of Rotated Poisson kernel sampling with pseudo random number generator. Image to the right shows results of Poisson sampling with increased radius of Poisson kernel but with same number of samples. As you can see random rotations of sampling kernel almost totally removes banding, increases possible kernel radius, but with low number of samples this method creates visible noise. Noise becomes less visible when shadows are combined with diffuse maps, lighting and other effects.