WebGPU notes / 32
WebGPU Deferred Shadow Mapping in Rust: 3x3 PCF with wgpu
Render animated Jax from three moving light cameras, preserve the resulting depth maps, fill a world-space G-buffer, and apply nine-tap percentage-closer filtering while eight colored spotlights illuminate the final fullscreen pass.
- Shadow maps
- 3 × 1024²
- Spotlights
- 8
- PCF taps per shadowed light
- 9
Add three shadowed spotlights to deferred shading
The previous Deferred Multisampling example keeps four coverage samples for each G-buffer pixel and manually averages their lighting. Deferred Shadows returns to one sample per pixel and spends its extra work elsewhere: three light-space depth passes record Jax's moving silhouette before the ordinary geometry pass. The fullscreen shader can then test whether each visible floor or character point lies behind that silhouette from a light's point of view.
The first three of eight spotlights own shadow maps. Five additional spotlights contribute color without shadow tests. This is classic shadow mapping inside a deferred renderer: visibility is generated from the caster first, while lighting and shadow lookup wait until world position, normal, and material data are available in the G-buffer.
The introducing Deferred Shadows commit added the Rust source, WGSL shader, README entry, and gallery registration. Its build-script registration had landed nine seconds earlier with Deferred Multisampling. A later screenshot and explicit-LOD comparison update added the current images and changed shadow lookups to textureSampleCompareLevel. Current Rust also has the modern WebAssembly entry and direct WebGPU projection. The shader's safe-normalize fix protects cleared background normals. Rendering runs through sib::render.
Load three Jax assets before starting the renderer
The shared asset helper and skinned glTF loader first read jax.gltf. They then load a one-item buffer batch containing jax.bin, followed by a one-item image batch containing jax_base_color.png. Native startup performs those two one-item batches sequentially. WebAssembly dispatches a temporary asset Worker for each batch in the same sequence.
Scroll sideways to see all table columns.
| Order | Input | Stored bytes | Encoded structure | Decoded or uploaded representation | Purpose |
|---|---|---|---|---|---|
| 1 | jax.gltf | 68,324 | JSON with 146 accessor declarations | 59 nodes, one mesh, one skin, one animation | References scene, geometry, skin, animation, and material |
| 2 | jax.bin | 1,957,752 | Binary data backing 146 buffer views | Attributes, indices, inverse binds, and keyframes | Mesh and skeletal animation payload |
| 3 | jax_base_color.png | 13,008 | 1024×1024 RGB8 PNG | 4,194,304-byte RGBA8 image | One-mip sRGB character texture |
| Compiled | deferredshadows.wgsl | 12,553 | 350 lines of WGSL | Shadow, floor MRT, skinned MRT, and composition pipelines | Depth capture, G-buffer output, lighting, and PCF |
| Compiled | Vazirmatn font | 122,752 | TrueType font | Framework-managed glyph resources | Title, GPU, FPS, light, map, and G-buffer overlay |
The three runtime files total 2,039,084 stored bytes, about 1.9446 MiB. Jax contains one scene, 59 nodes, one mesh and triangle primitive, one material, texture, image, sampler, skin, and animation. Its primitive supplies 35,880 vertices and 35,880 source u16 indices but no vertex color, so the loader supplies white. The material base-color factor is white, metallic factor is zero, and the default one-sided setting enables face culling.
The RGB PNG decodes into a 4,194,304-byte RGBA8 image and uploads as one-mip Rgba8UnormSrgb. Its sampler repeats U and V, clamps W, and uses linear magnification and minification. It has no lower-resolution mips. The three files came from the repository's Jax asset update and remain unchanged; the repository contains no separate provenance or license file for them.
Animate one 46-joint shadow caster
The one-second Walking_1 clip carries 138 channels: translation, rotation, and scale for every one of 46 joints. Rust advances the first animation, caps its delta at 1/15 second, wraps at the clip boundary, rebuilds the joint palette, and rewrites all 128 reserved matrices. The same joint palette deforms Jax in three shadow passes and once in the G-buffer pass.
A generated floor contributes four 40-byte vertices and six u32 indices without another file request. It spans X = −8.5 to 8.5, Z = −10 to 5, and sits at Y = −1.12. Jax uses a 1.75 uniform scale, 20° Y rotation, and translation to Z = −2, with its bind-pose minimum aligned to the floor.
Scroll sideways to see all table columns.
| Resource | Elements | Stride or block | Logical bytes | Update cadence | Use |
|---|---|---|---|---|---|
| Jax vertices | 35,880 | 76 bytes | 2,726,880 | Uploaded once | Three shadow draws + one G-buffer draw |
| Jax indices | 35,880 u32 | 4 bytes | 143,520 | Uploaded once | 11,960 triangles per indexed draw |
| Floor geometry | 4 vertices + 6 indices | 40-byte vertex | 184 | Uploaded once | 2 triangles in the G-buffer pass |
| Joint palette | 128 matrices; 46 used | 64-byte matrix | 8,192 | Fully rewritten every frame | GPU skinning in all four Jax draws |
| Camera-facing uniforms | Jax + floor | 144 + 224 bytes | 368 | Rewritten every frame | Main-camera geometry transforms |
| Shadow uniforms | 3 light cameras | 144 bytes each | 432 | Rewritten every frame | Jax model and light view-projection |
| Composition uniforms | 8 lights, camera, parameters | 928-byte block | 928 | Rewritten every frame | Lighting, light matrices, shadows, and debug state |
The explicit buffers total 2,880,504 bytes. Adding the 4,194,304-byte character texture produces 7,074,808 fixed logical bytes before shadow maps, G-buffer attachments, surface, and overlays. The palette and six uniform buffers write 9,920 bytes per frame. Later loader changes added a hierarchy-depth guard and stable first material and per-primitive material records; this one-primitive asset still fits the example's first-material path.
Render three 1024-square depth maps every frame
Each shadowed spotlight gets a separate 1024×1024 Depth32Float texture with one sample, one mip, and one layer. A 100° perspective camera looks from the animated light position toward its target with near and far planes of 0.1 and 64. The render loop clears each depth texture to one, draws Jax, and stores the result. The floor is a receiver but is not submitted as a shadow caster.
Because the current shadow pipeline includes fs_shadow, it also binds one shared 1024×1024 Rgba8Unorm color target. That fragment returns zero; each pass clears and discards the color. Jax's one-sided material switches the shadow pipeline to front-face culling. Depth comparison is LessEqual, with slope-scale bias 0.25 and zero constant or clamp bias.
Scroll sideways to see all table columns.
| Resource or rule | Count or extent | Format or value | Logical bytes | Producer behavior | Composition behavior |
|---|---|---|---|---|---|
| Shadow depth textures | 3 × 1024×1024 | Depth32Float; 1 sample; 1 mip | 12,582,912 | Clear 1; render Jax; store | Bound as three separate depth textures |
| Throwaway color | 1 × 1024×1024 | Rgba8Unorm; 1 sample; 1 mip | 4,194,304 | Clear black; write zero; discard | Never bound or read |
| Light cameras | 3 perspective views | 100°; aspect 1; 0.1–64 | Inside uniforms | Updated for moving lights | Project world position into shadow UV/depth |
| Raster bias | Each shadow draw | Slope 0.25; constant 0; clamp 0 | — | Offsets stored caster depth | Reduces surface acne |
| Receiver bias | Each shadowed light | max(0.00022(1-N·L), 0.00008) | — | — | Subtracts from comparison depth |
| PCF kernel | 3×3 = 9 taps | Offsets 1.5 shadow texels apart | — | — | Averages comparison results; clamps visibility to 0.2 |
The three depth maps occupy 12 MiB and the reused color target adds 4 MiB, for 16 MiB of fixed shadow resources before driver-specific overhead. They are created once and do not resize with the window. The comparison sampler clamps at texture edges, uses nearest filtering, and applies LessEqual; softness comes from nine explicit comparison samples rather than sampler filtering.
Fill a 24-byte single-sample G-buffer per pixel
After all three shadow passes, the main-camera geometry pass draws the two-triangle checkerboard and the 11,960-triangle skinned character. Their fragment shaders write world position, normalized world normal, and albedo with specular strength in alpha. Jax writes 0.45; the floor writes 0.08. A surface-sized depth texture handles visibility, then is discarded because composition reads stored world position instead.
Scroll sideways to see all table columns.
| Attachment | Format | Bytes per pixel | Logical bytes at 1280×720 | End-of-pass action | Composition use |
|---|---|---|---|---|---|
| World position | Rgba16Float | 8 | 7,372,800 | Store | Light vectors and shadow projection |
| World normal | Rgba16Float | 8 | 7,372,800 | Store | Diffuse, specular, and receiver bias |
| Albedo/specular | Rgba8Unorm | 4 | 3,686,400 | Store | Ambient, diffuse, and specular strength |
| Depth | Depth32Float | 4 | 3,686,400 | Discard | Not bound, sampled, or resolved |
| Total | Three colors + depth | 24 | 22,118,400 | Colors stored; depth discarded | 21.09375 MiB before allocation overhead |
At the screenshot resolution, fixed buffers, the base texture, shadow resources, and G-buffer total 45,970,424 logical bytes, about 43.841 MiB, before the presentation surface, glyphs, joystick, row alignment, and implementation overhead. Resizing recreates only the full-resolution G-buffer and its composition bind group; the three fixed-resolution shadow maps remain allocated.
Filter three shadowed lights during composition
The single-sample fullscreen pipeline loads one position, normal, and albedo texel. It starts with 3.5% ambient albedo, then loops across all eight spotlights. Every light uses a 15° inner cone, a 28° outer cone, inverse-square-like radius / (distance² + 1) attenuation, Lambert diffuse, and reflect-vector specular with exponent 18. Only light indices zero through two call the shadow function.
This is not a controlled shadows-only comparison with the earlier Deferred pages. The original uses six lights, 2.5% ambient, and specular exponent 16; Deferred Multisampling uses six lights, 15% ambient, and exponent 8. This example changes the count, positions, colors, spotlight cones, ambient level, and exponent as well as adding shadow visibility.
let ndotl = max(dot(normal, light_vector), 0.0);
let bias = max(0.00022 * (1.0 - ndotl), 0.00008);
let reference_depth = clamp(projected.z - bias, 0.0, 1.0);
for (var y: i32 = -1; y <= 1; y = y + 1) {
for (var x: i32 = -1; x <= 1; x = x + 1) {
let offset = vec2<f32>(f32(x), f32(y)) * texel;
visibility += sample_shadow_depth(light_index, uv + offset, reference_depth);
}
}
visibility /= 9.0;
Scroll sideways to see all table columns.
| Light | Linear RGB | Attenuation numerator | Motion or placement | Shadow lookup | Visible role |
|---|---|---|---|---|---|
| Red | (2.9, 0.08, 0.02) | 34 | High left, moving toward center | Map 0; up to 9 taps | Red cast shadow |
| Blue | (0.02, 0.25, 3.2) | 32 | High right, moving toward center | Map 1; up to 9 taps | Blue cast shadow |
| Warm | (2.3, 1.85, 0.45) | 30 | High rear, moving over center | Map 2; up to 9 taps | Warm cast shadow |
| Green | (0.02, 2.4, 0.18) | 24 | Low left rear | None | Green fill pool |
| Cyan | (0, 1.85, 2.6) | 24 | Low right rear | None | Cyan fill pool |
| Magenta | (2.55, 0.02, 2.45) | 22 | Low left front | None | Magenta fill pool |
| Orange | (2.6, 1.05, 0.02) | 22 | Low right front | None | Orange fill pool |
| Violet | (0.45, 0.08, 2.95) | 18 | Low camera side | None | Violet foreground fill |
A fragment inside all three shadow frusta executes at most 27 depth comparisons in the normal path. Points behind a light, outside its UV square, or outside depth zero through one return fully visible without sampling. Each nine-tap result clamps to at least 0.2, so a shadow suppresses at most 80% of that light rather than becoming fully black.
At 1280×720, the source expresses 18,432,000 logical G-buffer bytes loaded, 7,372,800 light evaluations, and at most 24,883,200 shadow comparisons per frame. These are source-level operation counts rather than measured memory bandwidth or GPU invocation totals; compiler optimization and texture caches can change the physical cost.
Rust's base light phase wraps every five seconds and all eight positions move. The green, magenta, and violet fill formulas multiply that phase by 1.4, 0.9, and 1.2, so they do not return to their starting values before the forced wrap and jump at the cycle boundary. WGSL has diagnostic views for position, normal, albedo, specular strength, and combined shadow visibility, plus a parameter that can disable shadows. Rust always uploads debug target zero and shadows enabled, and the UI exposes neither setting.
Record five passes and six explicit draws
Scroll sideways to see all table columns.
| Passes | Attachments | Explicit draws | Submitted triangles | Depth and store behavior | Result |
|---|---|---|---|---|---|
| Shadow maps 0–2 | One reused color + one distinct depth map per pass | Jax once per light | 3 × 11,960 = 35,880 | Front-face cull; store depth; discard color | Three light-space caster silhouettes |
| G-buffer | Position + normal + albedo + depth | Floor, then Jax | 2 + 11,960 = 11,962 | Back-face cull Jax; store colors; discard depth | Main-camera surface attributes |
| Composition and overlay | Single-sample presentation surface; no depth | Fullscreen triangle, text, optional joystick geometry | 1 explicit composition triangle + framework overlay | Store opaque surface color | Eight-light shading, shadows, text, and controls |
| Frame total | 5 render passes | 6 explicit scene draws | 47,843 | All pipelines are single-sample | One presented frame |
The composition triangle overwrites the surface clear, producing black wherever cleared albedo contains zero. Text and optional joystick geometry render afterward in the same pass and are not depth-tested. The 21 px overlay reads “Deferred shadows,” GPU information, FPS, “lights: 8,” “shadow maps: 3 x 1024,” and the three G-buffer channels.
Control the first-person camera
The example is interactive through the shared joystick and first-person camera helper. It starts at (0, 1.35, 5) with zero yaw and a slight −0.04-radian pitch. Projection uses a right-handed 60° field of view with near and far planes of 0.1 and 256.
- Use W and S to move forward and backward, and A and D to move left and right at four world units per second.
- Use the arrow keys to look at 1.6 radians per second.
- Press and drag on the left half of the canvas to move or the right half to look. Mouse and touch share 44 px virtual sticks.
Pitch clamps to ±1.45 radians, combined input clamps to unit length, and movement delta caps at 1/15 second. Focus loss resets input. There is no collision, reset, animation pause, light editor, shadow toggle, map preview, or debug-target selector.
Run and extend the example
From a local checkout with Rust installed, run the native WebGPU Deferred Shadows example:
cargo run --example deferredshadows
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 deferredshadows
cargo run --bin serve
Open http://127.0.0.1:8080/deferredshadows/ in a browser with WebGPU support. Both targets load the same three Jax files before calling sib::render, then execute the same three shadow passes, G-buffer pass, and composition pass.
The renderer hard-codes three 1024-square maps and rerenders the entire animated Jax mesh into each one every frame. It has no shadow atlas or texture array, caster culling beyond rasterization, update caching, adaptive resolution, cascades, point-light cubemaps, or runtime quality controls. Only Jax casts and only three spotlights receive shadow visibility. The floor receives shadows but does not cast them. Setting the internal RENDER_SHADOW_MAPS constant false is not a complete toggle because composition still enables shadows and samples the unwritten maps.
The shared throwaway color target and zero-output fragment stage are unnecessary for opaque depth-only shadow rendering. A depth-only pipeline could remove that 4 MiB target and its color work. The manual 3×3 kernel costs up to 27 comparisons per composition fragment; those lookups still run inside a valid light frustum when the spotlight cone, N dot L, or albedo would make the contribution zero, and all eight light loops still run over background pixels. Bias, the 20% visibility floor, wide 100° projections, and fixed resolution trade acne for peter-panning, light leaks, softness, and limited detail.
There is no transparency or alpha-tested shadow casting, although the current RGB texture has no alpha channel. The G-buffer stores world position instead of reconstructing it from depth, and its half-float position loses absolute precision as coordinates grow while the half-float normal target quantizes normalized directions. The base texture has no mip chain. The renderer has no HDR intermediate, exposure, tone mapping, ambient occlusion, emissive term, normal mapping, or physically based material model; bright light sums clamp at presentation.
Jax writes fixed 0.45 specular strength instead of material or texture alpha. Its white base-color factor hides a loader/shader double multiplication that would affect non-white materials. Normals are not inverse-transpose transformed, and specular is not gated by positive N dot L. The animation helper also smooths the glTF's 91 STEP and 47 LINEAR channels without retaining their declared interpolation modes, and the retained animated scene keeps CPU mesh and cloned image data after GPU upload.
Useful changes to try:
- Remove the shadow color attachment, use a depth-only pipeline, and measure the saved memory and fragment work.
- Pack shadow maps into an array or atlas, expose resolution and PCF radius, and visualize each map.
- Add caster culling and staggered shadow updates, then scale beyond three shadowed spotlights.
- Compare receiver-plane bias, rotated PCF, variance shadows, and cascades for directional lights.