RustyVulkan GuideContents

Part 5 · Chapter 11 · ~8 min prose skim

Shadows

Cascaded shadow maps: drawing the scene from the sun four times, and using the result to decide which pixels the sun can't reach.

Overview The big picture

Shadow mapping in two steps

A point is in shadow if something sits between it and the sun. Shadow mapping answers that with two renders:

  1. From the sun. Render the scene's depth from the sun's point of view, keeping only distances, no colour. The result, the shadow map, records how far the sunlight travels before it hits something, for every direction it can come from.
  2. From the camera. While shading each pixel, work out where that point would land in the shadow map and how far it is from the sun. If the map holds a nearer distance, something blocked the light first, so the point is in shadow.

The sun is so far away that its rays are effectively parallel, so step 1 uses an orthographic projection, a box rather than a pyramid.

Why four of them

A shadow map is an image, so its detail is limited by its texels (texture pixels). One map covering 75 metres of meadow would spread its 2048 × 2048 texels thinly, and near the camera, where a texel covers a large patch of the screen, shadows would look blocky. Cascaded shadow maps fix that. The camera's view (its frustum, the truncated pyramid of space it can see) is sliced by distance into four ranges, and each gets its own shadow map, fitted tightly around just that slice. The nearest slice is small, so its map is detailed; the farthest is large and coarse, which doesn't matter at that distance.

The camera frustum split into four cascades, seen from above camera 1 2 3 4
Each cascade's square is one shadow map's coverage. The squares grow with distance, but each has the same number of texels, so detail is concentrated near the camera.

The screenshot on the cover shows them in its "Shadow Cascades" panel: four depth images, darker meaning nearer the sun. Cascade 1 covers only the ground right in front of the camera; by cascade 4 the hut is a small shape in a wide view of the valley.

Softening the edges

A single shadow-map lookup gives a hard, jagged edge. The shader instead takes six lookups in a small pattern around the point and averages the answers, a technique called percentage-closer filtering (PCF), which turns the jaggies into a slightly soft edge.

Skip to next overview →

Deeper dive

Where to split

/// Split the shadow range between linear and logarithmic spacing. The logarithmic
/// part concentrates detail near the camera without starving the distant view.
fn cascade_splits(camera: &Camera, shadow_distance: f32) -> [f32; SHADOW_CASCADE_COUNT] {
    let near = camera.get_near().max(0.001);
    let far = (near + shadow_distance.max(0.001)).min(camera.get_far());
    let lambda = 0.65;
    std::array::from_fn(|index| {
        let fraction = (index + 1) as f32 / SHADOW_CASCADE_COUNT as f32;
        let logarithmic = near * (far / near).powf(fraction);
        let linear = near + (far - near) * fraction;
        lambda * logarithmic + (1.0 - lambda) * linear
    })
}
src/graphics/shadow_math.rs:18-30

Evenly spaced splits waste the near cascades on too much distance; purely logarithmic ones (each slice a fixed multiple of the last) squeeze the first slice to almost nothing. This blends the two, 65% logarithmic, over the range from the camera's near plane to the shadow distance (75 m by default, set in EngineSettings). With the default 0.5 m near plane, the slices end at about 7.9, 17.3, 33.8 and 75.5 m; a purely logarithmic split would end the first at 1.75 m. Beyond the last slice, nothing is shadowed.

pub fn compute_shadow_cascades(
    camera: &Camera,
    sun_dir: [f32; 3],
    shadow_res: u32,
    shadow_distance: f32,
) -> [ShadowCascade; SHADOW_CASCADE_COUNT] {
    let splits = cascade_splits(camera, shadow_distance);
    let mut slice_near = camera.get_near();
    std::array::from_fn(|index| {
        let slice_far = splits[index];
        let cascade = fit_frustum_slice(camera, sun_dir, shadow_res, slice_near, slice_far);
        slice_near = slice_far;
        cascade
    })
}
src/graphics/shadow_math.rs:33-47

