This commit is contained in:
Jérôme Bousquié
2026-09-22 20:47:36 +02:00
parent 531c43a457
commit f15e920109
16 changed files with 1981 additions and 264 deletions
+87 -5
View File
@@ -2,7 +2,8 @@
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**.
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
@@ -13,7 +14,9 @@ Each frame, before the render passes, two compute passes run over a fixed-capaci
(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).
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
@@ -77,6 +80,77 @@ out of view (false negative), but it will **never cull an object that is actuall
(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 greedy decimation (`Geometry::generate_lod_levels`): the smallest
triangles are removed first (no edge map, no crease handling — a subset of the faces of a valid
mesh is valid), duplicate corners are welded, 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
@@ -88,9 +162,10 @@ 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
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
@@ -137,6 +212,13 @@ and verified fixed, see the D14 note in `docs/tech/ARCHI_CPU_GPU.md`.)
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.