WebGPU notes / 22
WebGPU Cascaded Shadow Mapping in Rust and wgpu
Split a 0.5-to-48-unit camera frustum into four practical cascades, fit one orthographic light matrix to each slice, render a 2048×2048 depth layer per cascade, and choose the matching layer in WGSL.
- Cascades
- 4
- Depth per layer
- 2048×2048
- Filter taps
- 9
Why cascade a directional shadow map?
The previous WebGPU shadow mapping example uses one light-space image for a compact teapot-and-floor scene. A camera that sees tens or hundreds of world units asks one fixed texture to cover much more area. Nearby shadows then receive only a small fraction of its texels.
Cascaded shadow mapping divides the visible camera frustum by depth. A near slice gets a tightly fitted light projection; progressively farther slices get wider projections. This example keeps the algorithm inspectable with four fixed-size layers and a long row of teapots.
The cascade and omni shadow mapping commit introduced the Rust examples, shaders, screenshots, and gallery entries together. The current cascaded shadow mapping source, scene shader, and depth shader run through sib::render.
Build a long receiver and five casters
The only runtime model is the repository’s 225,816-byte teapot glTF. Its single primitive contains 4,690 positions and normals plus 27,384 source indices. The shared colored-glTF loader converts indices to u32 and produces a 40-byte vertex with position, normal, and RGBA color.
Rust clones and transforms that source mesh five times. The teapots sit at Z = 0, 6, 11, 18.5, and 27 with different X offsets, rotations, and pastel colors. One procedural floor spans X = −12 to 12 and Z = −6 to 36 at Y = −1.70.
The fourth color component is repurposed as a shadow-receiver flag. The floor stores alpha 1; every teapot stores alpha 0. The vertex shader passes it as shadow_receiver, and the fragment shader ultimately writes output alpha 1. The result is intentionally one-way: teapots cast into all four depth layers, but only the floor samples shadow visibility. They do not shadow themselves or each other in the final scene shading.
Scroll sideways to see all table columns.
| Mesh set | Meshes | Vertices | Indices | Logical buffer bytes | Use |
|---|---|---|---|---|---|
| Scene set | 5 teapots + floor | 23,454 | 136,926 | 1,485,864 | Visible color and depth pass |
| Caster set | 5 teapots | 23,450 | 136,920 | 1,485,680 | Repeated in each cascade depth pass |
The caster vertices and indices are uploaded separately from the identical transformed teapots in the scene set. That makes the two roles straightforward, but it duplicates 1,485,680 bytes of mesh data. The depth pipeline also reads the full 40-byte colored vertex even though its shader uses only position.
Split the camera frustum
The camera is fixed at (-0.4, 3.2, -12), looks toward (0, -0.65, 12), and uses a 45° vertical field of view. Near and far planes are 0.5 and 48. For split p, Rust blends a uniform distance with a logarithmic distance:
let p = (index + 1) as f32 / CASCADE_COUNT as f32;
let log = CAMERA_NEAR * ratio.powf(p);
let uniform = CAMERA_NEAR + clip_range * p;
let distance = CASCADE_SPLIT_LAMBDA * (log - uniform) + uniform;
*split = (distance - CAMERA_NEAR) / clip_range;
CASCADE_SPLIT_LAMBDA is 0.95, so the distribution is close to logarithmic and concentrates three boundaries near the camera.
Scroll sideways to see all table columns.
| Cascade layer | Normalized end ratio | View-depth range | Selection test |
|---|---|---|---|
| 0 | 0.03380 | 0.50 to 2.11 | Default layer |
| 1 | 0.11298 | 2.11 to 5.87 | Depth > first split |
| 2 | 0.33419 | 5.87 to 16.37 | Depth > second split |
| 3 | 1.00000 | 16.37 to 48.00 | Depth > third split |
The shader receives all four end depths but compares only X, Y, and Z; anything beyond the third boundary selects layer 3. Camera clipping still limits visible geometry to the fourth split at 48.
Fit four orthographic light projections
Rust inverts projection * view and unprojects the eight WebGPU NDC corners with Z at 0 and 1. For each cascade it interpolates the four near-to-far corner rays between the previous and current split ratios. That produces eight world-space corners for one frustum slice.
The light fit averages the corners to find a center, then finds the largest corner distance. It rounds that bounding-sphere radius upward to the nearest sixteenth and creates symmetric X, Y, and Z extents. A right-handed light view looks from the slice center opposite the directional-light vector, and a square orthographic projection encloses the sphere.
The radius rounding reduces small scale changes, but the light-space center is not snapped to a shadow texel. Camera movement is absent in this demo, yet adding a moving camera without stabilization could still produce shimmering. A sphere fit is rotation-stable but can leave unused texels compared with a tighter axis-aligned fit.
The normalized light direction follows:
let phase = animation_time * std::f32::consts::TAU;
glam::Vec3::new(
phase.cos() * 0.35 - 0.45,
-1.0,
phase.sin() * 0.35 - 0.25,
).normalize()
Normalized animation time advances by delta_seconds * 0.035 and wraps with fract, giving a roughly 28.57-second light cycle.
Allocate a four-layer depth array
The shadow map is one Depth32Float 2D-array texture: 2048×2048, four layers, one mip, one sample. Four layer views expose individual slices as render attachments; a single D2Array view exposes all layers to the scene fragment shader. Its logical texel payload is 67,108,864 bytes.
Scroll sideways to see all table columns.
| Resource | Format or size | Bindings | Lifetime and updates |
|---|---|---|---|
| Scene uniform | 512 bytes | Scene group binding 0, vertex + fragment | Fully rewritten every update and resize |
| Four depth uniforms | 4 × 64 bytes | One depth-pass group per cascade | All four rewritten every update and resize |
| Cascade shadow array | 2048×2048×4 Depth32Float | Scene group binding 1 | Allocated once; every layer cleared and rendered each frame |
| Comparison sampler | Linear, clamp-to-edge, LessEqual | Scene group binding 2 | Allocated once |
| Scene depth | Surface-size Depth32Float | Scene render attachment | Recreated on resize |
The depth pipeline is vertex-only, writes no color attachment, uses LessEqual, has no culling, and applies no pipeline depth bias. The scene pipeline back-face culls and writes surface color plus depth with the same depth comparison.
Select and filter one cascade in WGSL
The scene vertex shader records positive view depth as -view_position.z. The fragment shader selects a layer from the three interior split boundaries, transforms the world position by that layer’s light-space matrix, divides by W, and converts X/Y into shadow UV.
A normal-dependent sampling bias moves the comparison reference toward the light. Nine taps cover a 3×3 grid with spacing 1.2 / textureDimensions(shadow_map):
let slope_bias = max(
0.0004 * (1.0 - dot(normal, -light_direction_to_scene)),
0.00025,
);
let reference_depth = clamp(projected.z - slope_bias, 0.0, 1.0);
for (var x = -1; x <= 1; x = x + 1) {
for (var y = -1; y <= 1; y = y + 1) {
sum = sum + textureSampleCompare(
shadow_map, shadow_sampler,
shadow_uv + vec2<f32>(f32(x), f32(y)) * texel,
i32(index), reference_depth,
);
}
}
The average maps to visibility 0.22 + lit * 0.78, leaving ambient detail in full shadow. Positions with invalid W, negative W, UV outside 0–1, or projected depth outside 0–1 are treated as lit.
Ambient, Lambert diffuse, and a small power-18 specular term shade the vertex color. The scene uniform’s debug value is fixed at 0.12, so receiver fragments also receive a subtle red, green, blue, or yellow tint according to cascade. There is no runtime switch for that tint and no blend band across cascade boundaries.
Count passes, draws, and updates
Each frame renders all five casters into each depth layer, draws the six visible meshes once, then overlays four lines of Vazirmatn text. The framework FPS value measures CPU frame cadence over its sampling interval rather than GPU timestamp duration.
Scroll sideways to see all table columns.
| Passes | Draws | Index invocations | Submitted triangles | Result |
|---|---|---|---|---|
| 4 cascade depth passes | 20 indexed draws | 547,680 | 182,560 | Five teapots written to every array layer |
| 1 scene pass | 6 indexed draws | 136,926 | 45,642 | Floor and five colored teapots |
| 1 overlay pass | 1 framework glyph draw when text is visible | Varies with shaped glyphs | Instanced glyph quads | Title, device, FPS, and cascade count |
The example therefore records six render passes and 26 example-owned mesh draws, plus the overlay draw. It submits 228,202 mesh triangles and 684,606 mesh index invocations per frame. Mesh buffers total 2,971,544 logical bytes before uniforms, shadow images, surface attachments, overlay resources, and allocator padding.
Every normal update writes the 512-byte scene uniform and four 64-byte light-space uniforms: five queue writes and 768 bytes. Resizing recreates only the surface-size depth image, recalculates the aspect-dependent camera and cascades, rewrites those uniforms, and rebuilds overlay placement. The fixed 2048² array is not resized.
Run and extend the example
Run the native example from the repository root:
cargo run --example shadowmappingcascade
For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI version matching Cargo.lock, then build and serve:
scripts/build-wasm.sh --release shadowmappingcascade
cargo run --bin serve
Open http://127.0.0.1:8080/shadowmappingcascade/. Native loading reads the glTF synchronously; WebAssembly fetches it asynchronously from ../assets/models/teapot.gltf. Both use the same generated meshes, four depth layers, shaders, animation, and passes. The font and WGSL are compiled into the program. There are no mouse, keyboard, touch, camera, cascade, bias, or light controls.
Useful extensions include:
- Make the camera interactive, snap light-space centers to texel increments, and compare temporal shimmer before and after stabilization.
- Blend across split boundaries and expose the currently fixed 0.12 cascade tint as a debug control.
- Share caster and scene buffers, use a position-only shadow layout, and add depth-pass culling or bias after measuring acne and peter-panning.
- Let teapots receive shadows, then add more casters and verify that self-shadow bias remains stable across cascade scales.
- Vary layer resolution by cascade or pack cascades into an atlas and compare memory, sampling, and viewport complexity.
The current example prioritizes a readable CSM data flow. Its fixed 64 MiB depth array, duplicated mesh buffers, abrupt cascade selection, fixed filtering and bias, lack of texel snapping, and floor-only reception are the main limitations to address in a production renderer.