This commit is contained in:
Jérôme Bousquié
2026-09-25 13:43:59 +02:00
parent 9614156848
commit 8ece89ccba
22 changed files with 1998 additions and 245 deletions
+353 -227
View File
@@ -1,298 +1,424 @@
# Étape 24 — MSAA 4× (Anti-aliasing multi-échantillons)
# Étape 26 — Depth of Field (DoF)
**Statut** : ⬜ En cours
**Roadmap** : 6.4
**Prérequis** : Pipeline HDR (étape 20) + Bloom (étape 23)
> **Objectif** : Flou de profondeur post-process — les objets hors de la distance
> de focus sont flous, créant un effet cinématique. Opt-in via `with_dof()`,
> zéro coût quand désactivé.
---
## Problème
## Contexte & motivation
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 DoF (Depth of Field) simule le comportement d'un objectif photo : seuls les
objets à la distance de focus sont nets, le reste est flou. Utilité :
## Solution
- **Effet cinématique** — mettre en scène un objet/personnage
- **Guidage du regard** — diriger l'attention du joueur
- **Masquage subtil** — flou les zones non pertinentes (alternative douce au fog)
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 existant (avec HDR)
```text
Main pass → HDR texture (Rgba16Float)
↓
Bloom (si actif) → composite
↓
Tone Mapping → surface
```
### Pipeline avec DoF
```text
Main pass → HDR texture + depth buffer
↓
Bloom (si actif) → bloom_composite
↓
DoF (si actif) :
CoC pass: depth → coc_texture (R16F, radius en px)
Blur pass: color + coc → dof_output (Rgba16F)
↓
Tone Mapping → surface
```
Quand DoF est désactivé : TM lit directement la texture HDR/bloom (zéro coût).
---
## Pipeline actuel vs avec MSAA
## Décisions
### Sans HDR, sans MSAA (actuel)
```
Scene → swapchain (Rgba8UnormSrgb) → present
### D1 — 2 passes : CoC + Blur
| Pass | Entrées | Sortie | Format |
|------|---------|--------|--------|
| CoC | depth texture | coc_texture | `R16Float` (1 canal, radius en pixels) |
| Blur | color + coc | dof_output | `Rgba16Float` (4 canaux, couleur floutée) |
Le CoC est calculé séparément pour éviter de recalculer la linearisation du
depth dans chaque tap du blur.
### D2 — Formule du CoC
```wgsl
// Linearize NDC depth [0,1] → world distance (perspective)
fn linearize_depth(ndc_z: f32, near: f32, far: f32) -> f32 {
return near * far / (far - ndc_z * (far - near));
}
// CoC in pixels:
let dist = linearize_depth(depth, near, far);
let coc = max_blur * aperture * abs(dist - focus_distance) / max(focus_distance, 1e-4);
coc = min(coc, max_blur);
```
### 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).
- `focus_distance` : distance (unités monde) où l'image est parfaitement nette
- `aperture` : 0.0–1.0, contrôle l'intensité du flou (0 = pas de flou)
- `max_blur` : radius maximum en pixels (clamp, évite le flou excessif)
### Avec HDR, sans MSAA (actuel)
```
Scene → HDR texture (Rgba16Float) → [Bloom] → TM → swapchain → present
### D3 — Uniform struct (32 bytes)
```wgsl
struct DoFUniform {
focus_distance: f32, // world units
aperture: f32, // 0.0-1.0
max_blur: f32, // pixels
near: f32, // camera near plane
far: f32, // camera far plane
inv_width: f32, // 1.0 / texture width
inv_height: f32, // 1.0 / texture height
_pad: f32,
};
```
### Avec HDR, avec MSAA (nouveau)
```
Scene → MSAA HDR (4×, Rgba16Float) ──resolve──→ HDR texture (Rgba16Float)
+ MSAA depth (4×) → [Bloom] → TM → swapchain → present
Un seul uniform partagé entre les 2 passes (CoC et Blur) — les valeurs sont
identiques. Pas de ping-pong de buffers.
### D4 — Blur : disc 12-tap
Le blur utilise un pattern de 12 échantillons en disque (poisson-like),
scallé par le CoC local :
```text
· ·
· ·
· ·
· · ·
· ·
· ·
· ·
```
**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.
Chaque tap : `offset * coc_radius * texel_size`, pondéré uniformément (1/12).
Le radius variable (par pixel) donne un bokeh naturel.
---
> Pourquoi pas separable H+V comme bloom ? Le DoF produit un flou **circulaire**
> (bokeh), pas un flou directionnel. Un disc blur single-pass est plus fidèle.
> 12 taps × 1 texture = trivial GPU cost.
## Décisions de design
### D5 — Textures
| # | 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 |
| Texture | Format | Taille | Quand allouée |
|---------|--------|--------|---------------|
| `coc_texture` | `R16Float` | full-res (w×h) | DoF actif |
| `dof_output` | `Rgba16Float` | full-res (w×h) | DoF actif |
---
Quand DoF est désactivé : **aucune** texture DoF n'est allouée. Zéro coût.
## API utilisateur
### D6 — API publique
```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).
/// Configuration du Depth of Field.
#[derive(Clone, Copy, Debug)]
pub struct MsaaConfig {
pub sample_count: u32,
pub struct DoFConfig {
/// Distance de focus (unités monde). L'image est nette à cette distance.
pub focus_distance: f32,
/// Intensité du flou (0.0 = aucun, 1.0 = max).
pub aperture: f32,
/// Radius maximum du flou en pixels.
pub max_blur: f32,
}
impl Default for MsaaCapable {
fn default() -> Self {
Self { sample_count: 4 }
}
impl DoFConfig {
/// DoF standard : focus à `distance`, flou modéré.
pub fn new(focus_distance: f32, aperture: f32, max_blur: f32) -> Self;
/// Preset cinématique : flou prononcé, max_blur=12px.
pub fn cinematic(focus_distance: f32) -> Self;
/// Preset subtil : léger flou en arrière-plan, max_blur=6px.
pub fn subtle(focus_distance: f32) -> Self;
}
```
### 24.2 — Champs `Renderer`
Ajouter à `Renderer` :
**Builder** :
```rust
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>,
AppBuilder::with_dof(DoFConfig::cinematic(5.0))
```
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`
**Runtime** :
```rust
app.renderer_mut().set_dof(Some(DoFConfig::new(3.0, 0.5, 8.0)));
app.renderer_mut().set_dof(None); // désactiver
```
Quand `sample_count == 1` :
- Tous les `Option` sont `None`
- Le render pass utilise la texture/view existante (comportement actuel)
### D7 — Pipeline integration
### 24.3 — Allocation à l'init (`Renderer::new`)
Dans `Renderer::render_scene` :
```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,
// Après bloom (ou après main pass si pas de bloom) :
if let Some(dof) = &self.dof_pipeline {
// 1. CoC pass
let mut coc_pass = encoder.begin_render_pass(&RenderPassDescriptor {
color_attachments: &[Some(RenderPassColorAttachment {
view: &dof.coc_view,
resolve_target: None,
ops: ColorOps::ALL,
format: TextureFormat::R16Float,
..
})],
depth_stencil_attachment: None,
..
});
// + depth MSAA
}
```
coc_pass.set_pipeline(&dof.coc_pipeline);
coc_pass.set_bind_group(0, &dof.coc_bind_group, &[]);
coc_pass.draw(0, 3, 0, 1);
drop(coc_pass);
### 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,
// 2. Blur pass
let mut blur_pass = encoder.begin_render_pass(&RenderPassDescriptor {
color_attachments: &[Some(RenderPassColorAttachment {
view: &dof.output_view,
resolve_target: None,
ops: ColorOps::ALL,
format: TextureFormat::Rgba16Float,
..
})],
depth_stencil_attachment: None,
..
}),
..
});
});
blur_pass.set_pipeline(&dof.blur_pipeline);
blur_pass.set_bind_group(0, &dof.blur_bind_group, &[]);
blur_pass.draw(0, 3, 0, 1);
drop(blur_pass);
// 3. TM lit dof_output au lieu de HDR
// (re-pointer le bind group TM)
}
```
### 24.5 — Resize
### D8 — Shaders
```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(...);
#### `dof_coc.wgsl`
```wgsl
// Vertex : fullscreen triangle (identique à TM/bloom)
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
// même triangle que TM : (-1,-1), (3,-1), (-1,3)
}
struct DoFUniform {
focus_distance: f32,
aperture: f32,
max_blur: f32,
near: f32,
far: f32,
inv_width: f32,
inv_height: f32,
_pad: f32,
};
@group(0) @binding(0) var<uniform> u: DoFUniform;
@group(0) @binding(1) var depth_tex: texture_depth_2d;
@group(0) @binding(2) var sampler: sampler;
@fragment
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) f32 {
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
let ndc_z = textureSample(depth_tex, sampler, uv);
// Linearize: NDC [0,1] → world distance
let dist = u.near * u.far / (u.far - ndc_z * (u.far - u.near));
// CoC in pixels
var coc = u.max_blur * u.aperture * abs(dist - u.focus_distance)
/ max(u.focus_distance, 1e-4);
coc = min(coc, u.max_blur);
// Edge case: depth = 1.0 (far plane) → no blur
if (ndc_z >= 0.9999) { coc = 0.0; }
return coc;
}
```
#### `dof_blur.wgsl`
```wgsl
// Vertex : fullscreen triangle (id)
struct DoFUniform { /* idem */ };
@group(0) @binding(0) var<uniform> u: DoFUniform;
@group(0) @binding(1) var color_tex: texture_2d<f32>;
@group(0) @binding(2) var coc_tex: texture_2d<f32>;
@group(0) @binding(3) var sampler: sampler;
const TAPS: array<vec2<f32>, 12> = array<vec2<f32>, 12>(
vec2(0.0, 0.0),
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(-1.0, 0.0),
vec2(0.707, 0.707), vec2(0.707, -0.707),
vec2(-0.707, 0.707), vec2(-0.707, -0.707),
vec2(0.383, 0.924), vec2(-0.383, 0.924), vec2(0.383, -0.924),
);
@fragment
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
let coc = textureSample(coc_tex, sampler, uv).r;
if (coc < 0.5) {
// Below 0.5px: no blur needed
return textureSample(color_tex, sampler, uv);
}
// HDR resize existant
// Bloom resize existant
let radius = coc; // in pixels
var sum = vec4<f32>(0.0);
for (var i = 0u; i < 12u; i++) {
let offset = TAPS[i] * radius * vec2(u.inv_width, u.inv_height);
sum += textureSample(color_tex, sampler, uv + offset);
}
return sum / 12.0;
}
```
### 24.6 — Plomberie App/AppBuilder
### D9 — Bind group layouts
**CoC pipeline** (3 bindings) :
| Binding | Type | Description |
|---------|------|-------------|
| 0 | Uniform (32B) | DoF params |
| 1 | Texture (depth) | Depth buffer de la scène |
| 2 | Sampler | Linear, clamp |
**Blur pipeline** (4 bindings) :
| Binding | Type | Description |
|---------|------|-------------|
| 0 | Uniform (32B) | DoF params |
| 1 | Texture (color) | HDR/bloom color |
| 2 | Texture (color) | CoC texture |
| 3 | Sampler | Linear, clamp |
Chaque pipeline a **son propre** pipeline layout (règle wgpu 30).
### D10 — `DoFPipeline` struct
```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
}
pub(crate) struct DoFPipeline {
// Textures
coc_texture: Texture,
coc_view: TextureView,
output_texture: Texture,
output_view: TextureView,
// App
pub fn msaa_enabled(&self) -> bool { ... }
pub fn set_msaa(&mut self, sample_count: u32) { ... } // nécessite resize
// Sampler (shared between both passes)
sampler: Sampler,
// Pipelines
coc_pipeline: RenderPipeline,
blur_pipeline: RenderPipeline,
// Uniform buffer (shared: same values for both passes)
uniform_buffer: Buffer,
// Bind groups
coc_bind_group: BindGroup,
blur_bind_group: BindGroup,
}
```
Plomberie : `AppBuilder → App → AppRunner → Renderer::new(msaa_config)`
Méthodes :
- `DoFPipeline::new(device, width, height, depth_view, color_view)` → alloue tout
- `DoFPipeline::update_uniform(&mut self, queue, config, near, far)` → écrit le buffer
- `DoFPipeline::output_view(&self) -> &TextureView` → pour re-pointer le TM
- `DoFPipeline::output_texture(&self) -> &Texture` → pour le bind group TM
- `DoFPipeline::resize(...)` → recrée textures + bind groups
### 24.7 — Re-exports
### D11 — Resize
`lib.rs` + `prelude.rs` : `pub use crate::core::MsaaConfig;`
Dans `resize_depth` (ou équivalent) :
```rust
if let Some(dof) = &mut self.dof_pipeline {
dof.resize(device, queue, new_w, new_h, &new_depth_view, &new_color_view);
}
```
### 24.8 — Exemple `msaa.rs`
### D12 — Ordre des post-process
Scène simple (cube + sphere + ground) avec/without MSAA commutable à la runtime
(clavier `M`). Camera orbitale pour voir les bords de près.
```text
Main pass → HDR
→ Bloom (si actif) → bloom_composite
→ DoF (si actif) → dof_output
→ TM → surface
```
Contrôles :
- `M` — toggle MSAA (nécessite un resize/recréation des textures)
- `R`/`1`/`2`/`3` — presets caméra
- Drag/wheel — orbit/zoom
DoF **après** bloom : le glow du bloom est aussi flouté par le DoF → plus naturel.
### 24.9 — Documentation
### D13 — Compatibilité
- `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 → ✅
| Avec | OK ? | Note |
|------|------|------|
| HDR | ✅ **requis** | DoF opère sur la texture HDR |
| Bloom | ✅ | DoF après bloom (D12) |
| MSAA | ✅ | Après resolve, DoF voit la texture single-sample |
| Fog | ✅ | Fog est dans le main pass, DoF floute le résultat |
| Culling | ✅ | Indépendant |
### D14 — `with_dof` sans `with_hdr` = no-op
Comme bloom, DoF nécessite HDR. `with_dof()` sans `with_hdr()` → warning + no-op.
---
## Coût GPU
## Fichiers modifiés / créés
| 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× |
| Fichier | Action |
|---------|--------|
| `lib/src/core/dof.rs` | **NEW** — `DoFConfig` + `DoFPipeline` |
| `lib/src/core/mod.rs` | + `pub mod dof;` + re-exports |
| `lib/src/lib.rs` | + `pub use DoFConfig` |
| `lib/src/prelude.rs` | + `DoFConfig` |
| `lib/src/core/renderer.rs` | + `dof` field, `set_dof()`, render pass, resize |
| `lib/src/app.rs` | + `with_dof()`, plumbage App/Builder/Runner |
| `lib/src/shaders/dof_coc.wgsl` | **NEW** |
| `lib/src/shaders/dof_blur.wgsl` | **NEW** |
| `lib/tests/wgsl_validate.rs` | + 2 shaders DoF |
| `lib/examples/dof.rs` | **NEW** |
| `lib/examples/README.md` | + section DoF |
| `docs/user/dof.md` | **NEW** |
| `docs/user/README.md` | + ligne DoF |
| `docs/ROADMAP.md` | 6.17 → ✅ |
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
## Plan d'implémentation
- **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).
| # | Tâche | Dépend |
|---|-------|--------|
| 1 | `core/dof.rs` : `DoFConfig` + tests | — |
| 2 | `core/mod.rs` + `lib.rs` + `prelude.rs` : exports | 1 |
| 3 | `shaders/dof_coc.wgsl` + `shaders/dof_blur.wgsl` | — |
| 4 | `tests/wgsl_validate.rs` : ajouter les 2 shaders | 3 |
| 5 | `core/dof.rs` : `DoFPipeline` (textures, pipelines, BGL, bind groups) | 3 |
| 6 | `core/renderer.rs` : fields + `new` + `set_dof` + `render_scene` + `resize` | 5 |
| 7 | `app.rs` : `with_dof()` + plumbage | 6 |
| 8 | `examples/dof.rs` | 6 |
| 9 | Docs : examples README + user docs + ROADMAP | 8 |
| 10 | Vérification : `cargo check` + tests + examples | all |
## 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é)
## Estimation
## 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)`)
- **Effort** : Moyen (~200 lignes Rust + ~80 lignes WGSL)
- **Risque** : Bas (pattern identique à bloom, 2 passes simples)
- **Gain visuel** : ⭐⭐⭐ (effet cinématique immédiat)