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

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.

Four colored teapots inside a three-walled room, lit by a small moving point light and casting shadows in several directions.
A small gold cube marks the point light. Four teapots and the floor and walls cast into the six-face distance map, allowing shadows to extend in several directions at once.

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.

Omnidirectional shadow scene and duplicated mesh storage
Mesh setMeshesVerticesIndicesLogical bytesUse
Visible scene4 teapots + 4 room quads18,776109,5601,189,280Camera color and depth pass
Cube castersSame 8 meshes18,776109,5601,189,280Repeated for each of six light faces
Light marker1 box24361,104Visible 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.

Omnidirectional shadow mapping uniforms, textures, and bindings
ResourceFormat or sizePurposeUpdate cadence
Scene uniform224 bytesCamera matrices, identity model, light position/far range, and camera positionFully rewritten every update and resize
Marker uniform224 bytesSame scene data with a translation model at the lightFully rewritten every update and resize
Six face uniforms6 × 80 bytesOne light-space matrix and point-light vector per cube faceAll six rewritten every update and resize
Distance cube1024×1024×6 Rgba16FloatRender target and filterable cube textureEvery face cleared and rendered each frame
Offscreen depth1024×1024 Depth32Float; 4,194,304 logical bytesSelects the nearest rasterized surface for one faceShared sequentially; cleared for every face and discarded after each pass
Scene depthSurface-size Depth32FloatCamera depth testingRecreated 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.

The six cube-face view directions and up vectors
Array layerView directionUp 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.

Omnidirectional shadow mapping passes and indexed work per frame
PassesDrawsIndex invocationsSubmitted trianglesResult
6 cube-face passes48 indexed draws657,360219,120Radial distance for all eight casters in every direction
1 scene pass9 indexed draws109,59636,532Eight scene meshes plus light marker
1 overlay pass1 framework glyph draw when text is visibleVaries with shaped glyphsInstanced glyph quadsTitle, 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.