# 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, how to opt into **frustum culling**, and how the **Level of Detail (LOD)** system works. ## 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). With LOD on (the default), the count it writes comes from the mesh's **LOD table** at the level the CPU chose for the slot this frame — see [Level of Detail](#level-of-detail-lod). 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)`: ```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. ## Level of Detail (LOD) LOD is **on by default**: distant entities automatically draw a coarser version of their mesh, so the GPU stops spending fillrate and vertex work on detail the eye cannot see. It is a quality feature with a performance payoff — unlike culling, it is safe to leave on because the worst case (a level chosen too fine) is exactly what you would have drawn anyway. ### How it works LOD is a **CPU-decided, GPU-executed** split (the one deliberate per-entity decision kept on the CPU): 1. **Setup (once per mesh).** Each mesh can carry up to 4 levels. Levels 1..3 are generated automatically from level 0 by **quadric edge collapse** (Garland–Heckbert, `Geometry::generate_lod_levels`): edges are ranked by quadric error and collapsed cheapest-first; an interior collapse merges both incident triangles (−2 faces) and remaps the neighbours — no new face, so a **seam-free mesh stays closed** (no holes, no non-manifold "books") and a boundary collapse removes one face; duplicate corners are welded **aware of their attributes** (relative position tolerance 1e-6, merged only when the UVs are strictly less than half a tile apart on both coordinates — an offset of exactly ½ is ambiguous: a wrap seam at its widest or a legitimate half-tile jump — and the normals within ~25°; a seam or a hard edge therefore stays a separate corner, and the weld *records* the integer-apart pairs it refused); on a mesh with a UV seam those **seam twins are frozen** — every edge touching one is excluded from the collapse queue — so the zero-width slit stays closed at every level, and the rim protection (no boundary collapse while any interior edge remains) keeps the surface geometrically complete; a survivor **moved** by a collapse gets its UV/color/normal **blended linearly** between the collapsed endpoints (same λ as its new position — the chart is bilinear, so the blend is the exact chart value at the new point: texture and shading stay attached to the surface and coarsen smoothly across levels, and a seam is never crossed because its twins are frozen, not because a blend is rejected; normals are **inherited from the source, never recomputed**, so the lighting is identical to level 0 whatever the source's winding), and the levels are **packed into the mesh's single vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry. 2. **Per frame (CPU).** For each entity, the bounding sphere used by culling is projected to screen pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going finer is immediate, going coarser only below 80 % of the bound (a 20 % dead band) — which is what prevents flicker when an entity hovers around a threshold. Default thresholds: 48 px and 12 px (bigger than 48 px → full detail; smaller than 12 px → coarsest). 3. **Per frame (GPU).** The `cull` pass reads the slot's level, looks up the matching row of the mesh's LOD table (element-unit offsets + counts), and writes the indirect draw arguments from it. ### Using it ```rust // One level (the default): create_mesh is unchanged. let id = scene.create_mesh("hero", &geometry, &material)?; // Auto-generated levels 1..3 (decimated at half, quarter, eighth the triangle count). let id = scene.create_mesh_with_lod("hero", &geometry, &material, 4)?; // Or supply your own levels (same attributes, same indexed-ness as level 0). scene.add_mesh_lod("hero", 1, &my_coarse_geometry)?; ``` Toggle at runtime (off = every slot forced to level 0 = byte-identical rendering to the pre-LOD engine — the level-0 rows carry the full-mesh counts, so nothing else changes): ```rust app.renderer().set_lod_enabled(false); ``` ### Constraint: packed LOD buffers A level is **not a separate buffer**: the mesh's levels are concatenated into its one vertex buffer and one index buffer, and the per-mesh LOD table stores each level's offsets/counts. Two consequences: - **u16 indices** → the *sum* of all levels must stay under 65 535 vertices (the scene rejects a level set that would not fit, with a clear error); - **at most 4 levels** per mesh (`MAX_LOD_LEVELS`, also the size of the GPU table row). Indexed-ness: levels supplied through `add_mesh_lod` must match level 0's indexed-ness (validated). Auto-generated levels from a **non-indexed** level 0 are indexed anyway (decimation rebuilds with indices), and the packed buffer supports that mix — the per-slot draw command follows the level the CPU chose (the shadow pass always uses the level-0 command, so casters stay at full detail). ### How to verify LOD with the debug dump The debug dump (below) prints, per frame: the per-slot **levels** and each mesh's **LOD table** (rows = `vertex_offset / vertex_count / index_offset / index_count`, element units). The clean test is to **zoom the camera out**: the entities' perceived size drops below the thresholds, the levels step up (0 → 1 → 2), and the indirect argument counts shrink to the corresponding rows — e.g. the demo's 3 840-index sphere drops to 1 824, then 912 — while the levels stay **stable frame to frame** (hysteresis holding). Verified 2026-09-23: at the demo's default distance every entity sits at level 0 with full counts; zoomed to 4.6×, all multi-level meshes select level 1 with exactly their L1 rows, stable across frames. ## 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, the cull uniforms, the per-slot **LOD levels** and the per-mesh **LOD tables**. 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 — and with LOD on, the *row* the count comes from tells you the selected level (see above). 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 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). - **LOD levels are packed into the mesh's own buffers**: u16 indices cap the *total* across all levels at 65 535 vertices, and there are at most 4 levels. The decimation (quadric edge collapse + attribute-aware welding) is a setup-time cost only (a few ms for thousands of triangles); the per-frame cost is one sphere projection per entity on the CPU. - **LOD detail loss is visible by design** — the hysteresis dead band makes the pop rare and one-directional (immediate when gaining detail, delayed when losing it), but a coarse level is coarser. `set_lod_enabled(false)` is the escape hatch. 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)