# Étape 19 — LOD (Level of Detail) par entité ## Contexte 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. 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. 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. ## La contrainte d'architecture qui tout détermine `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 — Le CPU décide du niveau, le GPU mappe niveau → draw args 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). 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. ### D2 — Un seul buffer packé par mesh, **niveau 0 à l'offset 0** 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. ### D3 — Indices rebasés par niveau 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` 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é — ``` 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) ``` ## Niveaux dans le démo 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 : | 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 | 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.) ## API publique (ajouts — aucune rupture) ```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; /// 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>; // 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); ``` `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 code | 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>` + `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` é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`. | ### Fonction pure (testable, module `math` ou `resources::lod`) ```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).