Fitting a light box around one slice

fit_frustum_slice starts by finding the eight corners of the slice in world space, using the camera's position and axes read from the inverse of the view matrix. It then wraps them in a sphere:

let mut center = Vector3::new(0.0, 0.0, 0.0);
for corner in &corners_world {
    center += corner.to_vec();
}
let center = Point3::from_vec(center / corners_world.len() as f32);
let radius = corners_world
    .iter()
    .map(|corner| (corner - center).magnitude())
    .fold(0.0_f32, f32::max);

let mut direction = Vector3::new(sun_dir[0], sun_dir[1], sun_dir[2]);
direction /= direction.magnitude().max(1e-6);
let light_up = if direction.y.abs() > 0.99 {
    Vector3::unit_z()
} else {
    Vector3::unit_y()
};
// Orientation only: a light view that followed the slice would move the texel grid
// with the camera, defeating the snapping below.
let light_view = Matrix4::look_at_rh(Point3::origin(), Point3::from_vec(direction), light_up);
src/graphics/shadow_math.rs:75-94

The sphere's radius sets how far the box reaches towards the sun, below. The light view itself deliberately depends only on the sun's direction: it sits at the world origin looking along the sunlight, and never moves with the camera.

// A square projection changes less as the camera rotates than a tight rectangle.
let half_extent =
    ((max_light.x - min_light.x).max(max_light.y - min_light.y) * 0.51).max(0.001);
let units_per_texel = (half_extent * 2.0 / shadow_res.max(1) as f32).max(1e-6);
let center_x = ((min_light.x + max_light.x) * 0.5 / units_per_texel).floor() * units_per_texel;
let center_y = ((min_light.y + max_light.y) * 0.5 / units_per_texel).floor() * units_per_texel;
src/graphics/shadow_math.rs:108-113

In that fixed light space, the slice's corners give a rectangle; the box is made square, sized to the larger span, and its centre is snapped to a whole number of texels. Because the light space never moves, the snapped grid is fixed in the world: as the camera walks, the box jumps by exactly one texel at a time, so a shadow edge stays on the same texels instead of crawling. (Rotating the camera still changes the box's size slightly, so turning can still shimmer a little.)

// Casters up to r + 1 sunward of the slice's nearest corner still land in the map;
// the small margin beyond the far side keeps receivers at the slice's edge inside it.
let near = -max_light.z - (radius + 1.0);
let far = -min_light.z + radius * 0.25 + 0.5;
let projection_gl = cgmath::ortho(
    center_x - half_extent,
    center_x + half_extent,
    center_y - half_extent,
    center_y + half_extent,
    near,
    far,
);
const OPENGL_TO_VULKAN: Matrix4<f32> = Matrix4::new(
    1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.5, 1.0,
);
let world_to_light_clip = OPENGL_TO_VULKAN * projection_gl * light_view;

ShadowCascade {
    world_to_light_clip,
    view_to_light_clip: world_to_light_clip * inv_view,
    far_distance: slice_far,
}
src/graphics/shadow_math.rs:115-136

