Shadow Mapping
Your lit characters look pasted onto the floor, because nothing in the scene casts a shadow. Add shadows the standard way and you get stripes across lit surfaces, shadows that float away from their objects, and blocky edges that crawl as the camera moves. grounds them with a depth test run from the light: render the scene's depth from the light's point of view, then in the main pass ask each fragment whether something sits between it and the light. Most of this page covers those artifacts and their fixes: acne, peter-panning, aliasing, and cascade seams.
- Render a shadow map from a directional or spot light and sample it with Vulkan's depth conventions.
- Explain acne and peter-panning, and pick constant, slope-scaled and normal-offset bias to balance them.
- Soften edges with PCF and a comparison sampler, and know where PCF stops being physically right.
- Set up cascaded shadow maps without seams or crawling edges.
- Choose between cube maps, PCSS, variance maps and ray-traced shadows for the cases a basic map can't handle.
01The two-pass idea
A point on the floor is in shadow if something sits between it and the light, and the GPU has no direct way to ask that. It can, however, render depth from any viewpoint. Shadow mapping (Williams, 1978) is a two-pass image-space algorithm[1]. Pass 1 renders the scene's depth from the light into a depth texture, the . Pass 2 renders from the camera; for each fragment, reconstruct its position in the light's clip space and compare its light-space depth against the depth stored in the map. If the fragment is farther from the light than what the map recorded, something closer to the light blocks it, so the fragment is in shadow.
The shadow map is a finite-resolution, quantized raster of depth. Acne and aliasing come straight from that, and peter-panning comes from the bias used to fight acne. The comparison happens in the light's clip space: both the stored value and the test value are light-space depth under the same projection. The map holds depth rather than a shadow mask; the lit-or-shadowed decision is made per fragment in pass 2.
Worked example
A crate sits between the sun and a patch of ground. In pass 1, the shadow-map texel that covers that patch records the crate's top, at light-space depth 0.40, because the crate is the first thing the light's ray hits. In pass 2, the ground fragment computes its own light-space depth: 0.62. Since 0.62 is farther than 0.40, something is in the way and the fragment is shadowed. A ground fragment outside the crate's footprint finds its own depth stored, 0.62 against 0.62, and stays lit.
02The light view-projection
To render depth from the sun you need a camera that sees what the sun sees, and the sun has a direction but no position. Pass 1 needs a camera at the light. A directional light uses an orthographic projection (its rays are parallel, so a box frustum), fit tightly to the view so texels aren't wasted. A spot light uses a perspective projection with the cone's FOV. The only new matrix is lightViewProjection = lightProj · lightView.
A directional light has no position, so a perspective projection has nothing to converge on; spot lights do use perspective. The light camera needs the same 0..1 depth range as the main one: glam's orthographic_rh targets 0..1 (orthographic_rh_gl is the −1..1 variant), and glm needs GLM_FORCE_DEPTH_ZERO_TO_ONE (see Going 3D). The Y-flip matters less here, because the same matrix writes the map and reads it back, but keep it if the shadow pipeline shares the main pass's frontFace: without it the winding reverses and BACK culling silently becomes front-face culling. Fitting near/far tightly also improves depth precision, which reduces acne, but refitting the bounds freely every frame causes shimmering (§7).
Worked example
A 2048 × 2048 shadow map fit to a 100 m × 100 m area gives each texel 100 ÷ 2048 = 4.9 cm of ground. Stretch the same map over 400 m and each texel covers 19.5 cm, so a fence post's shadow becomes a couple of blocky texels. That's why the ortho box is fit as tightly as the visible scene allows.
// GLM_FORCE_DEPTH_ZERO_TO_ONE defined: glm::ortho targets Vulkan's 0..1 range.
glm::mat4 lightProj = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, nearPlane, farPlane);
lightProj[1][1] *= -1.0f; // same Vulkan Y-flip as the main camera
// lightDir points from the light toward the scene; back the eye off along it from the target.
// Pick a different up vector if lightDir is (anti)parallel to +Y, or lookAt degenerates.
glm::mat4 lightView = glm::lookAt(sceneCenter - lightDir * distance, sceneCenter, glm::vec3(0,1,0));
glm::mat4 lightViewProjection = lightProj * lightView;
// glam: orthographic_rh = 0..1 depth (orthographic_rh_gl is the -1..1 GL variant)
let mut light_proj = Mat4::orthographic_rh(-10.0, 10.0, -10.0, 10.0, near_plane, far_plane);
light_proj.y_axis.y *= -1.0; // Vulkan Y-flip
// light_dir points from the light toward the scene; swap the up vector if it is (anti)parallel to +Y
let light_view = Mat4::look_at_rh(scene_center - light_dir * distance, scene_center, Vec3::Y);
let light_view_projection = light_proj * light_view;
03The depth-only pass
The shadow map only needs to know how far away the nearest surface is, so pass 1 skips shading entirely. It is a depth-only variant of the Going 3D pipeline: a depth render target, no color attachment, geometry drawn with the light's view-projection. Under dynamic rendering, that's a VkRenderingInfo with pDepthAttachment set and colorAttachmentCount = 0. The result is a depth texture you sample in pass 2.
Culling front faces (rendering the back faces into the shadow map) moves self-shadowing acne onto the unseen back of objects, a common mitigation. But it breaks on thin, single-sided, or non-watertight geometry: a single-sided floor gets culled and casts nothing, sprite-based foliage has no back, and the small depth disparity near the base of walls invites peter-panning[2]. Sascha Willems's basic Vulkan example instead uses VK_CULL_MODE_NONE and leans on hardware slope-scaled bias[7]. Back-face rendering is one option among several. (D16 depth halves the bandwidth of D32 and often suffices with a tight near/far, which is Sascha's choice; D32 for precision-sensitive cases. A 2048 × 2048 map is 16 MiB in D32 and 8 MiB in D16.)
04Sampling the map
In the main pass every fragment has to find its own spot in the shadow map and compare depths. The vertex shader outputs fragPosLightSpace = lightViewProjection · worldPos; the fragment shader does the perspective divide, remaps to texture space, samples, and compares.
OpenGL's NDC depth is −1..1, so the classic LearnOpenGL code writes projCoords = projCoords * 0.5 + 0.5 for all three components[4]. In Vulkan, depth is already 0..1, so only X and Y get the *0.5+0.5 remap to [0,1] UV, and Z is used as-is. Copy the OpenGL line wholesale and your shadows are wrong. Fragments outside the light's frustum need two guards, because they fail in two directions. Outside the map in X or Y: a clamp-to-border sampler with an opaque-white border (depth 1.0) makes them compare as lit. Beyond the far plane, with projCoords.z > 1.0: no border helps, since the reference depth exceeds anything stored (and is clamped to 1.0 on UNORM formats), so the code below early-outs as lit. The compare direction depends on your depth convention; with standard 0..1 depth, greater is farther, so in shadow.
Worked example
A fragment lands at light clip position (0.3, −0.6, 0.45) with w = 1, so its NDC is the same. The UV is (0.3 × 0.5 + 0.5, −0.6 × 0.5 + 0.5) = (0.65, 0.2), and the depth to compare is 0.45 as it is. The copied OpenGL line would also remap z to 0.45 × 0.5 + 0.5 = 0.725, so the fragment would test as much farther from the light than it is, and most of the scene would fall into shadow.
layout(set = 1, binding = 0) uniform sampler2DShadow shadowMap; // comparison sampler
float shadowFactor(vec4 fragPosLightSpace, vec3 N, vec3 L) {
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; // perspective divide
projCoords.xy = projCoords.xy * 0.5 + 0.5; // remap XY only. Vulkan Z is ALREADY 0..1.
if (projCoords.z > 1.0) return 0.0; // beyond the light's far plane -> lit (XY outside: border color)
float bias = max(0.0015 * (1.0 - dot(N, L)), 0.0005); // grows as the light grazes the surface
float lit = 0.0;
vec2 texel = 1.0 / vec2(textureSize(shadowMap, 0));
for (int x = -1; x <= 1; ++x) // 3x3 PCF over the comparison sampler
for (int y = -1; y <= 1; ++y)
lit += texture(shadowMap, vec3(projCoords.xy + vec2(x, y) * texel, projCoords.z - bias));
return 1.0 - lit / 9.0; // fraction in shadow
}
05The bias problem
Turn on shadows and lit surfaces facing the light break into dark stripes: each surface is shadowing itself. Shadow acne is depth self-intersection from the map's finite resolution plus depth quantization, not "a bias bug." One shadow-map texel covers a finite slanted area of the receiver but stores a single depth, so part of that area falls behind the stored depth (self-shadowed) and part in front. Microsoft's phrasing: it occurs because "the shadow map quantizes the depth over an entire texel"[2]. It's worse at grazing light angles.
pushes the comparison depth off the surface to escape self-shadowing. Too much and the shadow detaches and floats: peter-panning[2]. The right bias is scene-, angle-, and resolution-dependent. Two mitigations that reduce the tradeoff: slope-scaled depth bias (more bias for polygons edge-on to the light; hardware vkCmdSetDepthBias(constant, clamp, slope) with depthBiasEnable), and normal-offset bias (offset the receiver position along its world normal before projecting, scaled by 1 − N·L and texel size), which moves the sample sideways in the map and so peter-pans much less[9]. The values you see (Sascha's 1.25/1.75, LearnOpenGL's 0.005) are scene-specific examples, not constants.
On flat ground lit at elevation θ, a texel's footprint slopes by half a texel's width times cot θ in depth, so the bias has to cover that:
In words: the bias has to be at least half a texel's worth of the ground's slope as the light sees it. A light straight overhead needs almost none; as the light drops toward the horizon, cot θ grows without limit, and so does the bias. Hover any term to see what it stands for.
Worked example
With 5 cm texels, a light 60° above the ground needs a bias of at least cot 60° × 0.05 ÷ 2 = 1.4 cm. At 30° it needs 4.3 cm, and at a sunset-like 10° it needs 14 cm, enough to lift the shadow of a small object visibly off the ground. That's why one constant bias can't serve every light angle, and why slope-scaled bias grows with the angle automatically.
The widget is a side view with a real one-dimensional shadow map:
06PCF
Real shadows have soft edges, and a shadow map's edge is a staircase of texels. A binary shadow test gives hard, stair-stepped edges. Percentage-closer filtering samples a neighborhood and performs the depth comparison at each tap, then averages the pass/fail results into a soft gradient[1]. The key subtlety: you filter the comparison results, not the depths. Averaging raw depths and comparing once just moves the hard edge[3].
A hardware comparison sampler (sampler2DShadow + VkSamplerCreateInfo.compareEnable = VK_TRUE, compareOp = VK_COMPARE_OP_LESS_OR_EQUAL) compares before filtering, so with a LINEAR filter one texture() fetch returns a 2×2 bilinear blend of compare results[6]; bigger kernels still need a loop of taps. Keep the sampler's compareEnable in step with the shader's Dref lookup: the spec notes that some implementations follow the shader when the two disagree, so a mismatch can work on one GPU and break on another. And PCF is not physically soft: a fixed kernel gives a constant-width penumbra regardless of how far the occluder is from the receiver. Distance-varying softness is PCSS (§10), which sizes the kernel from a blocker search.
Worked example
A 3×3 PCF fragment where 6 of the 9 taps pass is 6 ÷ 9 = 67% lit. Over a 1080p frame that's 2,073,600 × 9 = 18.7 million depth compares per light; 5×5 is 51.8 million. For comparison, a real penumbra comes from the light's size: the sun's angular radius is about 0.27°, so the shadow of a pole's tip 10 m above the ground has a soft edge about 2 × 10 × tan 0.27° = 9 cm wide, while at the pole's base, with no gap, the edge is sharp.
A plank floats above the ground under an overhead light. The left panel shadows it through a 32×32 shadow map; the right panel integrates a real area light:
07Cascaded shadow maps
The player looks across a valley: shadows at their feet need centimeter detail, and shadows on the far hills need to exist at all. One shadow map can't cover a whole open world at usable resolution. split the camera's view frustum into depth slices (cascades), each rendered into its own tightly-fit ortho map; near cascades cover a small volume at high texel density, far ones cover more at lower density[3]. It's the standard directional-light fix for perspective aliasing. The split is usually a blend of logarithmic and uniform (the practical/PSSM scheme)[8].
Seams: adjacent cascades have different resolutions, so a visible discontinuity appears at split boundaries. Blending across a band where both cascades are sampled hides it[3]. Crawling/swimming: recomputing each cascade's projection every frame as the camera moves shifts the texel grid by fractions of a texel, so shadow edges shimmer. Snapping the ortho bounds to texel-sized increments fixes it, which needs a constant-size projection per cascade, so the fit is padded rather than exact[2]. Bias must also scale per cascade (different texel sizes), and the cascade count is scene-dependent (3 to 4 is common). Cascade selection is usually by view-space depth; map-based selection instead picks the first cascade whose map contains the fragment[3].
Worked example
Take a camera with a 100 m far plane split at 10, 25 and 50 m, with each cascade's square map sized to its slice: 10, 20, 40 and 80 m across (the widget's layout). With 2048 × 2048 maps, the texels are 4.9 mm, 9.8 mm, 2 cm and 3.9 cm. A single 2048 map covering the whole 100 m frustum would give 4.9 cm texels everywhere, including at the player's feet: ten times coarser than the nearest cascade.
A top-down view with the light overhead: the camera slides back and forth, its frustum split into four cascades, each a 24×24-texel map:
08Omnidirectional shadows
A torch in a cave throws shadows in every direction at once, and one camera can't see all of them. A point light shines in all directions, so a single 2D map can't cover it. The usual answer is a depth cube map (six 90°-FOV faces) sampled with the fragment-to-light direction. Many implementations store linear distance in it instead of projected depth: write length(fragPos − lightPos) / farPlane in pass 1 and compare against length(fragToLight) / farPlane in pass 2, one scalar that means the same thing on every face, with no per-face projection to reconstruct[5].
A cube shadow renders the scene up to six times per light (faces with no casters in view can be skipped), one reason engines cap the number of shadow-casting point lights. The single-pass geometry-shader variant (emit to all six layers) isn't automatically faster: the GS overhead can offset the saved draws[5]. PCF on a cube uses 3D offset vectors around the sample direction. Dual-paraboloid and tetrahedral maps are alternatives to the cube. At 1024 × 1024 per face in D32, one cube map is 6 × 4 MiB = 24 MiB per light.
09Aliasing & the tradeoff
Even with a correct bias, shadows near the camera look blocky and shadows on steep slopes look smeared. Two named aliasing types cause this[2]: perspective aliasing (near the camera each screen pixel covers little of the world, so many pixels map to one shadow texel and near shadows go blocky; the reason for CSM), and projective aliasing (surfaces nearly parallel to the light rays stretch few texels over many pixels).
A fixed-resolution map either covers a large area coarsely (aliased near shadows) or a small area finely (no distant shadows). One map can't do both, which is the main justification for CSM and tight frustum fitting. Higher resolution isn't a free win either. Once shadow texels are much smaller than screen pixels, each pixel's lookup lands on one texel out of several, so the one-texel-wide shadow of a telephone wire can be skipped entirely; the scattered texture accesses also cost performance[2]. Perspective and projective aliasing have distinct causes and need separate treatment.
Worked example
With a 60° vertical field of view, a 1080-pixel-tall view of ground 2 m away spans 2 × 2 × tan 30° = 2.3 m, so each pixel covers about 2.1 mm. A shadow map with 4.9 cm texels then stretches one texel across about 23 pixels: a visible staircase right at the player's feet. That's perspective aliasing, and it's what the near cascade's finer texels fix.
10Beyond
A basic shadow map with PCF can't give a shadow that is sharp at the contact point and soft far from it, or filter the map like a texture. Three directions past basic PCF shadow maps, as pointers:
- PCSS / contact-hardening: a blocker search sizes the PCF kernel by occluder distance, giving the distance-varying penumbra fixed-kernel PCF can't[10].
- Variance / moment shadow maps: store depth and depth² (or more moments), filter the map like a normal texture (mips, blur, MSAA), estimate occlusion via Chebyshev's inequality. The drawback is light bleeding[11].
- Ray-traced shadows: trace shadow rays instead of sampling a map. There's no texel grid, so no shadow-map acne, aliasing, or peter-panning (rays still need a small origin offset), and several rays per pixel plus a denoiser give physically sized area-light penumbrae. Engines often mix them with shadow maps for performance.
Wrong answers, and why: the porting bug is the Vulkan 0..1 Z remap (not the attachment type or sampler support); and shimmer is quantization acne fixed by bias as a tradeoff (lower resolution worsens it; it isn't a missing clear).
11Pitfalls
12What's next
The scene has lit, shadowed surfaces. To light many of them efficiently, the renderer needs a different structure: the next module is Deferred Rendering, the G-buffer and the lighting pass that decouple shading cost from object count, then global illumination and post-processing. The full 3D path is on the series hub.
- Tomas Akenine-Möller, Eric Haines, Naty Hoffman, et al. Real-Time Rendering, 4th ed., ch. 7 (Shadows). realtimerendering.com. The authoritative survey: shadow maps, PCF, PCSS, filtered shadow maps.
- Microsoft. "Common Techniques to Improve Shadow Depth Maps." learn.microsoft.com. Acne, peter-panning, slope-scaled bias, perspective/projective aliasing, too-high resolution missing telephone wires, the front-face-culling caveats, tight frustum fitting, and moving the light in texel-sized increments.
- Microsoft. "Cascaded Shadow Maps." learn.microsoft.com. Cascade selection, the seam and the blend band, hardware PCF, and the filter-the-results point.
- Joey de Vries. LearnOpenGL, "Shadow Mapping." learnopengl.com. The two-pass walkthrough, the slope-bias formula, and the OpenGL
*0.5+0.5remap (the Vulkan delta to watch). - Joey de Vries. LearnOpenGL, "Point Shadows." learnopengl.com. Cube-map depth, the linear-distance trick, and the geometry-shader-versus-six-passes cost.
- The Khronos Group. Vulkan Specification, Samplers. docs.vulkan.org.
compareEnable/compareOp, the compare-before-filter rule, and the note that some implementations follow the shader whencompareEnabledoesn't match it. - Sascha Willems. Vulkan shadowmapping examples. github.com/SaschaWillems. The depth-only pass with culling disabled,
vkCmdSetDepthBias(1.25/1.75), D16, and (in the cascaded example) the lambda split. - Fan Zhang et al. "Parallel-Split Shadow Maps on Programmable GPUs." GPU Gems 3, ch. 10. developer.nvidia.com. The practical split as a blend of logarithmic and uniform.
- Daniel Holbert. "Saying Goodbye to Shadow Acne" (Normal Offset Shadows). GDC 2011. summary. Normal-offset bias: offset along the world normal to reduce acne with far less peter-panning.
- Randima Fernando. "Percentage-Closer Soft Shadows." NVIDIA, SIGGRAPH 2005. nvidia.com. Blocker search to size the PCF kernel for contact-hardening soft shadows.
- William Donnelly and Andrew Lauritzen. "Variance Shadow Maps." I3D 2006. dl.acm.org. Store depth and depth², filter the map, estimate occlusion via Chebyshev; the light-bleeding drawback.