Files
wsg/docs/user/gpu-driven.md
T
Jérôme Bousquié 531c43a457 material batching
2026-09-22 17:07:52 +02:00

7.6 KiB
Raw Blame History

GPU-driven rendering

WSG's scene rendering is GPU-driven: the per-entity world matrices and the indirect draw arguments are computed on the GPU each frame, so the CPU no longer loops over entities to issue draw calls. This page explains what that means for you and how to opt into frustum culling.

What runs on the GPU

Each frame, before the render passes, two compute passes run over a fixed-capacity slot table (256 entities, allocated once):

  1. compute_matrices derives each entity's world matrix from its transform (translation / rotation / scale). The result feeds the render pipelines as the per-entity model matrix.
  2. cull decides per-entity visibility and fills the indirect draw arguments (the vertex/index count, zeroed when the entity is culled or inactive).

The main and shadow render passes are then 100 % indirect: each active slot issues one indirect draw that reads its own count and world matrix. A culled or inactive slot has a zero count, so its draw is a no-op. The CPU only rewrites the transform slots and the cull uniforms each frame — it never iterates the entities to issue draws.

You do not need to do anything special to get this: render_scene is GPU-driven by default.

Batching by material

The main render pass batches the draws by material: all entities sharing the same material are drawn back to back, so the GPU pipeline and the material's texture bind group are switched once per distinct material, not once per entity (the per-draw work — matrix offset, vertex/index buffers, the indirect draw itself — is unchanged). The grouping is internal: it does not change the rendered image and there is nothing to configure.

Constraint: the batching reorders the draws, which is safe here because every pipeline in the engine is opaque (BlendState::REPLACE, no alpha blending) — the depth buffer resolves the draw order. If transparent materials are ever added, the transparent draws must be isolated (sorted back-to-front at the end of the pass) and must not interleave with the grouped opaque draws.

Frustum culling (opt-in)

Culling is off by default. The culling pass still runs, but with culling disabled it marks every active entity visible — so the rendered image is identical to a CPU-culled scene. This protects you from a culling bug (an object that should be visible vanishing) becoming a silent correctness issue.

To enable culling, build your App with .with_culling(true):

let app = AppBuilder::new()
    .title("My app")
    .with_culling(true)   // skip entities whose bounding sphere leaves the frustum
    .build()
    .await?;

Or toggle it at runtime on the renderer:

app.renderer().set_culling(true);   // enable
app.renderer().set_culling(false);  // disable again

How culling works

When culling is on, each entity's local-axis-aligned bounding box (computed once from its geometry, Geometry::bbox()) is treated as a bounding sphere:

  • center = the box center, transformed by the entity's world transform (rotation + translation; scale is folded into the radius),
  • radius = the box's circumradius scaled by the entity's largest scale component.

The sphere is tested against the six camera frustum planes. If it is fully outside (beyond a plane by more than its radius), the entity is culled; otherwise it is drawn.

The sphere is a conservative approximation of the box: it can draw an object that is partly out of view (false negative), but it will never cull an object that is actually visible (false positive). For tight culling you would need per-mesh sphere fitting or per-face tests, which are out of scope for v1.

Debugging the GPU path

If something looks wrong — a missing object, a black window — the GPU-side slot tables can be read back and printed. The Renderer ships a debug helper (intentionally not part of the documented API):

app.renderer().debug_dump(8);   // prints the first 8 GPU slots to stderr

It dumps exactly what the GPU sees: the transform slots, the derived world matrices, the indirect draw arguments, the mesh bounding boxes and the cull uniforms. A slot whose vertex count reads 0 was zeroed by the cull pass (culled, inactive, or beyond num_slots); a full count means the entity is drawn. In the demo example the dump is opt-in via an environment variable, so the showcase stays silent by default:

WSG_DEBUG_DUMP=120 cargo run -p wsg-lib --example demo

WSG_DEBUG_DUMP=N dumps for the first N frames. The demo stays silent when the variable is unset; a set-but-non-numeric value (e.g. WSG_DEBUG_DUMP=on) gives 3 frames.

How to verify culling is actually working (a correct culling pass is invisible — culled objects were off-screen anyway — so the proof is in the counts, not the image):

  1. Launch the demo with WSG_DEBUG_DUMP=120 (the demo has culling on and an orbiting camera — drag the mouse to orbit).
  2. Note first that orbiting/zooming this camera cannot cull the entity ring: the camera always looks at the origin, so each entity's angular offset from the view axis is bounded by atan(ring radius / camera distance) = atan(1.7/6.1) ≈ 15.5°, under the ~22° vertical half-FOV. The seven demo entities therefore keep their full counts (cube 36, sphere 3840, …) in every orientation — that is the expected and correct behaviour (verified 2026-09-22: 600-frame camera sweep, GPU cull verdicts matched an independent CPU sphere test on all 6000 entity frames, zero flips on the ring).
  3. To see the counts actually flip to 0, you need an entity well off the target axis — e.g. one placed far away so it ends up behind the near plane. Its count then toggles 0 ↔ full as the camera orbits, while the on-axis entities stay full. (This off-axis test is the one that verified the cull path end-to-end, positive and negative.)
  4. Optional A/B: temporarily build with .with_culling(false) and repeat — with culling off, every entity keeps its full count in every orientation (the off-axis one included).

This readback is the reference truth when a shader bug is suspected: it shows both the computed counts and the raw inputs of the cull pass, independently of what ends up on screen. (It is how the 2026-09-22 « black window » bug — an inverted WGSL select argument order — was diagnosed and verified fixed, see the D14 note in docs/tech/ARCHI_CPU_GPU.md.)

Limitations

  • Culling is all-or-nothing per entity. There is no partial (per-triangle) culling.
  • The sphere is a coarse bound for elongated meshes (a long thin box gets a large sphere). If your scene is dominated by such shapes, culling may bring little gain.
  • Capacity is 256 entities per render pass. Beyond that, extra entities are not drawn. This is the largest a single-buffer design can address under WebGPU's two uniform rules: a single uniform binding is capped at 64 KB, and a uniform offset must be a multiple of 256 B. A 64-byte matrix can never be individually addressable by a uniform offset, so each matrix slot is padded to 256 B — and 256 slots × 256 B = 64 KB is the maximum. It is amply generous for a simple scene (the demo has 7).
  • Mesh bounding boxes are recomputed when meshes are added; a scene whose mesh set changes at runtime simply re-uploads the small bbox table (a few bytes per mesh).

Culling is a performance feature, not a visual one: with it off you get the same image with the indirect-draw machinery still active.


Next: Examples · Back to User documentation index