21 KiB
É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, commebatch_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
rfranchit 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 passcull.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)
// 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>;
/// 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<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. |
Fonction pure (testable, module math ou resources::lod)
/// 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
- 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) ; clampmax_level;INFINITY → 0. - 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). - Unit tests du packer : offsets/counts par niveau sur un cas 2 niveaux ; niveau 0 à offset 0 ; indices rebasés.
- 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. - 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. - 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. - D11 (contrat) :
add_mesh_lods'applique à tout mesh (y compris Auto — le niveau explicite remplace le niveau décimé au même index) ; le modeOff(1 niveau) = comportement d'aujourd'hui ; un mesh updatable futur repasse enOff(D11). - 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).
- 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_pxpures, unit-testées (dont la série oscillante).decimatedpure, 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_meshseul = 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 fmtclean, 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).