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

331 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# É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)
```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>;
/// 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`)
```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;
/// 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).