WebGPU notes / 33
WebGPU SSAO in Rust: 32-Sample Ambient Occlusion with wgpu
Preserve depth beside a world-space G-buffer, probe 32 hash-rotated neighbors for nearby occluders, soften the result with a 25-tap blur, and tune the full-resolution ambient occlusion mask while animated Jax walks through eight colored spotlights.
- SSAO neighbors
- 32
- Blur taps
- 25
- Render passes
- 8
Add screen-space ambient occlusion after deferred shadows
The previous Deferred Shadows example evaluates eight spotlights and filters three shadow maps, but local contact can still look detached where small creases and nearby surfaces receive little direct shadowing. This example keeps that lighting system and adds screen-space ambient occlusion, or SSAO, between the G-buffer and final composition. The new stage estimates how much nearby visible geometry surrounds each pixel, blurs that estimate, and darkens the composed result.
This implementation is deliberately compact. It does not upload a hemisphere kernel or a noise texture, and it does not reconstruct positions from depth. Instead, WGSL reads stored world position and normal, rotates a two-dimensional golden-angle spiral with a per-pixel hash, and tests 32 neighboring screen positions. The result is a practical study of the data dependencies and costs behind SSAO rather than a production ambient-light model.
The introducing SSAO commit added the Rust example, WGSL shader, screenshots, build registration, README entry, and web gallery registration. The WGSL and screenshots remain unchanged. Current Rust also includes the modern WebAssembly entry, direct WebGPU projection, collapsible egui window, and mobile pointer-input fix. Rendering runs through sib::render.
Load three Jax files before rendering
The shared asset helper and skinned glTF loader first read jax.gltf. They then execute a one-item external-buffer batch for jax.bin, followed by a one-item image batch for jax_base_color.png. Native startup performs those batches sequentially. WebAssembly dispatches a temporary asset Worker for each batch in the same sequence, then starts sib::render.
Scroll sideways to see all table columns.
| Order | Input | Stored bytes | Encoded structure | Decoded or compiled result | Purpose |
|---|---|---|---|---|---|
| 1 | jax.gltf | 68,324 | JSON; 146 accessor declarations | 59 nodes, one mesh, one skin, one animation | Scene, material, skin, and animation description |
| 2 | jax.bin | 1,957,752 | Binary data backing 146 buffer views | Attributes, indices, inverse binds, and keyframes | Geometry and 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 | ssao.wgsl | 16,832 | 452 lines of WGSL | Six graphics pipelines | Shadows, MRT output, SSAO, blur, and composition |
| Embedded | Vazirmatn font | 122,752 | TrueType font; SIL OFL 1.1 | egui-managed font atlas | Live settings and performance panel |
The three runtime files total 2,039,084 stored bytes, about 1.9446 MiB. The glTF scene contains one mesh and triangle primitive, one material, texture, image, sampler, 46-joint skin, and one-second Walking_1 animation with 138 channels. Its 35,880 source u16 indices become u32 on the GPU and describe 11,960 triangles. The loader supplies white vertex color because the primitive has no COLOR_0 attribute.
The RGB texture decodes to 4,194,304 RGBA8 bytes and uploads as one-mip Rgba8UnormSrgb. Its sampler repeats U and V, clamps W, and uses linear minification and magnification. The Jax files arrived in the repository's Jax asset update; the repository does not include separate provenance or licensing for them.
Preserve depth with the world-space G-buffer
The geometry pass draws a generated two-triangle checkerboard and the skinned character into three color attachments. World position and normal use half-float RGBA targets. Albedo and fixed specular strength share an eight-bit RGBA target. Unlike the previous page, this pass stores its Depth32Float attachment because SSAO uses depth to reject the clear value and invalid neighbor samples.
Jax contributes a 2,726,880-byte vertex buffer, a 143,520-byte u32 index buffer, an 8,192-byte joint palette with 46 of 128 matrices used, and 11,960 triangles. The generated floor contributes 184 bytes and two triangles without another file request. Together with camera, shadow, composition, and the new 32-byte SSAO uniform, explicit buffers total 2,880,536 bytes. Adding the base texture gives 7,074,840 fixed logical bytes before render targets and GUI resources.
Scroll sideways to see all table columns.
| Target | Format | Bytes per pixel | Logical bytes | End-of-pass action | Consumer |
|---|---|---|---|---|---|
| World position | Rgba16Float | 8 | 7,372,800 | Store | SSAO and composition |
| World normal | Rgba16Float | 8 | 7,372,800 | Store | SSAO and composition |
| Albedo/specular | Rgba8Unorm | 4 | 3,686,400 | Store | Composition |
| Geometry depth | Depth32Float | 4 | 3,686,400 | Store | SSAO validity tests |
| Raw AO | Rgba8Unorm | 4 | 3,686,400 | Store | Blur pass |
| Blurred AO | Rgba8Unorm | 4 | 3,686,400 | Store | Composition and SSAO debug view |
| Total | Six single-sample targets | 32 | 29,491,200 | All stored | 28.125 MiB before allocation overhead |
The two AO masks are full-resolution RGBA8 textures even though only the red channel is consumed. They add 8 bytes per physical pixel, or 7.03125 MiB at 1280×720. Resizing recreates both masks, the four G-buffer attachments, and their bind groups. The three fixed 1024×1024 shadow depth maps and shared throwaway shadow color target remain allocated.
Sample a hash-rotated 32-point screen-space spiral
The first SSAO fullscreen pass reads the current world position, normal, and stored depth. A depth value at or beyond 0.999 or a near-zero normal returns white immediately. Despite the shader variable name linear_depth, this value is normalized device depth; the algorithm uses it only to reject cleared background and neighbor samples. Occlusion distance and hemisphere tests operate on the stored world positions.
For visible geometry, a sine-based hash rotates the pattern independently at each pixel. Sample angle advances by the golden angle, 2.39996323 radians. The normalized sample index is squared before multiplying the screen-pixel radius, concentrating probes near the center while still reaching the selected outer radius. Integer texture coordinates mean the earliest, nearest probes can address the same source pixel. This is a two-dimensional screen-space spiral, not a projected three-dimensional hemisphere kernel.
for (var i: u32 = 0u; i < 32u; i = i + 1u) {
let radius_step = (f32(i) + 1.0) / 32.0;
let angle = f32(i) * 2.39996323 + random * 6.2831853;
let spiral = vec2<f32>(cos(angle), sin(angle));
let sample_uv = input.uv
+ spiral * radius_step * radius_step * texel * sample_radius;
}
Scroll sideways to see all table columns.
| Stage | Center loads | Neighbor loads | Source texture operations | Logical source bytes | Result |
|---|---|---|---|---|---|
| Raw SSAO | Position + normal + depth | 32 × (position + depth) | 67 textureLoad calls | 404 | One RGBA8 occlusion value |
| Blur | None | 5×5 raw AO neighborhood | 25 textureLoad calls | 100 | One RGBA8 blurred value |
| Composition | Position + normal + albedo + AO | Up to 27 shadow comparisons | 4 ordinary loads + comparison taps | 24 before shadow data | Lit surface color |
Each valid neighbor contributes a smooth hemisphere term from dot(sample_position - position, normal) and a world-distance range term. The bias opens the hemisphere threshold, while range limits distant contributions. The shader divides by all 32 samples, scales by intensity, and clamps AO between 0.05 and 1. At full 1280×720 coverage, the raw source path describes 61,747,200 texture loads and 372,326,400 logical bytes, about 355.078 MiB, before compiler elimination, cache reuse, compression, or physical bandwidth behavior. These are source counts, not measured GPU traffic.
Blur the raw mask with 25 weighted taps
A second fullscreen pass reads a fixed 5×5 neighborhood from the raw AO texture. Weight falls with the length of the integer kernel offset. The blur-radius control scales and rounds those offsets, so a value of one uses offsets from −2 to 2, zero makes every tap read the center, and fractional values can make several taps address the same texel. The weights are always calculated from the original 5×5 coordinates and normalized before output.
The blur is spatial rather than bilateral: it does not compare depth, position, or normal. That makes it inexpensive to understand, but it can spread dark values across object silhouettes and unrelated surfaces. Its 25 RGBA8 loads describe 100 logical bytes per output pixel, or 92,160,000 bytes at 1280×720, before caching and optimization.
Compose ambient occlusion with eight lights and three shadows
The final fullscreen shader reads world position, normal, albedo/specular, and blurred AO. Its inherited lighting loop evaluates eight moving spotlights. The first three use separate 1024×1024 Depth32Float maps and up to nine textureSampleCompareLevel taps each; five fill lights have no shadow lookup. Three depth maps occupy 12 MiB, and a shared 1024×1024 RGBA8 color target required by the current shadow fragment pipeline adds another 4 MiB. Only Jax is rendered as a caster; the checkerboard receives shadows but does not cast them.
At the defaults, SSAO visibility is 0.56 + 0.44 * ao. The final-mix slider interpolates from no AO toward that factor. Because AO is clamped to at least 0.05, the darkest possible default factor is 0.582. The factor multiplies the complete lit color, including ambient, diffuse, specular, and already shadowed direct light. It is not limited to an indirect ambient term.
The shadow checkbox disables comparison lookups during composition, but the three shadow producer passes still execute. Turning SSAO off makes the raw pass return white before its 32-sample loop and removes AO from final composition, yet the raw fullscreen pass, 25-tap blur pass, blurred-texture load, and two AO targets remain active.
Tune SSAO and debug targets with egui
The live egui panel exposes both effect toggles, seven debug views, and six SSAO parameters. Controls update uniforms without rebuilding pipelines or textures. “Reset SSAO” restores the SSAO toggle and six numeric defaults; it does not reset the shadow toggle or selected debug view.
Scroll sideways to see all table columns.
| Control | Default | UI range | Shader role | Practical effect |
|---|---|---|---|---|
| SSAO | On | Off / on | Enables sampling and final mix | Off returns a white mask |
| Shadow maps | On | Off / on | Enables comparison lookup | Producer passes still run when off |
| Sample radius | 44 px | 8–96 | Outer spiral radius in screen pixels | Controls neighborhood scale |
| Intensity | 2.15 | 0–4 | Scales accumulated occlusion | Controls mask contrast |
| Bias | 0.015 | 0–0.08 | Offsets hemisphere threshold | Rejects shallow self-occlusion |
| Range | 2.15 | 0.35–4 | World-distance falloff endpoint; shader minimum 0.36 | Limits unrelated geometry |
| Final mix | 1 | 0–1 | Blends the visibility multiplier | Controls final AO strength |
| Blur radius | 1 | 0–2 | Scales and rounds 5×5 offsets | Changes blur footprint |
| Debug view | Final | 7 choices | Final, position, normal, albedo, specular, shadow mask, SSAO | Inspects intermediate data |
The GUI receives window input before the shared virtual joystick. When egui consumes a pointer event, the current mobile fix resets joystick pointer state so a settings gesture does not leave movement active. The window is fixed-width and non-resizable, but can be collapsed.
Position, normal, albedo, and specular debug targets return before the lighting loop. Shadow Mask and SSAO return afterward, so both still evaluate all eight lights first. With shadows enabled and all three projections in bounds, Shadow Mask can perform the normal 27 comparison taps and then repeat another 27 while rebuilding the diagnostic mask.
Record eight render passes per frame
Three shadow passes render Jax once per map. The G-buffer pass draws the floor and Jax. Raw SSAO, blur, and composition each draw one vertexless fullscreen triangle. The optional joystick renders after composition in the same pass, and egui opens an eighth pass that loads the presented surface and draws the settings panel.
Scroll sideways to see all table columns.
| Order | Pass | Main output | Explicit draw | Submitted triangles | Notes |
|---|---|---|---|---|---|
| 1–3 | Shadow maps | 3 depth maps + reused color | Jax ×3 | 35,880 | Real fragment stage; color discarded |
| 4 | G-buffer | 3 colors + stored depth | Floor + Jax | 11,962 | Single-sample MRT |
| 5 | Raw SSAO | Raw AO mask | Fullscreen triangle | 1 | 32 neighbor probes on visible geometry |
| 6 | SSAO blur | Blurred AO mask | Fullscreen triangle | 1 | 25 weighted RGBA8 loads |
| 7 | Composition | Presentation surface | Fullscreen triangle | 1 | Eight lights, shadows, AO, then joystick |
| 8 | egui | Loaded presentation surface | Framework-managed GUI | Not included | Settings and diagnostics |
| Frame total | 8 passes | One presented frame | 8 explicit scene/fullscreen draws | 47,845 | Excludes joystick and egui geometry |
At 1280×720, fixed buffers and the base texture use 7,074,840 logical bytes; fixed shadow resources use 16,777,216; the G-buffer uses 22,118,400; and the AO pair uses 7,372,800. Their total is 53,343,256 bytes, about 50.8721 MiB, before the presentation surface, egui and joystick resources, retained CPU data, row padding, and driver overhead. On a controls-stable frame, the listed uniform and joint buffers receive eight queue writes totaling 9,952 bytes before joystick or egui uploads. A GUI control change immediately repeats 1,760 bytes of uniform writes.
Control the first-person camera
The example shares the first-person camera and virtual joystick helper used by the preceding deferred pages. It starts at (0, 1.35, 5), with zero yaw and a slight −0.04-radian pitch. Projection is right-handed with a 60° field of view and near and far planes of 0.1 and 256.
- Use W and S to move forward and backward, and A and D to strafe at four world units per second.
- Use the arrow keys to look at 1.6 radians per second.
- Drag 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 camera movement and Jax animation each cap their delta at 1/15 second. The five-second light phase uses the uncapped frame delta. Focus loss resets input. There is no collision, camera reset, animation pause, light editor, SSAO resolution selector, sample-count selector, or GPU timing query.
Run and extend the example
From a local checkout with Rust installed, run the native WebGPU SSAO example:
cargo run --example ssao
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 ssao
cargo run --bin serve
Open http://127.0.0.1:8080/ssao/ in a browser with WebGPU support. Both targets load the same three Jax files before entering sib::render, then use the same single-sample frame graph and live controls.
SSAO only sees geometry present in the current frame's position and depth buffers. Off-screen occluders cannot contribute, disoccluded regions can change suddenly, and a radius measured in screen pixels changes its world-space reach with depth and resolution. Edge coordinates clamp, so samples beyond the viewport repeat edge texels. Thin geometry, view changes, and the per-pixel hash can produce noise or halos.
The 5×5 blur is not depth- or normal-aware, so it can bleed across silhouettes. Both AO targets store four channels although one is used. SSAO-off still records both fullscreen passes, and shadows-off still records all three shadow producer passes. A lower-resolution single-channel mask, bilateral blur, pass scheduling tied to toggles, temporal accumulation, and blue-noise rotation would improve cost or stability.
The variable called linear_depth is raw device depth, and the example stores world position instead of reconstructing it. Half-float position loses absolute precision as world coordinates grow, while half-float normal quantizes normalized directions. The composition shader normalizes the cleared zero background normal without the safe guard used by newer deferred shaders, so its behavior can depend on how the GPU handles that undefined direction.
AO multiplies direct and specular lighting as well as ambient, so this is a stylized visibility term rather than a physically isolated indirect-light approximation. The renderer has no temporal rejection, horizon search, bent normals, normal mapping, HDR intermediate, exposure, tone mapping, physically based material model, or light culling. The shadow limitations remain: only Jax casts, three of eight lights are shadowed, each PCF kernel costs up to nine comparisons, and a shared 4 MiB color attachment is written and discarded.
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, specular is not gated by positive N dot L, and the retained scene keeps CPU mesh and cloned image data after GPU upload. The animation helper also interpolates the file's STEP channels rather than preserving their declared transitions.
Useful changes to try:
- Switch the AO targets to a supported single-channel format, render at half resolution, and profile the bandwidth difference.
- Replace the spatial blur with a depth- and normal-aware bilateral filter.
- Reconstruct view-space position from depth, then compare precision and G-buffer memory.
- Add temporal reprojection and expose the sample count, pattern rotation, and resolution scale.