WebGPU notes / 21
WebGPU Shadow Mapping in Rust with wgpu
Render a teapot from a moving light into a depth texture, project nine hardware depth comparisons onto a procedural floor, and shade the visible scene with ambient, diffuse, and specular light.
- Shadow map
- 1024×1024
- Comparison taps
- 9
- Render passes
- 3
Project depth shadows in two stages
The previous WebGPU bloom example filters a color image before adding it over the scene. Shadow mapping uses a different offscreen signal: the closest depth seen by the light. A later scene pass projects each visible fragment into that light-space image and asks whether the fragment is nearer than the stored surface. A failed comparison means another surface blocks the light.
The original shadow-mapping commit introduced the basic projected-shadow example, its WGSL, screenshots, and web registration while reusing the repository's teapot. The current Rust example and WGSL shader preserve that design. Later changes removed an obsolete clip-space correction, corrected the comparison-bias direction, and supplied the real camera position for specular lighting. The demo runs through sib::render.
Load the teapot and build the floor
The demo makes one runtime asset request: teapot.gltf. The 225,816-byte JSON file embeds its only 167,328-byte binary buffer as a Base64 data URI. It has no external .bin, image, texture, skin, or animation request. The WGSL shader and 122,752-byte Vazirmatn font are compiled into the program.
Scroll sideways to see all table columns.
| Geometry | Source size | Vertices | Indices | Triangles | GPU buffers | Role |
|---|---|---|---|---|---|---|
teapot.gltf | 225,816 bytes | 4,690 | 27,384 | 9,128 | 297,136 bytes | Casts; does not receive |
| Procedural floor | No request | 4 | 6 | 2 | 184 bytes | Receives; does not cast |
The shared colored glTF loader uses the repository's asset helper, traverses the scene, bakes node transforms, and flattens the teapot's single triangle primitive. The primitive supplies positions, normals, and 16-bit source indices but no material or vertex color, so the loader supplies white and converts indices to u32. Its baked bounds run from approximately (-3.040, -1.722, -2) to (3.394, 1.428, 2).
Each GltfColoredVertex occupies 40 bytes: a position, a normal, and RGBA color. The teapot therefore uses 187,600 vertex bytes and 109,536 index bytes. Rust constructs the floor at Y = −1.70 from four taupe vertices spanning X = ±7.5 and Z = ±5.5, plus six indices. The two meshes reserve 297,320 bytes in total.
Initialization repurposes vertex alpha as a receiver flag. It writes 0 into every teapot vertex and 1 into every floor vertex. That makes the teapot the only shadow-map caster and the floor the only object that applies the sampled visibility.
Build the shadow-map resources
One 304-byte uniform block serves both example pipelines. It contains camera projection and view matrices, an identity model matrix, the light-space matrix, light and camera positions, and a final clip vector. The complete block is rewritten after every normal update and on resize. The shader never reads the 16-byte clip value.
Scroll sideways to see all table columns.
| Resource | Format or size | How it changes | Purpose |
|---|---|---|---|
| Shared uniforms | 304 bytes | Fully rewritten every update and resize | Camera, model, light-space, positions, and unused clip data |
| Shadow depth | 1024×1024 Depth32Float | Cleared and rendered every frame | Stores nearest light-space depth and supports comparison sampling |
| Offscreen color | 1024×1024 Rgba8Unorm | Cleared, rendered, then discarded every frame | Satisfies the current shadow pipeline's color attachment |
| Scene depth | Surface-size Depth32Float | Recreated on resize | Depth-tests the visible floor and teapot |
The fixed shadow depth texture has one mip and one sample with RENDER_ATTACHMENT | TEXTURE_BINDING usage. Its clamp-to-edge comparison sampler uses LessEqual with nearest minification, magnification, and mip filtering. At four bytes per texel it occupies 4,194,304 logical bytes.
The auxiliary color image has the same dimensions and byte count but only RENDER_ATTACHMENT usage. Nothing samples it, and its separately created default sampler is unused. Together these fixed offscreen images account for 8,388,608 logical bytes, or 8 MiB, before texture-object overhead. Neither changes size when the surface changes.
The offscreen bind group exposes only the uniform to the vertex stage. The scene bind group exposes the same uniform to both stages, plus the depth view and comparison sampler to the fragment stage.
Render the teapot from the light
The first render pass clears shadow depth to 1 and the auxiliary color target to black. It binds the offscreen pipeline and submits the teapot's 27,384 indices once. The vertex shader applies light_space * model, where light_space combines a 45° right-handed perspective with the light's look-at matrix.
@vertex
fn vs_offscreen(input: MeshVertexInput) -> @builtin(position) vec4<f32> {
return uniforms.light_space
* uniforms.model
* vec4<f32>(input.position, 1.0);
}
The triangle-list pipeline draws 9,128 triangles with one sample, no face culling, writable Depth32Float, and LessEqual. Raster depth bias remains zero. The floor is absent because it receives the projected result instead of contributing to the map.
This is not a strictly depth-only pipeline. Its fragment shader writes solid red into the 1024×1024 Rgba8Unorm attachment, but the pass uses StoreOp::Discard and never reads that color. The depth image uses StoreOp::Store because the next pass samples it.
Project world positions into the shadow map
The visible-scene vertex shader sends each world position through the same light-space matrix. The fragment function divides XYZ by W, remaps clip-space X and Y into texture coordinates, and flips Y for texture addressing:
let projected = shadow_position.xyz / safe_w;
let raw_shadow_uv = vec2<f32>(
projected.x * 0.5 + 0.5,
0.5 - projected.y * 0.5,
);
let reference_depth = clamp(
projected.z - 0.00035,
0.0,
1.0,
);
A valid lookup needs an absolute W above 0.0001, positive W, UV inside 0 to 1, and projected Z inside 0 to 1. Fragments outside the light frustum return full visibility. The base UV is clamped to 0.001 through 0.999 before the filter offsets are added. An offset coordinate can cross those limits, in which case the sampler's clamp-to-edge mode handles it.
The constant 0.00035 bias moves the reference depth toward the light. That direction matters for a LessEqual comparison: a sample is lit when the biased reference is less than or equal to the stored caster depth. The example uses no slope-scaled, normal-based, or rasterizer bias, so the fixed value must balance acne against detached shadows across every surface angle.
Filter nine depth comparisons with a PCF-style footprint
The fragment shader calls textureSampleCompare at the center, four cardinal neighbors, and four diagonal neighbors. Each nonzero axis component is 1.75 shadow texels, or 0.001708984375 in normalized coordinates for a 1024-wide map; diagonal taps move by that amount on both axes. The comparison sampler uses nearest filtering.
let texel = 1.75 / vec2<f32>(textureDimensions(shadow_map));
let c = textureSampleCompare(shadow_map, shadow_sampler, shadow_uv, reference_depth);
let n = textureSampleCompare(shadow_map, shadow_sampler, shadow_uv + vec2<f32>(0.0, texel.y), reference_depth);
let s = textureSampleCompare(shadow_map, shadow_sampler, shadow_uv - vec2<f32>(0.0, texel.y), reference_depth);
let e = textureSampleCompare(shadow_map, shadow_sampler, shadow_uv + vec2<f32>(texel.x, 0.0), reference_depth);
let w = textureSampleCompare(shadow_map, shadow_sampler, shadow_uv - vec2<f32>(texel.x, 0.0), reference_depth);
// ne, nw, se, and sw sample the four diagonals.
let lit = min(min(min(min(c, n), min(s, e)),
min(min(w, ne), min(nw, se))), sw);
The footprint resembles 3×3 percentage-closer filtering, but the reduction is deliberately important: it takes the minimum instead of the average. With nearest comparison results, all nine samples must report lit for lit to become 1. One occluded tap makes it 0. In-bounds visibility is consequently either 0.16 or 1, creating a conservative, expanded hard shadow instead of the fractional transition produced by conventional averaged PCF.
The function accepts a normal and light direction but does not currently use them. That leaves both the 1.75-texel footprint and 0.00035 depth bias fixed regardless of slope or distance.
Render the floor and teapot with projected visibility
The second pass clears the surface to gray (0.48, 0.52, 0.56, 1) and surface depth to 1. It draws the two-triangle floor followed by the 9,128-triangle teapot. This pipeline enables back-face culling and uses writable Depth32Float with LessEqual, one sample, and no blending.
WGSL computes world-space ambient, Lambert diffuse, and Phong-style specular terms. Ambient remains visible in shadow. Diffuse and the white specular highlight are multiplied by projected visibility:
let ambient = input.color * 0.16;
let diffuse = max(dot(normal, light_direction), 0.0) * input.color;
let specular = pow(
max(dot(reflected, view_direction), 0.0),
16.0,
) * vec3<f32>(0.25);
let visibility = select(
1.0,
shadow_visibility,
input.shadow_receiver > 0.5,
);
return vec4<f32>(
ambient + (diffuse + specular) * visibility,
1.0,
);
The white teapot's receiver alpha is 0, so its final visibility is 1 and it never self-shadows. The taupe floor's alpha is 1, so it receives the map. The WGSL source calculates shadow_factor before selecting by receiver flag, meaning the teapot path is not guarded from those nine calls at shader-source level.
Scroll sideways to see all table columns.
| Pass | Attachments | Draws | Triangles | Result |
|---|---|---|---|---|
| Light view | Fixed shadow depth + discarded color | Teapot | 9,128 | Nearest caster depth |
| Visible scene | Surface color + surface-size depth | Floor, then teapot | 9,130 | Lit color with projected floor shadow |
| Text overlay | Load surface color; no depth | Framework glyph draw | Framework-managed | Title, GPU information, and FPS |
Excluding overlay geometry, each frame records three render passes, three explicit scene draws, 18,258 submitted triangles, and 54,774 index invocations. The example owns two render pipelines; Glyphon adds its instanced text draw in the separate third pass when the overlay contains visible glyphs.
Animate the light around a fixed camera
The camera does not move. It uses a 60° right-handed perspective with the current surface aspect and near and far planes of 1 and 256. A 12.5-unit orbit at −30° yaw and 25° pitch places the eye near (5.6644, 5.2827, -9.8111), looking toward (0, -0.35, 0).
Rust advances normalized animation time by delta_seconds * 0.2 and keeps its fractional part, so one light cycle lasts five seconds. The phase drives this position:
vec3(
7.5 * cos(phase),
8.0 + 1.5 * sin(phase),
4.5 * sin(phase) - 4.0,
)
The light follows an ellipse centered at (0, 8, -4), ranges from 6.5 to 9.5 on Y and −8.5 to 0.5 on Z, and always looks toward (0, -0.45, 0). Its square perspective frustum uses a 45° field of view, aspect 1, near 1, and far 40. The model matrix stays identity.
Resizing recreates only the surface-size scene depth, rewrites the uniforms for the new camera aspect, and rebuilds overlay placement. The fixed shadow attachments remain 1024×1024. There are no keyboard, mouse, pointer, or touch controls.
The 22 px Vazirmatn overlay displays “Shadow mapping,” GPU device information, and FPS. That FPS is CPU frame cadence averaged over 500 ms, not GPU timing. Overlay preparation runs every frame; its text changes on the statistics cadence.
Run and extend the example
From a local checkout with Rust installed, run the native WebGPU shadow mapping example:
cargo run --example shadowmapping
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 shadowmapping
cargo run --bin serve
Open http://127.0.0.1:8080/shadowmapping/ in a browser with WebGPU support. Native code loads the glTF synchronously. The WebAssembly entry point starts an asynchronous task, awaits the same model, and then runs the example. Both targets use the same three passes and WGSL with no rendering fallback.
This compact implementation keeps the full projected-shadow path visible, but it has one fixed-resolution perspective map, one caster, one receiver, and one moving light. It has no cascades, cube-map coverage, texel snapping, dynamic resolution, multiple lights, MSAA, alpha-tested casters, transparency, normal mapping, PBR materials, or controls. The finite light frustum can clip geometry, and the fixed bias trades shadow acne against separation as angles and distances change.
Useful changes to try:
- Average or weight the nine comparison results to produce fractional PCF visibility, then compare edge quality with the current minimum reduction.
- Remove the discarded 4 MiB color target and red fragment work by creating a truly depth-only shadow pipeline.
- Add slope-scaled or normal bias, front-face culling for the caster pass, and controls for bias and filter radius.
- Enable teapot self-shadowing, add more casters and receivers, then extend the technique with cascades for directional lights or a cube depth map for point lights.