Point Shadows
The shadow map of the previous chapter looks in one direction. That suits a sun, whose light all travels the same way, and a spotlight, which only lights a cone. It does not suit a point light, a bare bulb hanging in a room, which shines in every direction and so casts shadows in every direction too. One map in one direction cannot cover that, which is why the previous technique is sometimes called
This chapter extends it to all directions at once. The technique goes by the name of
What changes is the shadow map itself. To store depth in every direction around a point we need something that is indexed by direction, and we already have one: the cubemap. Six square faces, one along each axis, together cover every direction from the centre. Render the scene's depth into each face from the light's position, and the cubemap records, for any direction, how far the light can see before it hits something.
In the second pass, each pixel looks up the cubemap in the direction from the light to itself and compares what it finds with its own distance from the light. Most of that is already familiar. The new work is in filling the depth cubemap.
Generating the depth cubemap
The obvious way is to draw the scene six times, once per face, each time with that face as the depth target and the light looking along that face's axis. It works, but it is six passes over all the geometry, and six sets of draw calls. A geometry shader lets us do it in one: draw the scene once, and have the geometry shader send a copy of every triangle to each of the six faces.
First the texture. A cubemap in Direct3D is a 2D texture array of six slices with the D3D11_RESOURCE_MISC_TEXTURECUBE flag, as in Cubemaps. This one holds depth, so, as with the 2D shadow map, its format is DXGI_FORMAT_R32_TYPELESS, and the views give it meaning. It is made by the same procedure in shadow.asm as the 2D map,
; rcx: &shadow_map, rdx: size of each face in texels
align 16
create_depth_cube proc out_ref:qword, map_size:qword
invoke create_shadow_texture, out_ref, map_size, 1
ret
create_depth_cube endp
mov desc.BindFlags, D3D11_BIND_DEPTH_STENCIL or D3D11_BIND_SHADER_RESOURCE
cmp cube, 0
je flat_texture
mov desc.ArraySize, 6
mov desc.MiscFlags, D3D11_RESOURCE_MISC_TEXTURECUBE
flat_texture:
vcall device, ID3D11Device_CreateTexture2D, ADDR desc, 0, ADDR texture
AssertHr
; drawn into as depth: one view of all six faces for a cube, which the geometry shader picks
; between with SV_RenderTargetArrayIndex
invoke mem_zero, ADDR dsv, sizeof D3D11_DEPTH_STENCIL_VIEW_DESC
mov dsv.Format, DXGI_FORMAT_D32_FLOAT
mov dsv.ViewDimension, D3D11_DSV_DIMENSION_TEXTURE2D
cmp cube, 0
je flat_dsv
mov dsv.ViewDimension, D3D11_DSV_DIMENSION_TEXTURE2DARRAY
mov dsv.Texture2DArray.ArraySize, 6
flat_dsv:
; ...
; read as one float a texel
invoke mem_zero, ADDR srv, sizeof D3D11_SHADER_RESOURCE_VIEW_DESC
mov srv.Format, DXGI_FORMAT_R32_FLOAT
mov srv.ViewDimension, D3D11_SRV_DIMENSION_TEXTURE2D
mov srv.Texture2D.MipLevels, 1
cmp cube, 0
je flat_srv
mov srv.ViewDimension, D3D11_SRV_DIMENSION_TEXTURECUBE
mov srv.TextureCube.MipLevels, 1
flat_srv:
The view descriptions end in a union, a different struct for each ViewDimension, and d3d11.inc spells out each member at its offset, so dsv.Texture2DArray.ArraySize is the field C would reach the same way. (For the cube's shader view, TextureCube.MipLevels sits where Texture2D.MipLevels does, so the second store only repeats the first; it is written for the reader.) The result is the same shadow_map struct as before, a depth view and a shader view, and the lesson makes one at startup:
invoke create_depth_cube, ADDR depth_map, shadow_size
The two views see the same six slices in two different shapes, and that is the heart of the method.
- The depth-stencil view is a
texture array view covering all six slices. Bound as the depth target, it lets a single draw write into any of the slices: each primitive says which one, as we will see. - The shader resource view is a cube view. Read through it, the six slices become one texture sampled by direction, and the hardware picks the face and the texel for us.
OpenGL does the first with
; 1. render scene to depth cubemap: all six faces in one pass
; -----------------------------------------------------------
call unbind_textures
vcall context, ID3D11DeviceContext_OMSetRenderTargets, 0, 0, depth_map.dsv
movf xmm3, 1.0 ; the depth, a float: xmm3
vcall context, ID3D11DeviceContext_ClearDepthStencilView, depth_map.dsv, D3D11_CLEAR_DEPTH, 0, 0
invoke set_viewport, shadow_size, shadow_size
One clear of the array view clears all six slices.
Light space transform
Each face needs its own light-space transform, a view and a projection. The projection is a perspective one this time, since the light is a point and its rays spread out from it, and the same projection serves all six faces. Its field of view is exactly 90 degrees, and its aspect ratio is 1: a face of a cube, seen from the cube's centre, spans 90 degrees each way, so this makes the six frusta meet edge to edge with no gaps and no overlaps.
The views look from the light along each axis, in the cubemap's slice order: +x, -x, +y, -y, +z, -z. Here are all six, built every frame because the light moves:
; rcx: out (six mat4), rdx: center (vec3), xmm2: z_near, xmm3: z_far
;
; The view-projection of each cubemap face, seen from `center`, in the order +x, -x, +y, -y, +z, -z.
; A cubemap's faces are laid out left-handed, which a right-handed look-at cannot produce. GL's
; bottom-up framebuffer flips every rendered image and so fixes that for free; D3D's is top-down,
; so the face projection flips y instead. That flip reverses the winding, so culling stays off.
align 16
cube_face_matrices proc out_ref:qword, center:qword, z_near:qword, z_far:qword
local projection:mat4, flip:mat4, face_view:mat4, target:vec3
USING rbx, rsi, rdi
SaveRegs
movss real4 ptr z_near, xmm2
movss real4 ptr z_far, xmm3
; projection = scale(1, -1, 1) * perspective(90 degrees, 1, near, far)
movss xmm0, real4 ptr z_far
movss real4 ptr [rsp + 32], xmm0
movf xmm0, 90.0
call radians
movaps xmm1, xmm0
movss xmm2, math_one
movss xmm3, real4 ptr z_near
lea rcx, projection
call m4_perspective
invoke m4_identity, ADDR flip
mov dword ptr flip.m[20], 0BF800000h ; -1
invoke m4_mul, ADDR projection, ADDR flip, ADDR projection
mov rdi, out_ref
lea rsi, cube_face_directions
xor ebx, ebx
faces:
; look from the center along the face's direction, with the face's up
mov rdx, center
lea rcx, target
lea r8, [rsi]
call v3_add
lea r9, [rsi + 12]
invoke m4_look_at, ADDR face_view, center, ADDR target, r9
invoke m4_mul, rdi, ADDR projection, ADDR face_view
add rdi, sizeof mat4
add rsi, 24
inc ebx
cmp ebx, 6
jb faces
RestoreRegs
ret
cube_face_matrices endp
.const
; for each face: the direction it looks in, then its up
align 4
cube_face_directions real4 1.0, 0.0, 0.0, 0.0, -1.0, 0.0
real4 -1.0, 0.0, 0.0, 0.0, -1.0, 0.0
real4 0.0, 1.0, 0.0, 0.0, 0.0, 1.0
real4 0.0, -1.0, 0.0, 0.0, 0.0, -1.0
real4 0.0, 0.0, 1.0, 0.0, -1.0, 0.0
real4 0.0, 0.0, -1.0, 0.0, -1.0, 0.0
xmm2 and xmm3, the registers of the third and fourth arguments. The procedure's first act is to store them into their own parameters' home slots: z_near and z_far are declared qword because every argument slot on the stack is eight bytes wide, and real4 ptr says only the low four bytes are written. That frees the registers for the calls that follow. [rsp + 32]. The flip is an identity matrix whose second diagonal element, at byte 20 (row 1, column 1), is overwritten with the bits of -1.0, 0BF800000h.
The directions and up vectors are LearnOpenGL's, kept as a table of six pairs of vec3, 24 bytes a face, which rsi steps through. The up vectors look odd, mostly pointing down, because they follow the cubemap's own convention for how each face's image is oriented, a convention inherited from RenderMan that both APIs share. The odd one out is the projection, which is flipped in y by the flip matrix in front of it, and that flip is specific to Direct3D.
The reason is that a cubemap's faces are laid out left-handed: for each face, the direction in which u grows and the direction in which v grows, together with the face's axis, make a left-handed frame. A right-handed look-at matrix can never produce such a frame; whatever up vector we choose, it can get either the face's right or its up direction correct, never both. OpenGL's framebuffer happens to rescue it. GL stores rendered images bottom row first, which flips every image vertically relative to the texture's rows, and for cubemap faces that flip is exactly the correction needed. Direct3D stores rendered images top row first, as textures are read, so the rescue does not happen and we have to flip ourselves. Negating y in the projection mirrors each face's image vertically, after which right and up both match the face's layout.
A mirror image also reverses the order in which a triangle's corners appear on screen, so every triangle that was counter-clockwise becomes clockwise. If culling were on, the shadow pass would cull the wrong faces. These lessons draw with culling off, so it does not matter here, but it is worth remembering whenever you render into cube faces.
The six matrices go into a constant buffer, along with the light's position and the far plane that the depth shaders need:
cbuffer shadow : register(b1)
{
float4x4 faces[6];
float3 light_position;
float far_plane;
};
; Mirror `cbuffer shadow` and `cbuffer surface` in point_shadows.hlsl.
shadow_constants struct
faces mat4 6 dup(<>) ; view-projection of each cube face
light_position vec3 <>
far_plane real4 ?
shadow_constants ends
; move light position over time: (0, 0, 3 sin(t / 2))
movss xmm0, current_frame
mulss xmm0, math_half
call sinf
movf xmm1, 3.0
mulss xmm0, xmm1
mov dword ptr light_position.x, 0
mov dword ptr light_position.y, 0
movss light_position.z, xmm0
lea rcx, sh.faces
lea rdx, light_position
movf xmm2, 0.1
movf xmm3, 25.0
call cube_face_matrices
v3load xmm0, light_position
v3store sh.light_position, xmm0
invoke update_buffer, shadow_buffer, ADDR sh, sizeof shadow_constants
The light drifts back and forth along z, three units each way, so the shadows move around the room. The near plane is 0.1 and the far plane 25, comfortably beyond the room's farthest corner; the far plane never changes, so it is stored into sh once, before the loop, with storef sh.far_plane, 25.0. A float of 0.0 is all zero bits, so the integer mov of 0 sets x and y. Notice that the buffer fits the cbuffer's layout with no padding: the float3 and the float share the last register. Both passes read it, in register b1.
Depth shaders
The depth pass now has three stages. The vertex shader only takes each vertex to world space, since which light-space transform to apply depends on the face, and that is the geometry shader's decision:
float4 vs_main(float3 position : POSITION) : WORLD
{
return mul(model, float4(position, 1.0));
}
Its output is not marked SV_Position. A vertex shader feeding a geometry shader can output whatever it likes; only the last stage before the rasterizer has to provide SV_Position. Here that is the geometry shader:
struct face_vertex
{
float4 position : SV_Position;
float3 world : WORLD;
uint face : SV_RenderTargetArrayIndex; // which slice of the cubemap
};
// every triangle, six times: once into each face, through that face's view
[maxvertexcount(18)]
void gs_main(triangle float4 world[3] : WORLD, inout TriangleStream<face_vertex> output)
{
for (uint face = 0; face < 6; face++)
{
for (int i = 0; i < 3; i++)
{
face_vertex v;
v.world = world[i].xyz;
v.position = mul(faces[face], world[i]);
v.face = face;
output.Append(v);
}
output.RestartStrip();
}
}
For each incoming triangle it emits six, one per face, so the most it ever writes is 18 vertices, which [maxvertexcount(18)] declares. Each copy's vertices go through that face's matrix into its clip space, and carry the face's number in a uint marked SV_RenderTargetArrayIndex. That system value, GLSL's gl_Layer, picks which slice of the bound array view the triangle is rasterized into. It only has an effect when the bound view is an array, which is why the depth view was made as one.
A copy that falls outside its face's frustum costs little: it is clipped away before rasterization. Most triangles land in one or two faces, and the other copies vanish.
Last, the pixel shader. In the previous chapter the pixel shader was empty and the hardware wrote the projected depth. Here we write our own depth: the straight-line distance from the light, divided by the far plane so that it fits the 0 to 1 a depth buffer stores.
// distance to the light, 0..1 over the far plane, written as the depth
float ps_main(face_vertex p) : SV_Depth
{
return length(p.world - light_position) / far_plane;
}
Returning a value marked SV_Depth replaces the depth the rasterizer computed, as assigning gl_FragDepth does in GLSL. The depth test then compares these distances, so the nearest surface in each direction still wins. Why bother? Each face's projected depth is measured along that face's own axis, so the same point would get different values in different faces, and comparing them would need to know which face a lookup came from. A distance from the light means the same thing in every face, so the second pass can compare it without caring which face the hardware picked. It is also linear in distance, which makes the bias easier to reason about.
Then the scene is drawn once, room and cubes, and all six faces fill at once:
invoke shader_use, ADDR depth_shader
invoke draw_vertices, transforms_buffer, ADDR t, cube_vb, 36, ADDR room
call draw_cubes
gs_main, and for shaders without one it binds the 0 that
vcall context, ID3D11DeviceContext_GSSetShader, qword ptr [rbx].shader.geometry, 0, 0
Omnidirectional shadow maps
The scene is a room, a large cube scaled by 10, with five wooden cubes scattered around the light. We see the room from inside, so its faces' normals point the wrong way, outward. The lighting shader multiplies normals by a constant from a third small buffer before using them:
o.normal = mul((float3x3)normal_matrix, v.normal * flip_normals);
surface_constants struct
flip_normals real4 ? ; -1 inside the room
pad real4 3 dup(?)
surface_constants ends
; the room, seen from inside, then the cubes
storef surface.flip_normals, -1.0
invoke update_buffer, surface_buffer, ADDR surface, sizeof surface_constants
invoke draw_vertices, transforms_buffer, ADDR t, cube_vb, 36, ADDR room
storef surface.flip_normals, 1.0
invoke update_buffer, surface_buffer, ADDR surface, sizeof surface_constants
call draw_cubes
The room's faces are also seen from their back sides, which is fine since culling is off. The lighting itself is the Blinn-Phong of the previous chapter, now with a point light, so the direction to the light is computed per pixel:
float4 ps_main(vs_out p) : SV_Target
{
float3 color = albedo.Sample(linear_wrap, p.uv).rgb;
float3 n = normalize(p.normal);
float3 to_light = normalize(light_position - p.world);
float3 to_eye = normalize(view_position - p.world);
float3 halfway = normalize(to_light + to_eye);
float diffuse = max(dot(n, to_light), 0.0);
float spec = pow(max(dot(n, halfway), 0.0), 64.0);
float lit = lit_fraction(p.world);
return float4((0.15 + lit * (diffuse + 0.3 * spec)) * color, 1.0);
}
The shadow map is declared as a cube, and the comparison sampler comes back:
Texture2D albedo : register(t0);
TextureCube<float> shadow_map : register(t1);
SamplerState linear_wrap : register(s0);
SamplerComparisonState shadow_compare : register(s1);
The sampler is the very one from the previous chapter, made by s1 next to the shadow map:
invoke bind_texture, 1, depth_map.srv
vcall context, ID3D11DeviceContext_PSSetSamplers, 1, 1, ADDR compare_sampler
Its border address mode makes no difference here. A cube lookup by direction never falls off the edge of the map, since every direction lands on some face, and filtering near a face's edge reads across into the neighbouring face, so there is no outside for the border colour to fill.
Looking up the shadow is simpler than with a 2D map, because no projection is needed. The vector from the light to the pixel is the lookup direction. It need not be normalized; a cube lookup only cares about direction. Its length, divided by the far plane, is the pixel's own distance in the same units the map stores. So the basic test, before any softening, is:
float3 from_light = world - light_position;
float distance = length(from_light) / far_plane;
lit = shadow_map.SampleCmpLevelZero(shadow_compare, from_light, distance - bias);
TextureCube works just like on a Texture2D, taking a direction instead of a uv: the hardware picks the face, compares the reference with the texels around the lookup, and returns the filtered fraction that passed. LearnOpenGL compares in world units, multiplying the stored value back by the far plane; we compare in the stored units directly, which saves the multiplication, and scales the bias by the same factor: our 0.002 times the far plane of 25 is a twentieth of a unit, the 0.05 of LearnOpenGL's first version.
The previous chapter's rasterizer depth bias is not used here. It works on the depth the rasterizer interpolates, and our pixel shader replaces that depth with its own, so the bias is applied in the shader instead.
Visualizing the cubemap depth
When point shadows go wrong, the first thing to check is whether the cubemap holds what it should. A simple test is to sample it without comparing and show the stored distance as grey. A TextureCube<float> can be read with an ordinary sampler as well as a comparison one, so in the pixel shader:
return float4(shadow_map.Sample(linear_wrap, p.world - light_position).xxx, 1.0);
The room then appears in shades of grey, darker near the light, with the cubes' shadows visible as darker shapes on the walls behind them: the map records the cube in that direction, not the wall. If the shapes on the walls are misplaced or mirrored compared with the cubes that cast them, the face matrices are wrong, which is exactly what the y flip above is for. The lesson does not have this mode built in; it makes a good first exercise.
PCF
A single hardware comparison already softens edges by about a texel, as before. For wider soft edges we take several lookups around the pixel and average them, but in three dimensions now, since the lookup is a direction. LearnOpenGL first tries a 4x4x4 grid of offsets, 64 lookups per pixel, and then points out that most of them are wasted: offsets along the lookup direction change nothing, and many of the rest are nearly the same as their neighbours. Better to take fewer offsets that point in clearly different directions. Twenty do well:
// directions to spread the shadow samples along
static const float3 disc[20] =
{
float3(1, 1, 1), float3(1, -1, 1), float3(-1, -1, 1), float3(-1, 1, 1),
float3(1, 1, -1), float3(1, -1, -1), float3(-1, -1, -1), float3(-1, 1, -1),
float3(1, 1, 0), float3(1, -1, 0), float3(-1, -1, 0), float3(-1, 1, 0),
float3(1, 0, 1), float3(-1, 0, 1), float3(1, 0, -1), float3(-1, 0, -1),
float3(0, 1, 1), float3(0, -1, 1), float3(0, -1, -1), float3(0, 1, -1),
};
These are the eight corners of a cube and the twelve midpoints of its edges, as seen from its centre. And the complete shadow function:
float lit_fraction(float3 world)
{
float3 from_light = world - light_position;
float distance = length(from_light) / far_plane;
float bias = 0.002;
// farther from the eye, a wider disc: softer where detail is lost anyway
float radius = (1.0 + length(view_position - world) / far_plane) / 25.0;
float sum = 0.0;
for (int i = 0; i < 20; i++)
{
sum += shadow_map.SampleCmpLevelZero(shadow_compare, from_light + disc[i] * radius * length(from_light), distance - bias);
}
return sum / 20.0;
}
Two details. The radius grows with the distance from the camera to the pixel, from 1/25 up close to twice that at the far plane, which is LearnOpenGL's trick: distant shadows get softer edges, where the eye would not see their detail anyway. And the offsets are scaled by the length of from_light. LearnOpenGL adds them to from_light as they are, so they are a fixed distance in world units, and the same offset bends the lookup through a large angle near the light and a tiny one far away. Scaling by the length makes the offset a fixed angle instead, so the edge covers roughly the same number of shadow map texels wherever the pixel is. The result is a soft edge that widens with distance from the light, which is also roughly what a real light of some size does.
Twenty lookups that each blend four comparisons: eighty depth tests per pixel, done mostly by the texture hardware. Here is the room, with the light, the small white cube, on the left:
Every cube casts its shadow away from the light, onto whichever wall is behind it, and the shadows on the far wall slide as the light moves forward and back. Use the camera to look around the room, up and down too: all six directions are shadowed.
A word on speed, the same one LearnOpenGL gives. Drawing all six faces through a geometry shader saves draw calls, but geometry shaders that multiply their input are not especially fast on many GPUs, and six separate passes, or instancing with one instance per face, can be quicker. Which wins depends on the scene and the hardware; if it matters, measure both. The full program is here, and its shaders here and here.
Additional resources
- Multipass shadow mapping with point lights: ogldev's version, rendering the six faces in separate passes.
- Omni-directional shadows: slides by Peter Houska on the technique and its problems.
- Semantics: Microsoft's list of HLSL system values, including SV_RenderTargetArrayIndex and SV_Depth.
- LearnOpenGL's Point Shadows: the OpenGL version of this chapter, with gl_Layer and gl_FragDepth.
Exercises
- Add a key that shows the depth cubemap as grey, as described above, using
shadow_map.Sample(linear_wrap, ...)in the pixel shader. Compare the shapes on the walls with the cubes that cast them. - Remove the flip from the face projection, the
m4_mulby flip incube_face_matrices , and run it. Where do the shadows go wrong? Look in several directions, not just at one wall. - Replace the 20-sample loop with a single lookup along from_light, and compare the edges. Then go back to 20 and add the offsets without the
length(from_light)factor, as LearnOpenGL does; how do the shadows near and far from the light change? - Render the six faces with six draws of the scene instead of a geometry shader: create six depth views, each covering one slice (FirstArraySlice = face, ArraySize = 1), and draw with a vertex shader that applies one face's matrix.