eac266dd86
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).
235 lines
13 KiB
Markdown
235 lines
13 KiB
Markdown
# 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 **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
|
||
|
||
```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 (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](examples.md) · Back to [User documentation index](README.md)
|