HDR
This commit is contained in:
+33
-328
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user