WebGPU notes / 23
WebGPU Omnidirectional Shadow Mapping in Rust
Render a moving point light through six 90° views, store normalized radial distance in a 1024×1024 floating-point cube map, and sample that cube from every visible surface in a small room.
- Cube faces
- 6
- Face size
- 1024×1024
- Distance format
- RGBA16Float
Shadow a point light in every direction
The previous cascaded shadow mapping example uses four orthographic layers for one directional light. A point light needs visibility over a sphere. A cube map approximates that sphere with six square perspective projections and provides direction-based sampling in the final shader.
The cascade and omni shadow mapping commit introduced this example with its companion CSM demo. A later projection-matrix fix adjusted the omnidirectional path. The current Rust source, offscreen WGSL, and scene WGSL run through sib::render.
Build the room and four casters
The example loads the same 225,816-byte teapot glTF used by the other shadow demos. Its single primitive contributes 4,690 vertices and 27,384 source indices. Rust transforms four copies to different X/Z positions and Y rotations, and assigns pale blue, salmon, green, and gold colors.
Four procedural quads make a floor, back wall, left wall, and right wall. The front and ceiling stay open for the fixed camera. The room spans X = −8 to 8, Z = −7 to 8, and Y = −1.70 to 5.
All eight meshes enter both scene_meshes and caster_meshes. Every room surface and teapot therefore casts and receives shadows. A separate 0.32-unit-wide cube marks the light in the visible pass, but that marker is not rendered into the shadow cube.
Scroll sideways to see all table columns.
| Mesh set | Meshes | Vertices | Indices | Logical bytes | Use |
|---|---|---|---|---|---|
| Visible scene | 4 teapots + 4 room quads | 18,776 | 109,560 | 1,189,280 | Camera color and depth pass |
| Cube casters | Same 8 meshes | 18,776 | 109,560 | 1,189,280 | Repeated for each of six light faces |
| Light marker | 1 box | 24 | 36 | 1,104 | Visible scene only |
The scene and caster vectors are cloned on the CPU and then uploaded as separate vertex and index buffers. Fixed mesh payload is 2,379,664 logical bytes. The shared 40-byte vertex includes position, normal, and color in both pipelines, even though the offscreen vertex shader needs only position.
Allocate a floating-point shadow cube
The shadow resource is a six-layer 2D texture with a cube view for sampling and one 2D view per render face. Each layer is 1024×1024 Rgba16Float, one mip, and one sample. At eight bytes per texel, the six layers occupy 50,331,648 logical bytes.
Only the red channel stores shadow distance. Green and blue are written as zero and alpha as one, so three quarters of the 64-bit texel is not needed by the comparison. The format is convenient as a filterable render target, but a production implementation can evaluate a narrower supported format or a depth-cube approach.
Scroll sideways to see all table columns.
| Resource | Format or size | Purpose | Update cadence |
|---|---|---|---|
| Scene uniform | 224 bytes | Camera matrices, identity model, light position/far range, and camera position | Fully rewritten every update and resize |
| Marker uniform | 224 bytes | Same scene data with a translation model at the light | Fully rewritten every update and resize |
| Six face uniforms | 6 × 80 bytes | One light-space matrix and point-light vector per cube face | All six rewritten every update and resize |
| Distance cube | 1024×1024×6 Rgba16Float | Render target and filterable cube texture | Every face cleared and rendered each frame |
| Offscreen depth | 1024×1024 Depth32Float; 4,194,304 logical bytes | Selects the nearest rasterized surface for one face | Shared sequentially; cleared for every face and discarded after each pass |
| Scene depth | Surface-size Depth32Float | Camera depth testing | Recreated on resize |
The scene bind group exposes its uniform, the cube view, and a linear clamp-to-edge filtering sampler. The light marker receives a second bind group with its own model uniform but the same cube and sampler. Six small face bind groups each expose one offscreen uniform.
Orient six light cameras
Each face uses a 90° right-handed perspective with aspect 1, near 0.1, and far 32. Rust prepends a Y-axis flip to the projection for rendering into the cube texture. The light view targets one cardinal direction with an up vector chosen to keep cube orientation consistent:
Scroll sideways to see all table columns.
| Array layer | View direction | Up vector |
|---|---|---|
| 0 | +X | −Y |
| 1 | −X | −Y |
| 2 | +Y | +Z |
| 3 | −Y | −Z |
| 4 | +Z | −Y |
| 5 | −Z | −Y |
let projection = glam::Mat4::from_scale(
glam::Vec3::new(1.0, -1.0, 1.0),
) * glam::Mat4::perspective_rh(
90.0_f32.to_radians(), 1.0, LIGHT_NEAR, LIGHT_FAR,
);
for (index, (direction, up)) in faces.into_iter().enumerate() {
let view = glam::Mat4::look_at_rh(
light_position, light_position + direction, up,
);
matrices[index] = projection * view;
}
The complete set of face matrices is recalculated as the point light moves. Six separate passes and bind groups are used instead of multiview, a geometry stage, or a layered draw.
Store radial distance while depth testing
Scene meshes are already transformed into world coordinates on the CPU, so the offscreen vertex shader directly applies the face matrix to input.position and also passes that position to the fragment stage. The fragment shader stores Euclidean distance from light to fragment, normalized by the 32-unit far range:
let light_to_fragment =
input.world_position - uniforms.light_position.xyz;
let normalized_distance = clamp(
length(light_to_fragment) / uniforms.light_position.w,
0.0,
1.0,
);
return vec4<f32>(normalized_distance, 0.0, 0.0, 1.0);
A conventional perspective depth attachment still decides which surface is nearest for each rasterized face pixel. Its value is not sampled later. The same 1024² depth image is cleared before each face and stored with Discard, allowing sequential reuse instead of allocating six depth layers.
The offscreen pipeline uses triangle lists, no face culling, LessEqual depth writes, no pipeline depth bias, and one sample. Disabling culling helps render the inward-facing room alongside the teapots, but it also submits both sides of all triangles.
Sample distance and shade the room
The visible fragment shader forms world_position - light_position and uses that 3D vector directly as the cube lookup direction. It explicitly samples mip level 0 with a linear sampler. Current fragment distance is normalized by the same 32-unit range and compared with the sampled red channel.
let sampled_distance = textureSampleLevel(
shadow_cube_map, shadow_sampler, light_to_fragment, 0.0,
).r;
let normalized_distance = distance / uniforms.light_position.w;
let normal_light = max(dot(normal, light_direction), 0.0);
let world_bias = max(0.08 * (1.0 - normal_light), 0.035);
let lit = normalized_distance <= sampled_distance
+ world_bias / uniforms.light_position.w;
return select(0.38, 1.0, lit);
Fragments within 0.0001 units of the light or at least 32 units away are treated as lit. Full shadow retains 38% visibility. There is one filtered lookup, not a multi-direction PCF kernel or a hardware depth comparison. Linear distance filtering softens texel transitions slightly but can blend unrelated depths, especially across cube-face seams.
Visible lighting combines 7% ambient with Lambert diffuse scaled by 3.1 and a small power-24 specular highlight. Diffuse and specular share quadratic attenuation 1 / (1 + 0.035 * distance²); ambient is not attenuated or shadowed. The scene pipeline also has no culling, writes depth with LessEqual, and renders directly to the surface without HDR or post-processing.
Animate the light and count frame work
The camera remains at (0, 4.6, -10.8), looks toward (0, -0.55, 0.75), and uses a 45° perspective with 0.1-to-64 clipping. The light moves around center (0, 4.05, 0.35):
LIGHT_CENTER + glam::Vec3::new(
phase.cos() * 3.6,
phase.sin() * 0.28,
phase.sin() * 2.8,
)
Animation time advances by delta_seconds * 0.08 and wraps at one, producing a 12.5-second loop. Y and Z use the same sine phase, while X uses cosine, so the light follows a slightly tilted ellipse.
Scroll sideways to see all table columns.
| Passes | Draws | Index invocations | Submitted triangles | Result |
|---|---|---|---|---|
| 6 cube-face passes | 48 indexed draws | 657,360 | 219,120 | Radial distance for all eight casters in every direction |
| 1 scene pass | 9 indexed draws | 109,596 | 36,532 | Eight scene meshes plus light marker |
| 1 overlay pass | 1 framework glyph draw when text is visible | Varies with shaped glyphs | Instanced glyph quads | Title, device, FPS, and face count |
A frame records eight render passes, 57 example-owned mesh draws, and 766,956 mesh index invocations, submitting 255,652 triangles before the overlay. There is no visibility culling per cube face; every one of the eight caster meshes is drawn six times.
Each normal update rewrites the 224-byte scene uniform, 224-byte marker uniform, and six 80-byte face uniforms: eight queue writes and 928 bytes. Resizing recreates the surface-size depth image, recalculates the aspect-dependent camera data, rewrites all eight buffers, and rebuilds overlay placement. The cube and its shared offscreen depth image remain fixed at 1024².
Run and extend the example
Run the native example from the repository root:
cargo run --example shadowmappingomni
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 shadowmappingomni
cargo run --bin serve
Open http://127.0.0.1:8080/shadowmappingomni/. Native loading reads the teapot glTF synchronously; WebAssembly fetches it asynchronously from ../assets/models/teapot.gltf. Rendering, geometry construction, cube faces, shaders, and animation are otherwise shared. The Vazirmatn font and both WGSL files are compiled in. There are no pointer, keyboard, touch, camera, light, bias, or quality controls.
Useful extensions include:
- Replace the single filtered distance sample with a small 3D PCF kernel and compare seam behavior and cost.
- Test a narrower renderable and filterable distance format, or a depth cube, to reduce the current 48 MiB color payload.
- Share visible and caster buffers, use a position-only offscreen layout, and cull meshes against each cube-face frustum.
- Add per-face culling and suitable bias controls, then inspect acne, detached shadows, and inward-facing room geometry.
- Skip or cache cube faces when the light and casters are static, and compare six-pass rendering with a supported multiview design.
The example makes radial cube-map shadows easy to trace, but its six complete caster passes, duplicated meshes, four-channel distance texture, single filtered lookup, fixed bias/range, lack of culling, and absent runtime controls leave clear room for a production renderer.