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

WebGPU notes   /   36

WebGPU Compute Culling and LOD in Rust: GPU-Driven Draws

Classify 8,000 procedural objects in a compute shader, compact the visible instances into six fixed LOD buckets, write six indexed-indirect commands on the GPU, and render only the selected sphere detail for each distance band.

Input objects
8,000
Cull workgroups
500
LOD draw buckets
6

Move visibility and LOD selection into compute

The previous compute-cloth example uses storage buffers to evolve simulation state. This demo uses compute for submission preparation instead. A first kernel tests every object against six frustum planes, chooses a distance LOD, and appends the instance to that bucket. A second kernel copies the six bucket counts into DrawIndexedIndirect commands. The render pass then submits six fixed indirect calls without a CPU-visible GPU result.

The introducing compute cull and LOD commit added the current Rust example, WGSL module, screenshots, gallery card, and WebAssembly registration. The shader remains byte-identical to that commit. A later UI update made the egui panel collapsible. Rendering runs through sib::render.

A three-dimensional grid of colored sphere LODs rendered after WebGPU compute frustum culling, beside live culling statistics.
The 20×20×20 object lattice changes from yellow and green near-detail spheres to blue and purple coarse spheres. In this captured frame, the CPU mirror reports 7,976 visible objects distributed across LODs 2–5.

Generate 8,000 objects and six sphere meshes

There are no runtime model or texture requests. Rust creates a centered 20×20×20 lattice with 2.25-unit spacing. Coordinates span −21.375 through 21.375 on every axis, and each instance stores a position plus scale 0.76 in a 16-byte record. The complete input instance buffer is 128,000 bytes.

Six latitude-longitude ellipsoids share one 36-byte vertex stream and one u32 index stream. Position radii are (0.92, 0.78, 1.08); normals remain unit-sphere normals, so the compact lighting is approximate. Every latitude strip also submits one degenerate triangle per pole and longitude.

Scroll sideways to see all table columns.

Procedural sphere LOD ranges
LODLatitude × longitudeDistance belowVerticesFirst indexIndicesSubmitted trianglesColor
014×281643502,352784Red
111×22242762,3521,452484Orange
29×18341903,804972324Yellow
37×14461204,776588196Green
45×1060665,364300100Blue
53×6Otherwise285,66410836Purple
Total1,1155,7721,924Six bands

The final LOD stores distance 512 in its uniform record, but the selection loops test only the first five thresholds. LOD 5 is the unconditional fallback, so that last numeric distance is currently unused.

Extract a culling frustum from the orbit camera

The camera orbits at radius 58 and height 18, looking at the origin through a 60° right-handed perspective with near 0.1 and far 512. Its angle starts at 0.2 radians and normally advances at 0.18 radians per second. Rust extracts and normalizes left, right, bottom, top, near, and far planes from the view-projection rows.

An object survives when a conservative sphere of radius scale × 1.8 = 1.368 is not wholly behind any plane. The largest rendered ellipsoid radius is only 0.8208 after scale, so the culling volume deliberately admits extra edge objects rather than dropping visible geometry.

Freeze culling frustum stores both the planes and the camera position used for LOD distance. The render camera can continue orbiting. This intentionally separates the frozen selection volume from the current view: newly visible objects may be absent, old objects may remain submitted and then be clipped by rasterization, and LOD colors stay relative to the captured camera.

Compact visible instances into fixed LOD slabs

The cull kernel uses 16 threads per workgroup. Exactly 8,000 invocations run as 500 workgroups, so there is no partial final group even though the bounds guard remains. Every survivor atomically increments its LOD counter and the total counter, then writes into one of six 8,000-entry slabs:

let slot = atomicAdd(&stats.values[lod_level + 1u], 1u);
atomicAdd(&stats.values[0], 1u);
visible_instances[lod_level * object_count + slot] = instance;

The 48,000-entry output reserves 768,000 bytes even though one object can enter only one bucket, so at most 8,000 records are meaningful in a frame. Fixed slabs avoid prefix sums and variable offsets. Atomic allocation makes order within a bucket nondeterministic, which does not affect these opaque, otherwise identical instances.

Turn atomic counts into six indirect commands

A separate write_commands pass dispatches one eight-thread workgroup. Six invocations copy the LOD index range and atomic instance count into six 20-byte commands; two invocations return. The pass boundary orders cull writes before command generation.

indirect_draws[lod_level].index_count = scene.lods[lod_level].index_count;
indirect_draws[lod_level].instance_count =
    atomicLoad(&stats.values[lod_level + 1u]);
