Files
wsg/docs/user/gpu-driven.md
T
Jérôme Bousquié eac266dd86 LOD: interpoler UVs/couleurs des vertices déplacés par le repli
Le vertex-cible d'un repli se déplace au point optimal de l'arête mais
conserveait l'UV du weld — désaccord position/UV croissant en cascade :
la texture 'fuit' et les motifs (rayures) disparaissent aux niveaux
lointains, avec un changement radical entre deux LOD.

- Collapse porte désormais les tables uvs/colors (clonées au weld).
- collapse_edge interpole les UVs de la cible : uv_t ← (1−λ)·uv_s + λ·uv_t,
  avec le même λ que le déplacement (cost_and_point renvoie désormais λ).
- Garde-fou seam : si |Δu| > 0.5 ou |Δv| > 0.5 (saut de texture), la cible
  garde son UV — l'interpolation ne traverse jamais une seam.
- Couleurs : toujours interpolées (espace colorimétrique continu).
- Compaction : la sortie lit les tables mises à jour (c.uvs/c.colors), pas
  les tables d'origine du weld.
- Docs : DRAFT.md (Sortie), gpu-driven.md (décimination), ARCHI_CPU_GPU (LOD).
2026-09-23 12:21:32 +02:00

13 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 closed mesh stays closed (no holes, no non-manifold "books"), a boundary collapse removes one face; duplicate corners are welded (relative tolerance 1e-6); a survivor moved by a collapse gets its UV/color interpolated between the collapsed endpoints (same λ as its new position — the texture stays attached to the surface and coarsens smoothly across levels, never across a UV seam), 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 (smallest-triangle removal + 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