# Étape 24 — MSAA 4× (Anti-aliasing multi-échantillons) **Statut** : ⬜ En cours **Roadmap** : 6.4 **Prérequis** : Pipeline HDR (étape 20) + Bloom (étape 23) --- ## Problème 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. ## 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 actuel vs avec MSAA ### Sans HDR, sans MSAA (actuel) ``` Scene → swapchain (Rgba8UnormSrgb) → present ``` ### 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). ### 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. --- ## Décisions de design | # | 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 ```rust use wsg_lib::prelude::*; 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). --- ## Implémentation ### 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 → ✅ --- ## Coût GPU | 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). ## Limitations / non-goals - **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 - [ ] `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 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)`)