Shadow Mapping
Lit surfaces float without shadows. grounds them with a beautifully simple idea: it's 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 "is something between me and the light?". The idea is five minutes; the artifacts (acne, peter-panning, aliasing, cascade seams) are the other fifty, and they're where this module lives.
01The two-pass idea
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 was already there, so the fragment is in shadow.
The shadow map is a finite-resolution, quantized raster of depth, so every artifact in this module (acne, aliasing, peter-panning) traces back to that. The comparison happens in the light's clip space, not world space: both the stored value and the test value are light-space depth under the same projection. And the map stores depth from the light, not a shadow mask, the mask is computed per fragment in pass 2.
02The light view-projection
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, using perspective for it is wrong (spot lights do use perspective, don't overcorrect). And the light camera needs the same Vulkan deltas as the main one: glam's orthographic_rh targets 0..1 depth (orthographic_rh_gl is the −1..1 variant), and you flip proj[1][1] the same way (cross-ref Going 3D). Fitting the near/far tightly also improves depth precision, which reduces acne, but fitting too aggressively per frame causes shimmering (§7).
// 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
glm::mat4 lightView = glm::lookAt(-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
let light_view = Mat4::look_at_rh(-light_dir * distance, scene_center, Vec3::Y);
let light_view_projection = light_proj * light_view;
03The depth-only pass
Pass 1 reuses the depth-only pipeline from Going 3D: 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 floor plane culled away casts no shadow, and foliage and sprites have no back[2]. Sascha Willems' basic Vulkan example instead uses VK_CULL_MODE_NONE and leans on slope-scaled bias[7]. There are several valid choices; don't present back-face rendering as the answer. (D16 depth halves bandwidth and often suffices with a tight near/far; D32 for precision-sensitive cases.)
04Sampling the map
In the main pass: 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. Also handle out-of-frustum fragments with either mechanism, not both: a clamp-to-border sampler with a white (depth 1.0 = lit) border, or an early-out that treats projCoords.z > 1.0 as lit (the code below uses the early-out). The compare direction depends on your depth convention; with standard 0..1 depth, "greater is farther, so in 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; // outside the light frustum -> lit
float bias = max(0.0015 * (1.0 - dot(N, L)), 0.0005); // slope-scaled in-shader bias
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
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.
Sweep the bias from acne through correct to peter-panning, with the light orbiting so the acne shimmers:
06PCF
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 then comparing once just moves the hard edge[3].
A hardware comparison sampler (sampler2DShadow + VkSamplerCreateInfo.compareEnable = VK_TRUE, compareOp = VK_COMPARE_OP_LESS_OR_EQUAL) does the compare and a 2×2 bilinear blend in one texture() fetch[6], bigger kernels still need a loop of taps. The sampler's compareEnable must match the shader's Dref op or it's invalid. 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.
Compare a hard edge to 3×3 and 5×5 PCF; drag the occluder height to see the penumbra width not change:
07Cascaded shadow maps
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, fix by blending across a band where both cascades are tested[3]. Crawling/swimming: recomputing each cascade's projection every frame as the camera moves makes edges shimmer, fix by snapping the ortho bounds to texel-sized increments (which needs a constant-size projection per cascade, so you pad rather than fit perfectly). Also: bias must scale per cascade (different texel sizes), and the cascade count is scene-dependent (3 to 4 is common, not universal). Selection is by view-space depth.
The view frustum split into colored cascades; move the camera to see near cascades tighten, toggle the seam blend and the texel-snap stabilization:
08Omnidirectional shadows
A point light shines in all directions, so a single 2D map can't cover it. The options: a depth cube map (six 90°-FOV faces) sampled with the fragment-to-light direction, or linear distance stored per texel. The linear trick (write length(fragPos − lightPos) / farPlane in pass 1, compare against length(fragToLight) in pass 2) sidesteps the cube's awkward depth distribution[5].
A cube shadow renders the scene six times per light, which is why engines limit 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.
09Aliasing & the tradeoff
Two named aliasing types[2]: perspective aliasing (near-camera pixels are denser than far ones, so many screen pixels map to one shadow texel near the viewer, blocky near shadows, 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), you can't win both with one map, which is the entire justification for CSM and tight frustum fitting. And higher resolution isn't a free win: too high and thin objects (wires, fences) skip texels and stop casting shadows, plus the texture-access cost rises[2]. Perspective and projective aliasing are distinct causes; don't merge them.
10Beyond
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, no acne, aliasing, or peter-panning, and true area-light penumbra, usually hybrid 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. The canonical reference for acne, peter-panning, slope-scaled bias, perspective/projective aliasing, and tight frustum fitting.
- 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 theDref-must-match requirement. - Sascha Willems. Vulkan shadowmapping examples. github.com/SaschaWillems. The depth-only pass,
vkCmdSetDepthBias(1.25/1.75), D16, and the cascade 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.