Loading WebGPU example...
More info
Back to the demo ↑

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.

A white teapot casting a long dark shadow across a beige floor against a gray background.
A perspective depth map follows the moving light. The white teapot casts onto the finite taupe floor, while the teapot itself deliberately remains outside the shadow-receiver path.

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.

Shadow mapping geometry and GPU mesh storage
GeometrySource sizeVerticesIndicesTrianglesGPU buffersRole
teapot.gltf225,816 bytes4,69027,3849,128297,136 bytesCasts; does not receive
Procedural floorNo request462184 bytesReceives; 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.

Shadow mapping buffers and textures
ResourceFormat or sizeHow it changesPurpose
Shared uniforms304 bytesFully rewritten every update and resizeCamera, model, light-space, positions, and unused clip data
Shadow depth1024×1024 Depth32FloatCleared and rendered every frameStores nearest light-space depth and supports comparison sampling
Offscreen color1024×1024 Rgba8UnormCleared, rendered, then discarded every frameSatisfies the current shadow pipeline's color attachment
Scene depthSurface-size Depth32FloatRecreated on resizeDepth-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.

Shadow mapping render passes and explicit scene draws
PassAttachmentsDrawsTrianglesResult
Light viewFixed shadow depth + discarded colorTeapot9,128Nearest caster depth
Visible sceneSurface color + surface-size depthFloor, then teapot9,130Lit color with projected floor shadow
Text overlayLoad surface color; no depthFramework glyph drawFramework-managedTitle, 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.