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
+350 -224
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`
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`)
**Runtime** :
```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
}
app.renderer_mut().set_dof(Some(DoFConfig::new(3.0, 0.5, 8.0)));
app.renderer_mut().set_dof(None); // désactiver
```
### 24.4 — Render pass scène (modification `render_scene`)
### D7 — Pipeline integration
Le render pass principal doit utiliser les views MSAA quand actif :
Dans `Renderer::render_scene` :
```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 {
// 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: &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,
view: &dof.coc_view,
resolve_target: None,
ops: ColorOps::ALL,
format: TextureFormat::R16Float,
..
}),
})],
depth_stencil_attachment: None,
..
});
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);
// 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
#### `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);
}
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;
}
```
### 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
fn resize(&mut self, device, width, height) {
// ... resize depth existant ...
pub(crate) struct DoFPipeline {
// Textures
coc_texture: Texture,
coc_view: TextureView,
output_texture: Texture,
output_view: TextureView,
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(...);
}
// Sampler (shared between both passes)
sampler: Sampler,
// HDR resize existant
// Bloom resize existant
// 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,
}
```
### 24.6 — Plomberie App/AppBuilder
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
### D11 — Resize
Dans `resize_depth` (ou équivalent) :
```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
if let Some(dof) = &mut self.dof_pipeline {
dof.resize(device, queue, new_w, new_h, &new_depth_view, &new_color_view);
}
// 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)`
### D12 — Ordre des post-process
### 24.7 — Re-exports
```text
Main pass → HDR
→ Bloom (si actif) → bloom_composite
→ DoF (si actif) → dof_output
→ TM → surface
```
`lib.rs` + `prelude.rs` : `pub use crate::core::MsaaConfig;`
DoF **après** bloom : le glow du bloom est aussi flouté par le DoF → plus naturel.
### 24.8 — Exemple `msaa.rs`
### D13 — Compatibilité
Scène simple (cube + sphere + ground) avec/without MSAA commutable à la runtime
(clavier `M`). Camera orbitale pour voir les bords de près.
| 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 |
Contrôles :
- `M` — toggle MSAA (nécessite un resize/recréation des textures)
- `R`/`1`/`2`/`3` — presets caméra
- Drag/wheel — orbit/zoom
### D14 — `with_dof` sans `with_hdr` = no-op
### 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 → ✅
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)
+2 -2
View File
@@ -70,11 +70,11 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT
| 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.13 | **Fog** (exponential / exponential² / linear, paramètre par scène) | ⭐⭐⭐ | 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 | ⬜ |
| 6.17 | **Depth of field** (post-process CoC : circle-of-confusion + bokeh blur) | ⭐⭐⭐ | Moyen | ✅ |
### Cibles techniques (refactoring)
+2
View File
@@ -23,6 +23,8 @@ GPU graphics background is required.
| [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)` |
| [Fog (distance)](fog.md) | Distance fog (3 modes), masks world edges, opt-in via `with_fog()` |
| [DoF (depth of field)](dof.md) | Cinematic bokeh blur, focus plane, opt-in via `with_dof()` |
| [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 |
+133
View File
@@ -0,0 +1,133 @@
# Brouillard de distance (Fog)
## Principe
Le brouillard de distance fond les objets vers une couleur prédéfinie en fonction
de leur distance à la caméra. C'est l'outil standard pour :
- **Masquer le bord du monde rendu** — illusion d'un monde infini (Skyrim, GTA, Minecraft)
- **Donner de la profondeur** — effet atmosphérique naturel
- **Camoufler les transitions** — chargement de tuiles, LOD pops
## Activation
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
.build()
.await?;
```
Sans `.with_fog()`, le brouillard est désactivé — **zéro coût GPU** (la branche
shader est jamais prise).
## Modes
| Mode | Formule | Usage |
|------|---------|-------|
| `Linear` | `saturate((far - d) / (far - near))` | Cutoff net entre deux distances |
| `Exponential` | `exp(-density × d)` | Brouillard naturel (forêt, lac) |
| `Exponential2` | `exp(-density² × d²)` | Départ progressif, cutoff net — **idéal pour masquer** |
### Constructeurs
```rust
// Linéaire : fondu entre near et far
FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0)
// Exponentiel : fondu naturel
FogConfig::exponential([0.6, 0.7, 0.8], 0.03)
// Exponentiel² : masquage de bord de monde
FogConfig::exponential2([0.7, 0.75, 0.85], 0.08)
```
## Paramètres
| Champ | Type | Description |
|-------|------|-------------|
| `mode` | `FogMode` | Linéaire / Exponentiel / Exponential2 |
| `color` | `[f32; 3]` | Couleur du brouillard (RGB, espace linéaire) |
| `near` | `f32` | Distance début (mode linéaire uniquement) |
| `far` | `f32` | Distance fin, brouillard complet (mode linéaire) |
| `density` | `f32` | Densité (modes exp / exp²). Typique : 0.01–0.3 |
### Choisir la couleur
La couleur du brouillard **doit correspondre à la couleur du ciel/clear color**
pour un effet "monde infini" seamless. Avec HDR + ACES, utiliser des valeurs
linéaires cohérentes avec le tone mapping.
### Choisir la densité (exp²)
Pour masquer le bord du monde à une distance `D` :
```
density ≈ 2.0 / D
```
Exemples :
- Monde visible jusqu'à 25 unités → `density = 0.08`
- Monde visible jusqu'à 50 unités → `density = 0.04`
- Monde visible jusqu'à 100 unités → `density = 0.02`
## Changement à l'exécution
```rust
// Dans update() :
if key_pressed(KeyCode::Digit1) {
app.renderer_mut().set_fog(Some(FogConfig::linear([0.7, 0.8, 0.9], 5.0, 30.0)));
}
if key_pressed(KeyCode::Digit4) {
app.renderer_mut().set_fog(None); // désactiver
}
```
Le changement prend effet au frame suivant.
## Pipeline
```text
Main pass (shader fragment)
↓
Lighting → final_rgb
↓
FOG: mix(final_rgb, fog_color, 1 - fog_factor) ← ici
↓
→ HDR texture / swapchain
↓
(Bloom) → Tone Mapping → surface
```
Le brouillard s'applique **avant** le tone mapping : les valeurs HDR restent
non clampées, et le TM applique la courbe ACES/Reinhard au résultat déjà
brouillé. Résultat : le brouillard est perceptuellement cohérent.
## Compatibilité
| Avec | OK ? | Note |
|------|------|------|
| HDR + TM | ✅ | Fog avant TM (recommandé) |
| Bloom | ✅ | Le bloom extrait les zones brillantes du résultat post-fog |
| MSAA | ✅ | Indépendant (rasterizer vs fragment shader) |
| Culling GPU | ✅ | Indépendant (culling décide quoi dessiner, fog décide la couleur) |
| Shadows | ✅ | L'ombre est calculée avant le fog |
## Limitations (v1)
- **Scene-level uniquement** : un seul brouillard pour toute la scène.
Un brouillard par matériau nécessiterait un paramètre additionnel dans le
bind group par objet.
- **Distance euclidienne** : pas de brouillard volumétrique ni directionnel.
- **Couleur fixe** : pas de gradient de couleur avec la distance.
## Exemple
Voir `examples/fog.rs` : 15 cubes en rangée + 5 sphères sur un plan 80×80,
avec commutation runtime entre les 3 modes.
```sh
cargo run -p wsg-lib --example fog --features "all-prims"
```
+4
View File
@@ -32,3 +32,7 @@ pollster = { version="1.0.1", features = ["macro"] }
# Étape 10 (Textures, DRAFT D3) : décodage d'images (PNG/JPEG) pour charger des textures diffuses.
# default-features = false pour n'emporter que les codecs utiles (plus petit arbre de compilation).
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
[[example]]
name = "import"
required-features = ["import-obj"]
+39
View File
@@ -18,6 +18,7 @@ cargo run -p wsg-lib --example <nom>
| `shadow` | Shadow mapping (ombre portée directionnelle) |
| `culling` | Culling GPU-driven (grille 15×15, objets hors frustum ignorés) |
| `msaa` | MSAA 4× (anti-aliasing multi-échantillons, arêtes lisses) |
| `fog` | Brouillard de distance (3 modes : linéaire, exp, exp²) |
| `manual` | Workflow bas niveau (Context + Renderer + PipelineCache) |
| `import` | Import de fichier OBJ (non graphique, stdout) |
@@ -251,6 +252,44 @@ identique, seules les arêtes diffèrent (escaler vs lisse).
---
## `fog` — Brouillard de distance
Démontre les 3 modes de brouillard : **linéaire**, **exponentiel**, **exponentiel²**.
La scène contient une rangée de cubes qui s'éloignent et des sphères dispersées sur
un grand plan au sol. Le brouillard fond les objets vers une couleur de fond,
créant l'illusion d'un monde infini.
```sh
cargo run -p wsg-lib --example fog --features "all-prims"
```
**Touches** : `1` = linéaire, `2` = exp, `3` = exp², `4` = désactivé, `R` = reset.
> Le brouillard est appliqué dans le shader fragment principal (après l'éclairage,
> avant le tone mapping). Il utilise la distance euclidienne du fragment à la caméra.
---
## `dof` — Depth of Field (bokeh cinématique)
Démontre le flou de profondeur de champ : un objet au centre reste net tandis que
le premier et arrière-plan se flouent selon leur distance au plan de mise au point.
Crée un effet d'attention naturelle (type cinématique).
La scène contient un cube de focus au centre, des sphères en premier plan (proches)
et des cubes en arrière-plan (loin), sur un plan au sol.
```sh
cargo run -p wsg-lib --example dof --features "all-prims"
```
**Touches** : `1` = cinématique, `2` = subtil, `3` = focus 2m, `4` = focus 10m, `5` = off, `R` = reset.
> DoF opère en HDR linéaire (après bloom, avant tone mapping). Deux passes :
> CoC (depth → rayon de flou par pixel) puis blur disque 12-taps à rayon variable.
---
## `manual` — Workflow bas niveau
Démontre l'API **sans** la façade `App` : utilisation directe de `Context`,
+175
View File
@@ -0,0 +1,175 @@
//! # Depth of Field Example (Étape 26)
//!
//! Demonstrates cinematic DoF: a row of cubes receding into the distance,
//! with the focus plane at a configurable depth. Cubes at the focus distance
//! stay sharp; those closer or farther blur proportionally.
//!
//! ## Pipeline
//! DoF operates in linear HDR space **after** bloom and **before** tone mapping:
//! 1. CoC pass: reads the depth buffer, linearizes to world distance, computes
//! per-pixel blur radius.
//! 2. Blur pass: 12-tap disc blur with variable radius (from CoC), producing
//! natural circular bokeh.
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `1` | Cinematic preset (focus=8m, strong blur) |
//! | `2` | Subtle preset (focus=8m, gentle blur) |
//! | `3` | Focus at 3m (near cubes sharp, far blurred) |
//! | `4` | Focus at 15m (far cubes sharp, near blurred) |
//! | `5` | DoF OFF |
//! | `R` | Reset camera |
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example dof --features "all-prims"
//! ```
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::{DoFConfig, ToneMapper, Transform};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
struct DoFDemo {
camera: CameraController,
}
impl AppHandler for DoFDemo {
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(80.0, 80.0, 1, 1), None)
.unwrap();
app.scene
.add_entity_with_transform(
"ground",
"ground_mesh",
Transform::identity(),
)
.unwrap();
// Row of cubes receding along -Z (distance ≈ 2 to 25 from camera at dist=8).
app.scene
.create_mesh("cube_mesh", cube(1.0), None)
.unwrap();
for i in 0..20 {
let z = 3.0 - i as f32 * 1.5; // from z=3 (close) to z=-25.5 (far)
let mut tf = Transform::identity();
tf.translation = Vec3::new(0.0, 0.5, z);
app.scene
.add_entity_with_transform(&format!("cube_{i}"), "cube_mesh", tf)
.unwrap();
}
// A few spheres scattered to the sides for visual interest.
app.scene
.create_mesh("sphere_mesh", icosphere(0.7, 3), None)
.unwrap();
let sphere_positions = [
Vec3::new(2.5, 0.7, -2.0),
Vec3::new(-3.0, 0.7, -6.0),
Vec3::new(3.5, 0.7, -10.0),
Vec3::new(-2.0, 0.7, -14.0),
Vec3::new(2.0, 0.7, -18.0),
];
for (i, pos) in sphere_positions.iter().enumerate() {
let mut tf = Transform::identity();
tf.translation = *pos;
app.scene
.add_entity_with_transform(&format!("sphere_{i}"), "sphere_mesh", tf)
.unwrap();
}
// Directional light.
let light_dir = Vec3::new(-0.4, -1.0, -0.3).normalize();
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.2)
.unwrap();
app.scene.set_ambient([0.08, 0.08, 0.1]);
// Camera — positioned to look down the row of cubes.
self.camera.yaw = 0.0;
self.camera.pitch = 0.1;
self.camera.distance = 8.0;
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
self.camera.apply_to(app.scene.camera_mut());
eprintln!("[DoF] Initial: Cinematic (focus=8m, aperture=0.3, max_blur=12)");
eprintln!("[DoF] Keys: 1=cinematic 2=subtle 3=focus 3m 4=focus 15m 5=off R=reset");
}
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);
// DoF presets.
if app.input.key_pressed(KeyCode::Digit1) {
app.renderer_mut()
.set_dof(Some(DoFConfig::cinematic(8.0)));
eprintln!("[DoF] → Cinematic (focus=8m, aperture=0.3, max_blur=12)");
}
if app.input.key_pressed(KeyCode::Digit2) {
app.renderer_mut()
.set_dof(Some(DoFConfig::subtle(8.0)));
eprintln!("[DoF] → Subtle (focus=8m, aperture=0.1, max_blur=8)");
}
if app.input.key_pressed(KeyCode::Digit3) {
app.renderer_mut()
.set_dof(Some(DoFConfig::new(3.0, 0.3, 12.0)));
eprintln!("[DoF] → Focus 3m (near sharp, far blurred)");
}
if app.input.key_pressed(KeyCode::Digit4) {
app.renderer_mut()
.set_dof(Some(DoFConfig::new(15.0, 0.3, 12.0)));
eprintln!("[DoF] → Focus 15m (far sharp, near blurred)");
}
if app.input.key_pressed(KeyCode::Digit5) {
app.renderer_mut().set_dof(None);
eprintln!("[DoF] → OFF");
}
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.0;
self.camera.pitch = 0.1;
self.camera.distance = 8.0;
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
}
self.camera.apply_to(app.scene.camera_mut());
}
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 — Depth of Field (Étape 26)")
.size(1280, 720)
.with_hdr(ToneMapper::Aces)
.with_dof(DoFConfig::cinematic(8.0))
.build()
.await?;
app.run(DoFDemo {
camera: CameraController::default(),
})
}
+163
View File
@@ -0,0 +1,163 @@
//! # Fog Example (Étape 25)
//!
//! Demonstrates distance fog: objects fade into the fog color as they recede,
//! creating the illusion of an infinite world (Skyrim/GTA pattern).
//!
//! The scene has a row of cubes receding into the distance and scattered spheres,
//! all sitting on a large ground plane. Switch fog modes with number keys to
//! compare the three attenuation curves.
//!
//! ## Pipeline
//! Fog is applied in the main pass fragment shader (after lighting, before tone
//! mapping). It uses the fragment's world-space distance to the camera and
//! blends the final color toward `fog_color`.
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `1` | Linear fog (near=5, far=30) |
//! | `2` | Exponential fog (density=0.04) |
//! | `3` | Exponential² fog (density=0.06) — best for masking |
//! | `4` | Fog OFF |
//! | `R` | Reset camera |
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example fog --features "all-prims"
//! ```
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::{FogConfig, ToneMapper, Transform};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
struct FogDemo {
camera: CameraController,
}
impl AppHandler for FogDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Large ground plane — will fade into fog at distance.
app.scene
.create_mesh("ground_mesh", plane(80.0, 80.0, 1, 1), None)
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// Row of cubes receding into the distance.
app.scene
.create_mesh("cube_mesh", cube(1.0), None)
.unwrap();
for i in 0..15 {
let z = -2.0 - i as f32 * 2.5;
let mut tf = Transform::identity();
tf.translation = Vec3::new(0.0, 0.5, z);
app.scene
.add_entity_with_transform(&format!("cube_{i}"), "cube_mesh", tf)
.unwrap();
}
// Scattered spheres at various distances.
app.scene
.create_mesh("sphere_mesh", icosphere(0.8, 3), None)
.unwrap();
let positions = [
Vec3::new(3.0, 0.8, -5.0),
Vec3::new(-4.0, 0.8, -10.0),
Vec3::new(5.0, 0.8, -15.0),
Vec3::new(-3.0, 0.8, -20.0),
Vec3::new(0.0, 0.8, -30.0),
];
for (i, pos) in positions.iter().enumerate() {
let mut tf = Transform::identity();
tf.translation = *pos;
app.scene
.add_entity_with_transform(&format!("sphere_{i}"), "sphere_mesh", tf)
.unwrap();
}
// Directional light.
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.2)
.unwrap();
app.scene.set_ambient([0.08, 0.08, 0.1]);
// Camera — positioned to look down the row of cubes.
self.camera.yaw = 0.0;
self.camera.pitch = 0.15;
self.camera.distance = 8.0;
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
self.camera.apply_to(app.scene.camera_mut());
// Print initial fog status.
eprintln!("[Fog] Initial: Exponential² (density=0.06)");
eprintln!("[Fog] Keys: 1=linear 2=exp 3=exp² 4=off R=reset");
}
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);
// Fog mode switching.
if app.input.key_pressed(KeyCode::Digit1) {
app.renderer_mut()
.set_fog(Some(FogConfig::linear([0.7, 0.75, 0.85], 5.0, 30.0)));
eprintln!("[Fog] → Linear (near=5, far=30)");
}
if app.input.key_pressed(KeyCode::Digit2) {
app.renderer_mut()
.set_fog(Some(FogConfig::exponential([0.7, 0.75, 0.85], 0.04)));
eprintln!("[Fog] → Exponential (density=0.04)");
}
if app.input.key_pressed(KeyCode::Digit3) {
app.renderer_mut()
.set_fog(Some(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06)));
eprintln!("[Fog] → Exponential² (density=0.06)");
}
if app.input.key_pressed(KeyCode::Digit4) {
app.renderer_mut().set_fog(None);
eprintln!("[Fog] → OFF");
}
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.0;
self.camera.pitch = 0.15;
self.camera.distance = 8.0;
}
self.camera.apply_to(app.scene.camera_mut());
}
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 — Fog (3 modes)")
.size(1024, 640)
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
.with_hdr(ToneMapper::Aces)
.build()
.await?;
app.run(FogDemo {
camera: CameraController::default(),
})
}
+1 -1
View File
@@ -63,7 +63,7 @@ impl ApplicationHandler for App {
// 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, None);
let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None, None, None, None, None);
renderer.set_unlit(true);
// 3. Material: uses renderer.device() and renderer.format()
+38 -1
View File
@@ -73,6 +73,10 @@ pub struct App {
/// MSAA configuration (Étape 24). `None` = no MSAA (default, zero overhead);
/// `Some(config)` activates multi-sample anti-aliasing.
msaa: Option<MsaaConfig>,
/// Fog configuration (Étape 25). `None` = no fog (default, zero overhead).
fog: Option<super::core::FogConfig>,
/// DoF configuration (Étape 26). `None` = no DoF (default, zero overhead). Requires HDR.
dof: Option<super::core::DoFConfig>,
/// 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.
@@ -141,6 +145,8 @@ impl App {
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
msaa: self.msaa.clone(),
fog: self.fog.clone(),
dof: self.dof.clone(),
handler,
app: None,
};
@@ -246,6 +252,10 @@ pub struct AppBuilder {
exposure: f32,
/// MSAA configuration (Étape 24). `None` = no MSAA (default).
msaa: Option<MsaaConfig>,
/// Fog configuration (Étape 25). `None` = no fog (default).
fog: Option<super::core::FogConfig>,
/// DoF configuration (Étape 26). `None` = no DoF (default). Requires HDR.
dof: Option<super::core::DoFConfig>,
}
impl AppBuilder {
@@ -262,6 +272,8 @@ impl AppBuilder {
bloom_config: None,
exposure: 1.0,
msaa: None,
fog: None,
dof: None,
}
}
/// Sets the window title to display in the OS taskbar/window decorations.
@@ -327,6 +339,23 @@ impl AppBuilder {
}
self
}
/// Enables distance fog (Étape 25). Fades objects into `config.color` based on their
/// distance from the camera. Use `FogConfig::exponential2(color, density)` to mask
/// the edge of the rendered world. Zero cost when not called.
pub fn with_fog(mut self, config: super::core::FogConfig) -> Self {
self.fog = Some(config);
self
}
/// Enables Depth of Field (Étape 26). Blurs pixels based on their distance from the
/// focus plane, creating a cinematic bokeh effect. **Requires HDR** (`with_hdr`):
/// without it, the DoF is silently ignored with a warning. Zero cost when not called.
pub fn with_dof(mut self, config: super::core::DoFConfig) -> Self {
if self.hdr.is_none() {
eprintln!("[wsg] Warning: with_dof() requires with_hdr() — DoF ignored.");
}
self.dof = Some(config);
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.
@@ -346,6 +375,8 @@ impl AppBuilder {
bloom_config: self.bloom_config,
exposure: self.exposure,
msaa: self.msaa,
fog: self.fog,
dof: self.dof,
event_loop: Some(event_loop),
context: None,
renderer: None,
@@ -376,6 +407,10 @@ struct AppRunner<H: AppHandler> {
exposure: f32,
/// MSAA config (Étape 24); passed to `Renderer::new` in `resumed`.
msaa: Option<MsaaConfig>,
/// Fog config (Étape 25); passed to `Renderer::new` in `resumed`.
fog: Option<super::core::FogConfig>,
/// DoF config (Étape 26); passed to `Renderer::new` in `resumed`. Only active with HDR.
dof: Option<super::core::DoFConfig>,
/// The user-provided game logic.
handler: H,
/// The fully-built App facade, populated on the first `resumed` event.
@@ -409,7 +444,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(), self.msaa.clone());
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone(), self.msaa.clone(), self.fog.clone(), self.dof.clone());
// Step 15, D8: apply the culling flag (off by default — non-regression).
renderer.set_culling(self.culling);
@@ -438,6 +473,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
msaa: self.msaa.clone(),
fog: self.fog.clone(),
dof: self.dof.clone(),
event_loop: None,
context: Some(context),
renderer: Some(renderer),
+570
View File
@@ -0,0 +1,570 @@
//! Depth of Field (DoF) configuration (Étape 26).
//!
//! DoF simulates camera lens behavior: objects at the focus distance are sharp,
//! everything else is progressively blurred. This is a post-process effect that
//! operates on the HDR texture + depth buffer before tone mapping.
//!
//! **Opt-in**: when no `DoFConfig` is set, no DoF textures are allocated and the
//! pipeline cost is zero.
/// Depth of Field configuration.
#[derive(Clone, Copy, Debug)]
pub struct DoFConfig {
/// Focus distance in world units. The image is perfectly sharp at this distance.
pub focus_distance: f32,
/// Blur intensity: 0.0 = no blur, 1.0 = maximum. Scales the CoC calculation.
pub aperture: f32,
/// Maximum blur radius in pixels. Clamps the CoC to prevent excessive blur.
pub max_blur: f32,
}
impl DoFConfig {
/// Creates a custom DoF configuration.
///
/// - `focus_distance`: world distance where the image is sharp
/// - `aperture`: blur intensity (0.0–1.0)
/// - `max_blur`: maximum blur radius in pixels
pub fn new(focus_distance: f32, aperture: f32, max_blur: f32) -> Self {
Self {
focus_distance,
aperture: aperture.clamp(0.0, 1.0),
max_blur: max_blur.max(0.0),
}
}
/// Cinematic preset: gradual blur building up to 12px at the extremes.
/// Good for cutscenes and character close-ups.
pub fn cinematic(focus_distance: f32) -> Self {
Self::new(focus_distance, 0.3, 12.0)
}
/// Subtle preset: very gentle blur, 8px max radius.
/// Good for gameplay with a hint of depth separation.
pub fn subtle(focus_distance: f32) -> Self {
Self::new(focus_distance, 0.1, 8.0)
}
/// Packs the config into the (fog-style) two vec4 uniform layout.
/// Returns `(dof_a, dof_b)` where:
/// - `dof_a = (focus_distance, aperture, max_blur, near)`
/// - `dof_b = (far, inv_width, inv_height, 0.0)`
///
/// `near` and `far` come from the camera projection. `inv_width`/`inv_height`
/// are the reciprocal texture dimensions.
pub fn pack(
&self,
near: f32,
far: f32,
inv_width: f32,
inv_height: f32,
) -> (glam::Vec4, glam::Vec4) {
(
glam::Vec4::new(self.focus_distance, self.aperture, self.max_blur, near),
glam::Vec4::new(far, inv_width, inv_height, 0.0),
)
}
}
use wgpu::{
BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture,
TextureView,
};
/// Internal DoF pipeline state. Allocated when DoF + HDR are both active.
/// Recreated on resize.
pub(crate) struct DoFPipeline {
// Textures
coc_texture: Texture,
coc_view: TextureView,
output_texture: Texture,
output_view: TextureView,
// Samplers: non-filtering for CoC (depth), filtering for blur (color + CoC).
coc_sampler: Sampler,
blur_sampler: Sampler,
// Pipelines
coc_pipeline: RenderPipeline,
blur_pipeline: RenderPipeline,
// Uniform buffer (shared: same values for both passes, 32 bytes)
uniform_buffer: Buffer,
// Bind groups
coc_bind_group: BindGroup,
blur_bind_group: BindGroup,
// Layouts (kept for resize)
coc_layout: BindGroupLayout,
blur_layout: BindGroupLayout,
// Dimensions
width: u32,
height: u32,
}
impl DoFPipeline {
pub fn new(
device: &wgpu::Device,
width: u32,
height: u32,
depth_view: &TextureView,
color_view: &TextureView,
) -> Self {
// Non-filtering sampler for the CoC pass (depth textures require non-filtering).
let coc_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("dof coc sampler (non-filtering)"),
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
// Filtering sampler for the blur pass (color + CoC textures).
let blur_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("dof blur sampler (filtering)"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
// CoC texture: R16Float, full-res.
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof coc"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let coc_view = coc_texture.create_view(&Default::default());
// Output texture: Rgba16Float, full-res.
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof output"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let output_view = output_texture.create_view(&Default::default());
// --- CoC bind group layout (3 bindings) ---
let coc_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("dof coc bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
count: None,
},
],
});
// --- Blur bind group layout (4 bindings) ---
let blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("dof blur bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
// Pipeline layouts.
let coc_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("dof coc pl"),
bind_group_layouts: &[Some(&coc_layout)],
..Default::default()
});
let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("dof blur pl"),
bind_group_layouts: &[Some(&blur_layout)],
..Default::default()
});
// Shader modules.
let coc_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("dof coc"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::DOF_COC_SHADER.into(),
),
});
let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("dof blur"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::DOF_BLUR_SHADER.into(),
),
});
// CoC pipeline (output: R16Float).
let coc_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("dof coc pipeline"),
layout: Some(&coc_pl),
vertex: wgpu::VertexState {
module: &coc_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &coc_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::R16Float,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Blur pipeline (output: Rgba16Float).
let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("dof blur pipeline"),
layout: Some(&blur_pl),
vertex: wgpu::VertexState {
module: &blur_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &blur_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba16Float,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Uniform buffer (32 bytes: 8 f32s).
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dof uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Bind groups.
let coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof coc bg"),
layout: &coc_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&coc_sampler),
},
],
});
let blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof blur bg"),
layout: &blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(color_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&coc_view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&blur_sampler),
},
],
});
Self {
coc_texture,
coc_view,
output_texture,
output_view,
coc_sampler,
blur_sampler,
coc_pipeline,
blur_pipeline,
uniform_buffer,
coc_bind_group,
blur_bind_group,
coc_layout,
blur_layout,
width,
height,
}
}
/// Writes the DoF uniform buffer with current config values.
pub fn update_uniform(
&self,
queue: &wgpu::Queue,
config: &DoFConfig,
near: f32,
far: f32,
) {
let (a, b) = config.pack(near, far, 1.0 / self.width as f32, 1.0 / self.height as f32);
let data: [f32; 8] = [a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w];
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&data));
}
/// Recreates textures and bind groups on resize.
pub fn resize(
&mut self,
device: &wgpu::Device,
width: u32,
height: u32,
depth_view: &TextureView,
color_view: &TextureView,
) {
self.width = width;
self.height = height;
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof coc"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let coc_view = coc_texture.create_view(&Default::default());
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof output"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let output_view = output_texture.create_view(&Default::default());
self.coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof coc bg"),
layout: &self.coc_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.coc_sampler),
},
],
});
self.blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof blur bg"),
layout: &self.blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(color_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&coc_view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.blur_sampler),
},
],
});
self.coc_texture = coc_texture;
self.coc_view = coc_view;
self.output_texture = output_texture;
self.output_view = output_view;
}
/// Returns the DoF output texture (for re-pointing the TM bind group).
pub fn output_texture(&self) -> &Texture {
&self.output_texture
}
/// Returns the DoF output view.
pub fn output_view(&self) -> &TextureView {
&self.output_view
}
pub fn coc_view(&self) -> &TextureView {
&self.coc_view
}
pub fn coc_pipeline(&self) -> &RenderPipeline {
&self.coc_pipeline
}
pub fn blur_pipeline(&self) -> &RenderPipeline {
&self.blur_pipeline
}
pub fn coc_bind_group(&self) -> &BindGroup {
&self.coc_bind_group
}
pub fn blur_bind_group(&self) -> &BindGroup {
&self.blur_bind_group
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_new_clamps_aperture() {
let c = DoFConfig::new(5.0, 2.0, 8.0);
assert_eq!(c.aperture, 1.0);
assert_eq!(c.focus_distance, 5.0);
assert_eq!(c.max_blur, 8.0);
}
#[test]
fn config_new_clamps_negative_aperture() {
let c = DoFConfig::new(5.0, -1.0, 8.0);
assert_eq!(c.aperture, 0.0);
}
#[test]
fn cinematic_preset() {
let c = DoFConfig::cinematic(5.0);
assert_eq!(c.focus_distance, 5.0);
assert!((c.aperture - 0.3).abs() < f32::EPSILON);
assert!((c.max_blur - 12.0).abs() < f32::EPSILON);
}
#[test]
fn subtle_preset() {
let c = DoFConfig::subtle(3.0);
assert_eq!(c.focus_distance, 3.0);
assert!((c.aperture - 0.1).abs() < f32::EPSILON);
assert!((c.max_blur - 8.0).abs() < f32::EPSILON);
}
#[test]
fn pack_layout() {
let c = DoFConfig::new(5.0, 0.5, 8.0);
let (a, b) = c.pack(0.1, 100.0, 1.0 / 1920.0, 1.0 / 1080.0);
assert!((a.x - 5.0).abs() < f32::EPSILON);
assert!((a.y - 0.5).abs() < f32::EPSILON);
assert!((a.z - 8.0).abs() < f32::EPSILON);
assert!((a.w - 0.1).abs() < f32::EPSILON);
assert!((b.x - 100.0).abs() < f32::EPSILON);
assert!((b.y - 1.0 / 1920.0).abs() < f32::EPSILON);
assert!((b.z - 1.0 / 1080.0).abs() < f32::EPSILON);
assert_eq!(b.w, 0.0);
}
}
+141
View File
@@ -0,0 +1,141 @@
//! # Fog Module (Étape 25)
//!
//! Distance fog: fades objects into a background color based on their distance
//! from the camera. Primary use case: masking the edge of the rendered world
//! to create the illusion of an infinite scene.
//!
//! Three modes are supported:
//! - **Linear**: hard cutoff between `near` and `far` distances
//! - **Exponential**: gradual falloff `exp(-density * d)`
//! - **Exponential²**: sharper cutoff `exp(-density² * d²)` — best for masking
//!
//! Zero cost when disabled: `fog_enabled = 0` → the shader branch is never taken.
/// Fog attenuation mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FogMode {
/// Linear fade between `near` and `far` distances.
Linear,
/// Exponential falloff: `exp(-density * distance)`.
#[default]
Exponential,
/// Exponential squared: `exp(-density² * distance²)`. Sharper cutoff.
Exponential2,
}
impl FogMode {
/// Numeric value written to the GPU uniform (0 = linear, 1 = exp, 2 = exp²).
pub fn as_f32(self) -> f32 {
match self {
FogMode::Linear => 0.0,
FogMode::Exponential => 1.0,
FogMode::Exponential2 => 2.0,
}
}
}
/// Fog configuration for the scene.
///
/// When not set (no `.with_fog()` call), the renderer writes `fog_enabled = 0`
/// and the shader skips the fog block entirely — zero GPU cost.
#[derive(Clone, Copy, Debug)]
pub struct FogConfig {
/// Attenuation mode (linear / exp / exp²).
pub mode: FogMode,
/// Fog color (RGB, linear space). Should match the sky/clear color for
/// a seamless "infinite world" illusion.
pub color: [f32; 3],
/// Near distance (Linear mode only). Fog starts at this distance.
pub near: f32,
/// Far distance (Linear mode only). Fully fogged at this distance.
pub far: f32,
/// Density (Exponential / Exponential² modes). Higher = thicker fog.
/// Typical range: 0.01 (very thin) to 0.3 (very dense).
pub density: f32,
}
impl FogConfig {
/// Linear fog: fades from `near` to `far` distance.
pub fn linear(color: [f32; 3], near: f32, far: f32) -> Self {
Self {
mode: FogMode::Linear,
color,
near,
far,
density: 0.0,
}
}
/// Exponential fog: `factor = exp(-density * distance)`.
/// Natural-looking fog (forest, lake, atmosphere).
pub fn exponential(color: [f32; 3], density: f32) -> Self {
Self {
mode: FogMode::Exponential,
color,
near: 0.0,
far: 0.0,
density,
}
}
/// Exponential² fog: `factor = exp(-density² * distance²)`.
/// Gradual start, sharp cutoff — ideal for masking world edges.
pub fn exponential2(color: [f32; 3], density: f32) -> Self {
Self {
mode: FogMode::Exponential2,
color,
near: 0.0,
far: 0.0,
density,
}
}
/// Pack into two `Vec4`s for the GPU uniform buffer.
/// - `a` = (enabled, mode, near, far)
/// - `b` = (density, color_r, color_g, color_b)
pub fn pack(&self, enabled: bool) -> (glam::Vec4, glam::Vec4) {
(
glam::Vec4::new(
if enabled { 1.0 } else { 0.0 },
self.mode.as_f32(),
self.near,
self.far,
),
glam::Vec4::new(
self.density,
self.color[0],
self.color[1],
self.color[2],
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_as_f32() {
assert_eq!(FogMode::Linear.as_f32(), 0.0);
assert_eq!(FogMode::Exponential.as_f32(), 1.0);
assert_eq!(FogMode::Exponential2.as_f32(), 2.0);
}
#[test]
fn linear_pack() {
let cfg = FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0);
let (a, b) = cfg.pack(true);
assert_eq!(a, glam::Vec4::new(1.0, 0.0, 5.0, 50.0));
assert_eq!(b, glam::Vec4::new(0.0, 0.7, 0.8, 0.9));
}
#[test]
fn exp2_pack_disabled() {
let cfg = FogConfig::exponential2([1.0, 1.0, 1.0], 0.1);
let (a, b) = cfg.pack(false);
assert_eq!(a.x, 0.0); // disabled
assert_eq!(a.y, 2.0); // exp² mode
assert_eq!(b.x, 0.1); // density
}
}
+4
View File
@@ -11,6 +11,8 @@
pub mod bloom;
pub mod context;
pub mod dof;
pub mod fog;
pub mod frame;
pub mod frustum;
pub mod geometry;
@@ -24,6 +26,8 @@ pub mod transform;
// Re-exports
pub use bloom::BloomConfig;
pub use context::Context;
pub use dof::DoFConfig;
pub use fog::{FogConfig, FogMode};
pub use frame::Frame;
pub use frustum::Frustum;
pub use geometry::{BBox, Geometry, GeometryError};
+134 -6
View File
@@ -170,6 +170,12 @@ pub struct Renderer {
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>,
/// Fog configuration (Étape 25). `None` = fog disabled (zero overhead).
fog: Option<super::fog::FogConfig>,
/// DoF configuration (Étape 26). `None` = DoF disabled (zero overhead).
dof: Option<super::dof::DoFConfig>,
/// DoF pipeline (Étape 26). Present only when DoF + HDR are both active.
dof_pipeline: Option<super::dof::DoFPipeline>,
}
/// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline.
@@ -213,6 +219,8 @@ impl Renderer {
hdr: Option<ToneMapper>,
bloom_config: Option<BloomConfig>,
msaa_config: Option<MsaaConfig>,
fog: Option<super::fog::FogConfig>,
dof_config: Option<super::dof::DoFConfig>,
) -> Self {
let queue: wgpu::Queue = context.queue.clone();
let device: wgpu::Device = context.device.clone();
@@ -220,7 +228,7 @@ impl Renderer {
// Step 9 (DRAFT 9.1): depth texture + view, allocated once at the initial surface
// size (D3). The isolated helper keeps the Phase 4.4 recreate trivial.
let (depth_texture, depth_view) = create_depth_texture(&device, width, height);
let (depth_texture, depth_view) = create_depth_texture(&device, width, height, dof_config.is_some());
// Shared frame uniforms: identity camera + white directional light, lit mode by default.
// Values become meaningful once an active camera is wired (Step 4.3); for now the default
@@ -606,11 +614,16 @@ impl Renderer {
hdr: None,
bloom: None,
bloom_config: bloom_config.clone().unwrap_or_default(),
msaa_config: msaa_config.clone().unwrap_or_default(),
// When MSAA is not requested (None), store sample_count=1 (disabled).
// Using `Default` here would give 4 and incorrectly trigger MSAA allocation.
msaa_config: msaa_config.unwrap_or(MsaaConfig { sample_count: 1 }),
msaa_color_texture: None,
msaa_color_view: None,
msaa_depth_texture: None,
msaa_depth_view: None,
fog,
dof: dof_config,
dof_pipeline: 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.
@@ -657,6 +670,30 @@ impl Renderer {
renderer.msaa_depth_texture = Some(msaa_depth_tex);
renderer.msaa_depth_view = Some(msaa_depth_view);
}
// Étape 26: allocate the DoF pipeline when DoF + HDR are both active.
if renderer.dof.is_some() {
if let Some(hdr) = &mut renderer.hdr {
// The color source for DoF is the HDR texture (or bloom composite if bloom is active).
let color_tex: &wgpu::Texture = if let Some(bloom) = &renderer.bloom {
bloom.composite_texture()
} else {
&hdr.texture
};
let color_view = color_tex.create_view(&Default::default());
let dof_pipe = super::dof::DoFPipeline::new(
&renderer.device, width, height, &renderer.depth_view, &color_view,
);
// Recreate the TM bind group to read from the DoF output texture.
let (bg, _buf) = create_hdr_bind_group(
&renderer.device, &hdr.layout, &hdr.sampler, dof_pipe.output_texture(), width, height,
);
hdr.bind_group = bg;
renderer.dof_pipeline = Some(dof_pipe);
} else {
eprintln!("[WSG] DoF requires HDR: call with_hdr() before with_dof(). DoF disabled.");
renderer.dof = None;
}
}
renderer
}
@@ -686,12 +723,26 @@ impl Renderer {
self.write_default_frame_uniforms();
}
/// Sets the fog configuration at runtime (Étape 25). `None` disables fog.
/// Takes effect on the next `render_scene` call.
pub fn set_fog(&mut self, fog: Option<super::fog::FogConfig>) {
self.fog = fog;
}
/// Sets the DoF configuration at runtime (Étape 26). `None` disables DoF.
/// Only effective when DoF was enabled at construction (pipeline already allocated).
pub fn set_dof(&mut self, config: Option<super::dof::DoFConfig>) {
if self.dof_pipeline.is_some() {
self.dof = config;
}
}
/// Recreates the depth texture at a new size, used on window resize (ROADMAP Phase 4.4).
/// The previous depth texture is dropped when its field is replaced — no leak, no double
/// allocation. The helper `create_depth_texture` (Step 9, D3) is reused so the recreate stays
/// trivial. Inputs: width/height — the new surface dimensions in pixels.
pub fn resize_depth(&mut self, width: u32, height: u32) {
let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height);
let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height, self.dof_pipeline.is_some());
self._depth_texture = depth_texture;
self.depth_view = depth_view;
// Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test.
@@ -744,6 +795,23 @@ impl Renderer {
self.msaa_depth_texture = Some(msaa_depth_tex);
self.msaa_depth_view = Some(msaa_depth_view);
}
// Étape 26: resize DoF textures + re-point TM bind group at the DoF output.
if self.dof_pipeline.is_some() {
if let Some(hdr) = &mut self.hdr {
let color_tex: &wgpu::Texture = if let Some(bloom) = &self.bloom {
bloom.composite_texture()
} else {
&hdr.texture
};
let color_view = color_tex.create_view(&Default::default());
let dof_pipe = self.dof_pipeline.as_mut().unwrap();
dof_pipe.resize(&self.device, width, height, &self.depth_view, &color_view);
let (bg, _buf) = create_hdr_bind_group(
&self.device, &hdr.layout, &hdr.sampler, dof_pipe.output_texture(), width, height,
);
hdr.bind_group = bg;
}
}
}
/// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4).
@@ -801,6 +869,9 @@ impl Renderer {
light_view_proj,
shadow_params,
options: [if self.unlit { 1 } else { 0 }, shadow_on, 0, 0],
// Étape 25: fog params (disabled by default → fog_a.x = 0).
fog_a: self.fog.as_ref().map(|f| f.pack(true).0).unwrap_or(glam::Vec4::ZERO),
fog_b: self.fog.as_ref().map(|f| f.pack(true).1).unwrap_or(glam::Vec4::ZERO),
};
self.queue
.write_buffer(&self.frame_buffer, 0, bytemuck::bytes_of(&frame));
@@ -1168,9 +1239,61 @@ impl Renderer {
bloom.record_passes(&mut encoder, &self.queue, &self.bloom_config);
}
// 8d. Étape 26: DoF passes (CoC → Blur).
// Only runs when DoF + HDR are active and DoF config is set.
// The DoF output texture becomes the input to the TM pass.
if let Some(dof_pipe) = &self.dof_pipeline {
if let Some(dof_cfg) = &self.dof {
// Update the shared uniform buffer.
dof_pipe.update_uniform(&self.queue, dof_cfg, 0.1, 100.0);
// Pass 1: CoC (depth → R16Float radius texture).
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("dof coc pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: dof_pipe.coc_view(),
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_pipeline(dof_pipe.coc_pipeline());
pass.set_bind_group(0, dof_pipe.coc_bind_group(), &[]);
pass.draw(0..3, 0..1);
}
// Pass 2: Blur (color + CoC → blurred Rgba16Float output).
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("dof blur pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: dof_pipe.output_view(),
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_pipeline(dof_pipe.blur_pipeline());
pass.set_bind_group(0, dof_pipe.blur_bind_group(), &[]);
pass.draw(0..3, 0..1);
}
}
}
// 9. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR
// texture (or the bloom composite when bloom is active), applies exposure + tone
// mapping curve, and writes to the surface.
// texture (or the bloom composite when bloom is active, or DoF output when DoF is active),
// applies exposure + tone mapping curve, and writes to the surface.
if let Some(hdr) = &self.hdr {
let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("tone mapping pass"),
@@ -1577,7 +1700,12 @@ fn create_depth_texture(
device: &wgpu::Device,
width: u32,
height: u32,
texturable: bool,
) -> (wgpu::Texture, wgpu::TextureView) {
let mut usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
if texturable {
usage |= wgpu::TextureUsages::TEXTURE_BINDING;
}
let depth_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth texture"),
size: wgpu::Extent3d {
@@ -1589,7 +1717,7 @@ fn create_depth_texture(
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
usage,
view_formats: &[],
});
let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default());
+1
View File
@@ -61,6 +61,7 @@ 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;
pub use crate::core::{DoFConfig, FogConfig, FogMode};
/// Re-export of the geometry data type (positions, normals, UVs, indices).
pub use crate::core::Geometry;
+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, MsaaConfig, ShadowConfig, ToneMapper};
pub use crate::core::{BloomConfig, DoFConfig, FogConfig, FogMode, MsaaConfig, ShadowConfig, ToneMapper};
pub use crate::resources::Material;
// Camera
+24 -4
View File
@@ -100,6 +100,11 @@ pub struct FrameUniforms {
/// `options[1]` = shadows enabled (1 → sample the shadow map, checked alongside
/// `shadow_light_index`). Offset 256 + 64·MAX_LIGHTS.
pub options: [u32; 4],
/// Fog params A (Étape 25): x = enabled (0/1), y = mode (0=linear, 1=exp, 2=exp²),
/// z = near (linear), w = far (linear).
pub fog_a: Vec4,
/// Fog params B (Étape 25): x = density (exp/exp²), y/z/w = fog color RGB.
pub fog_b: Vec4,
}
impl Default for FrameUniforms {
@@ -126,6 +131,9 @@ impl Default for FrameUniforms {
light_view_proj: Mat4::IDENTITY,
shadow_params: Vec4::ZERO,
options: [0, 0, 0, 0],
// Fog disabled by default (Étape 25): enabled=0 → shader branch skipped.
fog_a: Vec4::ZERO,
fog_b: Vec4::ZERO,
}
}
}
@@ -443,9 +451,10 @@ mod tests {
// The offsets below must match the offset table in standard_shader.wgsl.
// Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters (4×u32 = 16),
// light_view_proj (64) + shadow_params (16) + options (16) = 112 after the counters.
// Total = 160 + 64·8 + 16 + 112 = 784 bytes.
assert_eq!(size_of::<FrameUniforms>(), 784);
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112);
// Fog (Étape 25): fog_a (16) + fog_b (16) = 32 bytes.
// Total = 160 + 64·8 + 16 + 112 + 32 = 816 bytes.
assert_eq!(size_of::<FrameUniforms>(), 816);
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112 + 32);
assert_eq!(align_of::<FrameUniforms>(), 16);
let f = FrameUniforms::default();
@@ -482,13 +491,24 @@ mod tests {
offset_of!(FrameUniforms, options),
160 + 64 * MAX_LIGHTS + 96
);
// Étape 25: fog fields at the end (two vec4 = 32 bytes).
assert_eq!(
offset_of!(FrameUniforms, fog_a),
160 + 64 * MAX_LIGHTS + 112
);
assert_eq!(
offset_of!(FrameUniforms, fog_b),
160 + 64 * MAX_LIGHTS + 128
);
// Default is lit mode (unlit flag cleared), one directional light, no point/spot lights,
// shadows off (sentinel = MAX_LIGHTS).
// shadows off (sentinel = MAX_LIGHTS), fog disabled (all zeros).
assert_eq!(f.options[0], 0);
assert_eq!(f.num_directional, 1);
assert_eq!(f.num_point, 0);
assert_eq!(f.num_spot, 0);
assert_eq!(f.shadow_light_index, MAX_LIGHTS as u32);
assert_eq!(f.fog_a, glam::Vec4::ZERO);
assert_eq!(f.fog_b, glam::Vec4::ZERO);
}
#[test]
+74
View File
@@ -0,0 +1,74 @@
// DoF Blur pass (Étape 26)
// Reads the HDR color texture + CoC texture, applies a 12-tap disc blur with
// per-pixel variable radius (from CoC), and writes the blurred result.
// Output: Rgba16Float texture (full-res, HDR).
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 color_tex: texture_2d<f32>;
@group(0) @binding(2) var coc_tex: texture_2d<f32>;
@group(0) @binding(3) var s: sampler;
// 12-tap disc pattern (Poisson-disc-like) for natural bokeh.
const TAPS: array<vec2<f32>, 12> = array<vec2<f32>, 12>(
vec2( 0.000, 0.000), // center
vec2( 0.000, 1.000), // top
vec2( 1.000, 0.000), // right
vec2( 0.000, -1.000), // bottom
vec2(-1.000, 0.000), // left
vec2( 0.707, 0.707), // top-right diagonal
vec2( 0.707, -0.707), // bottom-right diagonal
vec2(-0.707, 0.707), // top-left diagonal
vec2(-0.707, -0.707), // bottom-left diagonal
vec2( 0.383, 0.924), // upper ring
vec2(-0.383, 0.924), // upper ring
vec2( 0.383, -0.924), // lower ring
);
// Fullscreen triangle (same as TM): top-left origin.
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
switch vid {
case 0u {
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
}
case 1u {
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
}
default {
return vec4<f32>(-1.0, 3.0, 0.0, 1.0);
}
}
}
@fragment
fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> @location(0) vec4<f32> {
// UV from fragment pixel position (same pattern as TM shader).
let uv = frag_pos.xy * vec2(u.inv_width, u.inv_height);
let coc = textureSample(coc_tex, s, uv).r;
// Below 0.5px: no visible blur, skip for performance.
if (coc < 0.5) {
return textureSample(color_tex, s, uv);
}
// Variable-radius disc blur.
let texel = vec2(u.inv_width, u.inv_height);
var sum = vec4<f32>(0.0);
for (var i = 0u; i < 12u; i++) {
let offset = TAPS[i] * coc * texel;
sum += textureSample(color_tex, s, uv + offset);
}
return sum / 12.0;
}
+58
View File
@@ -0,0 +1,58 @@
// DoF Circle-of-Confusion pass (Étape 26)
// Reads the scene depth buffer, linearizes it to world distance, and computes
// a per-pixel blur radius (in pixels) based on the DoF parameters.
// Output: R16Float texture (single channel = CoC radius in pixels).
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 s: sampler;
// Fullscreen triangle (same as TM): top-left origin.
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
switch vid {
case 0u {
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
}
case 1u {
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
}
default {
return vec4<f32>(-1.0, 3.0, 0.0, 1.0);
}
}
}
@fragment
fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> @location(0) f32 {
// UV from fragment pixel position (same pattern as TM shader).
let uv = frag_pos.xy * vec2(u.inv_width, u.inv_height);
let ndc_z = textureSample(depth_tex, s, uv);
// Linearize: NDC depth [0,1] → world distance (perspective projection)
let dist = u.near * u.far / (u.far - ndc_z * (u.far - u.near));
// CoC in pixels: proportional to |dist - focus_distance|
var coc = u.max_blur * u.aperture * abs(dist - u.focus_distance)
/ max(u.focus_distance, 1e-4);
coc = min(coc, u.max_blur);
// Far plane (depth ≈ 1.0) → no blur (sky/background)
if (ndc_z >= 0.9999) {
coc = 0.0;
}
return coc;
}
+31 -3
View File
@@ -8,7 +8,7 @@
//!
//! ## Uniform Contract
//! Four bind groups, shared by every material (one single pipeline layout — voir Étape 3) :
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow) [784 bytes]
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow + fog) [816 bytes]
//! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes]
//! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10)
//! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10)
@@ -93,6 +93,8 @@ struct FrameUniforms {
light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3)
shadow_params: vec4<f32>, // .x = map size, .y = constant bias, .z = slope bias
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
fog_a: vec4<f32>, // .x=enabled .y=mode .z=near .w=far (Étape 25)
fog_b: vec4<f32>, // .x=density .y/.z/.w=fog color RGB (Étape 25)
};
struct ObjectUniform {
@@ -151,7 +153,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex + emissive.
if (frame.options.x != 0u) {
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
return vec4<f32>(base + emissive_contrib, in.color.a);
let final_rgb = base + emissive_contrib;
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
}
let n = normalize(in.normal);
@@ -208,7 +211,32 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Étape 22 (6.2): emissive — added to the lit result (independent of lights/shadows).
// Zero emissive (default) → no change (non-regression). In HDR, intensity > 1.0 glows.
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
return vec4<f32>(lit + emissive_contrib, in.color.a);
let final_rgb = lit + emissive_contrib;
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
}
// Étape 25 : distance fog. Blends the final color toward the fog color based on the
// fragment's distance from the camera. Three modes: linear, exponential, exponential².
// When `fog_a.x == 0` (disabled), returns the input color unchanged — zero cost.
fn apply_fog(color: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
if (frame.fog_a.x < 0.5) {
return color;
}
let dist = length(world_pos - frame.cam_pos.xyz);
var fog_factor: f32;
if (frame.fog_a.y < 0.5) {
// Linear: 1.0 at near, 0.0 at far.
fog_factor = saturate((frame.fog_a.w - dist) / max(frame.fog_a.w - frame.fog_a.z, 1e-4));
} else if (frame.fog_a.y < 1.5) {
// Exponential: exp(-density * distance).
fog_factor = exp(-frame.fog_b.x * dist);
} else {
// Exponential²: exp(-density² * distance²) — sharper cutoff.
let d2 = frame.fog_b.x * frame.fog_b.x;
fog_factor = exp(-d2 * dist * dist);
}
let fog_color = frame.fog_b.yzw;
return mix(color, fog_color, 1.0 - fog_factor);
}
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
+6
View File
@@ -58,6 +58,12 @@ pub const BLOOM_BLUR_SHADER: &str = include_str!("../shaders/bloom_blur.wgsl");
/// to the full-res HDR texture, scaled by intensity. Writes to a full-res composite texture.
pub const BLOOM_COMPOSITE_SHADER: &str = include_str!("../shaders/bloom_composite.wgsl");
/// DoF circle-of-confusion shader (Étape 26).
pub const DOF_COC_SHADER: &str = include_str!("../shaders/dof_coc.wgsl");
/// DoF blur shader (Étape 26).
pub const DOF_BLUR_SHADER: &str = include_str!("../shaders/dof_blur.wgsl");
/// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and
/// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU
/// rewrites only the transform slots and the cull uniforms.
+44
View File
@@ -182,3 +182,47 @@ fn bloom_composite_shader_is_valid_wgsl() {
entry_names.sort();
assert_eq!(entry_names, vec!["fs_main", "vs_main"]);
}
/// Parses and fully validates the `dof_coc.wgsl` shader (Étape 26) via naga.
#[test]
fn dof_coc_shader_is_valid_wgsl() {
let src = include_str!("../src/shaders/dof_coc.wgsl");
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("dof_coc.wgsl: parsing error: {e:?}"));
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.unwrap_or_else(|e| panic!("dof_coc.wgsl: validation failed: {e:?}"));
let mut entry_names: Vec<&str> = module
.entry_points
.iter()
.map(|ep| ep.name.as_str())
.collect();
entry_names.sort();
assert_eq!(entry_names, vec!["fs_main", "vs_main"]);
}
/// Parses and fully validates the `dof_blur.wgsl` shader (Étape 26) via naga.
#[test]
fn dof_blur_shader_is_valid_wgsl() {
let src = include_str!("../src/shaders/dof_blur.wgsl");
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("dof_blur.wgsl: parsing error: {e:?}"));
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.unwrap_or_else(|e| panic!("dof_blur.wgsl: validation failed: {e:?}"));
let mut entry_names: Vec<&str> = module
.entry_points
.iter()
.map(|ep| ep.name.as_str())
.collect();
entry_names.sort();
assert_eq!(entry_names, vec!["fs_main", "vs_main"]);
}