WebGPU notes / 18
WebGPU Occlusion Queries in Rust: Visibility with wgpu
Count the samples that survive a depth test, resolve two 64-bit WebGPU query results, and use asynchronous CPU readback to visualize whether a teapot and sphere remain visible.
- Native query slots
- 2
- Readback payload
- 16 B
- Scene triangles
- 13,642
Test visibility with sample counts
The previous CPU particle system example sends updated simulation data from Rust to WebGPU. Occlusion queries move a small result in the opposite direction. The GPU counts samples that pass depth and stencil tests while a query is active; Rust resolves those counts into buffers and later reads them on the CPU. A zero count means the tested draw was fully hidden for that view. Any nonzero value means at least part of it survived.
The original occlusion query commit introduced the two-query scene, model assets, camera controls, asynchronous readback, and browser fallback. The current Rust example and WGSL shader preserve that query lifecycle. Later changes corrected projection setup and specular view direction, improved the shared mobile joystick, and moved overlay refreshes to the regular statistics cadence. The demo runs through sib::render.
Load the occluder and test meshes
The current colored glTF loader reads three top-level files in sequence. Each file embeds its binary buffer as a Base64 data URI, so there are no secondary buffer, image, material-texture, or texture requests.
Scroll sideways to see all table columns.
| Asset | File size | Vertices | Indices | Triangles | Role |
|---|---|---|---|---|---|
plane_z.gltf | 4,072 bytes | 4 | 6 | 2 | Blue translucent occluder |
teapot.gltf | 225,816 bytes | 4,690 | 27,384 | 9,128 | Query slot 0 |
sphere.gltf | 141,450 bytes | 2,399 | 13,536 | 4,512 | Query slot 1 |
The three files total 371,338 bytes and contain 7,093 vertices, 40,926 indices, and 13,642 triangles. The loader applies each glTF node transform, supplies white when vertex colors are absent, combines the material base-color factor, and converts every index stream to u32. A 40-byte GPU vertex stores position, normal, and RGBA color. The resulting vertex and index buffers occupy 447,424 bytes.
The plane's glTF transform rotates it into the XY plane at Z = 0, and its object transform scales it by 6. The teapot is translated to (0, 0, -3); the sphere moves to (0, 0, 3). Their red, green, and blue tints come from per-object uniforms rather than textures.
Create the query and readback resources
Initialization creates one QuerySet with QueryType::Occlusion and two slots. WebGPU stores each result as an eight-byte unsigned count, but a query resolve destination must obey a larger alignment requirement. The example therefore separates the aligned GPU resolve allocation from the compact CPU-readable payload.
Scroll sideways to see all table columns.
| Resource | Size or count | Usage | Purpose |
|---|---|---|---|
| Occlusion query set | 2 slots | QueryType::Occlusion | Counts passing teapot and sphere samples |
| Resolve buffer | 256 bytes | QUERY_RESOLVE | COPY_SRC | Meets the query-resolve alignment |
| Readback buffer | 16 bytes | MAP_READ | COPY_DST | Exposes two little-endian u64 values to Rust |
| Object uniforms | 3 × 256 bytes | UNIFORM | COPY_DST | Projection, view, model, lighting, color, and visibility |
All three object uniforms are rewritten after the camera and visibility state update, for 768 bytes of uniform traffic per frame. The query buffers are created on every target, although the current WebAssembly path does not execute or map them.
Record the native query pass
Native rendering begins with a depth-writing query pass. It draws the scaled plane first, outside either query, so the plane establishes the occluder's depth. Query slot 0 then wraps the complete teapot draw, and slot 1 wraps the complete sphere draw:
pass.set_pipeline(&pipelines.query);
draw_object(&mut pass, plane);
pass.begin_occlusion_query(0);
draw_object(&mut pass, teapot);
pass.end_occlusion_query();
pass.begin_occlusion_query(1);
draw_object(&mut pass, sphere);
pass.end_occlusion_query();
The query pipeline uses triangle lists, one sample, no face culling, and a Depth32Float attachment with LessEqual. Depth writes remain enabled, while the color write mask is empty. The pass therefore performs the rasterization and depth work needed for sample counting without preserving shaded color. It tests the full 9,128-triangle teapot and 4,512-triangle sphere rather than cheaper bounds or proxy meshes.
Resolve and map results asynchronously
After the query pass, Rust resolves both slots at offset zero, then copies only the first 16 bytes into the readback buffer:
encoder.resolve_query_set(query_set, 0..2, resolve_buffer, 0);
encoder.copy_buffer_to_buffer(
resolve_buffer,
0,
readback_buffer,
0,
16,
);
The update loop polls without blocking and starts map_async only when the readback buffer is idle. An atomic status moves through idle, pending, ready, or failed. Once mapping completes, Rust reads two little-endian u64 values, releases the mapped view, unmaps the buffer, and converts each count into a boolean with count > 0.
Only one readback can be in flight. Query passes continue while a mapping request is pending, but resolve and copy wait until the same readback buffer becomes available again. The visibility flag therefore describes an earlier frame and can lag behind camera movement by multiple frames. The raw count also changes with resolution, viewpoint, rasterization, and sample count; this example needs only its zero-versus-nonzero meaning.
Visualize visibility in the main pass
A separate visible pass clears the query pass's color and depth, draws the teapot and sphere with the solid pipeline, then blends the blue plane over them. The solid WGSL shader does not discard a hidden object or skip its draw. It returns opaque dark gray when the uploaded visibility flag is zero; visible objects receive simple diffuse and specular lighting from a fixed point at (10, -10, 10).
Scroll sideways to see all table columns.
| Pass | Target | Scene draws | Triangles | Depth writes | Color |
|---|---|---|---|---|---|
| Query test | Native only | 3 indexed | 13,642 | Yes | Writes disabled |
| Visible scene | Native and WASM | 3 indexed | 13,642 | Objects yes; plane no | Opaque objects + alpha plane |
| Overlay | Native and WASM | Text and active sticks | UI-generated | No attachment | Loads existing scene color |
Excluding the overlay, a native frame submits six draws and 27,284 submitted triangles because it renders all three meshes twice. The WASM fallback submits three scene draws and 13,642 triangles. Every object still enters the visible pass, so this is a visibility-result visualization rather than an occlusion-culling optimization.
Understand the browser fallback
The current source sets USE_GPU_OCCLUSION_QUERY to false for wasm32. wgpu 29 does not yet forward RenderPassDescriptor::occlusion_query_set to the browser render-pass descriptor, so the WebAssembly build skips the query pass, resolve, copy, mapping, and polling paths.
Instead, every browser update assigns [0, 18_432]: the teapot is always marked hidden and the sphere always marked visible. That keeps the same visual explanation and overlay layout without claiming unsupported readback. Moving the browser camera changes the view but cannot change those two sample counts. Run the native example to exercise the real query lifecycle described above.
Move the FPS camera
The shared FPS camera and virtual joystick make this an interactive demo. Use W/S to move forward and backward, A/D to strafe, and the arrow keys to look. With a pointer or touch screen, drag on the left half of the canvas to move and the right half to look; active sticks receive a GPU-drawn overlay.
The initial camera sits about 7.5 units from the origin at a yaw of −123.75°. Movement speed is 4 units per second, look speed is 1.6 radians per second, and the update delta is capped at 1/15 second. The right-handed projection uses a 60° field of view with near and far planes of 1 and 256. The text overlay reports CPU frame cadence, the adapter name, and both sample counts; it refreshes on the 500 ms statistics interval rather than through GPU timestamp queries.
Run and extend the example
Run the native build to execute the real WebGPU occlusion queries and asynchronous readback:
cargo run --example occlusionquery
For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI version matching Cargo.lock, then build and serve the illustrative fallback:
scripts/build-wasm.sh --release occlusionquery
cargo run --bin serve
Open http://127.0.0.1:8080/occlusionquery/ in a browser with WebGPU support. The page requests the three glTF files listed above. WGSL, the joystick shader, and the Vazirmatn overlay font are compiled into the WebAssembly binary, while this article and screenshot remain readable without WebGPU.
Useful changes to try:
- Query simple bounding boxes instead of the full meshes, then skip an object's visible-pass draw when a later result reports zero samples.
- Double-buffer the readback path so mapping one result does not delay resolving a newer query set.
- Add hysteresis or require repeated zero results before hiding an object to reduce flicker near visibility boundaries.
- Compare per-object queries with a depth-pyramid approach as the number of test objects grows.