LOD GPU
This commit is contained in:
+87
-5
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user