This commit is contained in:
Jérôme Bousquié
2026-09-24 11:21:35 +02:00
parent 004761252b
commit 805babe53d
18 changed files with 733 additions and 400 deletions
View File
View File
-45
View File
@@ -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.
+33 -328
View File
@@ -1,337 +1,42 @@
# Étape 19 — LOD (Level of Detail) par entité # DRAFT — Étape 20 : HDR + Tone Mapping ✅
## Contexte > **STATUT : TERMINÉ** — implémenté et testé.
> Ce document sera remplacé par le prochain draft.
Le culling (Étape 17) supprime les objets **hors écran**. Le LOD supprime le travail **invisible** ## Récapitulatif
sur les objets qui sont sur l'écran : un maillage de 3840 indices qui n'occupe que 20×20 pixels
à l'écran gaspille du GPU — presque tous ses triangles ne produisent aucun pixel.
Principe : chaque mesh peut porter **plusieurs niveaux de géométrie** (L0 = détaillée, L1, L2 = - [x] **20.1** — Shader TM (`tonemap.wgsl`) : fullscreen triangle + 2 curves (ACES/Reinhard) + validation naga ✅
de plus en plus simplifiée, silhouettes proches). Chaque frame, on mesure la **taille perçue** - [x] **20.2** — `ToneMapper` enum (`core/hdr.rs`) : dispatch compile-time ✅
de chaque entité (rayon de sa bounding sphere projeté en pixels) et on choisit le niveau le plus - [x] **20.3** — `AppBuilder::with_hdr(ToneMapper)` + plomberie App → AppRunner → Renderer ✅
grossier **suffisant**. La transition est protégée par une **hystérésis** (bande morte) pour - [x] **20.4** — Allocation HDR (`Rgba16Float` offscreen) dans `Renderer::new` ✅
éviter le scintillement d'un objet oscillant autour d'un seuil. - [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 ## Fichiers modifiés/créés
remplacé par ce draft — il est validé et son contenu est dans `ARCHI_CPU_GPU.md` + git history.
## La contrainte d'architecture qui tout détermine | 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 ## Tests
deux buffers par draw indirect. Donc **plusieurs buffers par niveau est exclu** : tous les
niveaux d'un mesh vivent dans **un seul buffer de vertices** (et un seul buffer d'indices), et
le GPU sélectionne le niveau en écrivant l'**offset** (`baseVertex` / `firstVertex`) et le
`count` dans les indirect draw args — des champs que le draw indirect lit déjà. C'est ce qui
rend le LOD compatible avec l'architecture GPU-driven existante sans aucun nouveau mécanisme de
rendu : le pass `cull` écrit déjà les draw args, il écrit juste les bonnes valeurs.
## Décisions - 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 Phase 4 complète (4.1 + 4.2 + 4.3 + HDR/TM). Le ROADMAP peut être mis à jour.
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).
+1
View File
@@ -20,6 +20,7 @@ GPU graphics background is required.
| [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures | | [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures |
| [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` | | [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` |
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall | | [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 | | [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 | | [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 | | [Examples](examples.md) | The 7 repo examples, the advanced `manual` workflow, adding your own example |
+77
View File
@@ -0,0 +1,77 @@
# HDR & Tone Mapping
> **Étape 20** — Opt-in HDR rendering with tone mapping.
## What it does
By default, the WSG renderer draws directly to the window's sRGB surface. Color values
above 1.0 are **clipped** (saturated to white) — you lose all information in bright areas.
When HDR is enabled, the pipeline becomes:
```
Main pass → offscreen Rgba16Float texture (unbounded float)
TM pass → fullscreen triangle samples HDR texture, applies curve, writes to sRGB surface
```
The tone mapping **compresses** the [0, ∞) range to [0, 1] with a perceptual curve,
so bright areas are smoothly rolled off instead of clipping.
## Enabling HDR
```rust
use wsg_lib::core::ToneMapper;
use wsg_lib::app::AppBuilder;
let app = AppBuilder::new()
.title("My HDR App")
.with_hdr(ToneMapper::Aces) // ← enables HDR
.build()
.await?;
```
Without `.with_hdr(...)`, the renderer operates in LDR mode (direct to surface, zero overhead).
## Tone mapping curves
| Variant | Curve | Use case |
|---------|-------|----------|
| `ToneMapper::Aces` | ACES Filmic (Narkowicz 2015) | Cinematic look, soft highlight rolloff, good contrast |
| `ToneMapper::Reinhard` | `x / (1 + x)` | Simple, flat; less contrast but computationally trivial |
The curve is **compiled into the pipeline** at construction time (one WGSL entry point
per variant) — there is no runtime branching cost.
## Cost
| HDR state | Extra per-frame cost |
|-----------|---------------------|
| Disabled (default) | **Zero** — no texture, no pass, no pipeline |
| Enabled | +1 fullscreen render pass (triangle, 3 verts) + 1 offscreen texture (same size as window) |
The extra pass is negligible on any GPU (a few hundred microseconds). The offscreen
texture costs ~12 bytes/pixel of VRAM (RGBA16F = 8 bytes/px + the surface's own buffer).
## How it works (technical)
- **Offscreen texture**: `Rgba16Float`, same size as the window. Created in `Renderer::new`,
recreated on resize.
- **Main pass**: the color attachment targets the HDR texture instead of the surface.
The `standard_shader.wgsl` fragment output (linear float, unbounded) is stored as-is.
- **TM pass**: a fullscreen triangle (3 vertices, no vertex buffer) samples the HDR texture,
multiplies by exposure (currently fixed at 1.0), applies the tone curve, and writes to
the sRGB surface. The hardware performs the linear→sRGB gamma conversion automatically
(the surface format is `Rgba8UnormSrgb`).
- **No double gamma**: the shader outputs linear [0,1]; the sRGB surface encoding is
handled by the rasterizer.
## Exposure
Currently fixed at 1.0 (no user control yet). A future step will expose an
`exposure` field in a `HdrConfig` struct for live adjustment.
## See also
- [Shadows](shadows.md) — the other opt-in visual feature
- [GPU-driven rendering](gpu-driven.md) — the compute pipeline that feeds the main pass
- [Examples](examples.md) — the `demo` example enables HDR by default
+8
View File
@@ -16,6 +16,10 @@
//! (`create_mesh_with_lod`, auto-decimated by halving targets); the CPU picks each entity's //! (`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 //! 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. //! 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 //! Doc (this header) follows the English convention used for examples; internal comments stay
//! concise and French where helpful. Run with: //! concise and French where helpful. Run with:
@@ -27,6 +31,7 @@ use winit::event::MouseButton;
use winit::keyboard::KeyCode; use winit::keyboard::KeyCode;
use wsg_lib::AppHandler; use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder; 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::math::{Transform, cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
use wsg_lib::resources::{CameraController, Texture}; use wsg_lib::resources::{CameraController, Texture};
use wsg_lib::utils::WsgError; use wsg_lib::utils::WsgError;
@@ -269,9 +274,12 @@ impl AppHandler for Demo {
#[pollster::main] #[pollster::main]
async fn main() -> Result<(), WsgError> { async fn main() -> Result<(), WsgError> {
// Culling enabled here (Step 15, D8) to exercise the GPU path; it is OFF by default elsewhere. // 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() let app = AppBuilder::new()
.title("WSG Demo") .title("WSG Demo")
.with_culling(true) .with_culling(true)
.with_hdr(ToneMapper::Aces)
.build() .build()
.await?; .await?;
app.run(Demo { app.run(Demo {
+2 -2
View File
@@ -11,7 +11,7 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowAttributes}; use winit::window::{Window, WindowAttributes};
use wsg_lib::core::Context; use wsg_lib::core::Context;
use wsg_lib::core::Frame; use wsg_lib::core::Frame;
use wsg_lib::core::Renderer; use wsg_lib::core::{Renderer, ShadowConfig};
use wsg_lib::pipeline::PipelineCache; use wsg_lib::pipeline::PipelineCache;
use wsg_lib::resources::{Geometry, Material, Mesh}; use wsg_lib::resources::{Geometry, Material, Mesh};
use wsg_lib::utils; 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 // 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). // 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); renderer.set_unlit(true);
// 3. Material: uses renderer.device() and renderer.format() // 3. Material: uses renderer.device() and renderer.format()
+52 -6
View File
@@ -23,7 +23,7 @@
//! once right after GPU initialization so users can register shaders/meshes/materials/entities. //! once right after GPU initialization so users can register shaders/meshes/materials/entities.
use crate::AppHandler; use crate::AppHandler;
use crate::core::{Context, InputState, Renderer}; use crate::core::{Context, InputState, Renderer, ShadowConfig, ToneMapper};
use crate::scene::Scene; use crate::scene::Scene;
use crate::utils::WsgError; use crate::utils::WsgError;
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH}; use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
@@ -58,6 +58,11 @@ pub struct App {
pub(crate) height: u32, pub(crate) height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`. /// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
pub(crate) culling: bool, 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<ToneMapper>,
/// Winit event loop for window management. Set to None after run() consumes it. /// Winit event loop for window management. Set to None after run() consumes it.
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle. /// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
@@ -121,6 +126,8 @@ impl App {
width: self.width, width: self.width,
height: self.height, height: self.height,
culling: self.culling, culling: self.culling,
shadow_config: self.shadow_config.clone(),
hdr: self.hdr,
handler, handler,
app: None, app: None,
}; };
@@ -156,9 +163,11 @@ impl App {
let new_format = context.configure(&context.adapter, width, height)?; let new_format = context.configure(&context.adapter, width, height)?;
self.renderer_mut().resize_depth(width, height); self.renderer_mut().resize_depth(width, height);
self.renderer_mut().set_format(new_format); self.renderer_mut().set_format(new_format);
if new_format != old_format { if new_format != old_format && self.hdr.is_none() {
// Surface format changed: re-wire the Scene's GPU context (device + queue + format) // Surface format changed (rare): re-wire the Scene's GPU context so its
// so its PipelineCache/pipelines match the new surface format. // 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()); let device = std::sync::Arc::new(self.renderer_mut().device().clone());
self.scene self.scene
.init_gpu(device, self.context().queue.clone(), new_format); .init_gpu(device, self.context().queue.clone(), new_format);
@@ -178,6 +187,11 @@ pub struct AppBuilder {
height: u32, height: u32,
/// GPU frustum culling enabled (Step 15, D8). Defaults to false (non-regression). /// GPU frustum culling enabled (Step 15, D8). Defaults to false (non-regression).
culling: bool, 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<ToneMapper>,
} }
impl AppBuilder { impl AppBuilder {
@@ -189,6 +203,8 @@ impl AppBuilder {
width: APP_DEFAULT_WIDTH, width: APP_DEFAULT_WIDTH,
height: APP_DEFAULT_HEIGHT, height: APP_DEFAULT_HEIGHT,
culling: false, culling: false,
shadow_config: ShadowConfig::default(),
hdr: None,
} }
} }
/// Sets the window title to display in the OS taskbar/window decorations. /// Sets the window title to display in the OS taskbar/window decorations.
@@ -212,6 +228,20 @@ impl AppBuilder {
self.culling = enabled; self.culling = enabled;
self 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 /// 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 /// 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. /// 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, width: self.width,
height: self.height, height: self.height,
culling: self.culling, culling: self.culling,
shadow_config: self.shadow_config,
hdr: self.hdr,
event_loop: Some(event_loop), event_loop: Some(event_loop),
context: None, context: None,
renderer: None, renderer: None,
@@ -246,6 +278,10 @@ struct AppRunner<H: AppHandler> {
height: u32, height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`. /// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
culling: bool, 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<ToneMapper>,
/// The user-provided game logic. /// The user-provided game logic.
handler: H, handler: H,
/// The fully-built App facade, populated on the first `resumed` event. /// The fully-built App facade, populated on the first `resumed` event.
@@ -278,14 +314,22 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
.configure(&context.adapter, self.width, self.height) .configure(&context.adapter, self.width, self.height)
.expect("surface configuration failed"); .expect("surface configuration failed");
let device = Arc::new(context.device.clone()); 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). // Step 15, D8: apply the culling flag (off by default — non-regression).
renderer.set_culling(self.culling); renderer.set_culling(self.culling);
// Step 7 (DRAFT 7.1): the PipelineCache now lives in the Scene. We wire the GPU context // 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. // (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(); 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 { let mut app = App {
scene, scene,
@@ -294,6 +338,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
width: self.width, width: self.width,
height: self.height, height: self.height,
culling: self.culling, culling: self.culling,
shadow_config: self.shadow_config.clone(),
hdr: self.hdr,
event_loop: None, event_loop: None,
context: Some(context), context: Some(context),
renderer: Some(renderer), renderer: Some(renderer),
+45
View File
@@ -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())
}
}
+4
View File
@@ -11,11 +11,15 @@
pub mod context; pub mod context;
pub mod frame; pub mod frame;
pub mod hdr;
pub mod input; pub mod input;
pub mod renderer; pub mod renderer;
pub mod shadow;
// Re-exports // Re-exports
pub use context::Context; pub use context::Context;
pub use frame::Frame; pub use frame::Frame;
pub use hdr::ToneMapper;
pub use input::InputState; pub use input::InputState;
pub use renderer::Renderer; pub use renderer::Renderer;
pub use shadow::ShadowConfig;
+261 -9
View File
@@ -36,9 +36,9 @@ use crate::resources::{
Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform, Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform,
}; };
use crate::scene::Scene; use crate::scene::Scene;
use crate::core::hdr::ToneMapper;
use crate::utils::conf::{ use crate::utils::conf::{
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER,
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
}; };
use glam::{Mat4, Quat, Vec3, Vec4}; use glam::{Mat4, Quat, Vec3, Vec4};
use std::cell::{Cell, RefCell}; 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 /// 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. /// the initial surface size in `new` and refreshed by `resize_depth` on window resize.
viewport_height: u32, 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<HdrPipeline>,
}
/// 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 { impl Renderer {
@@ -156,7 +180,14 @@ impl Renderer {
/// Returns a new Renderer instance sharing the same underlying GPU resources as Context. /// 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; /// 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. /// 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<ToneMapper>,
) -> Self {
let queue: wgpu::Queue = context.queue.clone(); let queue: wgpu::Queue = context.queue.clone();
let device: wgpu::Device = context.device.clone(); let device: wgpu::Device = context.device.clone();
let [frame_layout, object_layout] = create_uniform_bind_group_layouts(&device); 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 // 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 // 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). // 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 { let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("shadow comparison sampler"), label: Some("shadow comparison sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_u: wgpu::AddressMode::ClampToEdge,
@@ -508,7 +539,7 @@ impl Renderer {
}], }],
}); });
let renderer = Self { let mut renderer = Self {
queue, queue,
device, device,
format, format,
@@ -542,10 +573,14 @@ impl Renderer {
lod_enabled: Cell::new(true), lod_enabled: Cell::new(true),
last_lod_levels: RefCell::new(Vec::new()), last_lod_levels: RefCell::new(Vec::new()),
viewport_height: height, 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 // 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. // `render` path (which has no window/camera) sees coherent values before `render_scene` runs.
renderer.write_default_frame_uniforms(); 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 renderer
} }
@@ -585,6 +620,14 @@ impl Renderer {
self.depth_view = depth_view; self.depth_view = depth_view;
// Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test. // Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test.
self.viewport_height = height; 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). /// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4).
@@ -619,7 +662,12 @@ impl Renderer {
Some((index, vp)) => ( Some((index, vp)) => (
index as u32, index as u32,
vp, 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, 1,
), ),
None => (MAX_LIGHTS as u32, Mat4::IDENTITY, Vec4::ZERO, 0), None => (MAX_LIGHTS as u32, Mat4::IDENTITY, Vec4::ZERO, 0),
@@ -679,8 +727,8 @@ impl Renderer {
} }
crate::resources::LightType::Point => return None, crate::resources::LightType::Point => return None,
}; };
let r = SHADOW_SCENE_RADIUS; let r = self.shadow_config.scene_radius;
let target = Vec3::from(SHADOW_SCENE_CENTER); 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 // 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]. // every point in the box has depth within [near=0, far=r].
let eye = target - dir * 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 // 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 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. // 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 { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene render pass"), label: Some("scene render pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment { color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view, view: main_target,
resolve_target: None, resolve_target: None,
depth_slice: None, depth_slice: None,
ops: wgpu::Operations { 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())); self.queue.submit(std::iter::once(encoder.finish()));
} }
@@ -1429,6 +1507,180 @@ fn batch_slots<K: Eq + Hash + Clone>(keys: &[K]) -> Vec<Vec<usize>> {
groups.into_iter().map(|(_, idxs)| idxs).collect() 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<f32> 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<f32> 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+65
View File
@@ -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,
}
}
}
+8
View File
@@ -44,3 +44,11 @@ pub use crate::app::App;
/// Re-export of the user-defined game logic interface for convenient top-level access. /// 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. /// Users implement this trait to define update/render callbacks injected into the render loop.
pub use crate::handler::AppHandler; 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;
+26 -6
View File
@@ -91,7 +91,7 @@ struct FrameUniforms {
num_spot: u32, num_spot: u32,
shadow_light_index: u32, // packed index of the shadow light ; MAX_LIGHTS = off shadow_light_index: u32, // packed index of the shadow light ; MAX_LIGHTS = off
light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3) light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3)
shadow_params: vec4<f32>, // .x = shadow map size, .y = depth bias shadow_params: vec4<f32>, // .x = map size, .y = constant bias, .z = slope bias
options: vec4<u32>, // .x = unlit flag ; .y = shadows on options: vec4<u32>, // .x = unlit flag ; .y = shadows on
}; };
@@ -202,16 +202,20 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor; 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<f32>(lit, in.color.a); return vec4<f32>(lit, in.color.a);
} }
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position // É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 // 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 // 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 // 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow.
// pulled toward the viewer by `frame.shadow_params.y` (bias) to suppress acne. //
fn compute_shadow(world_pos: vec3<f32>) -> f32 { // 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<f32>, normal: vec3<f32>) -> f32 {
// Shadows off (options.y == 0) or no valid caster (sentinel = MAX_LIGHTS) → fully lit. // 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) { if (frame.options.y == 0u || frame.shadow_light_index == MAX_LIGHTS) {
return 1.0; return 1.0;
@@ -224,9 +228,25 @@ fn compute_shadow(world_pos: vec3<f32>) -> f32 {
// The light projection is built with the WebGPU `[0,1]` clip-depth convention (glam // 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. // directx/WebGPU module), so NDC z is already in [0,1]: no extra remap is needed.
let current_depth = shadow_ndc.z; let current_depth = shadow_ndc.z;
let bias = frame.shadow_params.y;
let texel = 1.0 / max(frame.shadow_params.x, 1.0); 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<f32>;
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. // 3×3 PCF : average of the comparison results around the fragment's texel.
var lit_count = 0.0; var lit_count = 0.0;
for (var ox = -1i; ox <= 1; ox++) { for (var ox = -1i; ox <= 1; ox++) {
+105
View File
@@ -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<f32>,
}
@group(0) @binding(0) var u_hdr_texture: texture_2d<f32>;
@group(0) @binding(1) var u_hdr_sampler: sampler;
@group(0) @binding(2) var<uniform> 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<f32> {
switch vid {
case 0u {
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
}
case 1u {
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
}
default {
return vec4<f32>(-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<f32>,
) -> @location(0) vec4<f32> {
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<f32>(
aces(color.r),
aces(color.g),
aces(color.b),
1.0,
);
}
@fragment
fn fs_reinhard(
@builtin(position) frag_pos: vec4<f32>,
) -> @location(0) vec4<f32> {
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<f32>(
reinhard(color.r),
reinhard(color.g),
reinhard(color.b),
1.0,
);
}
+15 -4
View File
@@ -41,6 +41,11 @@ pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl");
/// the library; no external file is read). /// the library; no external file is read).
pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl"); 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 /// 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 /// 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. /// 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. /// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes.
pub const SHADOW_MAP_SIZE: u32 = 1024; pub const SHADOW_MAP_SIZE: u32 = 1024;
/// Default shadow depth bias (Step 14, D5) subtracted from the reference depth before the /// Default shadow constant bias (Step 14, D5) subtracted from the reference depth before the
/// comparison, to suppress acne without killing contact shadows. Combined with the slope-scaled /// comparison, to suppress acne without killing contact shadows. This is the minimum bias;
/// bias applied on the shadow pipeline itself. /// the slope-scaled term (SHADOW_SLOPE_BIAS) adds more for surfaces at grazing angles.
pub const SHADOW_DEPTH_BIAS: f32 = 0.006; 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 /// 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. /// for a directional light (D3). Chosen to comfortably frame the unit-cube scene of the examples.
+31
View File
@@ -85,3 +85,34 @@ fn gpu_driven_shader_is_valid_wgsl() {
"the two compute entry points are expected" "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"
);
}