Cascaded Shadow Maps (3)

Having seen how we can render the shadow map for each split in the last post, it’s time to look at the final step: map the shadow atlas onto the scene. So, basically, for each pixel in the scene we need to determine if it lies in shadow or not. To do so we first need to determine the appropriate shadow split for that pixel. This can be done in a couple different ways, for example, by determining the split based on the distance to the viewer or by picking the split based on the look up texture coordinates. I chose the latter, as this makes better usage of the higher resolution maps. Check out this article for a nice comparison.

So in order to determine the shadow split we basically transform the current fragment’s world position by each shadow transform and then pick the first shadow map where the coordinates fall within the range of the shadow atlas partition. Let’s define some helper functions first:

#define NumSplits 4

// the "world to shadow atlas partition" transforms
float4x4 ShadowTransform[NumSplits];

// the bounding rectangle (in texture coordinates) for each split
float4 TileBounds[NumSplits];

// the shadow atlas
texture2D ShadowMap;
sampler ShadowMapSampler = sampler_state 
{	
    texture = <ShadowMap>;
    magfilter = POINT;    minfilter = POINT;    mipfilter = POINT;	
    AddressU  = clamp;	  AddressV  = clamp;
};

// Data passed from vertex shader to pixel shader
struct ShadowData
{
    float4 TexCoords_0_1;
    float4 TexCoords_2_3;
    float4 LightSpaceDepth;
};

// compute shadow parameters (tex coords and depth) 
// for a given world space position
ShadowData GetShadowData( float4 worldPosition )
{
    ShadowData result;
    float4 texCoords[NumSplits];
    float lightSpaceDepth[NumSplits];

    for( int i=0; i<NumSplits; ++i )
    {
        float4 lightSpacePosition = mul( worldPosition, ShadowTransform[i] );
        texCoords[i] = lightSpacePosition / lightSpacePosition.w;
        lightSpaceDepth[i] = texCoords[i].z;
    }

    result.TexCoords_0_1 = float4(texCoords[0].xy, texCoords[1].xy);
    result.TexCoords_2_3 = float4(texCoords[2].xy, texCoords[3].xy);
    result.LightSpaceDepth = float4( lightSpaceDepth[0], 
                                     lightSpaceDepth[1], 
                                     lightSpaceDepth[2], 
                                     lightSpaceDepth[3] );

    return result;
}

struct ShadowSplitInfo
{
    float2 TexCoords;
    float  LightSpaceDepth;
    int    SplitIndex;
};

// find split index, texcoords and light space depth for given shadow data
ShadowSplitInfo GetSplitInfo( ShadowData shadowData )
{	
    float2 shadowTexCoords[NumSplits] = 
    {
        shadowData.TexCoords_0_1.xy, 
        shadowData.TexCoords_0_1.zw,
        shadowData.TexCoords_2_3.xy,
        shadowData.TexCoords_2_3.zw
    };

    float lightSpaceDepth[NumSplits] = 
    {
        shadowData.LightSpaceDepth.x,
        shadowData.LightSpaceDepth.y,
        shadowData.LightSpaceDepth.z,
        shadowData.LightSpaceDepth.w,
    };
	
    for( int splitIndex=0; splitIndex < NumSplits; splitIndex++ )
    {
        if( shadowTexCoords[splitIndex].x >= TileBounds[splitIndex].x && 
            shadowTexCoords[splitIndex].x <= TileBounds[splitIndex].y && 
            shadowTexCoords[splitIndex].y >= TileBounds[splitIndex].z && 
            shadowTexCoords[splitIndex].y <= TileBounds[splitIndex].w )
        {
            ShadowSplitInfo result;
            result.TexCoords = shadowTexCoords[splitIndex];
            result.LightSpaceDepth = lightSpaceDepth[splitIndex];
            result.SplitIndex = splitIndex;

            return result;
        }
    }

    ShadowSplitInfo result = { float2(0,0), 0, NumSplits };
    return result;
}


// compute shadow factor: 0 if in shadow, 1 if not
float GetShadowFactor( ShadowData shadowData )
{
    ShadowSplitInfo splitInfo = GetSplitInfo( shadowData );
    float storedDepth = tex2Dlod( ShadowMapSampler, float4( splitInfo.TexCoords, 0, 0)).r;

    return (splitInfo.LightSpaceDepth <  storedDepth);
}

Armed with these definitions we can now add shadowing to any shader. All we need to do is call GetShadowData in order to convert a given world position into shadow atlas texture coordinates and then GetShadowFactor to do the lookup. Note that since the quantities in ShadowData are linear, I would suggest calling GetShadowFactor in the vertex shader in order to save fragment shader instructions. I collected these functions in a header file Shadow.h, which can be included by any shader.

What’s with ShadowTransform[i] though? The matrix is assumed to transform a vertex’s world space position into the texture coordinates of it’s corresponding shadow split i. Actually nothing new, the combined shadow view and projection matrix – if it weren’t for the nasty coordinate space differences: After projection, a vertex ends up in clip space which is defined as (ignoring the Z coordinate) [-1, \dots, 1] \times [-1, \dots, 1]. But we need texture coordinates for the shadow map lookup, which range from [0, \dots, 1] \times [0, \dots, 1] . Even more specific: we need to index into the sub rectangle of the corresponding shadow map in the shadow atlas. And to make things even worse: The y-axis of clip space points upwards while the y axis in texture space points downwards: top left in clip space is (-1,1) whereas top left in texture coordinates is (0,0). Luckily we can extend the combined shadow view and projection matrix to do the remapping:

// compute block index into shadow atlas
int tileX = i % 2;
int tileY = i / 2;

// tile matrix: maps from clip space to shadow atlas block
var tileMatrix = Matrix.Identity;
tileMatrix.M11 = 0.25f;
tileMatrix.M22 = -0.25f;
tileMatrix.Translation = new Vector3(0.25f + tileX * 0.5f, 0.25f + tileY * 0.5f, 0);

// now combine with shadow view and projection
var ShadowTransform = ShadowView * ShadowProjection * tileMatrix;

So the first two diagonal elements of tileMatrix scale the clip space x and y coordinates down to [-\frac{1}{4}, \dots, \frac{1}{4}] and the translation component shifts the coordinates to [0, \dots, \frac{1}{2}]. The tileX * 0.5f and tileY * 0.5f part then offsets the coordinates into the proper shadow atlas partition. Simple as that. Beware though, this only works for orthographic shadow projections.

Check out the images below, from left to right: The scene with shadows, shadows per split, shadow map resolution per split.

Have a look at the next visualization as well: Each pixel in the shadow map is back-projected into world space again and rendered as a cube. This sort of illustrates how the shadow map ‘sees’ the scene. You can clearly see the stepping on tilted surfaces, as well as discretization errors. Note that this rendering method is *very* demanding on the GPU (and I haven’t had time to put in some optimization), so the video is somewhat choppy.

This concludes the section about cascaded shadow mapping, next time we’ll look into how to stabilize the shadows during camera movement.

Links

Leave a Reply

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