This commit is contained in:
Jérôme Bousquié
2026-09-25 11:20:20 +02:00
parent 35aeb769a8
commit 9614156848
15 changed files with 822 additions and 333 deletions
+257 -302
View File
@@ -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<f32> 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<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
var pos: vec2<f32>;
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<f32>(pos.x, -pos.y, 0.0, 1.0);
out.uv = vec2<f32>(pos.x * 0.5 + 0.5, 0.5 - pos.y * 0.5);
return out;
}
struct ThresholdUniforms {
threshold: f32,
knee: f32,
pad: vec2<f32>,
};
@group(0) @binding(0) var<uniform> tmu: ThresholdUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;
@group(0) @binding(3) var<atomic u32> pad; // placeholder — not needed, use texture_storage
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let color = textureSample(src_tex, src_sampler, in.uv).rgb;
let lum = dot(color, vec3<f32>(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<f32>(color * contrib, 1.0);
}
```
### `bloom_blur.wgsl`
```wgsl
// Même VsOut / vs_main que threshold (fullscreen triangle)
struct BlurUniforms {
direction: vec2<f32>, // texel offset: (1/w, 0) or (0, 1/h)
radius: f32,
pad: vec2<f32>,
};
@group(0) @binding(0) var<uniform> bu: BlurUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;
const W: array<f32, 5> = array<f32, 5>(
0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162
);
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
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<f32>(sum, 1.0);
}
```
### `bloom_composite.wgsl`
```wgsl
// Même VsOut / vs_main
struct CompositeUniforms {
intensity: f32,
pad: vec3<f32>,
};
@group(0) @binding(0) var<uniform> cu: CompositeUniforms;
@group(0) @binding(1) var hdr_tex: texture_2d<f32>;
@group(0) @binding(2) var hdr_sampler: sampler;
@group(0) @binding(3) var bloom_tex: texture_2d<f32>;
@group(0) @binding(4) var bloom_sampler: sampler;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let hdr = textureSample(hdr_tex, hdr_sampler, in.uv).rgb;
let bloom = textureSample(bloom_tex, bloom_sampler, in.uv).rgb;
return vec4<f32>(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<BloomPipeline>`, `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<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`)
```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<BloomPipeline>`, `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)`)
+6 -1
View File
@@ -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)
+2
View File
@@ -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 |
+127
View File
@@ -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.
+32 -1
View File
@@ -16,7 +16,8 @@ cargo run -p wsg-lib --example <nom>
| `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`,
+2 -2
View File
@@ -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()
+151
View File
@@ -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,
})
}
+30 -4
View File
@@ -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<MsaaConfig>,
/// Winit event loop for window management. Set to None after run() consumes it.
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
/// 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<BloomConfig>,
/// Initial exposure multiplier (Étape 22, 6.1). Default 1.0.
exposure: f32,
/// MSAA configuration (Étape 24). `None` = no MSAA (default).
msaa: Option<MsaaConfig>,
}
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<H: AppHandler> {
bloom_config: Option<BloomConfig>,
/// 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<MsaaConfig>,
/// The user-provided game logic.
handler: H,
/// The fully-built App facade, populated on the first `resumed` event.
@@ -385,7 +409,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
.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<H: AppHandler> ApplicationHandler for AppRunner<H> {
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<H: AppHandler> ApplicationHandler for AppRunner<H> {
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),
+2
View File
@@ -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;
+65
View File
@@ -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());
}
}
+122 -8
View File
@@ -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<BloomPipeline>,
/// 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<wgpu::Texture>,
/// MSAA color view used as the main pass color attachment when MSAA is active.
msaa_color_view: Option<wgpu::TextureView>,
/// MSAA depth texture (N samples). `None` when MSAA is disabled.
msaa_depth_texture: Option<wgpu::Texture>,
/// MSAA depth view used as the main pass depth attachment when MSAA is active.
msaa_depth_view: Option<wgpu::TextureView>,
}
/// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline.
@@ -201,6 +212,7 @@ impl Renderer {
shadow_config: &super::shadow::ShadowConfig,
hdr: Option<ToneMapper>,
bloom_config: Option<BloomConfig>,
msaa_config: Option<MsaaConfig>,
) -> 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`
+4
View File
@@ -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;
+19 -13
View File
@@ -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<Texture>,
/// 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<wgpu::Device>, queue: wgpu::Queue) -> Self {
pub fn new(device: Arc<wgpu::Device>, 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<immediate> 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<Option<&wgpu::BindGroupLayout>> = 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<immediate> 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,
+1 -1
View File
@@ -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
+2 -1
View File
@@ -148,8 +148,9 @@ impl Scene {
device: Arc<wgpu::Device>,
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,