indirect_draws[lod_level].first_index = scene.lods[lod_level].first_index;
indirect_draws[lod_level].base_vertex = 0;
indirect_draws[lod_level].first_instance = 0u;

The CPU clears the 32-byte atomic buffer with queue.write_buffer before each submission. Old compacted instances are not erased; the new command counts prevent stale records beyond each bucket's active prefix from being read.

Render six indexed-indirect LOD buckets

The scene pass binds the shared mesh once. For each LOD, Rust changes the instance-buffer slice by 8,000 × 16 = 128,000 bytes and calls draw_indexed_indirect at the matching 20-byte command offset. Six draw commands are always encoded, including zero-instance buckets.

The vertex shader scales and translates the selected sphere, then applies the current camera matrices. The fragment shader combines a 0.24 ambient term, up to 0.86 diffuse light from (0, 30, 50), and a small term named rim. That term uses world/object normal Z rather than a view vector, so it is directional shading rather than a true camera-relative silhouette rim. Rendering is single-sample with back-face culling, no blending, and Depth32Float writes using LessEqual.

Account for fixed buffers and four passes

Scroll sideways to see all table columns.

Compute culling explicit GPU buffers
ResourceLayoutLogical bytesUse
LOD vertices1,115 × 3640,140Vertex
LOD indices5,772 u3223,088Index
Input instances8,000 × 16128,000Read-only storage
Visible slabs6 × 8,000 × 16768,000Storage + instance vertex
Indirect commands6 × 20120Storage + indirect
Atomic statistics8 u3232Storage; one slot unused
Scene uniform2 matrices, camera, 6 planes, params, 6 LODs352Compute + vertex
Fixed totalExcluding depth, surface, and egui959,732Initialized once

The single-sample depth image adds 4 × width × height logical bytes. At the 1600×1000 screenshot size it contributes 6,400,000 bytes, bringing the explicit fixed payload plus depth to 7,359,732 bytes (about 7.019 MiB), before alignment and framework resources.

Scroll sideways to see all table columns.

Per-frame passes and fixed submissions
PassWorkResult
Cull compute500 workgroups; 8,000 threadsCompacted instances + 7 used counters
Command compute1 workgroup; 8 threads, 6 activeSix indirect commands
Scene renderSix indexed-indirect callsVisible colored sphere LODs
egui renderFramework-managed drawsControls and CPU-mirrored statistics

Compare the moving camera with a frozen culling volume

The 330 px non-resizable, collapsible egui panel reports CPU-sampled frame time, FPS, device information, 8,000 objects, six indirect buckets, total visible objects, and one count per LOD. Animate camera defaults on; Freeze culling frustum defaults off. There is no direct keyboard, touch, orbit, speed, threshold, object-count, or wireframe control.

The displayed visibility numbers are not mapped back from the GPU. update_uniforms repeats the same six-plane tests and distance classification across all 8,000 instances on the CPU every frame, then uploads a 352-byte uniform. This keeps the panel synchronous but means the demo does not prove that shader counters match the display, and CPU work remains O(N). Together with the 32-byte counter clear, the example owns 384 bytes of steady per-frame queue writes before egui's variable updates.

Run and extend the example

Run the native example from the repository root:

cargo run --example computecullandlod

Build the WebAssembly target and serve the generated site:

scripts/build-wasm.sh --release computecullandlod
cargo run --bin serve

Open http://127.0.0.1:8080/computecullandlod/ in a browser with WebGPU support. Native and WebAssembly use the same procedural geometry and workload; only the compile-time Vazirmatn font accompanies the program, so startup has no runtime asset fetch.

This example demonstrates frustum culling, distance LOD compaction, and indirect counts, not a complete visibility system. It has no occlusion or back-to-front hierarchy, prefix-sum compaction, clustered bounds, hysteresis or cross-fade between LODs, mesh simplification, GPU readback, timestamps, dynamic object count, or capability-based fallback. All 8,000 objects are still tested each frame, the output buffer reserves six times the input capacity, and six indirect calls remain on the CPU command stream.

The CPU statistics duplicate the GPU algorithm, the conservative radius exceeds the rendered bound, and LOD uses center distance only. The final threshold value is unused, the sphere pole topology includes degenerate triangles, ellipsoid normals are approximate, and the so-called rim term is not camera-relative. Useful extensions include a hierarchical culler, scanned bucket offsets in one compact buffer, temporal LOD hysteresis, actual GPU-counter readback, and a debug view for the frozen planes.