Files
wsg/docs/DRAFT.md
T
Jérôme Bousquié eac266dd86 LOD: interpoler UVs/couleurs des vertices déplacés par le repli
Le vertex-cible d'un repli se déplace au point optimal de l'arête mais
conserveait l'UV du weld — désaccord position/UV croissant en cascade :
la texture 'fuit' et les motifs (rayures) disparaissent aux niveaux
lointains, avec un changement radical entre deux LOD.

- Collapse porte désormais les tables uvs/colors (clonées au weld).
- collapse_edge interpole les UVs de la cible : uv_t ← (1−λ)·uv_s + λ·uv_t,
  avec le même λ que le déplacement (cost_and_point renvoie désormais λ).
- Garde-fou seam : si |Δu| > 0.5 ou |Δv| > 0.5 (saut de texture), la cible
  garde son UV — l'interpolation ne traverse jamais une seam.
- Couleurs : toujours interpolées (espace colorimétrique continu).
- Compaction : la sortie lit les tables mises à jour (c.uvs/c.colors), pas
  les tables d'origine du weld.
- Docs : DRAFT.md (Sortie), gpu-driven.md (décimination), ARCHI_CPU_GPU (LOD).
2026-09-23 12:21:32 +02:00

22 KiB
Raw Blame History

É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<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 : quadric edge collapse (Garland–Heckbert) sur le maillage indexé —

Geometry::decimated(&self, target_triangles: u32) -> Geometry   (pure, sans GPU)
  • Weld préalable (tolérance relative 1e-6 — grille + 27 voisins : les seams trigonométriques diffèrent d'environ 1e-16, l'égalité exacte ne suffit pas), triangles dégénérés jetés, puis quadric edge collapse : file de priorité des arêtes classées par coût (erreur quadrique de l'arête + longueur d'arête) ; on replie la plus bon marché jusqu'à la cible. Un repli interne fusionne les 2 triangles incidents (ils dégénèrent — −2 faces) et re-mappe les voisins : pas de nouvelle face — caractéristique d'Euler et clôture préservées (un mesh fermé reste fermé : pas de trous, pas de « books ») ; un repli de bordure retire 1 face. Garde-fous : arête non-manifold (≥ 3 faces) ou face en double (pli) → repli rejeté. La cible est clampée à [1, T] et atteinte au mieux (best effort : la granularité −2/−1 peut s'en écarter d'un ou deux triangles — jamais de géométrie corrompue).
  • Sortie : re-indexation (le weld ci-dessus), normales lisses recalculées sur les faces survivantes (normales de faces accumulées par coin soudé, puis normalisées). UVs/couleurs : le vertex déplacé par un repli reçoit l'interpolation des UVs/couleurs de la paire (même λ que son nouveau point optimal) — la texture reste attachée à la surface et se grossit doucement d'un niveau à l'autre (pas de saut radical) ; jamais d'interpolation à travers une seam UV (extrémités à plus de 0.5 de tuile d'écart → la cible garde son UV) ; un groupe soudé garde l'UV du premier vertex rencontré (déterministe).
  • 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 (file de priorité, tie-break par identifiants de vertex, aucun aléatoire) → 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;

/// Quadric edge collapse (D10, Garland–Heckbert) : renvoie un `Geometry` indexé avec
/// ~`target_triangles` triangles (best effort ±1–2, clamp `[1, T]`) — les arêtes au coût
/// quadrique le plus faible partent en premier, 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).