diff --git a/.codewhale/state/subagents.v1.lock b/.codewhale/state/subagents.v1.lock deleted file mode 100644 index e69de29..0000000 diff --git a/.codewhale/workflow-runs.jsonl b/.codewhale/workflow-runs.jsonl deleted file mode 100644 index e69de29..0000000 diff --git a/AGENTS.md.bak b/AGENTS.md.bak deleted file mode 100644 index e63d6a3..0000000 --- a/AGENTS.md.bak +++ /dev/null @@ -1,45 +0,0 @@ -# WSG - WGPU Simple Graphics Library - -## Project Type -Rust workspace (2024 edition) wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. - -## Workspace Structure -``` -Cargo.toml # workspace root — no dependencies here -lib/Cargo.toml # wsg-lib crate: wgpu 30.0.0, winit 0.30 -examples/Cargo.toml # depends on wsg-lib via path reference -lib/lib.rs # lib entry point -lib/context.rs # Context type (aggregates wgpu objects: Instance, Surface, Adapter, Device, Queue) -lib/renderer.rs # renderer implementation -examples/src/main.rs # example binary -``` - -**Key convention**: `wsg-lib` is referenced from `examples/` via relative path (`path = "../lib"`). Do not publish this to crates.io as-is — it uses a local path dependency. - -## Essential Commands -| Action | Command | -|--------|---------| -| Build everything | `cargo build --workspace` | -| Run examples | `cargo run -p examples` | -| Test | `cargo test --workspace` | -| Check | `cargo check --workspace` | -| Format | `cargo fmt --all` | - -No custom scripts or linting tooling beyond standard Cargo conventions. - -## Architecture Overview -The library's purpose is to abstract the five core wgpu objects into a single **Context**: - -- **Instance** — GPU backend selection (Vulkan/Metal/DX12) -- **Surface** — window rendering surface (via winit) -- **Adapter** — physical/logical GPU device -- **Device** — buffer/texture/pipeline creation -- **Queue** — command submission - -WGPU doesn't have a native "Context" object — this type groups them together for a simpler user API. See README.md for the French documentation of each component. - -## Gotchas -- Rust 2024 edition is used. Ensure your Rust toolchain supports it (`rustup update`). -- wgpu 30.0.0 is pinned in `lib/Cargo.toml`. The comment says "check the latest version" — verify compatibility before upgrading. -- No feature flags, no dev-dependencies, no tests yet. Adding any requires updating both `Cargo.toml` files if the dependency spans crates. -- The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally. diff --git a/docs/DRAFT.md b/docs/DRAFT.md index 33da61a..f202fd2 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -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` 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; - -/// 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>` + `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` é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. diff --git a/docs/user/README.md b/docs/user/README.md index 89c31cb..67f4258 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -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 | diff --git a/docs/user/hdr.md b/docs/user/hdr.md new file mode 100644 index 0000000..8894aa6 --- /dev/null +++ b/docs/user/hdr.md @@ -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 diff --git a/lib/examples/demo.rs b/lib/examples/demo.rs index 861a8e6..478415e 100644 --- a/lib/examples/demo.rs +++ b/lib/examples/demo.rs @@ -16,6 +16,10 @@ //! (`create_mesh_with_lod`, auto-decimated by halving targets); the CPU picks each entity's //! level from its projected screen size (with hysteresis) — zoom in/out with the wheel and //! the sphere/cylinder/cone/torus visibly lose detail as they shrink on screen. +//! * **HDR + Tone Mapping** (Étape 20): the demo enables ACES Filmic tone mapping via +//! `AppBuilder::with_hdr(ToneMapper::Aces)`. The main pass renders to an offscreen +//! `Rgba16Float` texture, then a fullscreen TM pass compresses it to [0,1] and writes +//! to the sRGB surface — highlights are softly rolled off instead of clipping to white. //! //! Doc (this header) follows the English convention used for examples; internal comments stay //! concise and French where helpful. Run with: @@ -27,6 +31,7 @@ use winit::event::MouseButton; use winit::keyboard::KeyCode; use wsg_lib::AppHandler; use wsg_lib::app::AppBuilder; +use wsg_lib::core::ToneMapper; use wsg_lib::math::{Transform, cone, cube, cylinder, icosphere, plane, torus, uv_sphere}; use wsg_lib::resources::{CameraController, Texture}; use wsg_lib::utils::WsgError; @@ -269,9 +274,12 @@ impl AppHandler for Demo { #[pollster::main] async fn main() -> Result<(), WsgError> { // Culling enabled here (Step 15, D8) to exercise the GPU path; it is OFF by default elsewhere. + // HDR + ACES tone mapping (Étape 20): renders to an offscreen Rgba16Float texture, then + // tone-maps to the sRGB surface. Without `.with_hdr(...)`, the demo would be LDR direct. let app = AppBuilder::new() .title("WSG Demo") .with_culling(true) + .with_hdr(ToneMapper::Aces) .build() .await?; app.run(Demo { diff --git a/lib/examples/manual.rs b/lib/examples/manual.rs index b8eea5d..dc4ac98 100644 --- a/lib/examples/manual.rs +++ b/lib/examples/manual.rs @@ -11,7 +11,7 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::window::{Window, WindowAttributes}; use wsg_lib::core::Context; use wsg_lib::core::Frame; -use wsg_lib::core::Renderer; +use wsg_lib::core::{Renderer, ShadowConfig}; use wsg_lib::pipeline::PipelineCache; use wsg_lib::resources::{Geometry, Material, Mesh}; use wsg_lib::utils; @@ -63,7 +63,7 @@ impl ApplicationHandler for App { // Flat 2D rendering: `standard` in unlit mode (the frame+object bind groups are set by // draw_entity, the default frame matrix is the identity → NDC positions unchanged). - let mut renderer = Renderer::new(&context, format, 800, 600); + let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None); renderer.set_unlit(true); // 3. Material: uses renderer.device() and renderer.format() diff --git a/lib/src/app.rs b/lib/src/app.rs index 346cc72..d34b001 100644 --- a/lib/src/app.rs +++ b/lib/src/app.rs @@ -23,7 +23,7 @@ //! once right after GPU initialization so users can register shaders/meshes/materials/entities. use crate::AppHandler; -use crate::core::{Context, InputState, Renderer}; +use crate::core::{Context, InputState, Renderer, ShadowConfig, ToneMapper}; use crate::scene::Scene; use crate::utils::WsgError; use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH}; @@ -58,6 +58,11 @@ pub struct App { pub(crate) height: u32, /// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`. pub(crate) culling: bool, + /// Shadow mapping configuration; passed to `Renderer::new` in `resumed`. + pub(crate) shadow_config: ShadowConfig, + /// HDR / tone mapping (Étape 20). `None` = LDR direct (default, zero overhead); + /// `Some(t)` = render to Rgba16Float offscreen + tone mapping pass to the surface. + pub(crate) hdr: Option, /// Winit event loop for window management. Set to None after run() consumes it. event_loop: Option>, // On met en Option pour pouvoir faire .take() facilement /// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle. @@ -121,6 +126,8 @@ impl App { width: self.width, height: self.height, culling: self.culling, + shadow_config: self.shadow_config.clone(), + hdr: self.hdr, handler, app: None, }; @@ -156,9 +163,11 @@ impl App { let new_format = context.configure(&context.adapter, width, height)?; self.renderer_mut().resize_depth(width, height); self.renderer_mut().set_format(new_format); - if new_format != old_format { - // Surface format changed: re-wire the Scene's GPU context (device + queue + format) - // so its PipelineCache/pipelines match the new surface format. + if new_format != old_format && self.hdr.is_none() { + // Surface format changed (rare): re-wire the Scene's GPU context so its + // PipelineCache/pipelines match the new surface format. + // Étape 20: when HDR is active, the Scene uses Rgba16Float regardless of the + // surface format, so no re-init is needed on surface format change. let device = std::sync::Arc::new(self.renderer_mut().device().clone()); self.scene .init_gpu(device, self.context().queue.clone(), new_format); @@ -178,6 +187,11 @@ pub struct AppBuilder { height: u32, /// GPU frustum culling enabled (Step 15, D8). Defaults to false (non-regression). culling: bool, + /// Shadow mapping configuration (map size, biases, frustum). Defaults to sensible values. + shadow_config: ShadowConfig, + /// HDR / tone mapping (Étape 20). `None` = LDR direct (default); `Some(t)` activates + /// the offscreen HDR texture + tone mapping pass. + hdr: Option, } impl AppBuilder { @@ -189,6 +203,8 @@ impl AppBuilder { width: APP_DEFAULT_WIDTH, height: APP_DEFAULT_HEIGHT, culling: false, + shadow_config: ShadowConfig::default(), + hdr: None, } } /// Sets the window title to display in the OS taskbar/window decorations. @@ -212,6 +228,20 @@ impl AppBuilder { self.culling = enabled; self } + /// Sets the shadow mapping configuration (map size, depth/slope bias, ortho frustum). + /// Defaults to `ShadowConfig::default()` (1024² map, bias 0.002, slope 0.004, radius 5.0). + pub fn with_shadow_config(mut self, config: ShadowConfig) -> Self { + self.shadow_config = config; + self + } + /// Enables HDR rendering with the given tone mapping curve (Étape 20). The main pass + /// renders into an offscreen `Rgba16Float` texture, then a fullscreen tone mapping pass + /// compresses the result to [0,1] and writes it to the sRGB surface. Without this call, + /// the renderer draws directly to the surface (LDR, zero overhead). + pub fn with_hdr(mut self, tonemapper: ToneMapper) -> Self { + self.hdr = Some(tonemapper); + self + } /// Builds the configured `App` instance: creates the event loop and stores the window /// configuration. The GPU context, window and renderer are created later, when the event loop /// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase. @@ -226,6 +256,8 @@ impl AppBuilder { width: self.width, height: self.height, culling: self.culling, + shadow_config: self.shadow_config, + hdr: self.hdr, event_loop: Some(event_loop), context: None, renderer: None, @@ -246,6 +278,10 @@ struct AppRunner { height: u32, /// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`. culling: bool, + /// Shadow mapping configuration; passed to `Renderer::new` in `resumed`. + shadow_config: ShadowConfig, + /// HDR / tone mapping (Étape 20); passed to `Renderer::new` in `resumed`. + hdr: Option, /// The user-provided game logic. handler: H, /// The fully-built App facade, populated on the first `resumed` event. @@ -278,14 +314,22 @@ impl ApplicationHandler for AppRunner { .configure(&context.adapter, self.width, self.height) .expect("surface configuration failed"); let device = Arc::new(context.device.clone()); - let renderer = Renderer::new(&context, format, self.width, self.height); + let renderer = + Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr); // Step 15, D8: apply the culling flag (off by default — non-regression). renderer.set_culling(self.culling); // Step 7 (DRAFT 7.1): the PipelineCache now lives in the Scene. We wire the GPU context // (device + queue + format + cache) into the Scene before setup so it can build materials/meshes. + // Étape 20: when HDR is active, the main pass targets Rgba16Float (not the surface format), + // so the Scene's pipelines must be compiled for that format. + let main_format = if self.hdr.is_some() { + wgpu::TextureFormat::Rgba16Float + } else { + format + }; let mut scene = Scene::new(); - scene.init_gpu(device, context.queue.clone(), format); + scene.init_gpu(device, context.queue.clone(), main_format); let mut app = App { scene, @@ -294,6 +338,8 @@ impl ApplicationHandler for AppRunner { width: self.width, height: self.height, culling: self.culling, + shadow_config: self.shadow_config.clone(), + hdr: self.hdr, event_loop: None, context: Some(context), renderer: Some(renderer), diff --git a/lib/src/core/hdr.rs b/lib/src/core/hdr.rs new file mode 100644 index 0000000..1312195 --- /dev/null +++ b/lib/src/core/hdr.rs @@ -0,0 +1,45 @@ +//! # HDR / Tone Mapping Configuration (Étape 20) +//! +//! Defines the `ToneMapper` enum (selects the tone mapping curve) and provides the +//! configuration passed to the `Renderer` when HDR is enabled. The HDR pipeline +//! (offscreen `Rgba16Float` texture + fullscreen tone mapping pass) is **opt-in**: +//! without it, the renderer draws directly to the sRGB surface (zero overhead). + +/// Selects the tone mapping curve applied by the HDR pass. +/// +/// The choice is compiled into the pipeline at construction time (one entry point per +/// variant) — there is no runtime branching cost. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToneMapper { + /// ACES Filmic (Narkowicz 2015 approximation). Cinematic contrast, used in AAA + /// games and film pipelines. Softly compresses highlights while preserving + /// midtone contrast. + Aces, + /// Reinhard: `x / (1 + x)`. Simple, flat response. Less contrast than ACES but + /// computationally trivial. + Reinhard, +} + +impl ToneMapper { + /// Returns the WGSL entry point name for this tone mapper variant. + pub(crate) fn entry_point(&self) -> &'static str { + match self { + ToneMapper::Aces => "fs_aces", + ToneMapper::Reinhard => "fs_reinhard", + } + } + + /// Human-readable label (for debug output). + pub fn label(&self) -> &'static str { + match self { + ToneMapper::Aces => "ACES", + ToneMapper::Reinhard => "Reinhard", + } + } +} + +impl std::fmt::Display for ToneMapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} diff --git a/lib/src/core/mod.rs b/lib/src/core/mod.rs index 8c24de2..0a2a96a 100644 --- a/lib/src/core/mod.rs +++ b/lib/src/core/mod.rs @@ -11,11 +11,15 @@ pub mod context; pub mod frame; +pub mod hdr; pub mod input; pub mod renderer; +pub mod shadow; // Re-exports pub use context::Context; pub use frame::Frame; +pub use hdr::ToneMapper; pub use input::InputState; pub use renderer::Renderer; +pub use shadow::ShadowConfig; diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index 0ab3208..9cb543e 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -36,9 +36,9 @@ use crate::resources::{ Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform, }; use crate::scene::Scene; +use crate::core::hdr::ToneMapper; use crate::utils::conf::{ - GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, - SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, + GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER, }; use glam::{Mat4, Quat, Vec3, Vec4}; use std::cell::{Cell, RefCell}; @@ -145,6 +145,30 @@ pub struct Renderer { /// Viewport height in pixels (Step 19, D9): the unit of the LOD projected-size test. Set from /// the initial surface size in `new` and refreshed by `resize_depth` on window resize. viewport_height: u32, + /// Shadow mapping configuration (map size, biases, frustum). Set at construction time; + /// `map_size` determines the shadow texture allocation, the rest are used per-frame. + shadow_config: super::shadow::ShadowConfig, + /// HDR pipeline (Étape 20). Present only when HDR is enabled via `AppBuilder::with_hdr`. + /// When `None`, the main pass renders directly to the surface (LDR, zero overhead). + hdr: Option, +} + +/// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline. +/// Allocated in `Renderer::new` when HDR is active; recreated on resize. +struct HdrPipeline { + /// Offscreen HDR color texture (`Rgba16Float`), sized to the surface. + texture: wgpu::Texture, + /// View of the HDR texture, used as the main pass color attachment. + view: wgpu::TextureView, + /// Tone mapping render pipeline (fullscreen triangle + ACES/Reinhard curve). + pipeline: wgpu::RenderPipeline, + /// Bind group for the TM pass (HDR texture + sampler + uniform with exposure & viewport). + /// The uniform buffer is owned by the bind group (freed when the bind group is replaced). + bind_group: wgpu::BindGroup, + /// Bind group layout for the TM pass (reused on resize to recreate the bind group). + layout: wgpu::BindGroupLayout, + /// Sampler for the HDR texture (linear, clamp). + sampler: wgpu::Sampler, } impl Renderer { @@ -156,7 +180,14 @@ impl Renderer { /// Returns a new Renderer instance sharing the same underlying GPU resources as Context. /// Called once at application startup during scene setup. The Renderer shares these resources via Arc; /// Context retains ownership and can continue using them after this call. - pub fn new(context: &Context, format: wgpu::TextureFormat, width: u32, height: u32) -> Self { + pub fn new( + context: &Context, + format: wgpu::TextureFormat, + width: u32, + height: u32, + shadow_config: &super::shadow::ShadowConfig, + hdr: Option, + ) -> Self { let queue: wgpu::Queue = context.queue.clone(); let device: wgpu::Device = context.device.clone(); let [frame_layout, object_layout] = create_uniform_bind_group_layouts(&device); @@ -206,7 +237,7 @@ impl Renderer { // Step 14 (DRAFT 3.2): shadow mapping resources — shadow map texture/view, comparison // sampler, group-3 bind group, shadow-light uniform buffer + group-0 bind group, and the // depth-only shadow pipeline. All allocated once here at the default resolution (D2/D8). - let (shadow_texture, shadow_view) = create_shadow_map(&device, SHADOW_MAP_SIZE); + let (shadow_texture, shadow_view) = create_shadow_map(&device, shadow_config.map_size); let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("shadow comparison sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -508,7 +539,7 @@ impl Renderer { }], }); - let renderer = Self { + let mut renderer = Self { queue, device, format, @@ -542,10 +573,14 @@ impl Renderer { lod_enabled: Cell::new(true), last_lod_levels: RefCell::new(Vec::new()), viewport_height: height, + shadow_config: shadow_config.clone(), + hdr: None, }; // Seed the shared frame buffer with an identity camera + current unlit flag so the low-level // `render` path (which has no window/camera) sees coherent values before `render_scene` runs. renderer.write_default_frame_uniforms(); + // Étape 20: allocate the HDR pipeline (offscreen texture + TM pipeline) when enabled. + renderer.hdr = hdr.map(|tm| create_hdr_pipeline(&renderer.device, &renderer.queue, width, height, tm, format)); renderer } @@ -585,6 +620,14 @@ impl Renderer { self.depth_view = depth_view; // Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test. self.viewport_height = height; + // Étape 20: recreate the HDR texture + bind group at the new size (D10). + if let Some(hdr) = &mut self.hdr { + let (tex, view) = create_hdr_texture(&self.device, width, height); + let bg = create_hdr_bind_group(&self.device, &hdr.layout, &hdr.sampler, &tex, width, height); + hdr.texture = tex; + hdr.view = view; + hdr.bind_group = bg; + } } /// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4). @@ -619,7 +662,12 @@ impl Renderer { Some((index, vp)) => ( index as u32, vp, - Vec4::new(SHADOW_MAP_SIZE as f32, SHADOW_DEPTH_BIAS, 0.0, 0.0), + Vec4::new( + self.shadow_config.map_size as f32, + self.shadow_config.depth_bias, + self.shadow_config.slope_bias, + 0.0, + ), 1, ), None => (MAX_LIGHTS as u32, Mat4::IDENTITY, Vec4::ZERO, 0), @@ -679,8 +727,8 @@ impl Renderer { } crate::resources::LightType::Point => return None, }; - let r = SHADOW_SCENE_RADIUS; - let target = Vec3::from(SHADOW_SCENE_CENTER); + let r = self.shadow_config.scene_radius; + let target = Vec3::from(self.shadow_config.scene_center); // Eye one scene-radius behind the target along the light path, so distance(target)=r and // every point in the box has depth within [near=0, far=r]. let eye = target - dir * r; @@ -867,11 +915,17 @@ impl Renderer { // The matrix + draw-args are read via per-slot offsets; a culled/inactive slot's args // are zero, so its draw is a no-op. State changes (pipeline + texture bind group @2) // are hoisted out of the slot loop: one per DISTINCT material, not one per entity. + // Étape 20: when HDR is active, the color attachment targets the offscreen HDR texture + // instead of the surface; the TM pass (step 8) then copies it to the surface. + let main_target = match &self.hdr { + Some(h) => &h.view, + None => view, + }; { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("scene render pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view, + view: main_target, resolve_target: None, depth_slice: None, ops: wgpu::Operations { @@ -952,6 +1006,30 @@ impl Renderer { } } } + + // 8. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR + // texture, applies exposure + tone mapping curve, and writes to the surface. + // Only runs when HDR is active; the surface is the color target (no depth needed). + if let Some(hdr) = &self.hdr { + let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("tone mapping pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + tm_pass.set_pipeline(&hdr.pipeline); + tm_pass.set_bind_group(0, &hdr.bind_group, &[]); + tm_pass.draw(0..3, 0..1); + } + self.queue.submit(std::iter::once(encoder.finish())); } @@ -1429,6 +1507,180 @@ fn batch_slots(keys: &[K]) -> Vec> { groups.into_iter().map(|(_, idxs)| idxs).collect() } +/// Allocates the offscreen HDR color texture (`Rgba16Float`) + view at the given size (Étape 20, D3). +/// Used both at initial allocation and on resize. +fn create_hdr_texture(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("hdr texture"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) +} + +/// Creates the tone mapping bind group: HDR texture (binding 0) + sampler (binding 1) + uniform (binding 2). +/// The uniform contains exposure (1.0) and viewport size (pad.xy). +fn create_hdr_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + sampler: &wgpu::Sampler, + texture: &wgpu::Texture, + width: u32, + height: u32, +) -> wgpu::BindGroup { + // Write the uniform: exposure = 1.0, pad.xy = viewport size. + // WGSL uniform layout: f32 at offset 0 (4B), vec3 at offset 16 (16B, aligned to 16). + // Total = 32 bytes. We pack as 8 f32s: [exposure, 0, 0, 0, w, h, 0, 0]. + let uniform_data = [ + 1.0f32, // exposure (offset 0) + 0.0, 0.0, 0.0, // padding to align vec3 to offset 16 + width as f32, height as f32, 0.0, // pad: vec3 at offset 16 + 0.0, // trailing pad to 32 bytes + ]; + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("tm uniform"), + size: 32, + usage: wgpu::BufferUsages::UNIFORM, + mapped_at_creation: true, + }); + { + let mut w = uniform_buffer.slice(..).get_mapped_range_mut().expect("mapped buffer"); + w.copy_from_slice(bytemuck::cast_slice(&uniform_data)); + drop(w); + uniform_buffer.unmap(); + } + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("tm bind group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&texture.create_view(&wgpu::TextureViewDescriptor::default())), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &uniform_buffer, + offset: 0, + size: None, + }), + }, + ], + }) +} + +/// Creates the full HDR pipeline (Étape 20): offscreen texture + TM pipeline + bind group. +/// The pipeline uses the `TONEMAP_SHADER` with the entry point selected by the `ToneMapper` variant. +fn create_hdr_pipeline( + device: &wgpu::Device, + _queue: &wgpu::Queue, + width: u32, + height: u32, + tonemapper: ToneMapper, + format: wgpu::TextureFormat, +) -> HdrPipeline { + // 1. Offscreen HDR texture + view. + let (texture, view) = create_hdr_texture(device, width, height); + + // 2. Sampler (linear, clamp). + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("hdr sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + ..Default::default() + }); + + // 3. Bind group layout: texture (0) + sampler (1) + uniform (2). + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("hdr bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }, + ], + }); + + // 4. Render pipeline: fullscreen triangle (no vertex buffer) + selected TM entry point. + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("tonemap shader"), + source: wgpu::ShaderSource::Wgsl(TONEMAP_SHADER.into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("hdr pipeline layout"), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("tone mapping pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some(tonemapper.entry_point()), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState::from(format))], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + + // 5. Bind group with the initial texture + viewport size. + let bind_group = create_hdr_bind_group(device, &layout, &sampler, &texture, width, height); + + HdrPipeline { + texture, + view, + pipeline, + bind_group, + layout, + sampler, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/lib/src/core/shadow.rs b/lib/src/core/shadow.rs new file mode 100644 index 0000000..b280c47 --- /dev/null +++ b/lib/src/core/shadow.rs @@ -0,0 +1,65 @@ +//! Shadow mapping configuration. +//! +//! Users of the WSG library can tune shadow quality/behavior without modifying the library +//! source. All fields have sensible defaults (see [`ShadowConfig::default`]); pass a custom +//! config via [`AppBuilder::with_shadow_config`](crate::app::AppBuilder::with_shadow_config). + +use crate::utils::conf::{ + SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, + SHADOW_SLOPE_BIAS, +}; + +/// Configuration for the shadow mapping system. +/// +/// Controls the shadow map resolution, depth bias (anti-acne), and the orthographic frustum +/// that frames the scene from the shadow-casting light's point of view. +/// +/// # Usage +/// ```ignore +/// use wsg_lib::core::ShadowConfig; +/// +/// let app = AppBuilder::new() +/// .with_shadow_config(ShadowConfig { +/// map_size: 2048, // higher resolution → sharper shadows +/// depth_bias: 0.002, // constant bias (NDC depth units) +/// slope_bias: 0.006, // slope-scaled bias coefficient +/// scene_center: [0.0, 0.0, 0.0], // where to center the ortho frustum +/// scene_radius: 8.0, // half-extent of the ortho frustum (world units) +/// ..Default::default() +/// }) +/// .build() +/// .await?; +/// ``` +#[derive(Debug, Clone)] +pub struct ShadowConfig { + /// Shadow map resolution in pixels per side (square map). Higher = sharper shadows, + /// more VRAM. Defaults to 1024. + pub map_size: u32, + /// Constant depth bias subtracted from the reference depth before the shadow comparison. + /// This is the *minimum* bias; the slope-scaled term adds more for grazing angles. + /// Defaults to 0.002. + pub depth_bias: f32, + /// Slope-scaled bias coefficient. The effective bias is + /// `max(depth_bias, slope_bias * (1.0 - |dot(N, L)|))` — it grows as the surface normal + /// becomes perpendicular to the light direction, where shadow acne is worst. + /// Defaults to 0.004. + pub slope_bias: f32, + /// World-space center of the orthographic shadow frustum. The frustum is oriented along + /// the shadow light's direction and centered on this point. Defaults to `[0.0, 0.0, 0.0]`. + pub scene_center: [f32; 3], + /// Half-extent (world units) of the orthographic shadow frustum. Must be large enough to + /// encompass all shadow-casting and receiving geometry. Defaults to 5.0. + pub scene_radius: f32, +} + +impl Default for ShadowConfig { + fn default() -> Self { + Self { + map_size: SHADOW_MAP_SIZE, + depth_bias: SHADOW_DEPTH_BIAS, + slope_bias: SHADOW_SLOPE_BIAS, + scene_center: SHADOW_SCENE_CENTER, + scene_radius: SHADOW_SCENE_RADIUS, + } + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 1a333e3..816671e 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -44,3 +44,11 @@ pub use crate::app::App; /// Re-export of the user-defined game logic interface for convenient top-level access. /// Users implement this trait to define update/render callbacks injected into the render loop. pub use crate::handler::AppHandler; + +/// Re-export of the shadow mapping configuration for convenient top-level access. +/// Users tune shadow quality via `AppBuilder::with_shadow_config`. +pub use crate::core::ShadowConfig; + +/// Re-export of the tone mapping curve selector for convenient top-level access. +/// Users enable HDR via `AppBuilder::with_hdr(ToneMapper::Aces)`. +pub use crate::core::ToneMapper; diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl index d33f03e..0a8b850 100644 --- a/lib/src/shaders/standard_shader.wgsl +++ b/lib/src/shaders/standard_shader.wgsl @@ -91,7 +91,7 @@ struct FrameUniforms { num_spot: u32, shadow_light_index: u32, // packed index of the shadow light ; MAX_LIGHTS = off light_view_proj: mat4x4, // world → shadow light clip space (Étape 14, D3) - shadow_params: vec4, // .x = shadow map size, .y = depth bias + shadow_params: vec4, // .x = map size, .y = constant bias, .z = slope bias options: vec4, // .x = unlit flag ; .y = shadows on }; @@ -202,16 +202,20 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor; } - let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos); + let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos, n); return vec4(lit, in.color.a); } // Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position // into the shadow light's clip space, converts to depth-map UVs + normalized depth, then averages // a 3×3 `textureSampleCompare` neighborhood using the comparison sampler (LessEqual). Returns -// 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow. The reference depth is -// pulled toward the viewer by `frame.shadow_params.y` (bias) to suppress acne. -fn compute_shadow(world_pos: vec3) -> f32 { +// 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow. +// +// Bias strategy : **slope-scaled** — the reference depth is pulled toward the viewer by +// `max(constant_bias, slope_bias * (1.0 - abs(dot(n, light_dir))))`. The slope term grows as the +// surface becomes perpendicular to the light (grazing angle), where acne is worst. This prevents +// the large black patches that a constant bias alone cannot suppress on large flat surfaces. +fn compute_shadow(world_pos: vec3, normal: vec3) -> f32 { // Shadows off (options.y == 0) or no valid caster (sentinel = MAX_LIGHTS) → fully lit. if (frame.options.y == 0u || frame.shadow_light_index == MAX_LIGHTS) { return 1.0; @@ -224,9 +228,25 @@ fn compute_shadow(world_pos: vec3) -> f32 { // The light projection is built with the WebGPU `[0,1]` clip-depth convention (glam // directx/WebGPU module), so NDC z is already in [0,1]: no extra remap is needed. let current_depth = shadow_ndc.z; - let bias = frame.shadow_params.y; let texel = 1.0 / max(frame.shadow_params.x, 1.0); + // Slope-scaled bias (fixes the large acne patches on surfaces at grazing angles to the light). + // Direction from surface toward the shadow-casting light: + // directional → position_dir.xyz (already the surface→light direction) + // spot → normalize(light_position - world_pos) + let sl_idx = frame.shadow_light_index; + let sl = frame.lights[sl_idx]; + let is_dir = (sl_idx < frame.num_directional); + var light_dir: vec3; + if (is_dir) { + light_dir = normalize(sl.position_dir.xyz); + } else { + light_dir = normalize(sl.position_dir.xyz - world_pos); + } + // The slope factor: 0 when the normal faces the light (no bias needed), 1 when perpendicular. + let slope = 1.0 - abs(dot(normalize(normal), light_dir)); + let bias = max(frame.shadow_params.y, frame.shadow_params.z * slope); + // 3×3 PCF : average of the comparison results around the fragment's texel. var lit_count = 0.0; for (var ox = -1i; ox <= 1; ox++) { diff --git a/lib/src/shaders/tonemap.wgsl b/lib/src/shaders/tonemap.wgsl new file mode 100644 index 0000000..6ea67f9 --- /dev/null +++ b/lib/src/shaders/tonemap.wgsl @@ -0,0 +1,105 @@ +// Tone mapping fullscreen pass shader (Étape 20). +// +// Renders a fullscreen triangle (no vertex buffer — position derived from vertex_index) +// that samples the HDR texture, applies exposure + tone mapping curve, and writes the +// result to the sRGB surface. The hardware handles the linear→sRGB gamma conversion +// automatically (the surface is Rgba8UnormSrgb). +// +// Two fragment entry points: `fs_aces` (ACES Filmic, Narkowicz 2015) and `fs_reinhard` +// (simple Reinhard). The pipeline is compiled with the appropriate entry point at +// construction time. + +struct TmUniforms { + exposure: f32, + pad: vec3, +} + +@group(0) @binding(0) var u_hdr_texture: texture_2d; +@group(0) @binding(1) var u_hdr_sampler: sampler; +@group(0) @binding(2) var u_params: TmUniforms; + +// Fullscreen triangle vertex shader: generates three vertices covering the entire +// NDC viewport. The triangle is (-1,-1), (3,-1), (-1,3) — the fourth NDC corner (1,1) +// is outside the triangle and gets clipped away; the visible portion exactly covers [-1,1]². +// The fragment shader derives UVs from the built-in position (window coords). +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4 { + switch vid { + case 0u { + return vec4(-1.0, -1.0, 0.0, 1.0); + } + case 1u { + return vec4(3.0, -1.0, 0.0, 1.0); + } + default { + return vec4(-1.0, 3.0, 0.0, 1.0); + } + } +} + +// --- ACES Filmic tone curve (Narkowicz 2015) --- +fn aces(x: f32) -> f32 { + let a = 2.51; + let b = 0.03; + let c = 2.43; + let d = 0.59; + let e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); +} + +// --- Reinhard tone curve --- +fn reinhard(x: f32) -> f32 { + return clamp(x / (1.0 + x), 0.0, 1.0); +} + +// Convert window-space position to texture UVs [0,1]². +// @builtin(position) in a fragment shader is in window coordinates (pixels, top-left origin). +// We need the draw size to normalize; pass it via a uniform or use the known viewport. +// Here we use a simpler trick: the NDC position is available via the vertex interpolation, +// but since we only output @builtin(position), we derive UVs in the fragment from +// @builtin(position) / viewport. The viewport is the full window, so we normalize by +// the known draw size. +// +// Actually, the simplest correct approach: since the triangle covers the full viewport, +// we can use `@builtin(position)` (in pixels) and normalize by the viewport size. +// But we don't have the viewport size as a binding here... +// +// Alternative: use a second vertex output for UVs. Since tuple returns aren't supported +// in this naga version, we use a different trick — the UVs are linearly interpolated +// from the vertex positions. We compute them as (ndc + 1) / 2 in the vertex shader +// and pass them through an @location. But we can only have one return value... +// +// Simplest fix: just use @builtin(position) in the fragment and divide by the +// viewport size (stored in the uniform). + +// We add viewport size to the uniform (reusing the _pad field). +// _pad.xy = viewport size in pixels (width, height). +// _pad.z = unused, _pad.w = unused. + +@fragment +fn fs_aces( + @builtin(position) frag_pos: vec4, +) -> @location(0) vec4 { + let uv = frag_pos.xy / u_params.pad.xy; + let color = textureSample(u_hdr_texture, u_hdr_sampler, uv).rgb * u_params.exposure; + return vec4( + aces(color.r), + aces(color.g), + aces(color.b), + 1.0, + ); +} + +@fragment +fn fs_reinhard( + @builtin(position) frag_pos: vec4, +) -> @location(0) vec4 { + let uv = frag_pos.xy / u_params.pad.xy; + let color = textureSample(u_hdr_texture, u_hdr_sampler, uv).rgb * u_params.exposure; + return vec4( + reinhard(color.r), + reinhard(color.g), + reinhard(color.b), + 1.0, + ); +} diff --git a/lib/src/utils/conf.rs b/lib/src/utils/conf.rs index b6bc1f2..ca2ea5f 100644 --- a/lib/src/utils/conf.rs +++ b/lib/src/utils/conf.rs @@ -41,6 +41,11 @@ pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl"); /// the library; no external file is read). pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl"); +/// The tone mapping fullscreen pass shader source (Étape 20), embedded at compile time. +/// Carries one vertex entry point (`vs_main`, fullscreen triangle) and two fragment entry +/// points (`fs_aces`, `fs_reinhard`). Compiled directly by the renderer when HDR is enabled. +pub const TONEMAP_SHADER: &str = include_str!("../shaders/tonemap.wgsl"); + /// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and /// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU /// rewrites only the transform slots and the cull uniforms. @@ -76,10 +81,16 @@ pub const GPU_WORKGROUP_SIZE: u32 = 64; /// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes. pub const SHADOW_MAP_SIZE: u32 = 1024; -/// Default shadow depth bias (Step 14, D5) subtracted from the reference depth before the -/// comparison, to suppress acne without killing contact shadows. Combined with the slope-scaled -/// bias applied on the shadow pipeline itself. -pub const SHADOW_DEPTH_BIAS: f32 = 0.006; +/// Default shadow constant bias (Step 14, D5) subtracted from the reference depth before the +/// comparison, to suppress acne without killing contact shadows. This is the minimum bias; +/// the slope-scaled term (SHADOW_SLOPE_BIAS) adds more for surfaces at grazing angles. +pub const SHADOW_DEPTH_BIAS: f32 = 0.002; + +/// Slope-scaled bias coefficient (Étape 14 fix, 2026-09-24). The effective bias is +/// `max(SHADOW_DEPTH_BIAS, SHADOW_SLOPE_BIAS * (1.0 - |dot(N, L)|))` — it grows as the surface +/// normal becomes perpendicular to the light direction, where shadow acne is worst. A value of +/// 0.004 works well for a 1024² map with a 10-unit ortho frustum; tune per scene scale. +pub const SHADOW_SLOPE_BIAS: f32 = 0.006; /// Default half-extent (world units) of the orthographic shadow frustum around the scene center /// for a directional light (D3). Chosen to comfortably frame the unit-cube scene of the examples. diff --git a/lib/tests/wgsl_validate.rs b/lib/tests/wgsl_validate.rs index 7e80864..ccf9294 100644 --- a/lib/tests/wgsl_validate.rs +++ b/lib/tests/wgsl_validate.rs @@ -85,3 +85,34 @@ fn gpu_driven_shader_is_valid_wgsl() { "the two compute entry points are expected" ); } + +/// Parses and fully validates the embedded `tonemap.wgsl` shader (Étape 20) via naga. +/// The renderer compiles it into one `RenderPipeline` (vertex `vs_main` + one of the two +/// fragment entry points `fs_aces` / `fs_reinhard`), so this offline validation is the +/// guarantee of its validity. The contract expects three entry points. +#[test] +fn tonemap_shader_is_valid_wgsl() { + let src = include_str!("../src/shaders/tonemap.wgsl"); + let module = naga::front::wgsl::parse_str(src) + .unwrap_or_else(|e| panic!("tonemap.wgsl: parsing error: {e:?}")); + + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .unwrap_or_else(|e| panic!("tonemap.wgsl: validation failed: {e:?}")); + + let mut entry_names: Vec<&str> = module + .entry_points + .iter() + .map(|ep| ep.name.as_str()) + .collect(); + entry_names.sort(); + assert_eq!( + entry_names, + vec!["fs_aces", "fs_reinhard", "vs_main"], + "the three entry points are expected" + ); +}