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
+288 -158
View File
@@ -1,190 +1,320 @@
# DRAFT — Étape 18 : Batching par Material (ROADMAP 4.3)
# Étape 19 — LOD (Level of Detail) par entité
> **Statut** : brouillon de conception (à valider avant implémentation).
> Couvre le 1er item de la Phase 4.3 de `docs/ROADMAP.md` :
> « Batching par Material (réduction des state changes GPU) ».
>
> **Archive** : le draft Étape 17 (rendu GPU-driven) a été vidé après validation (2026-09-22).
> Référence durable : `docs/tech/ARCHI_CPU_GPU.md` (écarts D1/D4/D5/D12 + piège D14) ;
> texte intégral : git `3a424af` (`git show 3a424af:docs/DRAFT.md`).
## Contexte
## Contexte — où on en est
Le culling (Étape 17) supprime les objets **hors écran**. Le LOD supprime le travail **invisible**
sur les objets qui sont sur l'écran : un maillage de 3840 indices qui n'occupe que 20×20 pixels
à l'écran gaspille du GPU — presque tous ses triangles ne produisent aucun pixel.
Le rendu est 100 % indirect (Étape 17) : un draw indirect **par slot**, dans l'ordre d'insertion
des entités. À chaque draw, la passe principale met à jour (`Renderer::render_scene`,
`renderer.rs` §7) :
Principe : chaque mesh peut porter **plusieurs niveaux de géométrie** (L0 = détaillée, L1, L2 =
de plus en plus simplifiée, silhouettes proches). Chaque frame, on mesure la **taille perçue**
de chaque entité (rayon de sa bounding sphere projeté en pixels) et on choisit le niveau le plus
grossier **suffisant**. La transition est protégée par une **hystérésis** (bande morte) pour
éviter le scintillement d'un objet oscillant autour d'un seuil.
| Appel | Coût |
|---|---|
| `set_pipeline(material.pipeline)` | **changement d'état** (swap de pipeline côté driver) |
| `set_bind_group(0, frame)` | constant dans la passe (re-set inutile mais pas cher) |
| `set_bind_group(1, object, [offset dynamique])` | paramètre de draw — **pas** un changement d'état |
| `set_bind_group(2, material.texture_bind_group)` | **changement d'état** (un bind group par `Material`) |
| `set_bind_group(3, shadow)` | constant |
| `set_vertex_buffer` / `set_index_buffer` | par mesh, pas cher |
| `draw_*_indirect` | le draw lui-même |
ROADMAP : item 4.3 « Level of Detail (LOD) ». Le DRAFT de l'Étape 18 (batching par material) est
remplacé par ce draft — il est validé et son contenu est dans `ARCHI_CPU_GPU.md` + git history.
→ Le nombre de changements d'état (pipeline + bind group @2) est proportionnel au **nombre
d'entités**, même quand des dizaines d'entités partagent le même `Material`. La doc du module
promet déjà « Entity sorting ... minimizes pipeline switches (batching by material) » — c'est ce
que fait cette étape.
## La contrainte d'architecture qui tout détermine
Le pass d'ombre n'a qu'**un** pipeline et l'appelle **une seule fois** avant la boucle
(`renderer.rs` §5) → déjà « batché » sur l'état : rien à y faire.
## Objectif
Réduire les changements d'état de la passe principale de **O(entités)** à **O(matériaux
distincts)**, sans changer le rendu, sans changer l'API publique, et sans toucher au chemin
GPU-driven (compute, culling, buffers, indirect) ni au pass d'ombre.
## Contraintes & principes
- **Aucune rupture d'API** : optimisation interne du `Renderer` ; aucun type/méthode publique
nouveau ; les exemples ne sont pas modifiés.
- **Aucune régression visuelle** : tous les pipelines de l'engine sont **opaques**
(`BlendState::REPLACE`, `pipeline_cache.rs`) → le depth buffer résout l'ordre → réordonner
les draws est visuellement neutre (D4).
- **Culling/indirect intacts** : les verdicts GPU (args = 0 → no-op) et les buffers ne changent
pas ; seul l'**ordre d'émission** des draws change.
- **Déterminisme** : l'ordre des draws doit rester reproductible frame après frame (slots
append-only stables, Étape 17 D9).
- Capacité ≤ 256 slots → le groupage (O(N), `HashMap` ≤ 256 entrées) est du bruit ; on le
refait **chaque frame** (toujours correct, aucun cache à invalider).
`set_vertex_buffer` est un **état de passe posé par le CPU** — le GPU ne peut pas choisir entre
deux buffers par draw indirect. Donc **plusieurs buffers par niveau est exclu** : tous les
niveaux d'un mesh vivent dans **un seul buffer de vertices** (et un seul buffer d'indices), et
le GPU sélectionne le niveau en écrivant l'**offset** (`baseVertex` / `firstVertex`) et le
`count` dans les indirect draw args — des champs que le draw indirect lit déjà. C'est ce qui
rend le LOD compatible avec l'architecture GPU-driven existante sans aucun nouveau mécanisme de
rendu : le pass `cull` écrit déjà les draw args, il écrit juste les bonnes valeurs.
## Décisions
### D1 — Clé de groupage : l'identité du `Material` (pointeur `Arc`), pas le shader_id
- Le vrai « état » qui change entre deux draws partageant un pipeline est le **bind group @2**
(texture/sampler) : chaque `Material` en possède un propre (`Material::build`). Deux
materials qui partagent le même `shader_id` partagent le pipeline (Arc, `PipelineCache`)
mais **pas** le bind group @2 → grouper au niveau pipeline ferait alterner le @2.
- Clé retenue : `Arc::as_ptr(&material)` — même pointeur ⟺ même objet `Material` ⟺ même
pipeline **et** même bind group @2 → les deux changements d'état sont figés dans le groupe.
- Le fallback `scene.default_material()` est un `Arc` unique en cache (`RefCell`) → toutes les
entités sans material forment un groupe.
- **Subtilité de durée de vie** : le groupage matérialise d'abord les `Arc<Material>` dans un
`Vec` (un par slot actif) ; les pointeurs-clés ne sont dérivés qu'ensuite. Les `Arc` restent
donc vivants pendant toute la passe → aucun pointeur ne pend (le `Arc` retourné par
`default_material()` est un clone : sans le `Vec`, il serait libéré en fin de closure).
### D1 — Le CPU décide du niveau, le GPU mappe niveau → draw args
### D2 — Ordre des groupes : première apparition en ordre de slots ; intra-groupe : ordre de slots
- On parcourt les slots dans leur ordre stable (insertion, Étape 17 D9) ; le groupe d'un slot
est créé à la **première** apparition de sa clé. `HashMap<clé, index>` + `Vec<groupe>` :
O(N), sans tri, **déterministe** et stable frame à frame (scène inchangée → ordre inchangé).
- En pratique, le premier draw de chaque groupe est le même que l'ancien premier draw du slot
→ l'impression visuelle est préservée ; seuls les draws de matériaux *différents*
s'intercalent moins.
Le CPU calcule chaque frame le niveau par entité (rayon projeté + hystérésis) et l'uploade dans
un petit buffer par slot ; le pass `cull` existe déjà et lit ce niveau pour choisir la ligne du
tableau par mesh (count + offset).
### D3 — Les slots cullés (no-op) restent émis dans leur groupe
- Le CPU ne connaît pas le verdict GPU du culling (un readback par frame stallerait la boucle
— cf. `debug_dump`) : le draw d'un slot cullé est un indirect **zéro count ≈ gratuit** ; on
l'émet quand même, dans son groupe.
- Le nombre de **draw calls** est donc inchangé (1 par slot actif) ; seul le nombre de
**changements d'état** baisse. Réduire aussi les draw calls = multi-instancing (exclu —
Périmètre).
Raisonnement :
- **Testabilité** : la décision est une fonction pure Rust `lod_level(radius_px, last, max,
thresholds)` → testable sans GPU, comme `batch_slots` (maison).
- Le CPU **réécrit déjà** les transform slots chaque frame — un niveau de plus dans la même
passe coûte ~1 flop multipli par entité.
- Aucune extension de `CullUniforms` (pas de view-projection à ajouter) — le CPU a déjà
caméra + viewport.
- L'alternative (décision GPU : viewProj + hauteur dans les cull uniforms, état hystérésis GPU)
est documentée comme **extension future** — elle n'apporte qu'un gain marginal (1 KB de
upload/frame) au prix d'un test par readback.
### D4 — Réordonnancement sûr : pipelines 100 % opaques
- `pipeline_cache.rs` crée toutes les pipelines avec `blend: Some(BlendState::REPLACE)`
(aucun alpha blending dans l'engine) et le depth write est actif partout (Étape 9) →
l'ordre de rasterisation n'a pas d'impact visuel.
- **Contrainte à documenter** (docs user + rustdoc) : si du blending transparent est ajouté un
jour, il faudra isoler les matériaux transparents (trier back-to-front en fin de passe) —
signalé ici comme prérequis d'un futur `Material.blend`. Le groupage par Material reste
correct en l'état ; seule l'ordre inter-groupes devra évoluer.
### D2 — Un seul buffer packé par mesh, **niveau 0 à l'offset 0**
### D5 — Groupage en fonction pure, testable sans GPU
- Le groupage est factorisé en fonction libre :
`fn batch_slots<K: Eq + Hash>(keys: &[K]) -> Vec<Vec<usize>>`
(groupes dans l'ordre de première apparition de la clé ; indices dans l'ordre d'entrée ;
chaque indice apparaît exactement une fois).
- Le `Renderer` l'appelle avec `keys = [*const Material]` (D1) ; les **tests unitaires**
l'appellent avec des clés `u32` → testable sans instance/device wgpu (un `Material` exige
un pipeline compilé = device ; les tests de la crate restent headless/CI-safe).
- Les tombstones (`active == false`) sont filtrés **avant** l'appel (comme aujourd'hui) :
`batch_slots` ne voit que les slots actifs.
Les vertices de tous les niveaux sont concaténés dans le `vertex_buffer` du mesh (idem indices).
Niveau 0 en tête : la voie basse `draw_entity` (`draw(0..num_vertices)`, sans LOD) et tout draw
direct existant restent **inchangés** — ils lisent le début du buffer, qui est encore L0.
### D6 — Pass d'ombre : inchangé (déjà batché)
- Un seul `shadow_pipeline`, `set_pipeline` une seule fois avant la boucle ; l'état résiduel
par slot (offset dynamique @1 + vertex/index buffers) n'est pas un changement d'état
driver → `render_shadow_map` n'est pas modifié par cette étape.
### D3 — Indices rebasés par niveau
## Mécanisme par frame (seul le point 7 change)
Chaque niveau est une géométrie indépendante (indices 0-based sur ses propres vertices). Le
packer décale les indices du niveau k de `vertex_offset(k)` ; le draw indirect du niveau k pose
`baseVertex = vertex_offset(k)` → l'index buffer concaténé fonctionne tel quel. (Un niveau reste
< 65 536 vertices, sinon erreur à l'ajout — `u16`.)
### D4 — Hystérésis **asymétrique**
Seuils descendants `t[0] > t[1] > …` (pixels) : `t[k]` = rayon **au-dessus** duquel le niveau
k+1 est exigé (équiv. : le niveau k+1 est suffisant tant que `r ≤ t[k]`). Le niveau cible sans
hystérésis est le **plus grossier** dont la borne est encore respectée (L0 n'a pas de borne).
- Passer à un niveau **plus grossier** : seulement si `r ≤ borne × 0.8` (bande morte 20 %).
- Passer à un niveau **plus fin** : immédiatement dès que `r` franchit la borne.
Si un mesh a plus de niveaux que de seuils, les niveaux excédentaires partagent la dernière
borne (clamp) — avec `[48, 12]` seuls les 3 premiers niveaux sont distincts.
Le pop « perte de détail » (le plus visible) est retardé ; le pop « retour au détail » est
immédiat. C'est la pratique standard des moteurs — et c'est testable en série de rayons
oscillants autour d'un seuil.
### D5 — Deux nouveaux petits buffers, layout des slots existants intact
Les 4 composants de `TransformSlot.flags` sont tous occupés (x=mesh, y=active, z=count,
w=has_index) — étendre le slot à 68 B ferait ripple dans tout le contrat documenté. À la place :
- `lod_levels` : `array<u32>` par **slot** (256 × 4 B = 1 KB), re-uploadé chaque frame (comme
les transforms). Nouveau `@group(2) @binding(3)` du pass `cull`.
- `lod_tables` : par **mesh** (80 B = count + pad + 4 lignes de 16 B), re-uploadé chaque frame
(mêmes motifs que le bbox buffer : petit, et le mapping mesh-index → tableau reste correct si
des meshes/niveaux sont ajoutés à chaud).
`flags.z` (count plein) reste dans le slot (métadonne ; le GPU ne l'utilise plus pour la
branche visible, qui passe par le tableau — voir D6).
### D6 — Les deux branches visibles écrivent depuis le tableau LOD
Dans `cull`, la branche « culling désactivé » et la branche « visible » remplacent
`set_draw_count(i, flags.z)` par un helper `write_level_args(i, t)` : lire `lod_levels[i]`
(clampé au count du mesh), indexer la ligne du mesh, écrire
`.a = (count_ligne, 1, 0, baseVertex_ligne)` (indexé : `baseVertex = .a.w` ; non indexé :
`firstVertex = .a.z`). Les branches zéro (slot ≥ num_slots, inactive) sont inchangées.
Conséquence : un mesh **sans** LOD (1 niveau) a un tableau d'une seule ligne = count + offset 0
→ comportement **bit-identique** à aujourd'hui.
### D7 — Le pass d'ombre partage les draw args → il dessine le niveau sélectionné
Le pass d'ombre lit le même buffer de draw args : les ombres utilisent **automatiquement** le
niveau LOD (ombres moins chères, cohérent). Accepté pour v1 ; documenté. (Si un jour on veut
des ombres au niveau fin, ce serait un deuxième buffer de draw args — hors périmètre.)
### D8 — Niveaux ≤ 4, seuils `[48, 12]` px en `conf.rs` (v1)
`MAX_LOD_LEVELS = 4`. Un mesh avec 1 seul niveau = LOD éteint pour lui (aucun changement de
comportement). Les seuils par défaut sont des constantes (pas encore configurables par scene —
extension triviale plus tard). Rayon projeté = le **même** rayon sphere que le culling
(circonradius × max scale, centre transformé) → pas de nouvelle donnée géométrique.
### D9 — Interrupteur global LOD au niveau du Renderer
`Renderer::set_lod_enabled(bool)` (défaut `true`) — coupure générale, orthogonale au mode
par mesh. Quand il est éteint, le calcul par entité est **court-circuité** (niveaux tous à
0, aucune projection calculée) : tout se dessine au niveau 0, le GPU n'est pas touché
(il lit simplement le niveau 0). Appelable à l'instanciation (juste après `Renderer::new`)
ou **à chaud** (toggle de debug au runtime). Aucun changement de signature existante.
### D10 — Niveaux **générés automatiquement** pour les géométries utilisateur
L'utilisateur déclare sa géométrie comme aujourd'hui (`positions`/`indices`/`normals`/`uvs`,
y compris générée procéduralement par lui) et la bibliothèque **calcule les niveaux LOD sous le
capot**. Mécanisme : décimation gloutonne du maillage indexé —
```
[CPU] write_buffer TransformBuffer / CullUniforms / (BBoxBuffer si génération changée)
[Compute 1] compute_matrices [Compute 2] cull (inchangés)
[Ombre] (si caster) draw indirect par slot, 1 pipeline (inchangé — D6)
[Main] slots groupés par Material (D1/D2) :
groups = batch_slots(keys)
pour chaque groupe G :
set_pipeline(G) + set_bind_group(0) + set_bind_group(2, G) + set_bind_group(3)
pour chaque slot s de G :
set_bind_group(1, [offset(s)]) + set_vertex/set_index + draw_indirect(s)
[Queue] submit
Geometry::decimated(&self, target_triangles: u32) -> Geometry (pure, sans GPU)
```
- Triangles (non dégénérés) triés par aire croissante ; on retire le plus petit jusqu'à la
cible — **sans carte de topologie** (« Règle A ») : tout sous-ensemble de faces d'un mesh valide
est un mesh valide (pas de trou à boucher, pas de flip). La cible est donc toujours atteignable
(clamp `[1, T]`).
- Sortie : re-indexation (weld par position exacte), **normales lisses recalculées** sur les faces
survivantes (normales de faces accumulées par coin soudé, puis normalisées), UVs/couleurs =
valeur du premier vertex de chaque groupe soudé (documenté : évite le bleeding entre seams UV ;
le LOD sacrifie la précision UV au profit de la silhouette).
- Non indexé à l'entrée → weld par position préalable (la sortie est **toujours indexée**).
Triangles dégénérés jetés.
- **Déterministe** (tri stable, tie-break par index d'origine) → counts reproductibles en tests.
- Fallback : la cible est clampée à `[1, T]` — un mesh n'est jamais décimé sous un triangle, et
le count **réel** va dans le tableau LOD (`validate()` OK, jamais de géométrie corrompue).
**L0 reste la géométrie exacte de l'utilisateur, byte-pour-byte** — seule la voie basse et les
niveaux générés sont concernés par la simplification. Coût : setup uniquement (quelques ms pour
des milliers de triangles), jamais par frame. Ratios par niveau : 0.5^k (50 %, 25 %, 12.5 %).
`add_mesh_lod(id, level, geometry)` (niveaux explicites) est la voie **générale** : elle
**remplace** un niveau existant ou **ajoute** le niveau libre suivant (≤ 4 ; même jeu
**d'attributs** et même **indexation** que L0 — validé). Applicable à **tout** mesh, y compris
Auto — ex. : remplacer un niveau décimé d'une primitive de la maison par une régénération à
la tessellation divisée par 2 (plus propre que la décimation pour les meshes réguliers).
`create_mesh_with_lod` est l'ergonomie qui génère les niveaux 1..n à la création ; les deux
voies sont combinables.
### D11 — Mode LOD par mesh (`Off` / `Auto` / `Manual`) — contrat pour les meshes updatables
Le mesh porte un mode interne : `Off` (1 niveau = comportement d'aujourd'hui), `Auto` (niveaux
décimés, D10), `Manual` (niveaux fournis, `add_mesh_lod`).
Question posée : **un mesh updatable** (positions/normals réécrits par frame) — faut-il pouvoir
désactiver le LOD au niveau mesh ? **Oui, et automatiquement** : les niveaux générés sont
**dérivés de l'ancien L0** ; si la géométrie change, ils représentent une forme obsolète. Contrat
réservé (pas d'API d'update dans v1, mais le design le prévoit) : toute future
`mesh.update_geometry(…)` **réinitialise le mode à `Off`** (niveaux jetés, buffer packé reconstruit
avec le seul nouveau L0, tableau à 1 ligne) — l'utilisateur n'a rien à désactiver : un mesh
mis à jour par frame est simplement un mesh mono-niveau. Documenter cette règle quand l'API
d'update existera.
## Flux de données (changement en surgraisse)
```
CPU (chaque frame) GPU
───────────────────── ───
Transforms (slots) ──upload──► transforms
CullUniforms (planes,…) ──upload──► cull_u
BBoxes (par mesh) ──upload──► bboxes
** LodLevels (par slot) ──upload──► lod_levels (binding 3)
** LodTables (par mesh) ──upload──► lod_tables (binding 4)
│
compute_matrices (inchangé) │
cull : zéro si culled/inactive│
sinon write_level_args └──► draw_args
│
main pass (Étape 18 : groupé par material, set_vertex_buffer
du buffer packé par slot, draw indirect lit count+offset)
```
## Gain attendu (changements d'état / frame, passe principale)
## Niveaux dans le démo
| Scène | Avant (par entité) | Après (par matériau distinct) |
|---|---|---|
| `demo` (7 entités, ~5 materials distincts) | 7 × (pipeline + @2) | 5 × (pipeline + @2) |
| 200 cubes / 1 material | 200 × (pipeline + @2) | **1** × (pipeline + @2) |
Le démo exerce l'**API utilisateur demandée** : géométrie L0 déclarée, niveaux générés sous le
capot (D10). Les primitives de la maison sont des « géométries utilisateur » pour le LOD :
Le `set_bind_group(1, offset dynamique)` et les vertex/index buffers restent par draw
(paramètres de draw, pas d'état driver). Le gain est maximal quand peu de matériaux distincts
pour beaucoup d'entités — le cas « instancé » en attendant le multi-instancing.
| Mesh | L0 (déclaré) | L1, L2 (auto : décimation 50 % / 25 %) |
|-----------|--------------|-----------------------------------------|
| sphere | 32×20 | générés par `decimated` |
| cylinder | 32 | générés |
| cone | 32 | générés |
| torus | 24×16 | générés |
| icosphere | subdiv 2 | générés (triangles quasi uniformes → décimation propre) |
| cube,plane| — | `create_mesh` simple, 1 niveau |
## Fichiers touchés
Setup du démo : `create_mesh_with_lod(id, geo, mat, 3)` pour les 5 meshes tessellés, `create_mesh`
pour cube/plane. (Les niveaux « re-tessellés » du draft initial servent aux tests unitaires de
l'issue de secours `add_mesh_lod`, pas au démo.)
| Fichier | Action |
|---------|--------|
| `lib/src/core/renderer.rs` | `render_scene` : boucle plate → `batch_slots` + boucle par groupe ; fonction libre `batch_slots` + tests unitaires ; rustdoc du module alignée |
| `docs/user/gpu-driven.md` | + note : draws groupés par matériau (interne, sans effet API) ; contrainte blending (D4) |
| `docs/tech/ARCHI_CPU_GPU.md` | + note : la passe main émet les draws groupés par Material (Étape 18) |
| `docs/ROADMAP.md` | Coche « Batching par Material » (4.3) + date |
## API publique (ajouts — aucune rupture)
## Périmètre exclus (reportés)
```rust
// Scene
/// Crée un mesh dont les `levels` niveaux (2..=4) sont générés automatiquement
/// par décimation (D10) de la géométrie fournie. L0 = la géométrie, exacte.
pub fn create_mesh_with_lod(
&mut self, id: &str, geometry: Geometry, material: Option<&str>, levels: u8,
) -> Result<String, String>;
- **Multi-instancing** (1 draw par groupe mesh+material, matrice par instance) : demande un
changement de shader (matrice instanciée) + draw instancié par groupe — étape distincte,
plus lourde (déjà reportée depuis l'Étape 17).
- **LOD, HDR + tone mapping** (items 2-3 de la Phase 4.3) : étapes distinctes.
- **Sauter les no-ops par readback** (réduire les draw calls, pas seulement les états) : un
readback synchro stalle la boucle de rendu → rejeté en v1.
- **Blending/transparent** : hors engine actuel (D4).
/// Remplace (ou ajoute, si `level` est le niveau libre suivant) un niveau **explicite** du mesh
/// `id` (≤ 4 niveaux ; même jeu d'attributs et même indexation que L0 ; pack total < 65 536
/// vertices). Reconstruit les buffers packés (D2). Combinable avec `create_mesh_with_lod`.
pub fn add_mesh_lod(&mut self, id: &str, level: u8, geometry: Geometry) -> Result<(), String>;
## Risques & mitigations
// Renderer
/// Interrupteur global LOD (D9). Défaut `true`. `false` → tout se dessine au niveau 0,
/// calcul par entité court-circuité. Appelable à l'instanciation ou à chaud.
pub fn set_lod_enabled(&self, enabled: bool);
```
| Risque | Mitigation |
|--------|-----------|
| **Réordonnancement → rendu différent** | D4 : pipelines opaques (`REPLACE`) + depth write → le depth buffer résout l'ordre ; vérifié headless + visuellement (plan 4-5), draw args `debug_dump` inchangés (plan 6). |
| **Pointeur-clé `Arc::as_ptr` en pend** | D1 : les `Arc` sont matérialisés dans un `Vec` vivant pendant la passe ; les groupes reconstruits chaque frame → aucune hypothèse de stabilité entre frames. |
| **Groupage O(N) par frame** | N ≤ 256, `HashMap` ≤ 256 entrées → bruit ; pas de cache (D5 : toujours correct). |
| **Ordre des groupes non déterministe** | D2 : première apparition sur des slots stables (Étape 17 D9) → déterministe ; verrouillé par test (D5). |
| **Matériau transparent futur** | D4 documenté comme contrainte ; le groupage par Material reste correct, seule l'ordre inter-groupes devra évoluer. |
`create_mesh` (sans LOD) reste la voie par défaut : un mesh simple se comporte exactement comme
aujourd'hui. Le LOD s'active **explicitement** (`create_mesh_with_lod`) ou par niveaux explicites
(`add_mesh_lod`) — pas de changement de comportement pour un utilisateur qui ne demande rien.
## Plan de vérification
## Plan de code
1. `cargo build --workspace` — OK, sans avertissement.
2. `cargo test --workspace` — vert, y compris les nouveaux tests `batch_slots` (première
apparition, ordre intra-groupe, chaque indice une fois, entrées vides, un seul groupe,
tout distinct).
3. `cargo fmt --all -- --check` — clean.
4. **`demo` headless** (`WGPU_BACKEND=vulkan timeout 10 ... --example demo`) → exit 0.
5. **A/B changements d'état** : compteur temporaire de `set_pipeline` par frame (sous
`WSG_DEBUG_DUMP`) — avant : 7 dans le `demo` ; après : nombre de materials distincts.
(Compteur retiré après mesure, ou conservé sous `WSG_DEBUG_DUMP` au choix.)
6. **Rendu identique** : `demo` avant/après → même image (opaque, D4) ; `debug_dump` :
draw args inchangés (le culling n'est pas touché).
7. Check liens doc — 0 lien cassé.
| Fichier | Changement |
|---|---|
| `resources/uniform.rs` | `LodTable` (80 B : `count` + 4 × `LodRow{base, count}` 16 B) + `LOD_TABLE_SIZE` ; export. |
| `shaders/gpu_driven.wgsl` | structs `LodTable`/`LodRow` ; bindings 3+4 group(2) ; helper `write_level_args` ; `cull` : les 2 branches visibles l'appellent ; commentaire header mis à jour. |
| `math/geometry.rs` | `Geometry::decimated(target) -> Geometry` (D10, pure) + tests ; `Geometry::generate_lod_levels(levels)` (ratios 0.5^k). |
| `resources/mesh.rs` | champ `lod_geometries: Vec<Arc<Geometry>>` + `lod_mode` (D11) ; **packer** : concatène `to_vertices()` des niveaux → `vertex_buffer` packé ; indices rebasés concaténés → `index_buffer` packé ; `num_*` = niveau 0 ; `LodTable::from_geometry` par mesh. |
| `scene/scene.rs` | `create_mesh_with_lod` + `add_mesh_lod` (validation + rebuild packé via le contexte GPU de la scene) ; `packed_lod_levels()` (par frame, par slot — décision D1) ; `mesh_lod_tables()` (par mesh, comme `mesh_bboxes`). |
| `core/renderer.rs` | buffer `lod_levels_buffer` (1 KB) + `lod_table_buffer` ; layout group(2) +2 bindings ; upload par frame ; **D1** : `lod_levels: Vec<u32>` état par slot + fn pures `lod_level`/`projected_radius_px` (view + proj[1][1] + hauteur viewport, déjà disponibles) ; **D9** : champ `lod_enabled` + `set_lod_enabled` (court-circuit du calcul) ; `debug_dump` : readback du buffer de niveaux. |
| `utils/conf.rs` | `MAX_LOD_LEVELS = 4` ; `LOD_THRESHOLDS: [f32; 2] = [48.0, 12.0]`. |
| `examples/demo.rs` | `create_mesh_with_lod(id, geo, mat, 3)` pour les 5 meshes tessellés (D10) ; cube/plane en `create_mesh`. |
## Critères d'acceptation (definition of done)
### Fonction pure (testable, module `math` ou `resources::lod`)
- [x] `render_scene` émet les draws **groupés par Material** (D1/D2) ; pass d'ombre inchangé (D6).
- [x] `batch_slots` fonction pure testable (D5) + tests unitaires verts (6 tests, CI-safe).
- [x] Aucune rupture d'API publique ; exemples non modifiés.
- [x] Rendu identique (D4) — vérifié headless ; draw args `debug_dump` inchangés (6/36/3840/960/384/192/2304).
- [x] Changements d'état réduits : compteur `set_pipeline` du `demo` = 3 = nb de materials distincts (avant : 7).
- [x] Docs : `gpu-driven.md` § « Batching by material » + `ARCHI_CPU_GPU.md` + ROADMAP 4.3 coché.
```rust
/// Niveau cible : le plus grossier dont la borne est respectée, avec hystérésis asymétrique (D4).
/// `thresholds[k]` = rayon px au-dessus duquel le niveau k+1 est exigé (descendants) ;
/// niveaux au-delà du nb de seuils : borne clamped (dernier seuil).
/// `last` = niveau de la frame précédente. Renvoie un niveau ≤ `max_level`.
pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32]) -> u32;
/// Rayon projeté en pixels de la bounding sphere (même sphere que le culling, D8).
/// `depth <= eps` (objet dans la caméra) → f32::INFINITY (niveau 0).
pub fn projected_radius_px(center_world: Vec3, radius: f32, view: Mat4, proj: Mat4, height_px: f32) -> f32;
/// Décimation gloutonne (D10, Règle A) : renvoie un `Geometry` indexé avec `target_triangles`
/// triangles (clamp `[1, T]`) — les plus petits partent en premier (faible impact visuel),
/// normales lisses recalculées. Déterministe.
pub fn decimated(&self, target_triangles: u32) -> Geometry; // sur Geometry
```
## Vérification
1. **Unit tests `lod_level`** (sans GPU) : seuils simples (r > t0 → 0, entre → 1, < t1 → 2) ;
**anti-scintillement** : série de rayons oscillant ±10 % autour d'un seuil → le niveau reste
stable (montée immédiate, descente retardée par le facteur 0.8) ; clamp `max_level` ;
`INFINITY → 0`.
2. **Unit tests `projected_radius_px`** : objet à l'origine, caméra sur Z → valeur analytique ;
invariance d'échelle (objets 10× plus gros 10× plus loin → même rayon px).
3. **Unit tests du packer** : offsets/counts par niveau sur un cas 2 niveaux ; niveau 0 à
offset 0 ; indices rebasés.
4. **Unit tests `decimated`** : grille 2×2 (4 triangles de tailles différentes) → cible 2 :
count exact, positions ⊆ entrée, les plus petits retirés en premier ; entrée non indexée →
sortie indexée weldée (coins dupliqués fondus, UV = premier rencontré) ; triangle dégénéré
en entrée → jeté ; **déterminisme** (deux appels → sortie identique) ; icosahèdre 20 → 10 :
count exact + bbox dans l'originale ; icosphere subdiv 1 → 50 % : count exact ; clamp : cible
0 ou > T → clone ; `validate()` OK.
5. **GPU A/B** : entités temporaires à z = +6 / +30 / +60 (démo) → readback `debug_dump` :
niveaux 0 / 1 / 2 selon la distance et **draw args = count de la ligne du tableau
CPU-uploadé** (self-cohérent, pas de count codé en dur — les niveaux auto ont des counts
déterministes mais calculés) ; entité culled (hors frustum) → 0 quel que soit le niveau.
6. **D9** : `set_lod_enabled(false)` au démarrage → draw args **bit-identiques** à l'exécutable
pré-LOD (tous niveaux 0) ; toggle à chaud → bascule visible au readback.
7. **D11** (contrat) : `add_mesh_lod` s'applique à tout mesh (y compris Auto — le niveau
explicite remplace le niveau décimé au même index) ; le mode `Off` (1 niveau) = comportement
d'aujourd'hui ; un mesh updatable futur repasse en `Off` (D11).
8. **Régression** : 69 tests verts ; démo silencieux par défaut ; batching (Étape 18) intact
(compteur de switches inchangé) ; mesh à 1 niveau → draw args **bit-identiques** à avant
l'étape (D6).
9. **Visuel** : zoom arrière sur le démo → les primitives basses du ring passent au niveau
simplifié sans pop visible (silhouettes décimées proches + hystérésis D4).
## Critères d'acceptation
- [ ] `lod_level` + `projected_radius_px` pures, unit-testées (dont la série oscillante).
- [ ] `decimated` pure, déterministe, unit-testée (counts, retire-les-plus-petits, weld + UV
premier rencontré, clamp `[1, T]`, dégénérés, `validate()` OK).
- [ ] Packer multi-niveaux : un buffer packé, niveau 0 à offset 0, indices rebasés.
- [ ] `create_mesh_with_lod` (niveaux auto) + `add_mesh_lod` (niveaux explicites, ≤ 4,
validation attributs/indexation/65536, combinables) ; aucune rupture d'API ;
`create_mesh` seul = comportement d'aujourd'hui.
- [ ] `Renderer::set_lod_enabled` (D9) : `false` → bit-identique à pré-LOD ; toggle à chaud.
- [ ] Pass `cull` : les 2 branches visibles écrivent depuis le tableau LOD ; mesh 1 niveau
bit-identique au comportement actuel.
- [ ] A/B GPU : niveaux 0/1/2 choisis selon la distance, counts correspondants, culling intact.
- [ ] 69+ tests verts, `cargo fmt` clean, démo silencieux par défaut.
- [ ] Docs : `gpu-driven.md` (section LOD + contrainte buffers) ; `ARCHI_CPU_GPU.md` ; ROADMAP
4.3 coché.
- [ ] DRAFT.md vidé après validation utilisateur (convention de la maison).
## Hors périmètre (suivant)
- **API d'update de géométrie** (mesh updatable par frame) — D11 réserve le contrat
(update ⇒ LOD réinitialisé à `Off`) mais l'API d'update elle-même est un autre sujet.
- Décision du niveau **côté GPU** (viewProj dans CullUniforms + état GPU) — option D1 future.
- Seuils **configurables par scene/mesh** (v1 : constantes `conf.rs`).
- Réglage fin du **qualité/cout** de la décimation (poids par arête, quadric edge collapse)
— v1 : aire + flip, suffisant pour les silhouettes.
- Geometric morphing / transitions douces entre niveaux (les pops restent discrets avec les
primitives procédurales ; le morphing est un sujet à part entière).
- LOD de **shader** (simplification du lighting par distance) — autre item (HDR/tone mapping).
- Ombres au niveau fin (D7 accepte le niveau LOD dans les ombres).
+1 -1
View File
@@ -162,7 +162,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
### 4.3 Optimisations
- [x] Batching par Material (réduction des state changes GPU) — 2026-09-22 (Étape 18 : draws groupés par `Arc<Material>` dans la passe principale, 1 `set_pipeline` par matériau distinct — le démo passe de 7 à 3 ; pass d'ombre inchangé)
- [ ] Level of Detail (LOD)
- [x] Level of Detail (LOD) — 2026-09-23 (Étape 19 : ≤ 4 niveaux/mesh — L0 exacte, L1–L3 par décimation gloutonne au setup (`Geometry::decimated`/`generate_lod_levels`, Règle A : retrait des plus petits triangles, weld par position exacte, rebase u16), packés dans les buffers vertex/index du mesh (offsets en unités d'élément, plafond 65 535 sommets) ; décision par frame **côté CPU** (sphère bounding projetée en pixels + hystérésis asymétrique ×0.8 — `math/lod.rs` pure, unit-testée), exécution **côté GPU** (le pass `cull` mappe niveau → ligne de la table LOD → args indirects) ; **activé par défaut**, `set_lod_enabled(false)` → rendu bit-à-bit identique au pré-LOD. Vérifié par readback GPU : zoom 4,6× → tous les meshes multi-niveaux passent au niveau 1 avec exactement leurs lignes L1 (ex. sphère 3840 → 1824 indices), stable frame à frame)
- [ ] HDR + Tone Mapping (optionnel)
### 4.4 Gestion du Resize (cycle de vie Surface + Depth)
+24 -4
View File
@@ -16,7 +16,7 @@ Bonnes Pratiques & Guide d'Implémentation
Ce document sert de spécification technique et de trame d'implémentation pour l'architecture de rendu 3D pilotée par le GPU (GPU-Driven Rendering) utilisant wgpu. L'objectif est de déléguer un maximum de charges de calcul au GPU pour soulager le CPU et maximiser les performances de parallélisme.
> **État du document : ACTUEL (implémenté — Phase 3 du ROADMAP, Étape 17, validé 2026-09-22).**
> **État du document : ACTUEL (implémenté — Phase 3 du ROADMAP, Étapes 17–19, validé 2026-09-22).**
> La répartition CPU/GPU, le compute pass (World Matrices + Frustum Culling), l'Indirect Draw Buffer
> et les buffers persistants en VRAM décrits ici sont en place : `shaders/gpu_driven.wgsl`
> (deux entry points `compute_matrices` + `cull`, un module, layout explicite à 3 groupes) et les
@@ -37,6 +37,16 @@ Ce document sert de spécification technique et de trame d'implémentation pour
> le pass d'ombre — un seul pipeline — est inchangé). Réordonnancement sûr car tous les pipelines
> sont opaques (`BlendState::REPLACE`) ; les no-ops cullés restent émis dans leur groupe.
> Détail : `docs/user/gpu-driven.md` § « Batching by material ».
> **LOD (Étape 19, 2026-09-23)** : le pass `cull` remplit désormais les arguments indirects à partir
> du **niveau de détail** du slot, et non d'un seul jeu de comptes. Le choix du niveau est fait **côté CPU**
> (rayon de la sphère bounding projeté en pixels + hystérésis asymétrique — `math/lod.rs`, pur et unit-testé) ;
> le GPU n'effectue que le mappage niveau → ligne de la table LOD du mesh. Les niveaux d'un mesh sont
> générés par décimation gloutonne au setup (`Geometry::decimated` : suppression des plus petits triangles,
> soudure par position exacte, rebase u16) et **empilés dans les buffers vertex/index du mesh** (offsets en
> unités d'élément, pas d'octet — c'est ce qu'exigent les arguments `drawIndirect*` de WebGPU ; plafond u16 :
> 65 535 sommets/mesh, 4 niveaux max). LOD activé par défaut ; `set_lod_enabled(false)` restaure un rendu
> bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md`
> § « Level of Detail ».
1. Répartition des Rôles : CPU vs GPU (La Source de Vérité)
@@ -45,7 +55,8 @@ Pour éviter les goulets d'étranglement dus aux allers-retours sur le bus PCIe,
Côté CPU (Source de Vérité)
- Ce qu'il conserve : Les données logiques et les transformations brutes des objets (ex: Vec<Transform> contenant la position, la rotation, et l'échelle).
- Ce qu'il fait : Il gère la logique de jeu, l'IA, le réseau et les interactions globales.
- Ce qu'il ne fait plus : Il ne calcule plus les matrices de transformation mondiales (World Matrices) en masse, et ne fait plus de tests de visibilité unitaires.
- Ce qu'il ne fait plus : Il ne calcule plus les matrices de transformation mondiales (World Matrices) en masse, et ne fait plus de tests de visibilité unitaires (le culling frustum reste 100 % GPU).
- Ce qu'il fait en plus (LOD, Étape 19) : le **choix du niveau de détail** par entité — O(N) projections de sphères en pixels + hystérésis, coût négligeable. C'est l'unique décision de visibilité/détail conservée côté CPU : elle dépend de la taille écran (un choix artistique), pas de la géométrie, et l'hystérésis a besoin de l'état de la frame précédente.
Côté GPU (Exécutant Autonome)
- Ce qu'il calcule : Les World Matrices, le Frustum Culling, et la génération des listes de dessin indirectes.
@@ -64,11 +75,11 @@ L'exécution des tâches s'appuie sur une structure séquentielle stricte au sei
```
Étape par étape :
- Mise à jour CPU (Minimaliste) : Le CPU écrit les transformations brutes (Transform) modifiées dans un buffer GPU mappé (single buffer en phase initiale — la synchronisation est assurée par `queue.submit()` qui garantit la séquence d'exécution). Double buffering sera ajouté uniquement si des artefacts apparaissent à haute fréquence (> 90 fps).
- Mise à jour CPU (Minimaliste) : Le CPU écrit les transformations brutes (Transform) modifiées dans un buffer GPU mappé (single buffer en phase initiale — la synchronisation est assurée par `queue.submit()` qui garantit la séquence d'exécution), **et le niveau LOD de chaque slot** (1 u32/slot, Étape 19). Double buffering sera ajouté uniquement si des artefacts apparaissent à haute fréquence (> 90 fps).
- Pass de Calcul (Compute Pass) :
- Calcul des World Matrices : Un compute shader lit les transformations brutes et génère la matrice 4x4 finale pour chaque mesh.
- Frustum Culling GPU : Un compute pass dédié (`cull`) compare la **sphère bounding** de chaque objet (D5 — conservative, dérivée de l'AABB locale du mesh et de l'échelle de l'entité) avec les 6 plans du frustum de la caméra.
- Remplissage du Buffer Indirect : le pass `cull` écrit le **compte de sommets/indices** de chaque objet dans son `DrawSlot` (80 o) — mis à 0 si l'objet est cullé ou inactif (no-op).
- Remplissage du Buffer Indirect : le pass `cull` lit le **niveau LOD** du slot, en choisit la ligne dans la table LOD du mesh (`LodTable` : 4 lignes d'offsets/comptes en unités d'élément) et écrit les arguments dans le `DrawSlot` (80 o) — mis à 0 si l'objet est cullé ou inactif (no-op). Le niveau 0 porte les comptes du mesh complet, donc LOD désactivé ≡ pré-LOD bit-à-bit.
- Pass de Rendu (Render Pass) :
- Le CPU émet **un draw indirect par slot** (écart D1 — la spécification initiale prévoyait une commande unique fusionnée) ; les slots à compte 0 (cullés/inactifs/vides) sont des no-ops.
- Le GPU pioche directement dans le buffer préparé par le compute pass et dessine uniquement les objets visibles, sans intervention du CPU.
@@ -89,6 +100,15 @@ L'implémentation utilise les buffers wGPU suivants (tous créés par le `Render
| Bounding Box Buffer | Coins min/max de l'AABB de chaque mesh (32 o/slot) | Storage Buffer | CPU → GPU (quand l'ensemble des meshes change) |
| Indirect Draw Buffer | Comptes de draw par slot (80 o/slot, zéro = no-op) | Indirect + Storage Buffer | GPU (Rempli par Compute) → GPU (Lu par le Render) |
| CullUniforms | 6 plans du frustum + `num_slots` + `culling` (112 o) | Uniform Buffer | CPU → GPU (chaque frame) — réservé au compute (group 2) |
| Lod Levels | Niveau LOD par slot choisi par le CPU (4 o/slot) | Storage Buffer (read-only) | CPU → GPU (chaque frame) |
| Lod Tables | Table par mesh : `count` + 4 lignes de 16 o (offset/compte d'éléments) (80 o/mesh) | Storage Buffer (read-only) | CPU → GPU (quand l'ensemble des meshes change) |
**Buffers de géométrie LOD (Étape 19)** : les niveaux d'un mesh sont **empilés** — un seul buffer vertex et un
seul buffer index par mesh, contenant les niveaux concaténés (L0, L1, …). Les lignes de la table LOD portent
les offsets en **unités d'élément** (premier vertex / premier index), car les arguments `drawIndirect*` de
WebGPU s'expriment en éléments, et le buffer est lié en entier à l'offset 0. Conséquences : un mesh LOD ne
peux dépasser 65 535 sommets au total (indices u16) et 4 niveaux (`MAX_LOD_LEVELS`) ; le mélange indexé/non-indexé
dans un même mesh est supporté (la commande de draw par slot suit le niveau choisi par le CPU).
## Liens
+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.