diff --git a/docs/DRAFT.md b/docs/DRAFT.md index b452ee8..895838f 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -1,343 +1,298 @@ -# Étape 23 — Bloom (post-process HDR) +# Étape 24 — MSAA 4× (Anti-aliasing multi-échantillons) -**Statut** : ✅ Terminé -**Prérequis** : HDR + Tone Mapping (Étape 20 ✅), Emissive (Étape 22 ✅) +**Statut** : ⬜ En cours +**Roadmap** : 6.4 +**Prérequis** : Pipeline HDR (étape 20) + Bloom (étape 23) --- -## Objectif +## Problème -Ajouter un effet **bloom** : les zones très brillantes de la scène (emissive > 1.0, spéculaires, -overbright lighting) diffusent une lueur vers les zones voisines. C'est l'effet "glow" qui rend -les néons et les sources de lumière visuellement impactants. +Sans anti-aliasing, les bords des géométries présentent du **staircasing** (aliasing) : +les silhouettes ont des escaliers visibles, surtout sur les contours fins et les +lointains. C'est le défaut visuel le plus flagrant d'un moteur sans post-process. -Le bloom est un **post-process** qui opère sur la texture HDR, entre le rendu de la scène et le -tone mapping. Il est **opt-in** (`AppBuilder::with_bloom(...)`) et n'a **zéro coût** quand -désactivé (aucune texture/pipeline allouée). +## Solution + +Rendre la scène avec **N échantillons par pixel** (4× par défaut), puis **résoudre** +(en moyenne) vers une texture single-sample. Le reste du pipeline (bloom, TM) +opère sur la texture résolue — aucun changement. --- -## Pipeline +## Pipeline actuel vs avec MSAA +### Sans HDR, sans MSAA (actuel) ``` -Scene render → HDR texture (Rgba16Float, full res) - │ - ├─[bloom actif?]─→ 1. Threshold (half res) : extrait les pixels > threshold - │ 2. Blur H (half res) : Gaussian 9 taps - │ 3. Blur V (half res) : Gaussian 9 taps - │ 4. Composite (full res) : HDR += bloom × intensity - │ - ▼ - TM pass → surface +Scene → swapchain (Rgba8UnormSrgb) → present ``` -Quand bloom est désactivé : `Scene → HDR → TM → surface` (comme aujourd'hui, zéro overhead). +### Sans HDR, avec MSAA +``` +Scene → MSAA texture (4×, format surface) ──resolve──→ swapchain → present + + MSAA depth (4×) +``` +Le resolve est fait **automatiquement par wgpu** dans le render pass +(`resolve_target` sur la color attachment). -**4 passes fullscreen** supplémentaires (seulement si HDR + bloom actifs). +### Avec HDR, sans MSAA (actuel) +``` +Scene → HDR texture (Rgba16Float) → [Bloom] → TM → swapchain → present +``` + +### Avec HDR, avec MSAA (nouveau) +``` +Scene → MSAA HDR (4×, Rgba16Float) ──resolve──→ HDR texture (Rgba16Float) + + MSAA depth (4×) → [Bloom] → TM → swapchain → present +``` + +**Principe** : MSAA s'insère **uniquement** entre le rasterizer et le premier +consommateur de la texture de scène. Les post-processes (bloom, TM) voient +toujours une texture single-sample. --- -## Composants +## Décisions de design -### `BloomConfig` (pub, dans `core/bloom.rs`) - -```rust -pub struct BloomConfig { - /// Seuil de luminance (en unités HDR linéaires). Au-dessus → contribue au bloom. - /// Défaut : 1.0 (seul ce qui dépasse 1.0 "bloom" — les emissives > 1.0, les spéculaires). - pub threshold: f32, - /// Intensité du bloom (multiplicateur sur le résultat du blur). Défaut : 0.8. - pub intensity: f32, - /// Rayon du blur en pixels (à la résolution half-res). Défaut : 4.0. - pub radius: f32, -} -impl Default for BloomConfig { /* threshold=1.0, intensity=0.8, radius=4.0 */ } -``` - -### `BloomPipeline` (interne, dans `core/bloom.rs`) - -```rust -struct BloomPipeline { - /// Texture half-res pour le bloom (Rgba16Float). - bright_texture: wgpu::Texture, - bright_view: wgpu::TextureView, - /// Texture half-res pour le blur ping-pong (2nd buffer). - blur_texture: wgpu::Texture, - blur_view: wgpu::TextureView, - /// Sampler linear pour le blur. - sampler: wgpu::Sampler, - /// Pipeline threshold (fullscreen → half-res). - threshold_pipeline: wgpu::RenderPipeline, - /// Pipeline blur (fullscreen half-res, direction via uniform). - blur_pipeline: wgpu::RenderPipeline, - /// Pipeline composite (full-res: HDR += bloom). - composite_pipeline: wgpu::RenderPipeline, - /// Bind groups pré-alloués. - threshold_bg: wgpu::BindGroup, - blur_bg_a: wgpu::BindGroup, // reads bright, writes blur - blur_bg_b: wgpu::BindGroup, // reads blur, writes bright (ping-pong) - composite_bg: wgpu::BindGroup, // reads HDR + bright - /// Uniform buffer pour le blur (direction + radius). - blur_uniform: wgpu::Buffer, - /// Uniform buffer pour le threshold (threshold value). - threshold_uniform: wgpu::Buffer, - /// Half-res dimensions. - width: u32, - height: u32, -} -``` - -### Shaders (3 fichiers WGSL) - -#### `bloom_threshold.wgsl` -- Vertex : fullscreen triangle -- Fragment : lit la texture HDR (full res), calcule la luminance, sort `color × smoothstep(threshold, threshold+knee, lum)` ou `max(color - threshold, 0)` si `lum > threshold`, sinon `0` -- Écrit dans la texture half-res - -#### `bloom_blur.wgsl` -- Vertex : fullscreen triangle (à la résolution half-res) -- Fragment : 9-tap Gaussian séparable. L'offset est `texel_size × radius × i` dans la direction donnée par l'uniform. -- Uniform : `vec2 direction` (dx, dy), `f32 radius` -- Weights Gaussian : `[0.227027, 0.194595, 0.121622, 0.054054, 0.016216]` (symétrique) - -#### `bloom_composite.wgsl` -- Vertex : fullscreen triangle (full res) -- Fragment : `result = hdr_color + bloom_color × intensity` -- Uniform : `f32 intensity` -- Lit les 2 textures (HDR full-res + bloom half-res, upscalé par le sampler linear) - ---- - -## Shaders - -### `bloom_threshold.wgsl` - -```wgsl -// Fullscreen triangle vertex (même pattern que tonemap) -struct VsOut { - @builtin(position) pos: vec4, - @location(0) uv: vec2, -}; - -@vertex -fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { - var pos: vec2; - pos.x = f32((vi << 1) & 2) * 2.0 - 1.0; - pos.y = f32(vi & 2) * 2.0 - 1.0; - var out: VsOut; - out.pos = vec4(pos.x, -pos.y, 0.0, 1.0); - out.uv = vec2(pos.x * 0.5 + 0.5, 0.5 - pos.y * 0.5); - return out; -} - -struct ThresholdUniforms { - threshold: f32, - knee: f32, - pad: vec2, -}; - -@group(0) @binding(0) var tmu: ThresholdUniforms; -@group(0) @binding(1) var src_tex: texture_2d; -@group(0) @binding(2) var src_sampler: sampler; -@group(0) @binding(3) var pad; // placeholder — not needed, use texture_storage - -@fragment -fn fs_main(in: VsOut) -> @location(0) vec4 { - let color = textureSample(src_tex, src_sampler, in.uv).rgb; - let lum = dot(color, vec3(0.2126, 0.7152, 0.0722)); - // Soft knee: smooth transition above threshold - let soft = max(lum - tmu.threshold, 0.0); - let contrib = soft / (soft + tmu.knee); // 0..1 smooth - return vec4(color * contrib, 1.0); -} -``` - -### `bloom_blur.wgsl` - -```wgsl -// Même VsOut / vs_main que threshold (fullscreen triangle) - -struct BlurUniforms { - direction: vec2, // texel offset: (1/w, 0) or (0, 1/h) - radius: f32, - pad: vec2, -}; - -@group(0) @binding(0) var bu: BlurUniforms; -@group(0) @binding(1) var src_tex: texture_2d; -@group(0) @binding(2) var src_sampler: sampler; - -const W: array = array( - 0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162 -); - -@fragment -fn fs_main(in: VsOut) -> @location(0) vec4 { - let center = textureSample(src_tex, src_sampler, in.uv).rgb; - var sum = center * W[0]; - for (var i: u32 = 1u; i < 5u; i = i + 1u) { - let off = bu.direction * (f32(i) * bu.radius); - let s = textureSample(src_tex, src_sampler, in.uv + off).rgb - + textureSample(src_tex, src_sampler, in.uv - off).rgb; - sum = sum + s * W[i]; - } - return vec4(sum, 1.0); -} -``` - -### `bloom_composite.wgsl` - -```wgsl -// Même VsOut / vs_main - -struct CompositeUniforms { - intensity: f32, - pad: vec3, -}; - -@group(0) @binding(0) var cu: CompositeUniforms; -@group(0) @binding(1) var hdr_tex: texture_2d; -@group(0) @binding(2) var hdr_sampler: sampler; -@group(0) @binding(3) var bloom_tex: texture_2d; -@group(0) @binding(4) var bloom_sampler: sampler; - -@fragment -fn fs_main(in: VsOut) -> @location(0) vec4 { - let hdr = textureSample(hdr_tex, hdr_sampler, in.uv).rgb; - let bloom = textureSample(bloom_tex, bloom_sampler, in.uv).rgb; - return vec4(hdr + bloom * cu.intensity, 1.0); -} -``` - ---- - -## Intégration dans `Renderer::render_scene` - -``` -Step 7: Main render pass → HDR texture (ou surface si pas HDR) -Step 8: [Bloom] Si HDR + bloom actifs : - 8a. Threshold pass (HDR full → bright half) - 8b. Blur H (bright half → blur half) - 8c. Blur V (blur half → bright half) [ping-pong] - 8d. Composite (HDR full + bright half → HDR full) - 8e. write_buffer(exposure) — comme aujourd'hui -Step 9: TM pass (HDR full → surface) -``` - -Le composite **modifie la texture HDR in-place** (rend dans une 2ème texture puis swap, ou -rend directement dans la HDR texture si on utilise un ping-pong). En pratique : le composite -rend dans la `HDR texture` elle-même (le bind group lit la HDR comme input ET écrit dedans — -**NON**, c'est undefined behavior en wgpu). - -**Solution** : le composite écrit dans un **3ème buffer full-res** (ou on swap les rôles : -le bloom écrit dans la HDR texture en lisant une copie). La solution la plus simple : -- Le threshold lit la HDR texture et écrit dans `bright` (half res) -- Le blur ping-ponge entre `bright` et `blur` (half res) -- Le composite lit la HDR texture + `bright` (half res) et écrit dans la **HDR texture** - (c'est OK car le composite est une pass séparée qui commence APRÈS que le threshold/blur - ont fini d'écrire — et le composite lit la HDR texture en input mais écrit aussi dedans) - -Attendez — **non**, en wgpu/WebGPU, on ne peut PAS lire et écrire la même texture dans la même -render pass. Mais on peut le faire dans des **passes différentes** (le composite est une pass -séparée du threshold). Le problème est que le composite lit la HDR texture (qui n'a pas été -modifiée par threshold/blur — ils ont écrit dans bright/blur) et écrit dans la HDR texture. -C'est **valide** car c'est dans une render pass unique : le GPU ne permet pas de lire ET écrire -la même texture attachment dans la même pass. - -**Solution propre** : utiliser un **ping-pong full-res** : -- `hdr_texture` (existante) : contient le rendu de la scène -- `bloom_composite_texture` (full-res, allouée avec le bloom) : reçoit le résultat du composite -- Le TM pass lit `bloom_composite_texture` au lieu de `hdr_texture` - -Quand bloom est inactif : le TM lit `hdr_texture` directement (comme aujourd'hui). +| # | Décision | Rationale | +|---|----------|-----------| +| D1 | `AppBuilder::with_msaa(count)` — opt-in, zero cost désactivé | Principe WSG : chaque effet est optionnel | +| D2 | `sample_count` configurable (2, 4, 8) — default 4 | 4× est le bon rapport qualité/coût ; 8× pour du "max" | +| D3 | Texture MSAA **offscreen** (jamais le swapchain en MSAA direct) | Uniformité : même code path que HDR, resize plus simple | +| D4 | Depth buffer recréé en MSAA quand actif | Le depth doit avoir le même `sample_count` que le color | +| D5 | Resolve via `RenderPassColorAttachment::resolve_target` | Natif wgpu, pas de shader supplémentaire | +| D6 | **Aucun nouveau shader** | MSAA est une feature rasterizer, pas un post-process | +| D7 | Format MSAA = format de la cible (Rgba16Float si HDR, surface format sinon) | Le resolve produit la même texture qu'avant | +| D8 | Resize recrée les textures MSAA + depth | Même pattern que HDR resize | +| D9 | Bloom/TM inchangés — ils lisent la texture résolue (single-sample) | Zéro impact sur les passes post | +| D10 | `MsaaConfig { sample_count: u32 }` — public, re-exporté | API minimale, extensible | --- ## API utilisateur -| Composant | Changement | -|-----------|-----------| -| `AppBuilder` | `with_bloom(config: BloomConfig)` — active le bloom | -| `App` | `set_bloom_config(config)`, `bloom_enabled() -> bool` | -| `Renderer` | Champ `bloom: Option`, `bloom_config: BloomConfig` | -| `core/mod.rs` | `pub mod bloom;` + re-export `BloomConfig` | -| `lib.rs` | Re-export `BloomConfig` | -| `prelude.rs` | Re-export `BloomConfig` | +```rust +use wsg_lib::prelude::*; -**Règle** : le bloom n'a d'effet que si HDR est actif. `with_bloom()` sans `with_hdr()` est -un no-op (log un warning). +let app = AppBuilder::new() + .title("MSAA Demo") + .with_hdr(ToneMapper::Aces) + .with_msaa(4) // ← 4 échantillons (2, 4, ou 8) + .with_bloom(BloomConfig::default()) + .build() + .await?; +``` + +Sans `.with_msaa(...)` → comportement identique à aujourd'hui (0 échantillon overhead). --- -## Resize +## Implémentation -Au resize, si le bloom est actif : -- Recréer les textures half-res (bright, blur) -- Recréer le composite texture full-res -- Recréer les bind groups -- Mettre à jour les uniforms (dimensions) +### 24.1 — `MsaaConfig` (public) + +Fichier : `lib/src/core/msaa.rs` + +```rust +/// Configuration MSAA. `sample_count` doit être 2, 4, ou 8 +/// (valeur supportée par le GPU — vérifiée à l'init). +#[derive(Clone, Copy, Debug)] +pub struct MsaaConfig { + pub sample_count: u32, +} + +impl Default for MsaaCapable { + fn default() -> Self { + Self { sample_count: 4 } + } +} +``` + +### 24.2 — Champs `Renderer` + +Ajouter à `Renderer` : +```rust +msaa_config: MsaaConfig, // toujours présent (sample_count=1 si désactivé) +msaa_color_texture: Option, // Some si msaa active +msaa_color_view: Option, +msaa_depth_texture: Option, +msaa_depth_view: Option, +``` + +Quand `msaa_config.sample_count > 1` : +- `msaa_color_texture` = texture `sample_count=N`, format = HDR ou surface +- `msaa_depth_texture` = texture depth `sample_count=N` +- Le render pass scène utilise ces views + `resolve_target` + +Quand `sample_count == 1` : +- Tous les `Option` sont `None` +- Le render pass utilise la texture/view existante (comportement actuel) + +### 24.3 — Allocation à l'init (`Renderer::new`) + +```rust +let sample_count = msaa_config.map(|c| c.sample_count).unwrap_or(1); + +// Vérifier que le format supporte ce sample_count +let formats = device.limits(); // ou surface.capabilities() +// Pour le surface : surface.capabilities().formats +// Pour l'offscreen : device.limits().max_color_attachments, etc. +// En pratique : Rgba16Float et Rgba8Unorm supportent 4× partout. + +if sample_count > 1 { + let msaa_tex = device.create_texture(&TextureDescriptor { + size: Extent3d { width, height, depth_or_array_layers: 1 }, + sample_count, + dimension: Dimension::D2, + format: target_format, // Rgba16Float ou surface format + usage: TextureUsages::RENDER_ATTACHMENT, + .. + }); + // + depth MSAA +} +``` + +### 24.4 — Render pass scène (modification `render_scene`) + +Le render pass principal doit utiliser les views MSAA quand actif : + +```rust +// Déterminer la color attachment +let (color_view, resolve_target) = if let Some(msaa_view) = &self.msaa_color_view { + // MSAA actif : render dans MSAA, resolve vers la texture single + let resolve = if self.hdr.is_some() { + Some(self.hdr.as_ref().unwrap().view.clone()) // resolve → HDR tex + } else { + Some(view.clone()) // resolve → swapchain + }; + (msaa_view.clone(), resolve) +} else { + // Pas de MSAA : comportement actuel + let color_view = if let Some(hdr) = &self.hdr { + hdr.view.clone() + } else { + view.clone() + }; + (color_view, None) +}; + +// Depth : MSAA ou single +let depth_view = if let Some(msaa_depth) = &self.msaa_depth_view { + msaa_depth.clone() +} else { + self.depth_view.clone() +}; + +encoder.render_pass(RenderPassDescriptor { + color_attachments: &[Some(RenderPassColorAttachment { + view: &color_view, + resolve_target: resolve_target.as_ref(), + load_op: LoadOp::Clear, + store_op: StoreOp::Store, // Store même en MSAA (wgpu gère le resolve) + })], + depth_stencil_attachment: Some(RenderPassDepthStencilAttachment { + view: &depth_view, + .. + }), + .. +}); +``` + +### 24.5 — Resize + +```rust +fn resize(&mut self, device, width, height) { + // ... resize depth existant ... + + if self.msaa_config.sample_count > 1 { + // Recréer MSAA color + depth + self.msaa_color_texture = Some(create_msaa_texture(...)); + self.msaa_color_view = Some(...); + self.msaa_depth_texture = Some(create_msaa_depth(...)); + self.msaa_depth_view = Some(...); + } + + // HDR resize existant + // Bloom resize existant +} +``` + +### 24.6 — Plomberie App/AppBuilder + +```rust +// AppBuilder +pub fn with_msaa(mut self, sample_count: u32) -> Self { + assert!((2..=8).contains(&sample_count) && sample_count.is_power_of_two(), + "sample_count must be 2, 4, or 8"); + self.msaa_config = Some(MsaaConfig { sample_count }); + self +} + +// App +pub fn msaa_enabled(&self) -> bool { ... } +pub fn set_msaa(&mut self, sample_count: u32) { ... } // nécessite resize +``` + +Plomberie : `AppBuilder → App → AppRunner → Renderer::new(msaa_config)` + +### 24.7 — Re-exports + +`lib.rs` + `prelude.rs` : `pub use crate::core::MsaaConfig;` + +### 24.8 — Exemple `msaa.rs` + +Scène simple (cube + sphere + ground) avec/without MSAA commutable à la runtime +(clavier `M`). Camera orbitale pour voir les bords de près. + +Contrôles : +- `M` — toggle MSAA (nécessite un resize/recréation des textures) +- `R`/`1`/`2`/`3` — presets caméra +- Drag/wheel — orbit/zoom + +### 24.9 — Documentation + +- `docs/user/msaa.md` : activation, config, coûts, limitations +- `docs/user/README.md` : section "Anti-aliasing" +- `lib/examples/README.md` : entrée `msaa` +- `docs/ROADMAP.md` : 6.4 → ✅ --- -## Décisions +## Coût GPU -| # | Décision | Justification | -|---|----------|---------------| -| D1 | 4 passes (threshold + blur H + blur V + composite) | Bonne qualité/performances. Un seul niveau de mip suffit pour un bloom "soft" | -| D2 | Résolution half-res pour le bloom | Standard. Le blur à half-res est 4× moins coûteux et le résultat upscalé par le sampler linear est lisse | -| D3 | Soft-knee threshold (pas un cutoff dur) | `soft/(soft+knee)` donne une transition douce, pas d'aliasing au seuil | -| D4 | Composite via ping-pong full-res (3ème texture) | Évite le conflit read/write sur la même texture dans une même pass | -| D5 | Bloom seulement si HDR actif | Le bloom opère en espace linéaire HDR. Sans HDR, les valeurs sont déjà clampées [0,1] → pas de "bright" à extraire | -| D6 | `BloomConfig` avec 3 champs (threshold, intensity, radius) | Minimum utile. Pas de multi-mip, pas de directional bloom pour MVP | -| D7 | Sampler `Linear` + `ClampToEdge` pour le blur | Les bords ne doivent pas sampler hors-texture (artefacts noirs) | -| D8 | Le TM pass lit la texture composite (si bloom) ou la HDR (si pas bloom) | Le TM est agnostique de la source — il lit juste une texture full-res Rgba16Float | -| D9 | Uniform threshold : 16 bytes (threshold + knee + 2 pad) | Aligned 16, simple | -| D10 | Uniform blur : 16 bytes (direction vec2 + radius + pad) | Aligned 16 | -| D11 | Uniform composite : 16 bytes (intensity + 3 pad) | Aligned 16 | +| Config | Coût relatif | +|--------|:---:| +| Sans MSAA | 1× | +| MSAA 4× | ~1.3–1.5× (le rasterizer sur-échantillonne, le fill rate est partagé) | +| MSAA 8× | ~1.5–2× | ---- +MSAA est **beaucoup** moins coûteux qu'un post-process AA (FXAA, TAA) car le +sur-coût est dans le rasterizer (edges only) et pas dans un blur fullscreen. +Les post-processes (bloom, TM) ne sont **pas** affectés — ils tournent sur la +texture résolue (1 échantillon/pixel). -## Fichiers modifiés / créés +## Limitations / non-goals -| Fichier | Changement | -|---------|-----------| -| `lib/src/core/bloom.rs` | **Nouveau** : `BloomConfig`, `BloomPipeline`, allocation + bind groups | -| `lib/src/core/renderer.rs` | + `bloom: Option`, `bloom_config` ; passes 8a-8d ; TM lit composite ou HDR ; resize | -| `lib/src/core/hdr.rs` | `create_hdr_bind_group` accepte une texture arbitraire (pas seulement `self.texture`) | -| `lib/src/core/mod.rs` | + `pub mod bloom;` + re-exports | -| `lib/src/shaders/bloom_threshold.wgsl` | **Nouveau** | -| `lib/src/shaders/bloom_blur.wgsl` | **Nouveau** | -| `lib/src/shaders/bloom_composite.wgsl` | **Nouveau** | -| `lib/src/shaders/conf.rs` | + `BLOOM_THRESHOLD_SHADER`, `BLOOM_BLUR_SHADER`, `BLOOM_COMPOSITE_SHADER` | -| `lib/src/app.rs` | + `bloom_config`, `bloom_enabled`, `set_bloom_config`, builder `with_bloom` | -| `lib/src/lib.rs` | Re-export `BloomConfig` | -| `lib/src/prelude.rs` | Re-export `BloomConfig` | -| `lib/tests/wgsl_validate.rs` | + 3 tests (threshold, blur, composite) | -| `lib/examples/demo.rs` | + `with_bloom(BloomConfig::default())` | -| `docs/user/bloom.md` | **Nouveau** : doc utilisateur | -| `docs/ROADMAP.md` | 6.3 → ✅ | - ---- +- **Pas de TAA** (temporal AA) — nécessiterait un history buffer + motion vectors, + bien plus complexe. MSAA 4× couvre 90% du besoin pour un lib "simple". +- **Pas de MSAA sur les post-processes** — le bloom/TM lissent déjà l'image. +- **Pas de coverage sampling** (DX12-only) — WebGPU expose seulement MSAA. +- **Resize = recréation** des textures MSAA (pas de resize in-place). ## Tests -| Test | Vérifie | -|------|---------| -| `bloom_config_default` | threshold=1.0, intensity=0.8, radius=4.0 | -| `bloom_requires_hdr` | `with_bloom` sans `with_hdr` → warning, bloom inactif | -| `bloom_pipeline_allocates_half_res` | dimensions = (w/2, h/2) | -| `bloom_zero_intensity_is_noop` | intensity=0 → composite = HDR (pas de changement) | -| WGSL threshold | compile avec naga | -| WGSL blur | compile avec naga | -| WGSL composite | compile avec naga | - ---- +- [ ] `MsaaConfig` Default = 4 +- [ ] Validation `sample_count` (rejette 3, 5, 16) +- [ ] Build avec `with_msaa(4)` + HDR + Bloom → compile +- [ ] Exemple `msaa.rs` compile +- [ ] WGSL validation inchangé (pas de nouveau shader) +- [ ] Test unit : `Renderer::new` avec `msaa_config=Some(4)` ne panique pas + (nécessite un device mock — peut être un test intégration ignoré) ## Critères d'acceptation -- [ ] `cargo test` passe (tous tests existants + nouveaux) -- [ ] `cargo run --example demo` : le glow sphere produit un halo visible -- [ ] Sans bloom : rendu identique à avant (zéro régression) -- [ ] Sans HDR + avec bloom : pas de crash (bloom ignoré, warning) -- [ ] Resize : le bloom continue de fonctionner -- [ ] 0 warnings +1. `cargo check` 0 errors, 0 warnings +2. `cargo test` tous verts +3. `cargo run -p wsg-lib --example msaa` → bords lisses avec M, escaliers sans M +4. `cargo run -p wsg-lib --example demo` → inchangé (pas de `with_msaa`) +5. Combinaison HDR + Bloom + MSAA fonctionne (demo avec `.with_msaa(4)`) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 073b094..1257d26 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -66,10 +66,15 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT | 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ✅ | | 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ✅ | | 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ✅ | -| 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | Moyen | ⬜ | +| 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | Moyen | ✅ | | 6.5 | **Normal mapping / PBR** (nouveau shader, tangent space, metalness-roughness) | ⭐⭐⭐ | Élevé | ⬜ | | 6.6 | **Cascaded Shadow Maps** (2–3 cascades + blend, plus de précision près de la camera) | ⭐⭐ | Élevé | ⬜ | | 6.7 | **SSAO** (ambient occlusion screen-space, depth + normal buffer) | ⭐⭐ | Élevé | ⬜ | +| 6.13 | **Fog** (exponential / exponential² / height fog, paramètre par scène ou par matériau) | ⭐⭐⭐ | Faible | ⬜ | +| 6.14 | **Area lights** (rectangular area light, BRDF approx — specular + diffuse) | ⭐⭐⭐ | Élevé | ⬜ | +| 6.15 | **Textured area lights** (area light avec texture d’émission, e.g. panneaux LED, néons) | ⭐⭐⭐ | Moyen | ⬜ | +| 6.16 | **Volumetric lighting** (god rays / light scattering — radial blur ou ray-march 3D) | ⭐⭐⭐⭐ | Élevé | ⬜ | +| 6.17 | **Depth of field** (post-process CoC : circle-of-confusion + bokeh blur) | ⭐⭐⭐ | Moyen | ⬜ | ### Cibles techniques (refactoring) diff --git a/docs/user/README.md b/docs/user/README.md index 803c9d3..2861d0c 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -22,6 +22,7 @@ GPU graphics background is required. | [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall | | [Mesh & primitives](mesh.md) | Procedural generators + file import (OBJ), feature-gated | | [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` | +| [MSAA (anti-aliasing)](msaa.md) | Multi-sample edge smoothing, opt-in via `with_msaa(4)` | | [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 | @@ -36,6 +37,7 @@ WSG follows a strict rule: **a feature you don't enable costs nothing at runtime |---------|--------------|----------------| | Shadows | `scene.set_shadow_caster(Some(idx))` | No shadow map allocated, no depth pass, no PCF sampling | | HDR + Tone mapping | `AppBuilder::with_hdr(ToneMapper::Aces)` | No offscreen texture, no TM pass, direct-to-surface render | +| MSAA | `AppBuilder::with_msaa(4)` | Single-sample (1×), zero overhead | | GPU-driven culling | `AppBuilder::with_gpu_driven(true)` | No compute pipeline, no indirect draw buffers | | LOD | `scene.create_mesh_with_lod(…, levels)` | Single-level mesh, no decimation, no hysteresis | | Primitives | Cargo feature `prim-*` (default: all) | Not compiled at all | diff --git a/docs/user/msaa.md b/docs/user/msaa.md new file mode 100644 index 0000000..fa2f7ed --- /dev/null +++ b/docs/user/msaa.md @@ -0,0 +1,127 @@ +# MSAA (Anti-aliasing) + +Multi-Sample Anti-Aliasing (MSAA) smooths jagged edges by rendering the scene +at a higher sample count (e.g. 4 samples per pixel), then averaging the samples +into the final image. + +## Activation + +MSAA is opt-in via the builder. When disabled (default), the renderer uses +single-sample rendering with zero overhead: + +```rust +let app = AppBuilder::new() + .title("My App") + .with_msaa(4) // 4× MSAA (also: 2 or 8) + .build() + .await?; +``` + +## How it works + +MSAA is a **rasterizer feature** — no new shader is needed. The pipeline: + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ Without MSAA (default) │ +│ │ +│ Main pass ──→ HDR texture (1 sample) ──→ [Bloom] ──→ TM ──→ Surface │ +└─────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────┐ +│ With MSAA 4× (and HDR) │ +│ │ +│ Main pass ──→ MSAA texture (4 samples, Rgba16Float) │ +│ ↓ resolve (hardware average) │ +│ HDR texture (1 sample) │ +│ ↓ │ +│ [Bloom] ──→ TM ──→ Surface │ +└─────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────┐ +│ With MSAA 4× (no HDR) │ +│ │ +│ Main pass ──→ MSAA texture (4 samples, surface format) │ +│ ↓ resolve │ +│ Swapchain (surface) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +Key points: +- The **main scene pass** renders into the MSAA texture (N samples/pixel). +- The **resolve** (hardware average) produces the single-sample output. +- **Post-processes** (bloom, tone mapping) operate on the **resolved** + single-sample texture — they are completely unaffected by MSAA. +- The **shadow map** is always single-sample (depth-only, not visible directly). + +## Sample count + +| Count | Quality | Cost (approx.) | Use case | +|-------|---------|----------------|----------| +| 2 | Basic | ~1.1× | Low-end / battery | +| **4** | **Good** | **~1.3–1.5×** | **Default, most games** | +| 8 | Excellent | ~1.6–2.0× | High-end / static scenes | + +The cost is in the rasterizer/fill-rate (each pixel is shaded N times at +triangles' edges). Interior pixels (covered by a single triangle) are +shaded only once — MSAA only multiplies the **edge** cost. + +## Runtime query + +```rust +// In AppHandler::setup or update: +let active = app.renderer().msaa_enabled(); // true if sample_count > 1 +let count = app.renderer().msaa_sample_count(); // 1, 2, 4, or 8 +``` + +MSAA is a **build-time** setting: the multi-sample textures are allocated +at startup. Changing the sample count requires recreating the textures +(window resize does this automatically). + +## Configuration struct + +```rust +use wsg_lib::MsaaConfig; + +let config = MsaaConfig { sample_count: 4 }; +// Or use the builder shortcut (validated): +let app = AppBuilder::new().with_msaa(4).build().await?; +``` + +## Compatibility + +| Feature | Compatible? | Notes | +|---------|-------------|-------| +| HDR + Tone Mapping | ✅ | MSAA texture is `Rgba16Float`, resolves into HDR | +| Bloom | ✅ | Bloom reads the resolved (single-sample) HDR texture | +| Shadows | ✅ | Shadow map is always single-sample | +| Frustum Culling | ✅ | Independent (compute pass) | +| LOD | ✅ | Independent (draw args) | +| Emissive | ✅ | Per-entity, in the main pass | + +## Limitations + +- **Does not smooth UV-dependent aliasing** (texture shimmer). For that, + use mipmaps + anisotropic filtering (future: texture module). +- **Cost scales with overdraw**: fully transparent or heavily overlapping + geometry pays the full N× cost. +- **GPU support**: most modern GPUs support 4× for all formats. 8× may be + limited for float formats (check `Device::limits().max_color_attachment_samples`). + +## Example + +```rust +use wsg_lib::app::AppBuilder; +use wsg_lib::core::ToneMapper; + +let app = AppBuilder::new() + .title("MSAA Demo") + .size(1280, 720) + .with_msaa(4) + .with_hdr(ToneMapper::Aces) + .build() + .await?; +``` + +See `examples/msaa.rs` for a full interactive demo with cube, sphere, +and ground plane where aliasing is clearly visible without MSAA. diff --git a/lib/examples/README.md b/lib/examples/README.md index 9d73e3b..194c547 100644 --- a/lib/examples/README.md +++ b/lib/examples/README.md @@ -16,7 +16,8 @@ cargo run -p wsg-lib --example | `hdr` | HDR + Tone Mapping (ACES) + contrôle d'exposition | | `emissive` | Matériaux émissifs (intensités croissantes 0 → 4.0) | | `shadow` | Shadow mapping (ombre portée directionnelle) | -| `culling` | Culling GPU-driven (grille 20×20, objets hors frustum ignorés) | +| `culling` | Culling GPU-driven (grille 15×15, objets hors frustum ignorés) | +| `msaa` | MSAA 4× (anti-aliasing multi-échantillons, arêtes lisses) | | `manual` | Workflow bas niveau (Context + Renderer + PipelineCache) | | `import` | Import de fichier OBJ (non graphique, stdout) | @@ -220,6 +221,36 @@ cargo run -p wsg-lib --example culling --- +## `msaa` — MSAA 4× (Anti-aliasing) + +Démontre l'anti-aliasing multi-échantillons : les arêtes des objets (cube, sphère) +sont lisses au lieu d'être "en escalier". La scène contient un cube (arêtes nettes), +une sphère (silhouette courbe) et un petit cube près de la caméra (aliasing maximal). + +```sh +cargo run -p wsg-lib --example msaa +``` + +### Touches + +| Touche | Action | +|--------|--------| +| Glisser (LMB) | Orbiter la caméra | +| Molette | Zoom | +| `R` | Reset caméra | +| `M` | Afficher le nombre d'échantillons | + +### Pour comparer avec/sans MSAA + +Supprimer la ligne `.with_msaa(4)` dans le source et recompiler : la scène est +identique, seules les arêtes diffèrent (escaler vs lisse). + +> **Note** : MSAA est un réglage de build-time (allocation de textures multi-échantillons). +> Il fonctionne indépendamment de HDR : avec HDR, la texture MSAA est `Rgba16Float` +> et résout dans la texture HDR avant bloom/TM. + +--- + ## `manual` — Workflow bas niveau Démontre l'API **sans** la façade `App` : utilisation directe de `Context`, diff --git a/lib/examples/manual.rs b/lib/examples/manual.rs index 5ce0fc6..d543ae2 100644 --- a/lib/examples/manual.rs +++ b/lib/examples/manual.rs @@ -56,14 +56,14 @@ impl ApplicationHandler for App { // 2. Renderer initialization (it retrieves everything it needs) let device = Arc::new(context.device.clone()); - let mut cache = PipelineCache::new(device, context.queue.clone()); + let mut cache = PipelineCache::new(device, context.queue.clone(), 1); cache .register_shader("standard", utils::STANDARD_SHADER_PATH) .unwrap(); // 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, &ShadowConfig::default(), None, None); + let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None, None, None); renderer.set_unlit(true); // 3. Material: uses renderer.device() and renderer.format() diff --git a/lib/examples/msaa.rs b/lib/examples/msaa.rs new file mode 100644 index 0000000..1f0fd49 --- /dev/null +++ b/lib/examples/msaa.rs @@ -0,0 +1,151 @@ +//! **MSAA (Multi-Sample Anti-Aliasing)** — demonstrates 4× MSAA edge smoothing. +//! +//! Shows how MSAA eliminates the jagged "staircase" artifacts (aliasing) along +//! sharp edges. The scene contains a cube (sharp edges), a sphere (curved surface), +//! and a ground plane — all with high-contrast edges where aliasing is most visible. +//! +//! To compare with/without MSAA: remove the `.with_msaa(4)` line from the builder +//! below and rebuild. The scene and lighting are identical — only the edge +//! smoothness differs. +//! +//! ## Pipeline (MSAA + HDR) +//! ```text +//! Main pass → MSAA texture (4 samples, Rgba16Float) +//! ↓ resolve (average 4 samples → 1) +//! HDR texture (single sample) +//! ↓ +//! Tone Mapping → surface +//! ``` +//! +//! ## Controls +//! | Key | Action | +//! |-----|--------| +//! | Drag (LMB) | Orbit camera | +//! | Wheel | Zoom | +//! | `R` | Reset camera | +//! | `M` | Toggle MSAA info (shows sample count) | +//! +//! ## Build & Run +//! ```sh +//! cargo run -p wsg-lib --example msaa +//! ``` + +use glam::Vec3; +use winit::event::MouseButton; +use winit::keyboard::KeyCode; +use wsg_lib::app::AppBuilder; +use wsg_lib::camera::CameraController; +use wsg_lib::core::{Transform, ToneMapper}; +use wsg_lib::mesh::{cube, icosphere, plane}; +use wsg_lib::AppHandler; +use wsg_lib::utils::WsgError; + +struct MsaaDemo { + camera: CameraController, + show_info: bool, +} + +impl AppHandler for MsaaDemo { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + + // Ground plane. + app.scene + .create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None) + .unwrap(); + app.scene.add_entity("ground", "ground_mesh").unwrap(); + + // Cube — sharp edges make aliasing very visible. + app.scene + .create_mesh("cube_mesh", cube(1.0), None) + .unwrap(); + let mut cube_tf = Transform::identity(); + cube_tf.translation = Vec3::new(1.5, 0.5, 0.0); + app.scene + .add_entity_with_transform("cube", "cube_mesh", cube_tf) + .unwrap(); + + // Sphere — curved surface, aliasing visible on the silhouette. + app.scene + .create_mesh("sphere_mesh", icosphere(0.6, 3), None) + .unwrap(); + let mut sphere_tf = Transform::identity(); + sphere_tf.translation = Vec3::new(-1.5, 0.6, 0.0); + app.scene + .add_entity_with_transform("sphere", "sphere_mesh", sphere_tf) + .unwrap(); + + // Small cube near the camera — very close edges, maximum aliasing. + app.scene + .create_mesh("small_cube_mesh", cube(0.3), None) + .unwrap(); + let mut small_tf = Transform::identity(); + small_tf.translation = Vec3::new(0.0, 0.15, 1.5); + app.scene + .add_entity_with_transform("small_cube", "small_cube_mesh", small_tf) + .unwrap(); + + // Directional light (strong, creates high-contrast edges). + let light_dir = Vec3::new(0.5, 1.0, 0.3).normalize(); + app.scene + .add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.5) + .unwrap(); + app.scene.set_ambient([0.08, 0.08, 0.1]); + + // Camera. + self.camera.yaw = 0.4; + self.camera.pitch = 0.2; + self.camera.distance = 4.0; + self.camera.target = Vec3::new(0.0, 0.4, 0.0); + self.camera.apply_to(app.scene.camera_mut()); + + // Print MSAA status. + let sc = app.renderer().msaa_sample_count(); + eprintln!("[MSAA] sample_count = {} ({})", sc, if sc > 1 { "active" } else { "disabled" }); + } + + fn update(&mut self, app: &mut wsg_lib::App) { + // Orbit camera. + let (dx, dy) = app.input.mouse_delta(); + if app.input.mouse_button_held(MouseButton::Left) { + self.camera.orbit(dx, dy); + } + let (_, sy) = app.input.scroll_delta(); + self.camera.zoom(sy); + + if app.input.key_pressed(KeyCode::KeyR) { + self.camera.yaw = 0.4; + self.camera.pitch = 0.2; + self.camera.distance = 4.0; + } + self.camera.apply_to(app.scene.camera_mut()); + + // Toggle info display. + if app.input.key_pressed(KeyCode::KeyM) { + self.show_info = !self.show_info; + let sc = app.renderer().msaa_sample_count(); + eprintln!("[MSAA] {}× {}", sc, if sc > 1 { "enabled" } else { "disabled (single sample)" }); + } + } + + fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) { + app.render_scene(frame.view()); + } +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + let app = AppBuilder::new() + .title("WSG MSAA 4×") + .size(960, 640) + .with_msaa(4) // ← Enable 4× MSAA (remove for comparison) + .with_hdr(ToneMapper::Aces) // MSAA works with or without HDR + .build() + .await?; + app.run(MsaaDemo { + camera: CameraController::default(), + show_info: false, + }) +} diff --git a/lib/src/app.rs b/lib/src/app.rs index 82c036e..e024d7d 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::{BloomConfig, Context, Renderer, ShadowConfig, ToneMapper}; +use crate::core::{BloomConfig, Context, MsaaConfig, Renderer, ShadowConfig, ToneMapper}; use crate::input::InputState; use crate::scene::Scene; use crate::utils::WsgError; @@ -70,6 +70,9 @@ pub struct App { /// Exposure multiplier (Étape 22, 6.1). Applied in the tone mapping pass before the curve. /// Default 1.0. Adjustable at runtime via `set_exposure` or keyboard (+/-). pub exposure: f32, + /// MSAA configuration (Étape 24). `None` = no MSAA (default, zero overhead); + /// `Some(config)` activates multi-sample anti-aliasing. + msaa: 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. @@ -137,6 +140,7 @@ impl App { hdr: self.hdr, bloom_config: self.bloom_config.clone(), exposure: self.exposure, + msaa: self.msaa.clone(), handler, app: None, }; @@ -211,8 +215,9 @@ impl App { // É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 sc = self.renderer_mut().msaa_sample_count(); self.scene - .init_gpu(device, self.context().queue.clone(), new_format); + .init_gpu(device, self.context().queue.clone(), new_format, sc); } Ok(()) } @@ -239,6 +244,8 @@ pub struct AppBuilder { bloom_config: Option, /// Initial exposure multiplier (Étape 22, 6.1). Default 1.0. exposure: f32, + /// MSAA configuration (Étape 24). `None` = no MSAA (default). + msaa: Option, } impl AppBuilder { @@ -254,6 +261,7 @@ impl AppBuilder { hdr: None, bloom_config: None, exposure: 1.0, + msaa: None, } } /// Sets the window title to display in the OS taskbar/window decorations. @@ -306,6 +314,19 @@ impl AppBuilder { self.exposure = exposure; self } + /// Enables MSAA (Multi-Sample Anti-Aliasing) with the given sample count (Étape 24). + /// The count must be 2, 4, or 8 (validated at build time; invalid values fall back to no MSAA + /// with a warning). When disabled (not set), the renderer uses single-sample (zero overhead). + /// Works independently of HDR: with HDR, the MSAA texture is `Rgba16Float` and resolves + /// into the HDR texture before bloom/TM; without HDR, it resolves directly to the swapchain. + pub fn with_msaa(mut self, sample_count: u32) -> Self { + if let Some(reason) = MsaaConfig::validate(sample_count) { + eprintln!("[wsg] Warning: with_msaa({}) — {} — MSAA disabled.", sample_count, reason); + } else { + self.msaa = Some(MsaaConfig { sample_count }); + } + 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. @@ -324,6 +345,7 @@ impl AppBuilder { hdr: self.hdr, bloom_config: self.bloom_config, exposure: self.exposure, + msaa: self.msaa, event_loop: Some(event_loop), context: None, renderer: None, @@ -352,6 +374,8 @@ struct AppRunner { bloom_config: Option, /// Initial exposure (Étape 22, 6.1); stored in the App for per-frame use. exposure: f32, + /// MSAA config (Étape 24); passed to `Renderer::new` in `resumed`. + msaa: Option, /// The user-provided game logic. handler: H, /// The fully-built App facade, populated on the first `resumed` event. @@ -385,7 +409,7 @@ impl ApplicationHandler for AppRunner { .expect("surface configuration failed"); let device = Arc::new(context.device.clone()); let renderer = - Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone()); + Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone(), self.msaa.clone()); // Step 15, D8: apply the culling flag (off by default — non-regression). renderer.set_culling(self.culling); @@ -399,7 +423,8 @@ impl ApplicationHandler for AppRunner { format }; let mut scene = Scene::new(); - scene.init_gpu(device, context.queue.clone(), main_format); + let msaa_sc = self.msaa.as_ref().map(|c| c.sample_count).unwrap_or(1); + scene.init_gpu(device, context.queue.clone(), main_format, msaa_sc); let mut app = App { scene, @@ -412,6 +437,7 @@ impl ApplicationHandler for AppRunner { hdr: self.hdr, bloom_config: self.bloom_config.clone(), exposure: self.exposure, + msaa: self.msaa.clone(), event_loop: None, context: Some(context), renderer: Some(renderer), diff --git a/lib/src/core/mod.rs b/lib/src/core/mod.rs index 34c6a22..6d645a0 100644 --- a/lib/src/core/mod.rs +++ b/lib/src/core/mod.rs @@ -16,6 +16,7 @@ pub mod frustum; pub mod geometry; pub mod hdr; pub mod lod; +pub mod msaa; pub mod renderer; pub mod shadow; pub mod transform; @@ -28,6 +29,7 @@ pub use frustum::Frustum; pub use geometry::{BBox, Geometry, GeometryError}; pub use hdr::ToneMapper; pub use lod::{lod_level, projected_radius_px}; +pub use msaa::MsaaConfig; pub use renderer::Renderer; pub use shadow::ShadowConfig; pub use transform::Transform; diff --git a/lib/src/core/msaa.rs b/lib/src/core/msaa.rs new file mode 100644 index 0000000..cc144db --- /dev/null +++ b/lib/src/core/msaa.rs @@ -0,0 +1,65 @@ +//! MSAA (Multi-Sample Anti-Aliasing) configuration (Étape 24, 6.4). +//! +//! When enabled, the main scene pass renders into a multi-sampled texture +//! (N samples per pixel) and wgpu resolves it (averages) into the single-sample +//! target (HDR texture or swapchain). Post-processes (bloom, TM) operate on +//! the resolved single-sample texture — they are unaffected. +//! +//! MSAA is a rasterizer feature: **no new shader** is needed. The cost is +//! in the rasterizer/fill-rate (edges are over-sampled), typically 1.3–1.5× +//! for 4× MSAA. + +/// MSAA configuration. +/// +/// `sample_count` must be a power of two (2, 4, or 8) and must be supported +/// by the GPU for the target texture format. The default is 4. +/// +/// When disabled (not set in the builder), the renderer uses `sample_count = 1` +/// (single sample, no MSAA) and the behavior is identical to pre-MSAA. +#[derive(Clone, Copy, Debug)] +pub struct MsaaConfig { + /// Number of samples per pixel. Must be 2, 4, or 8. + pub sample_count: u32, +} + +impl Default for MsaaConfig { + fn default() -> Self { + Self { sample_count: 4 } + } +} + +impl MsaaConfig { + /// Validates that `sample_count` is a supported value (2, 4, or 8). + /// Returns `None` if valid, `Some(reason)` if not. + pub fn validate(sample_count: u32) -> Option<&'static str> { + match sample_count { + 2 | 4 | 8 => None, + _ => Some("sample_count must be 2, 4, or 8"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_4() { + assert_eq!(MsaaConfig::default().sample_count, 4); + } + + #[test] + fn validate_accepts_powers_of_two() { + assert_eq!(MsaaConfig::validate(2), None); + assert_eq!(MsaaConfig::validate(4), None); + assert_eq!(MsaaConfig::validate(8), None); + } + + #[test] + fn validate_rejects_invalid() { + assert!(MsaaConfig::validate(1).is_some()); + assert!(MsaaConfig::validate(3).is_some()); + assert!(MsaaConfig::validate(16).is_some()); + assert!(MsaaConfig::validate(0).is_some()); + } +} diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index 016198e..6d1d06d 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -40,6 +40,7 @@ use crate::resources::{ use crate::scene::Scene; use crate::core::bloom::{BloomConfig, BloomPipeline}; use crate::core::hdr::ToneMapper; +use crate::core::msaa::MsaaConfig; use crate::utils::conf::{ GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER, }; @@ -159,6 +160,16 @@ pub struct Renderer { bloom: Option, /// Bloom configuration (used per-frame for uniform writes). Only meaningful when bloom is active. bloom_config: BloomConfig, + /// MSAA configuration (Étape 24). `sample_count = 1` means MSAA is disabled (zero overhead). + msaa_config: MsaaConfig, + /// MSAA color texture (N samples). `None` when MSAA is disabled. + msaa_color_texture: Option, + /// MSAA color view used as the main pass color attachment when MSAA is active. + msaa_color_view: Option, + /// MSAA depth texture (N samples). `None` when MSAA is disabled. + msaa_depth_texture: Option, + /// MSAA depth view used as the main pass depth attachment when MSAA is active. + msaa_depth_view: Option, } /// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline. @@ -201,6 +212,7 @@ impl Renderer { shadow_config: &super::shadow::ShadowConfig, hdr: Option, bloom_config: Option, + msaa_config: Option, ) -> Self { let queue: wgpu::Queue = context.queue.clone(); let device: wgpu::Device = context.device.clone(); @@ -594,6 +606,11 @@ impl Renderer { hdr: None, bloom: None, bloom_config: bloom_config.clone().unwrap_or_default(), + msaa_config: msaa_config.clone().unwrap_or_default(), + msaa_color_texture: None, + msaa_color_view: None, + msaa_depth_texture: None, + msaa_depth_view: 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. @@ -613,6 +630,33 @@ impl Renderer { renderer.bloom_config = bloom_config.clone().unwrap(); } } + // Étape 24: allocate MSAA textures when sample_count > 1. + // The MSAA color texture uses the same format as the main target (HDR or surface). + if renderer.msaa_config.sample_count > 1 { + let sc = renderer.msaa_config.sample_count; + let color_format = if renderer.hdr.is_some() { + wgpu::TextureFormat::Rgba16Float + } else { + format + }; + let msaa_tex = renderer.device.create_texture(&wgpu::TextureDescriptor { + label: Some("MSAA color texture"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: sc, + dimension: wgpu::TextureDimension::D2, + format: color_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default()); + let (msaa_depth_tex, msaa_depth_view) = + create_msaa_depth_texture(&renderer.device, width, height, sc); + renderer.msaa_color_texture = Some(msaa_tex); + renderer.msaa_color_view = Some(msaa_view); + renderer.msaa_depth_texture = Some(msaa_depth_tex); + renderer.msaa_depth_view = Some(msaa_depth_view); + } renderer } @@ -674,6 +718,32 @@ impl Renderer { hdr.bind_group = bg; } } + // Étape 24: recreate MSAA textures at the new size. + if self.msaa_config.sample_count > 1 { + let sc = self.msaa_config.sample_count; + let color_format = if self.hdr.is_some() { + wgpu::TextureFormat::Rgba16Float + } else { + self.format + }; + let msaa_tex = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("MSAA color texture"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: sc, + dimension: wgpu::TextureDimension::D2, + format: color_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default()); + let (msaa_depth_tex, msaa_depth_view) = + create_msaa_depth_texture(&self.device, width, height, sc); + self.msaa_color_texture = Some(msaa_tex); + self.msaa_color_view = Some(msaa_view); + self.msaa_depth_texture = Some(msaa_depth_tex); + self.msaa_depth_view = Some(msaa_depth_view); + } } /// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4). @@ -963,26 +1033,38 @@ impl Renderer { // 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, + // Étape 24: when MSAA is active, the color attachment targets the MSAA texture and + // resolves into the single-sample target (HDR or swapchain). The depth is also MSAA. + let (color_view, resolve_target, depth_attach) = if let Some(msaa_view) = &self.msaa_color_view { + // MSAA active: render into MSAA, resolve to single-sample target. + let resolve = match &self.hdr { + Some(h) => Some(h.view.clone()), + None => Some(view.clone()), + }; + let depth = self.msaa_depth_view.as_ref().unwrap(); + (msaa_view.clone(), resolve, depth) + } else { + // No MSAA: current behavior. + let color = match &self.hdr { + Some(h) => &h.view, + None => view, + }; + (color.clone(), None, &self.depth_view) }; { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("scene render pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: main_target, - resolve_target: None, + view: &color_view, + resolve_target: resolve_target.as_ref(), depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], - // Step 9 (DRAFT 9.2): same depth attachment as the low-level path, for a - // coherent z-test (D2 — both render passes share the depth_view). depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.depth_view, + view: depth_attach, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, @@ -1406,6 +1488,16 @@ impl Renderer { self.bloom_config = config.clone(); } + /// Returns `true` if MSAA is active (sample_count > 1). (Étape 24) + pub fn msaa_enabled(&self) -> bool { + self.msaa_config.sample_count > 1 + } + + /// Returns the current MSAA sample count (1 = disabled). (Étape 24) + pub fn msaa_sample_count(&self) -> u32 { + self.msaa_config.sample_count + } + /// Computes the per-slot LOD levels for this frame (Step 19, D8): for each ACTIVE slot, the /// entity's bounding sphere — the **same sphere** the GPU frustum culling uses (D8: bbox /// center + max half-extent × max scale component, rotated by the entity's quaternion) — is @@ -1504,6 +1596,28 @@ fn create_depth_texture( (depth_texture, depth_view) } +/// Creates an MSAA depth texture (N samples) with a view (Étape 24). Used when MSAA is active: +/// the main pass needs a multi-sampled depth buffer matching the MSAA color attachment. +fn create_msaa_depth_texture( + device: &wgpu::Device, + width: u32, + height: u32, + sample_count: u32, +) -> (wgpu::Texture, wgpu::TextureView) { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("MSAA depth texture"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count, + dimension: wgpu::TextureDimension::D2, + format: DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + (tex, view) +} + /// Allocates the shadow-map texture + view backing the depth-only shadow pass's /// `depth_stencil_attachment` (Step 14, D2/D8). Square (`size` x `size`), `DEPTH_FORMAT`, single /// mip, no MSAA. Unlike the screen depth texture this one is flagged **both** `RENDER_ATTACHMENT` diff --git a/lib/src/lib.rs b/lib/src/lib.rs index f3b14c3..82ac768 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -58,6 +58,10 @@ pub use crate::core::ShadowConfig; /// Users enable HDR via `AppBuilder::with_hdr(ToneMapper::Aces)`. pub use crate::core::ToneMapper; +/// Re-export of the MSAA configuration for convenient top-level access. +/// Users enable MSAA via `AppBuilder::with_msaa(4)`. +pub use crate::core::MsaaConfig; + /// Re-export of the geometry data type (positions, normals, UVs, indices). pub use crate::core::Geometry; diff --git a/lib/src/pipeline/pipeline_cache.rs b/lib/src/pipeline/pipeline_cache.rs index 0a0951e..c1422b4 100644 --- a/lib/src/pipeline/pipeline_cache.rs +++ b/lib/src/pipeline/pipeline_cache.rs @@ -202,6 +202,9 @@ pub struct PipelineCache { /// White 1×1 placeholder texture bound by materials that have no diffuse texture (DRAFT D1/D2). /// A white texel is the multiplicative identity, so sampling it reproduces the pre-Step-10 look. placeholder: Arc, + /// MSAA sample count for pipeline compilation (Étape 24). Must match the render pass's + /// attachment sample count. 1 = no MSAA (default). + sample_count: u32, } impl PipelineCache { @@ -210,7 +213,7 @@ impl PipelineCache { /// Inputs: device (owned Arc reference to wgpu Device), queue (used once to upload the white /// placeholder). Returns a new PipelineCache ready for shader registration via register_shader(). /// Called at application startup before any Material creation. Shader paths must be registered via register_shader() first. - pub fn new(device: Arc, queue: wgpu::Queue) -> Self { + pub fn new(device: Arc, queue: wgpu::Queue, sample_count: u32) -> Self { let placeholder = Texture::white_placeholder(&device, &queue).arc(); let texture_bind_group_layout = create_texture_bind_group_layout(&device); Self { @@ -221,6 +224,7 @@ impl PipelineCache { shader_paths: HashMap::new(), texture_bind_group_layout, placeholder, + sample_count, } } @@ -305,7 +309,7 @@ impl PipelineCache { .map(|s| s.as_str()) .unwrap_or(shader_id); let shader = self.load_shader(&self.device, path); - let pipeline = Self::build_pipeline(&self.device, format, &shader); + let pipeline = self.build_pipeline(format, &shader); // Step 3: Cache the new pipeline behind Arc and return it let pipeline_arc = Arc::new(pipeline); @@ -330,13 +334,11 @@ impl PipelineCache { } /// Builds a RenderPipeline from a shader module, device, and surface texture format. - /// Inputs: device (GPU command source), format (output texture format), shader (compiled WGSL module). + /// Inputs: format (output texture format), shader (compiled WGSL module). + /// Uses `self.device` and `self.sample_count` (Étape 24: MSAA-aware compilation). /// Returns a fully configured RenderPipeline ready for draw calls. Called internally by `get_or_create()`. - /// Internal steps: 1) define VertexBufferLayout from Vertex struct offsets → - /// 2) create PipelineLayout with bind_group_layouts + immediate_size → - /// 3) create RenderPipeline with vertex/fragment states, primitive config, multisample state. fn build_pipeline( - device: &wgpu::Device, + &self, format: wgpu::TextureFormat, shader: &wgpu::ShaderModule, ) -> wgpu::RenderPipeline { @@ -349,9 +351,9 @@ impl PipelineCache { // attached to EVERY pipeline (Step 3, decision ratified "a single layout for all"), even // if a given shader does not read them. // `immediate_size` stays 0 (no var used). - let uniform_layouts = create_uniform_bind_group_layouts(device); - let texture_layout = create_texture_bind_group_layout(device); - let shadow_layout = create_shadow_map_bind_group_layout(device); + let uniform_layouts = create_uniform_bind_group_layouts(&self.device); + let texture_layout = create_texture_bind_group_layout(&self.device); + let shadow_layout = create_shadow_map_bind_group_layout(&self.device); let layout_refs: Vec> = vec![ Some(&uniform_layouts[0]), // frame @0 Some(&uniform_layouts[1]), // object @1 @@ -359,14 +361,14 @@ impl PipelineCache { Some(&shadow_layout), // shadow map @3 ]; let render_pipeline_layout = - device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("render_pipeline_layout"), bind_group_layouts: &layout_refs, immediate_size: 0, // no var used }); // Create the full RenderPipeline — vertex state + fragment state + primitive configuration. - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Render Pipeline"), layout: Some(&render_pipeline_layout), vertex: wgpu::VertexState { @@ -399,7 +401,11 @@ impl PipelineCache { stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), }), - multisample: wgpu::MultisampleState::default(), + // Étape 24: MSAA-aware — the sample count must match the render pass's attachments. + multisample: wgpu::MultisampleState { + count: self.sample_count, + ..Default::default() + }, // multiview → replaced by multiview_mask (NonZeroU32) and cache fields in wgpu 30. multiview_mask: None, cache: None, diff --git a/lib/src/prelude.rs b/lib/src/prelude.rs index 51b0501..0069fc8 100644 --- a/lib/src/prelude.rs +++ b/lib/src/prelude.rs @@ -15,7 +15,7 @@ // Core types pub use crate::core::geometry::{BBox, Geometry}; pub use crate::core::transform::Transform; -pub use crate::core::{BloomConfig, ShadowConfig, ToneMapper}; +pub use crate::core::{BloomConfig, MsaaConfig, ShadowConfig, ToneMapper}; pub use crate::resources::Material; // Camera diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index 6b06bf0..9b70000 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -148,8 +148,9 @@ impl Scene { device: Arc, queue: wgpu::Queue, format: wgpu::TextureFormat, + sample_count: u32, ) -> &mut Self { - let cache = PipelineCache::new(device.clone(), queue.clone()); + let cache = PipelineCache::new(device.clone(), queue.clone(), sample_count); self.gpu = Some(SceneGpu { device, format,