Files
wsg/docs/user/gpu-driven.md
T
Jérôme Bousquié 004761252b LOD: fige les jumeaux de fente (slit twins) + blend UV linéaire (le fold est supprimé)
- weld: Δ UV = 0.5 exact n'est plus soudé (ambigu: fente à sa plus large
  vs saut légitime — la colonne u=1 du cône vs le chart disque du cap
  tombait exactement dessus et mélangeait les charts)
- collapse: les UV se blendent linéairement. Le fold par coordonnée
  (Δ entier → 0) figeait l'UV du sommet sur les vertices de base
  (bande de rayures, signalée par l'utilisateur) — il était inutile:
  les jumeaux de fente sont gelés, aucun repli ne traverse la fente
- welded renvoie un struct Welded (clippy)
- test cône: dual-chart (fan latéral bilinéaire + disque du cap),
  surface latérale r = λ; test seam/span réécrit selon la sémantique finale
- doc: gpu-driven.md, ARCHI_CPU_GPU.md (box LOD), DRAFT.md (D10) alignés
  sur la sémantique finale (gel des jumeaux, blend linéaire, seuil strict)
- AGENTS.md: gotcha 'ne jamais folder un saut entier de tuile'

102 tests passent, demo lance et rend sans erreur.
2026-09-23 16:55:44 +02:00

14 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, 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.

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.

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

// 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):

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):

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:

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 · Back to User documentation index