The depth range reaches well beyond the slice towards the sun (by the sphere's radius plus a metre), so a tall object outside the slice can still cast a shadow into it. cgmath builds OpenGL-style matrices, so the same fixed matrix the camera uses (Chapter 12) remaps depth from OpenGL's −1..1 to Vulkan's 0..1. Each cascade keeps two matrices: one from world space, for drawing the shadow map, and one from the camera's view space, for the shader that reads it, which works in view space throughout.

The shadow pass

ShadowPass builds its own pipeline rather than using Pipeline, because almost everything about it is different. It has no fragment shader and no colour attachment, and reads only vertex positions:

let attribute_descs = [vk::VertexInputAttributeDescription {
    location: 0,
    binding: 0,
    format: vk::Format::R32G32B32_SFLOAT,
    offset: 0,
}];
src/vulkan/shadow_pass.rs:72-77

It still steps through the full 68-byte Vertex (the binding stride), but reads only the position at offset 0. The vertex shader is the shortest in the project:

#version 450

layout(location = 0) in vec3 inPosition;

layout(push_constant) uniform PushConstants {
    mat4 mvp;
} push_constants;

void main() {
    gl_Position = push_constants.mvp * vec4(inPosition, 1.0);
}
assets/shaders/shadow_depth.vert:1-11

With no fragment shader, the hardware writes depth and nothing else, which is as cheap as rendering gets. The rasterizer state carries the two shadow-specific settings:

// The light projection has no Y flip (the camera's does), so winding is reversed
// relative to the main pass: cull FRONT here drops the faces pointing away from the
// light, leaving light-facing surfaces in the map.  Acne on those faces is handled by
// the slope-scaled bias below plus the receiver bias in main.frag.
let rasterizer = vk::PipelineRasterizationStateCreateInfo {
    depth_clamp_enable: vk::FALSE,
    rasterizer_discard_enable: vk::FALSE,
    polygon_mode: vk::PolygonMode::FILL,
    cull_mode: vk::CullModeFlags::FRONT,
    front_face: vk::FrontFace::COUNTER_CLOCKWISE,
    // Enable a small depth bias to reduce acne
    depth_bias_enable: vk::TRUE,
    depth_bias_constant_factor: 0.0,
    depth_bias_slope_factor: 1.0,
    depth_bias_clamp: 0.0,
    line_width: 1.0,
    ..Default::default()
};
src/vulkan/shadow_pass.rs:115-132

Depth bias pushes the stored depths slightly further from the light, more so on surfaces seen at a glancing angle (the slope factor). Without it, a lit surface compares against its own depth, loses to limited precision on a patchy, angle-dependent basis, and is covered in stripes known as shadow acne.

The culling may look backwards. The comment explains it: unlike the camera's, this projection isn't flipped in Y, which reverses which way round triangles appear, so FRONT here removes the faces pointing away from the light.

Recording draws every visible scene object four times, once per cascade, each into one layer of the shadow array through that layer's own view (Chapter 10):

for (cascade_index, cascade) in cascades.iter().enumerate() {
    let depth_attachment = vk::RenderingAttachmentInfo::default()
        .image_view(shadow_att.layer_views[cascade_index])
        .image_layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL)
        .load_op(vk::AttachmentLoadOp::CLEAR)
        .store_op(vk::AttachmentStoreOp::STORE)
        .clear_value(clear_depth);
    let rendering_info = vk::RenderingInfo::default()
        .render_area(scissor)
        .layer_count(1)
        .depth_attachment(&depth_attachment);
    device.cmd_begin_rendering(cmd, &rendering_info);

    for object in &ctx.scene.objects {
        if !object.visible {
            continue;
        }
        let object_model = object.transform.model_matrix();
        for part in &object.parts {
            let model = object_model * part.transform.model_matrix();
            let push = ShadowPushConstants {
                mvp: (cascade.world_to_light_clip * model).into(),
            };
            device.cmd_push_constants(
                cmd,
                self.pipeline_layout,
                vk::ShaderStageFlags::VERTEX,
                0,
                bytemuck::bytes_of(&push),
            );
            ctx.mesh_manager.meshes[part.mesh_id].record(device, cmd);
        }
    }
    device.cmd_end_rendering(cmd);
src/vulkan/shadow_pass.rs:243-276

ShadowPushConstants stores just one matrix on the stack; bytemuck::bytes_of passes its bytes without a per-draw allocation. The matrix is light-space projection × model, for each object part. Note that the grass isn't drawn here: dense vegetation doesn't cast shadows into the map in this renderer.

Reading the shadow map

The main pass's fragment shader (Chapter 6) calls shadow_factor with the fragment's view-space position. (The terrain, rock, vertex-colour and grass shaders each carry their own copy of this function, with slightly different constants.) First it picks a cascade by distance, using the split distances from the uniform buffer, and projects the point into that cascade's light space:

float shadow_factor(vec3 fragPosView) {
    float viewDepth = -fragPosView.z;
    if (viewDepth > ubo.cascade_splits.w) return 1.0;
    int cascade = viewDepth > ubo.cascade_splits.z ? 3
                : viewDepth > ubo.cascade_splits.y ? 2
                : viewDepth > ubo.cascade_splits.x ? 1 : 0;
    vec4 lightPos = ubo.light_vp[cascade] * vec4(fragPosView, 1.0);
// ...
    vec3 ndc = lightPos.xyz / lightPos.w;
    // Convert to [0,1]
    vec2 uv = ndc.xy * 0.5 + 0.5;
    float depth = ndc.z; // [0,1] inside light frustum after Vulkan correction
assets/shaders/main.frag:27-33,39-42

Then come the PCF taps:

    // Manual PCF with a few jittered taps (compat path for platforms without compare samplers).
    // Poisson-ish offsets (in texels):
    const vec2 OFFS[6] = vec2[](
        vec2( 0.0,  0.0),
        vec2( 0.35, 0.12),
        vec2(-0.28,-0.34),
        vec2(-0.57, 0.49),
        vec2( 0.62,-0.41),
        vec2( 0.15, 0.68)
    );
    ivec3 ts = textureSize(shadowMap, 0);
    vec2 texel = 1.0 / vec2(ts.xy);
    float radius = 1.5; // in texels; tweak per taste

    float sum = 0.0;
    for (int i = 0; i < 6; ++i) {
        vec2 uvOff = uv + OFFS[i] * texel * radius;
        float sm = texture(shadowMap, vec3(uvOff, float(cascade))).r;
        sum += (depth - bias <= sm) ? 1.0 : 0.4;
    }
    return sum / 6.0;
}
assets/shaders/main.frag:57-78

Each tap samples the cascade's layer of the array at a small offset, measured in texels, and compares the stored depth with the fragment's own (minus a tiny extra bias). Lit taps count 1, shadowed ones 0.4, so a point in full shadow keeps 40% of the sun's light: a simple stand-in for light bounced in from the sky. Points outside every cascade, or outside the chosen cascade's map, count as lit.

Keeping the math honest

Shadow fitting is pure arithmetic on the CPU, so it can be tested without a GPU (tests are explained in Chapter 4). This one checks the snapping: it moves the camera without turning it and checks that every cascade shifted by a whole number of texels:

#[test]
fn moving_the_camera_shifts_cascades_by_whole_texels() {
    let res = 2048;
    let mut camera = Camera::new();
    camera.set_view_yaw_pitch(Point3::new(1.0, 2.0, 3.0), 30.0, -10.0);
    let before = compute_shadow_cascades(&camera, [0.3, -1.0, 0.2], res, 75.0);
    camera.set_view_yaw_pitch(Point3::new(1.37, 2.0, 3.21), 30.0, -10.0);
    let after = compute_shadow_cascades(&camera, [0.3, -1.0, 0.2], res, 75.0);

    // Orthographic: clip.x = row0 · p + w.x, so a change in w.x is a shift of
    // (res / 2) texels.
    for (a, b) in before.iter().zip(&after) {
        for row in 0..2 {
            let shift_texels = (b.world_to_light_clip.w[row] - a.world_to_light_clip.w[row])
                * res as f32
                / 2.0;
            assert!(
                (shift_texels - shift_texels.round()).abs() < 0.05,
                "cascade shifted by {shift_texels} texels"
            );
        }
    }
}
src/graphics/shadow_math.rs:188-210

The others check that the splits increase and end at the shadow distance plus the near plane, that every cascade still contains all eight corners of its slice, and that no matrix element is infinite or NaN.

Recap