WebGPU notes / 43
WebGPU Metropolis Renderer in Rust: GPU-Driven Sponza Crowds
Render the Crytek Sponza atrium with a GPU-skinned Jax crowd, compute frustum compaction, one indexed indirect draw, clustered Forward+ lights, cached and local shadows, reflections, probe lighting, procedural weather, volumetric fog, and a temporal post stack.
- Sponza triangles
- 262,266
- Native crowd budget
- 48–384
- Light clusters
- 3,456
Assemble one broad WebGPU rendering workload
The previous Nanite-style example concentrates on meshlets, page residency, hierarchical-Z rejection, and indirect geometry submission. Metropolis uses one conventional static environment and asks how many real-time systems can cooperate around it. The renderer updates one shared skeletal pose, moves a tier-sized crowd, compacts visible instances, bins local lights, builds shadows, shades into HDR, derives reflections and atmospheric effects, and resolves a dynamically scaled image through bloom, tone mapping, and anti-aliasing.
The introducing Metropolis and ReSTIR commit added the wrapper, renderer modules, WGSL, assets, gallery entry, and both captures. A follow-up refactor simplified the first implementation. The later fog and light-shafts change added the froxel volume, and the geometry and presentation update added the current portable ground path and revised crowd movement. Read the current Metropolis renderer, its small example wrapper, and the WGSL modules. Application setup and rendering run through sib::render.
The current working implementation also expands shared WGSL declarations before module creation. Light, cluster, spot-shadow, attenuation, and tone-map definitions can therefore be included by several shaders without keeping duplicate structs and functions synchronized by hand.
Load a 262,266-triangle atrium and one shared skinned mesh
The shared scene loader parses sponza.gltf, batches its external binary, 69 material images, and committed BVH, then loads jax.gltf and its two dependencies. The checked-in runtime set contains 75 files and 66,810,867 stored bytes. Sponza contributes 262,266 triangles, 25 glTF materials, and 524,531 compact 32-byte BVH nodes. Metropolis expands that triangle soup to 786,798 non-indexed raster vertices and derives tangents per corner. The prebuilt BVH is retained for the optional Ultra reflection path.
The loader resizes every Sponza material to matching arrays: base color at 512×512 and normal plus metallic-roughness at 256×256. Adding Jax creates layer and material 26. The GPU upload builds complete mip chains on the CPU, averages base color in linear space, renormalizes downsampled tangent-space normals, and uses ordinary linear averaging for material data. The raster sampler enables trilinear filtering and an anisotropy clamp of eight.
Jax contains one primitive with 35,880 indexed corners, or 11,960 triangles, one 46-joint skin, and one 138-channel walking animation. Rust evaluates at most 128 joint matrices once per frame. Every crowd member then reuses that palette, and the vertex shader blends four joint matrices before applying the instance scale, yaw, and translation. “GPU-skinned crowd” describes that vertex-stage work; animation sampling and palette construction still happen on the CPU.
Scroll sideways to see all table columns.
| Input | Files | Stored bytes | Decoded or compiled role | Repository evidence |
|---|---|---|---|---|
| Sponza glTF + binary | 2 | 9,694,626 | 262,266 static triangles and 25 materials | glTF-Transform 1.2.3 generator metadata |
| Sponza material images | 69 | 38,292,133 | 65 KTX + 4 PNG files resized into three 26-layer arrays after Jax is appended | Sponza license declaration |
| Sponza BVH | 1 | 16,785,024 | 524,531 nodes plus a 32-byte validated header | RSTBVH01, version 1 |
| Jax glTF + binary + PNG | 3 | 2,039,084 | 11,960 triangles, 46 joints, one walk animation, 1024×1024 base color | Khronos Blender glTF I/O 5.0.21 generator metadata |
| Vazirmatn font | 1 embedded file | 122,752 | HUD and egui glyph atlas | SIL Open Font License 1.1 |
| Metropolis WGSL | 17 embedded modules | Build-time source | Graphics, compute, temporal, and post pipelines | Compiled through wgpu at startup |
The repository's Sponza declaration assigns files associated with that model to the CryEngine Limited License Agreement and its metadata documentation to CC BY 4.0. Jax's glTF generator string identifies the export tool, not the model's author or license; no Jax-specific license file is present, so this article does not infer one. The weather system is procedural and does not load the repository's particle KTX textures.
Animate one pose, move tier-sized agents, and compact visible instances
Character height is normalized to nine percent of Sponza's vertical extent. The renderer places the initial crowd on a square grid inside 55 percent of the atrium footprint, with spacing capped at 1.8 character heights. Startup adapter detection chooses 48 Low, 128 Medium, 256 High, or 384 Ultra instances. Browser startup clamps that budget to 32 even when adapter detection selected more.
Native movement uses Rapier kinematic capsules, six nested eight-waypoint routes, turn-rate limiting, simple O(N²) separation, and a thin ground box plus central rectangular boundary. It deliberately does not collide every capsule against all 262,266 Sponza triangles. WebAssembly omits Rapier and advances deterministic lane walkers that reflect at the portable floor boundary. Both paths stream the resulting 32-byte transforms to the instance buffer every frame.
On native targets, a 64-thread compute shader tests a bounding sphere against six normalized frustum planes. Surviving transforms are compacted with an atomic increment that also fills instance_count in a 20-byte indexed-indirect command. Jax currently has one primitive, so the forward pass binds the compacted buffer and submits one draw_indexed_indirect. The CPU repeats the sphere test only to print the visible count in the HUD; it does not read the GPU counter back.
for (var p = 0; p < 6; p = p + 1) {
let plane = cull.planes[p];
if (dot(plane.xyz, center) + plane.w < -radius) {
return;
}
}
let slot = atomicAdd(&draw_args.instance_count, 1u);
visible[slot] = inst;
Sun-shadow rendering still draws the whole crowd, while each spot tile receives a CPU-built subset of nearby casters. If a future Jax file contains several primitives, the forward path falls back to one direct instanced draw per primitive and no longer consumes the compacted indirect command.
Assign eight local lights to 3,456 Forward+ clusters
The current light builder creates four warm/cool point lights and four downward spot lights. A 16×9×24 grid divides the camera frustum into 3,456 exponentially spaced clusters. One 64-thread compute dispatch uses 54 workgroups; every cluster tests all eight live lights and stores at most 64 indices. The buffers reserve capacity for 128 lights, but the scene currently submits eight. The static and skinned fragment shaders recover the cluster from pixel coordinates and view-space depth, then evaluate only its list with metallic-roughness BRDF terms and windowed inverse-square attenuation.
One Depth32Float sun map combines a static cache with dynamic crowd depth. Sponza is re-rendered only when the sun matrix changes; each frame copies the cached texture into the live map and draws every Jax instance over it. Four spot matrices occupy aligned uniform slots, and one 2×2 depth atlas gives each spot a viewport. These local tiles draw nearby crowd casters only. Sponza does not enter the spot atlas, and the implementation has one sun projection rather than cascaded shadow maps. Both sun and spot sampling use 3×3 percentage-closer filtering.
Scroll sideways to see all table columns.
| System | Logical size | Normal native dispatch or draw | Contribution | Boundary |
|---|---|---|---|---|
| Crowd culling | 48–384 instances | 1–6 workgroups of 64 | Compacted forward instances + indirect count | Frustum spheres only; no occlusion test |
| Light clusters | 16×9×24 = 3,456 | 54 workgroups of 64 | Up to 64 light indices per cluster | Eight lights are populated; capacity is 128 |
| Irradiance probes | 8×4×6 = 192 | 3 workgroups of 64 | Four SH-L1 vectors per probe | Analytic lights; no visibility rays |
| Sun shadow | One tier-sized map | Cached Sponza + all crowd | Directional visibility | One projection, not cascades |
| Spot shadows | Four tiles in one 2×2 atlas | Up to four filtered crowd draws | Local spot visibility | No static Sponza casters |
Layer a static reflection probe, SSR, optional BVH rays, and SH probes
A 256×256 six-face Rgba16Float cubemap captures static Sponza from the atrium center. The capture runs after startup and is marked stale when the sun moves, with at least 15 rendered frames between rebuilds. Half-resolution screen-space reflections march the forward depth and sample lit HDR, then fall back to that cubemap when a ray leaves the screen.
On an Ultra startup tier, the renderer additionally uploads the 524,531-node BVH and 262,266 Sponza triangles to storage buffers and builds a full-screen fragment pipeline that traces off-screen reflection rays through the hierarchy. The panel's ray-tracing checkbox selects it only when those Ultra resources exist; otherwise the renderer falls back to SSR. This path contains static Sponza only, so animated Jax and particles cannot appear as BVH hits. Particles are rendered after reflection generation specifically to prevent additive flame color from contaminating SSR without matching particle depth.
The indirect-light volume stores four first-order spherical-harmonic vectors at each of 192 probes. Its compute shader projects ambient sky and ground, the sun, and all eight punctual lights, then the forward shaders trilinearly interpolate neighboring probes. It does not trace probe rays, test Sponza visibility, retain temporal state, or implement DDGI relocation. IBL tiers set its contribution to zero, although the native compute dispatch still runs.
The Ultra tier is labeled GiMode::Restir in the HUD, but the current Metropolis renderer still uses this same analytic 192-probe shader. It has no GI reservoirs or ReSTIR reuse passes. The next ReSTIR direct-illumination example contains the actual reservoir workflow.
Simulate 11,400 particles and integrate 589,824 fog froxels
The native particle buffer always contains 6,000 snowflakes, 5,000 rain streaks, and 400 fire sprites. A 64-thread compute shader advances all 11,400 records in 179 workgroups every frame, even when a weather group is hidden. The render stage issues separate procedural six-vertex billboard draws for enabled groups. Snow and rain use alpha blending; fire uses additive blending. All test against scene depth without writing it. Current defaults render only the 400 fire particles, positioned around four lion-eye emitters.
When fog is enabled, a 128×72×64 volume represents 589,824 froxels. The injection pass dispatches 16×9×64 = 9,216 workgroups of 8×8×1, evaluates ambient, sun, local lights, height density, shadow maps, and a Henyey-Greenstein phase term, and ping-pongs temporal scatter. A second 16×9 = 144-workgroup pass marches each depth column into integrated scattering and transmittance. Its history factor becomes 0.88 after the first frame; an eight-frame jitter moves the sample within each depth slice. Turning light shafts off removes shadowed direct in-scatter while retaining the participating medium.
Resolve a dynamic-resolution HDR frame through temporal post-processing
Metropolis allocates full-sized render targets but rasterizes into a top-left viewport scaled within the startup tier's range. After a 60-frame warmup, an exponential CPU-frame-duration average decreases scale by 0.02 above the target band and increases it by 0.01 below it. Auto-tuning can promote or demote the tier, but it preserves resource-sized values such as shadow resolution, crowd budget, and cluster capacity. The measurement is not a GPU timestamp.
Scroll sideways to see all table columns.
| Order | Stage | Reads | Writes | When it runs |
|---|---|---|---|---|
| 1 | Cull, particle, GI, and cluster compute | Transforms, particles, lights, camera | Visible instances, indirect args, particle state, SH probes, cluster lists | Native; all four dispatches are recorded |
| 2 | Sun + local shadows | Static vertices, full or filtered crowd | Sun depth and optional spot atlas | Static sun cache only when dirty; crowd each frame |
| 3 | Volumetric injection + integration | Lights, shadows, prior scatter | Scatter history and integrated volume | Fog enabled |
| 4 | Forward+ | Sponza arrays, skinned Jax, clusters, probes, shadows | Scaled HDR color and depth | Every frame |
| 5 | SSR or BVH reflection | HDR, depth, cubemap or BVH | Half-resolution reflection viewport | Every native frame; selected mode may have zero strength |
| 6 | Particle render | Simulated particles and depth | Loaded HDR color | Enabled native weather groups |
| 7 | Bloom bright + two blur passes | HDR | Two half-resolution ping-pong targets | Tier bloom enabled |
| 8 | Present | HDR, reflection, bloom, volume | Full-size LDR target | Every frame; ACES-fit tone map |
| 9 | TAA or FXAA resolve | LDR, depth, optional history | History and swapchain | Selected anti-aliasing mode |
| 10 | Joystick, HUD, gizmos, egui | CPU diagnostics and UI geometry | Loaded swapchain | Every frame; gizmos optional |
FXAA is the initial Low and Medium resolve. High and Ultra start with an eight-sample Halton jitter and TAA that reconstructs world position from current depth, rejects sky, behind-camera, and off-screen history, clamps the reprojected color to the current 3×3 neighborhood, and blends at 0.9 before a plain final blit. Bloom uses a bright pass plus horizontal and vertical half-resolution blurs. The present shader composites reflection, bloom, and integrated fog into HDR, applies exposure and an ACES-fit curve, then writes the LDR target.
Navigate the scene and separate native features from the browser fallback
Keyboard movement uses W, A, S, and D; arrow keys look around. Holding and dragging the left half of the surface acts as a movement stick, while the right half controls view direction, which also supports touch. Press V to cycle lit, shadow-factor, sun N·L, and cluster-heat views. egui consumes pointer events over its panel before the virtual sticks.
Scroll sideways to see all table columns.
| Group | Current defaults | What changes | Workload consequence |
|---|---|---|---|
| Sun | Azimuth 2.3, elevation 0.9, intensity 3.6 | Directional lighting and projection | Moving it invalidates static shadow and reflection-probe caches |
| Lights | Point 6 / range 4; spot 10 / range 6; animation off | Four points and four spots; gizmos and local shadows | Ranges change cluster membership; local shadows add atlas draws |
| Weather | Snow off, rain off, fire on | Which particle ranges are drawn | All 11,400 native particles still simulate |
| Volumetrics | Fog on; density 0.05; range 45; shafts on | Density, height falloff, ambient, shaft gain, anisotropy | Fog enables 9,360 compute workgroups |
| Post and GI | Exposure 0.7; ambient 0.32; reflection 0.6; bloom 0.35; GI 0.6 | Compositing and probe contribution | Tier gates actual SSR, bloom, GI, and RT availability |
| Quality and debug | Auto-tune on; AA chosen by tier; lit view | Dynamic resolution, FXAA/TAA, four diagnostics | Auto-tune does not rebuild baked-size resources |
The WebAssembly route intentionally presents a smaller workload than the native screenshot. It caps the crowd at 32, omits Rapier, GPU crowd culling, cluster assignment, probe compute, native particles, Sponza's forward draw, SSR, and BVH reflections, and submits all Jax instances directly over a procedural six-vertex ground plane. Browser startup selects FXAA and turns off local shadows, bloom, weather, fog, and shafts. It still loads the full Sponza and Jax asset set and constructs shared scene resources, so the portable visual fallback does not yet reduce network transfer proportionally.
Several TierConfig fields describe intended quality choices that the current frame graph does not consume directly. shadow_cascades, contact_shadows, and reflection_probes do not switch distinct passes; the renderer uses one sun map and always creates its static cubemap. The live cluster grid remains 16×9×24 with 64 slots regardless of tier fields. Medium's Msaa4x enum is mapped to FXAA, and no multisampled Metropolis pipeline is built. Ultra's ReSTIR GI label still selects the analytic probe contribution described above.
Run the native renderer or its portable browser branch
From a local checkout with Rust installed, run the full native Metropolis renderer:
cargo run --example metropolis
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 metropolis
cargo run --bin serve
Open http://127.0.0.1:8080/metropolis/ in a browser with WebGPU support. The wrapper loads the shared scene asynchronously on WebAssembly and synchronously on native, then passes the resulting assets to run_metropolis and sib::render.
This is a feature-integration demo, not a production city renderer. Static Sponza is one unculled non-indexed draw; only the crowd receives frustum compaction, with no HZB or occlusion stage. The CPU still performs crowd separation, the HUD mirrors culling on the CPU, and adaptive quality uses CPU frame delivery rather than GPU timestamps. The large asset batch and CPU-generated texture mips make startup heavy, especially in a browser.
Lighting approximations have visible boundaries. Probe GI contains no scene occlusion or temporal convergence. The static reflection cubemap cannot show Jax, weather, or moving lights between rebakes; SSR cannot recover off-screen dynamic geometry; the optional BVH only contains Sponza. Spot maps omit static casters. Volumetric history has no explicit camera-teleport detector despite the nearby comment, and the TAA path has no motion-vector target for independently moving crowd vertices.
Useful changes to try:
- Build a browser-specific loader that skips Sponza geometry, textures, and BVH when the portable ground path is selected.
- Connect the tier fields to truly variable cluster buffers, cascaded sun shadows, contact shadows, and a real multisampled path.
- Add GPU timestamps and feed dynamic resolution from measured pass cost rather than event-loop frame duration.
- Add per-instance previous transforms and skinned motion vectors, then compare TAA crowd ghosting before and after.
- Move static geometry to meshlets or indirect chunks and reuse the previous example's HZB for environment occlusion.