# 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. ## 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)`: ```rust 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: ```rust 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): ```rust 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: ```sh 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 DRAFT D14 in `docs/DRAFT.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](examples.md) · Back to [User documentation index](README.md)