LOD GPU
This commit is contained in:
+288
-158
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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