This commit is contained in:
Jérôme Bousquié
2026-09-24 11:21:35 +02:00
parent 004761252b
commit 805babe53d
18 changed files with 733 additions and 400 deletions
+33 -328
View File
@@ -1,337 +1,42 @@
# Étape 19 — LOD (Level of Detail) par entité
# DRAFT — Étape 20 : HDR + Tone Mapping ✅
## Contexte
> **STATUT : TERMINÉ** — implémenté et testé.
> Ce document sera remplacé par le prochain draft.
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.
## Récapitulatif
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.
- [x] **20.1** — Shader TM (`tonemap.wgsl`) : fullscreen triangle + 2 curves (ACES/Reinhard) + validation naga ✅
- [x] **20.2** — `ToneMapper` enum (`core/hdr.rs`) : dispatch compile-time ✅
- [x] **20.3** — `AppBuilder::with_hdr(ToneMapper)` + plomberie App → AppRunner → Renderer ✅
- [x] **20.4** — Allocation HDR (`Rgba16Float` offscreen) dans `Renderer::new` ✅
- [x] **20.5** — Main pass conditionnel (cible HDR vs surface) ✅
- [x] **20.6** — Passe TM (fullscreen triangle → surface sRGB) ✅
- [x] **20.7** — Resize : recreation texture HDR + bind group ✅
- [x] **20.8** — Démo HDR + documentation (`docs/user/hdr.md`) ✅
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.
## Fichiers modifiés/créés
## La contrainte d'architecture qui tout détermine
| Fichier | Action |
|---------|--------|
| `lib/src/shaders/tonemap.wgsl` | **Nouveau** — fullscreen triangle + fs_aces + fs_reinhard |
| `lib/src/core/hdr.rs` | **Nouveau** — `ToneMapper` enum |
| `lib/src/core/mod.rs` | + `pub mod hdr` + re-export |
| `lib/src/core/renderer.rs` | + `HdrPipeline` struct, + HDR alloc, + TM pass, + resize, + helpers |
| `lib/src/utils/conf.rs` | + `TONEMAP_SHADER` constant |
| `lib/src/app.rs` | + `with_hdr()`, + `hdr` field plomberie |
| `lib/src/lib.rs` | + `pub use ToneMapper` |
| `lib/examples/demo.rs` | + `.with_hdr(ToneMapper::Aces)` |
| `lib/examples/manual.rs` | + `None` param (backward compat) |
| `lib/tests/wgsl_validate.rs` | + test tonemap |
| `docs/user/hdr.md` | **Nouveau** — doc utilisateur |
| `docs/user/README.md` | + lien HDR |
`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.
## Tests
## Décisions
- 99 unit tests ✅
- 4 WGSL validation (dont `tonemap_shader_is_valid_wgsl`) ✅
- 3 doctests ✅
### D1 — Le CPU décide du niveau, le GPU mappe niveau → draw args
## Prochaine étape
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 **conscient des attributs** (tolérance relative 1e-6 — grille + 27 voisins : les
seams trigonométriques diffèrent d'environ 1e-16, l'égalité exacte ne suffit pas ; un doublon ne
fusionne que si UV strictement < ½ tuile par coordonnée — un Δ = ½ exact est ambigu, fente à sa
plus large vs saut légitime — ET normales à ~25° ; les paires refusées à UV écart d'entier sont
**enregistrées** comme jumeaux de fente), 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 : **héritées de la source, jamais
recalculées** (elles passent telles quelles et sont interpolées par le repli — l'éclairage
reste identique au niveau 0 quelle que soit l'orientation de la source). UVs/couleurs :
le vertex **déplacé** par un repli reçoit le **blend linéaire** de la paire (même λ que son
nouveau point optimal) — le chart est bilinéaire, donc le blend est la valeur exacte du chart
au nouveau point : la texture reste attachée à la surface et se grossit doucement d'un niveau
à l'autre (pas de saut radical) ; **jamais de blend à travers une seam UV** — les jumeaux de
fente (même position, UV écart d'entier) sont **gelés** : toute arête qui y touche est exclue
de la file, donc aucun repli ne traverse la fente (c'est le gel qui protège, pas un rejet de
blend) ; 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 **héritées** de la source (jamais
/// recalculées), UVs/couleurs blendés linéairement, jumeaux de fente gelés. 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).
Phase 4 complète (4.1 + 4.2 + 4.3 + HDR/TM). Le ROADMAP peut être mis à jour.
+1
View File
@@ -20,6 +20,7 @@ GPU graphics background is required.
| [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures |
| [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` |
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall |
| [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` |
| [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling |
| [Camera & input](camera-input.md) | Active camera, orbital controller, unified keyboard/mouse state |
| [Examples](examples.md) | The 7 repo examples, the advanced `manual` workflow, adding your own example |
+77
View File
@@ -0,0 +1,77 @@
# HDR & Tone Mapping
> **Étape 20** — Opt-in HDR rendering with tone mapping.
## What it does
By default, the WSG renderer draws directly to the window's sRGB surface. Color values
above 1.0 are **clipped** (saturated to white) — you lose all information in bright areas.
When HDR is enabled, the pipeline becomes:
```
Main pass → offscreen Rgba16Float texture (unbounded float)
TM pass → fullscreen triangle samples HDR texture, applies curve, writes to sRGB surface
```
The tone mapping **compresses** the [0, ∞) range to [0, 1] with a perceptual curve,
so bright areas are smoothly rolled off instead of clipping.
## Enabling HDR
```rust
use wsg_lib::core::ToneMapper;
use wsg_lib::app::AppBuilder;
let app = AppBuilder::new()
.title("My HDR App")
.with_hdr(ToneMapper::Aces) // ← enables HDR
.build()
.await?;
```
Without `.with_hdr(...)`, the renderer operates in LDR mode (direct to surface, zero overhead).
## Tone mapping curves
| Variant | Curve | Use case |
|---------|-------|----------|
| `ToneMapper::Aces` | ACES Filmic (Narkowicz 2015) | Cinematic look, soft highlight rolloff, good contrast |
| `ToneMapper::Reinhard` | `x / (1 + x)` | Simple, flat; less contrast but computationally trivial |
The curve is **compiled into the pipeline** at construction time (one WGSL entry point
per variant) — there is no runtime branching cost.
## Cost
| HDR state | Extra per-frame cost |
|-----------|---------------------|
| Disabled (default) | **Zero** — no texture, no pass, no pipeline |
| Enabled | +1 fullscreen render pass (triangle, 3 verts) + 1 offscreen texture (same size as window) |
The extra pass is negligible on any GPU (a few hundred microseconds). The offscreen
texture costs ~12 bytes/pixel of VRAM (RGBA16F = 8 bytes/px + the surface's own buffer).
## How it works (technical)
- **Offscreen texture**: `Rgba16Float`, same size as the window. Created in `Renderer::new`,
recreated on resize.
- **Main pass**: the color attachment targets the HDR texture instead of the surface.
The `standard_shader.wgsl` fragment output (linear float, unbounded) is stored as-is.
- **TM pass**: a fullscreen triangle (3 vertices, no vertex buffer) samples the HDR texture,
multiplies by exposure (currently fixed at 1.0), applies the tone curve, and writes to
the sRGB surface. The hardware performs the linear→sRGB gamma conversion automatically
(the surface format is `Rgba8UnormSrgb`).
- **No double gamma**: the shader outputs linear [0,1]; the sRGB surface encoding is
handled by the rasterizer.
## Exposure
Currently fixed at 1.0 (no user control yet). A future step will expose an
`exposure` field in a `HdrConfig` struct for live adjustment.
## See also
- [Shadows](shadows.md) — the other opt-in visual feature
- [GPU-driven rendering](gpu-driven.md) — the compute pipeline that feeds the main pass
- [Examples](examples.md) — the `demo` example enables HDR by default