Files
wsg/docs/DRAFT.md
T
Jérôme Bousquié 9614156848 MSAA
2026-09-25 11:20:20 +02:00

9.5 KiB
Raw Blame History

É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

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

/// 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 :

msaa_config: MsaaConfig,           // toujours présent (sample_count=1 si désactivé)
msaa_color_texture: Option<wgpu::Texture>,  // Some si msaa active
msaa_color_view: Option<wgpu::TextureView>,
msaa_depth_texture: Option<wgpu::Texture>,
msaa_depth_view: Option<wgpu::TextureView>,

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)

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 :

// 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

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

// 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))