Workshop 7: Soft Shadows

For overcoming the typical aliasing and 'jaggies' problems of shadow mapping, the natural approach is blurring the shadows. This has also the advantage that blurred shadows look better anyway - most light sources in nature are indirect lights and don't produce sharp shadow edges. The method described below is not exclusively for shadow maps, but can be applied to the engine's stencil shadows as well. For blurring shadows we just add two more steps to the view chain:

Render the scene from the light position. Store the light distance of all illuminated pixels in a depth buffer.

Render the scene from the camera position. Compare the light distance of any pixel with the depth buffer value at that position, and put the result in a shadow buffer. Repeat steps 1 and 2 for any light.

Apply a blur postprocessing shader to the shadow buffer.

Render the scene with any arbitrary surface shader, but multiply the pixel brightness with the content of the blurred shadow buffer.

Steps 1 and 2 are the same as in the previous workshop, with the only exception that the shadow terms are now not rendered onto the scene but into another buffer. Let's have a look at the new shaders used for steps 3 and 4. At first the buffer is blurred in step 3 with a postprocessing shader, pp_poisson.fx:

The Blur Shader

static const float fScale = 6.0;

// Application fed data:
float4 vecViewPort;

texture TargetMap;
sampler ShadowSampler = sampler_state { Texture = <TargetMap>; };

#define NUM_TAPS 12

static const float2 fTaps_Poisson[NUM_TAPS] = {
	{-.696, .457},
	{-.203, .621},
	{ .962,-.195},
	{ .473,-.480},
	{ .519, .767},
	{ .185,-.893},
	{ .507, .064},
	{ .896, .412},

// Poisson blur pixel shader
float4 PoissonPS (in float2 inShadow: TEXCOORD0) : COLOR0
	float fShadow = 0.0;
	for (int i=0; i < NUM_TAPS; i++)
		fShadow += tex2D(ShadowSampler,inShadow + fTaps_Poisson[i]*fScale*;
	fShadow *= 1.0/NUM_TAPS;
	return float4(fShadow,fShadow,fShadow,1.0);

technique techPoisson
pass p0
PixelShader = compile ps_2_0 PoissonPS();

For blurring the shadow buffer we're using a 12-sample Poisson filter. This sounds impressive, but is a rather cheap filter that averages 12 samples per pixels. The samples are taken from 12 random but even-distributed positions in a circle around the pixel - we've precalculated them in the strange fTaps_Poisson array of coordinates. In the for loop we retrieve the shadow values at the 12 sample positions in the shadow map, sum them up in the fShadow variable, and then generate the average by dividing the sum by 12. The result is the average, blurred shadow value at the pixel position. The fScale parameter can be used to adjust the 'blurriness' by widening the sample circle.

More sophisticated filters blur better, but require more samples or several passes. The more samples and passes, the slower the shader. The Poisson filter is a good compromise.

The Shadow Scene Shader

For rendering the scene we're using our Diffuse shader again, but with a small modification for applying the content of the blurred shadow buffer (DiffuseShadow.fx):

// Application fed data:
const float4x4 matWorldViewProj;	// World*view*projection matrix.
const float4x4 matWorld;	// World matrix.
const float4 vecSunDir;	// Sun direction vector.

texture TargetMap;
texture entSkin1;
sampler ShadowSampler = sampler_state { Texture = <TargetMap>; };
sampler ColorMapSampler = sampler_state {	Texture = <entSkin1>; MipFilter = Linear; };

// Vertex Shader:
void DiffuseVS(in float4 inPos: POSITION,
		in float3 inNormal: NORMAL,
		in float2 inTex: TEXCOORD0,
		out float4 outPos: POSITION,
		out float2 outTex: TEXCOORD0,
		out float3 outNormal: TEXCOORD1,
		out float4 outScreen: TEXCOORD2)
// Transform the vertex from object space to clip space:
	outPos = mul(inPos, matWorldViewProj);
// Transform the normal from object space to world space:
	outNormal = normalize(mul(inNormal,matWorld));
// Pass the texture coordinate to the pixel shader:
	outTex = inTex;
// Calculate the homogenous screen coordinates of the current vertex
	outScreen.x = ( outPos.x + outPos.w)*0.5;
	outScreen.y = (-outPos.y + outPos.w)*0.5;
	outScreen.z = outPos.z;
	outScreen.w = outPos.w;

// Pixel Shader:
float4 DiffusePS(	in float2 inTex: TEXCOORD0,
		in float3 inNormal: TEXCOORD1,
		in float4 inScreen: TEXCOORD2) : COLOR
// Calculate the diffuse term:
	float fDiffuse = saturate(dot(-vecSunDir, normalize(inNormal)));
// Fetch the pixel color from the color map:
	float4 Color = tex2D(ColorMapSampler, inTex);
// Fetch the shadow color from the blurred shadow buffer
	float fShadow = tex2Dproj(ShadowSampler,inScreen);
// Calculate final color:
	return fDiffuse * fShadow * Color;

technique techDiffuseShadow
pass P0
VertexShader = compile vs_2_0 DiffuseVS();
PixelShader = compile ps_2_0 DiffusePS();

Additionally to the usual calculations, the vertex shader outputs the screen coordinates of the current vertex. We will need the screen coordinates for looking up the shadow value in the pixel shader. The .w component of any vertex contains the value by which transformed screen coordinates are to be divided for a projection transformation. In the pixel shader, the tex2Dproj command uses the coordinates to look up the shadow value from the ShadowSampler, and multiplies the result with the color value that is painted on the screen.

This is our reward when we run the smoothdemo.c script:

By the way, when you develop pre- and postprocessing shaders it is often useful to observe the result of a certain view stage. This can be done by temporarily outcommenting both the bmap and the stage parameters of the view you want to observe (or setting them to NULL by a script command). The view then just renders to the screen, and you can see the result.

Some final considerations

In a real game with complex levels and many dynamic lights, shadow mapping requires some considerations that we haven't dealt with in this workshop. The first issue is that lights are usually not in an optimal position for just rendering into a simple depth buffer. The view arc of the light view must be large enough for covering all objects that are seen in the scene - otherwise a part of the scene would not receive shadows. When the light itself is close or even visible in the scene, we'd need a 360° field of view to achieve this - that's a little difficult for any renderer. Thus shadow mappers usually render two 180° views per light (hemispheric shadow mapping), or - for better quality - six 90° views (six-sided shadow mapping). Rendering six view per light makes shadow mapping quite slow, therefore advanced renderers skip views depending on which of the 90° sectors of the light sphere are outside the camera view frustum.

There are many other tricks for improving shadow mapping. We could use the D16 or D24 depth texture formats supported by nVidia hardware, which is faster than rendering into floating point textures and also natively perfoms PCF filtering. We could render into several shadow maps that are automatically selected depending on in which range the shadow throwing objects fall (cascaded shadow mapping). And we could adjust the shadow blur factor dependent on the distance difference between depth map value and pixel light distance, which produces a more natural-looking penumbra.

The shadow mapping shader that comes with Gamestudio uses several of the above mentioned methods; a description of this commercial quality shader however lies outside the scope of this tutorial. A good description of how to optimize shadow mapping with several methods can be found in the Shader X3 book in the chapter by G.King / W.Newhall, Efficient Omnidirectional Shadow Maps.