diff --git a/docs/DRAFT.md b/docs/DRAFT.md index 895838f..d68bc43 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -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, // Some si msaa active -msaa_color_view: Option, -msaa_depth_texture: Option, -msaa_depth_view: Option, +AppBuilder::with_dof(DoFConfig::cinematic(5.0)) ``` -Quand `msaa_config.sample_count > 1` : -- `msaa_color_texture` = texture `sample_count=N`, format = HDR ou surface -- `msaa_depth_texture` = texture depth `sample_count=N` -- Le render pass scène utilise ces views + `resolve_target` +**Runtime** : +```rust +app.renderer_mut().set_dof(Some(DoFConfig::new(3.0, 0.5, 8.0))); +app.renderer_mut().set_dof(None); // désactiver +``` -Quand `sample_count == 1` : -- Tous les `Option` sont `None` -- Le render pass utilise la texture/view existante (comportement actuel) +### D7 — Pipeline integration -### 24.3 — Allocation à l'init (`Renderer::new`) +Dans `Renderer::render_scene` : ```rust -let sample_count = msaa_config.map(|c| c.sample_count).unwrap_or(1); - -// Vérifier que le format supporte ce sample_count -let formats = device.limits(); // ou surface.capabilities() -// Pour le surface : surface.capabilities().formats -// Pour l'offscreen : device.limits().max_color_attachments, etc. -// En pratique : Rgba16Float et Rgba8Unorm supportent 4× partout. - -if sample_count > 1 { - let msaa_tex = device.create_texture(&TextureDescriptor { - size: Extent3d { width, height, depth_or_array_layers: 1 }, - sample_count, - dimension: Dimension::D2, - format: target_format, // Rgba16Float ou surface format - usage: TextureUsages::RENDER_ATTACHMENT, +// Après bloom (ou après main pass si pas de bloom) : +if let Some(dof) = &self.dof_pipeline { + // 1. CoC pass + let mut coc_pass = encoder.begin_render_pass(&RenderPassDescriptor { + color_attachments: &[Some(RenderPassColorAttachment { + view: &dof.coc_view, + resolve_target: None, + ops: ColorOps::ALL, + format: TextureFormat::R16Float, + .. + })], + depth_stencil_attachment: None, .. }); - // + depth MSAA -} -``` + coc_pass.set_pipeline(&dof.coc_pipeline); + coc_pass.set_bind_group(0, &dof.coc_bind_group, &[]); + coc_pass.draw(0, 3, 0, 1); + drop(coc_pass); -### 24.4 — Render pass scène (modification `render_scene`) - -Le render pass principal doit utiliser les views MSAA quand actif : - -```rust -// Déterminer la color attachment -let (color_view, resolve_target) = if let Some(msaa_view) = &self.msaa_color_view { - // MSAA actif : render dans MSAA, resolve vers la texture single - let resolve = if self.hdr.is_some() { - Some(self.hdr.as_ref().unwrap().view.clone()) // resolve → HDR tex - } else { - Some(view.clone()) // resolve → swapchain - }; - (msaa_view.clone(), resolve) -} else { - // Pas de MSAA : comportement actuel - let color_view = if let Some(hdr) = &self.hdr { - hdr.view.clone() - } else { - view.clone() - }; - (color_view, None) -}; - -// Depth : MSAA ou single -let depth_view = if let Some(msaa_depth) = &self.msaa_depth_view { - msaa_depth.clone() -} else { - self.depth_view.clone() -}; - -encoder.render_pass(RenderPassDescriptor { - color_attachments: &[Some(RenderPassColorAttachment { - view: &color_view, - resolve_target: resolve_target.as_ref(), - load_op: LoadOp::Clear, - store_op: StoreOp::Store, // Store même en MSAA (wgpu gère le resolve) - })], - depth_stencil_attachment: Some(RenderPassDepthStencilAttachment { - view: &depth_view, + // 2. Blur pass + let mut blur_pass = encoder.begin_render_pass(&RenderPassDescriptor { + color_attachments: &[Some(RenderPassColorAttachment { + view: &dof.output_view, + resolve_target: None, + ops: ColorOps::ALL, + format: TextureFormat::Rgba16Float, + .. + })], + depth_stencil_attachment: None, .. - }), - .. -}); + }); + blur_pass.set_pipeline(&dof.blur_pipeline); + blur_pass.set_bind_group(0, &dof.blur_bind_group, &[]); + blur_pass.draw(0, 3, 0, 1); + drop(blur_pass); + + // 3. TM lit dof_output au lieu de HDR + // (re-pointer le bind group TM) +} ``` -### 24.5 — Resize +### D8 — Shaders -```rust -fn resize(&mut self, device, width, height) { - // ... resize depth existant ... - - if self.msaa_config.sample_count > 1 { - // Recréer MSAA color + depth - self.msaa_color_texture = Some(create_msaa_texture(...)); - self.msaa_color_view = Some(...); - self.msaa_depth_texture = Some(create_msaa_depth(...)); - self.msaa_depth_view = Some(...); +#### `dof_coc.wgsl` + +```wgsl +// Vertex : fullscreen triangle (identique à TM/bloom) +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4 { + // 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 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) -> @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 u: DoFUniform; +@group(0) @binding(1) var color_tex: texture_2d; +@group(0) @binding(2) var coc_tex: texture_2d; +@group(0) @binding(3) var sampler: sampler; + +const TAPS: array, 12> = array, 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) -> @location(0) vec4 { + let uv = pos.xy * vec2(u.inv_width, u.inv_height); + let coc = textureSample(coc_tex, sampler, uv).r; + + if (coc < 0.5) { + // Below 0.5px: no blur needed + return textureSample(color_tex, sampler, uv); } - - // HDR resize existant - // Bloom resize existant + + let radius = coc; // in pixels + var sum = vec4(0.0); + for (var i = 0u; i < 12u; i++) { + let offset = TAPS[i] * radius * vec2(u.inv_width, u.inv_height); + sum += textureSample(color_tex, sampler, uv + offset); + } + return sum / 12.0; } ``` -### 24.6 — Plomberie App/AppBuilder +### D9 — Bind group layouts + +**CoC pipeline** (3 bindings) : +| Binding | Type | Description | +|---------|------|-------------| +| 0 | Uniform (32B) | DoF params | +| 1 | Texture (depth) | Depth buffer de la scène | +| 2 | Sampler | Linear, clamp | + +**Blur pipeline** (4 bindings) : +| Binding | Type | Description | +|---------|------|-------------| +| 0 | Uniform (32B) | DoF params | +| 1 | Texture (color) | HDR/bloom color | +| 2 | Texture (color) | CoC texture | +| 3 | Sampler | Linear, clamp | + +Chaque pipeline a **son propre** pipeline layout (règle wgpu 30). + +### D10 — `DoFPipeline` struct ```rust -// AppBuilder -pub fn with_msaa(mut self, sample_count: u32) -> Self { - assert!((2..=8).contains(&sample_count) && sample_count.is_power_of_two(), - "sample_count must be 2, 4, or 8"); - self.msaa_config = Some(MsaaConfig { sample_count }); - self -} +pub(crate) struct DoFPipeline { + // Textures + coc_texture: Texture, + coc_view: TextureView, + output_texture: Texture, + output_view: TextureView, -// App -pub fn msaa_enabled(&self) -> bool { ... } -pub fn set_msaa(&mut self, sample_count: u32) { ... } // nécessite resize + // Sampler (shared between both passes) + sampler: Sampler, + + // Pipelines + coc_pipeline: RenderPipeline, + blur_pipeline: RenderPipeline, + + // Uniform buffer (shared: same values for both passes) + uniform_buffer: Buffer, + + // Bind groups + coc_bind_group: BindGroup, + blur_bind_group: BindGroup, +} ``` -Plomberie : `AppBuilder → App → AppRunner → Renderer::new(msaa_config)` +Méthodes : +- `DoFPipeline::new(device, width, height, depth_view, color_view)` → alloue tout +- `DoFPipeline::update_uniform(&mut self, queue, config, near, far)` → écrit le buffer +- `DoFPipeline::output_view(&self) -> &TextureView` → pour re-pointer le TM +- `DoFPipeline::output_texture(&self) -> &Texture` → pour le bind group TM +- `DoFPipeline::resize(...)` → recrée textures + bind groups -### 24.7 — Re-exports +### D11 — Resize -`lib.rs` + `prelude.rs` : `pub use crate::core::MsaaConfig;` +Dans `resize_depth` (ou équivalent) : +```rust +if let Some(dof) = &mut self.dof_pipeline { + dof.resize(device, queue, new_w, new_h, &new_depth_view, &new_color_view); +} +``` -### 24.8 — Exemple `msaa.rs` +### D12 — Ordre des post-process -Scène simple (cube + sphere + ground) avec/without MSAA commutable à la runtime -(clavier `M`). Camera orbitale pour voir les bords de près. +```text +Main pass → HDR + → Bloom (si actif) → bloom_composite + → DoF (si actif) → dof_output + → TM → surface +``` -Contrôles : -- `M` — toggle MSAA (nécessite un resize/recréation des textures) -- `R`/`1`/`2`/`3` — presets caméra -- Drag/wheel — orbit/zoom +DoF **après** bloom : le glow du bloom est aussi flouté par le DoF → plus naturel. -### 24.9 — Documentation +### D13 — Compatibilité -- `docs/user/msaa.md` : activation, config, coûts, limitations -- `docs/user/README.md` : section "Anti-aliasing" -- `lib/examples/README.md` : entrée `msaa` -- `docs/ROADMAP.md` : 6.4 → ✅ +| Avec | OK ? | Note | +|------|------|------| +| HDR | ✅ **requis** | DoF opère sur la texture HDR | +| Bloom | ✅ | DoF après bloom (D12) | +| MSAA | ✅ | Après resolve, DoF voit la texture single-sample | +| Fog | ✅ | Fog est dans le main pass, DoF floute le résultat | +| Culling | ✅ | Indépendant | + +### D14 — `with_dof` sans `with_hdr` = no-op + +Comme bloom, DoF nécessite HDR. `with_dof()` sans `with_hdr()` → warning + no-op. --- -## Coût GPU +## Fichiers modifiés / créés -| Config | Coût relatif | -|--------|:---:| -| Sans MSAA | 1× | -| MSAA 4× | ~1.3–1.5× (le rasterizer sur-échantillonne, le fill rate est partagé) | -| MSAA 8× | ~1.5–2× | +| Fichier | Action | +|---------|--------| +| `lib/src/core/dof.rs` | **NEW** — `DoFConfig` + `DoFPipeline` | +| `lib/src/core/mod.rs` | + `pub mod dof;` + re-exports | +| `lib/src/lib.rs` | + `pub use DoFConfig` | +| `lib/src/prelude.rs` | + `DoFConfig` | +| `lib/src/core/renderer.rs` | + `dof` field, `set_dof()`, render pass, resize | +| `lib/src/app.rs` | + `with_dof()`, plumbage App/Builder/Runner | +| `lib/src/shaders/dof_coc.wgsl` | **NEW** | +| `lib/src/shaders/dof_blur.wgsl` | **NEW** | +| `lib/tests/wgsl_validate.rs` | + 2 shaders DoF | +| `lib/examples/dof.rs` | **NEW** | +| `lib/examples/README.md` | + section DoF | +| `docs/user/dof.md` | **NEW** | +| `docs/user/README.md` | + ligne DoF | +| `docs/ROADMAP.md` | 6.17 → ✅ | -MSAA est **beaucoup** moins coûteux qu'un post-process AA (FXAA, TAA) car le -sur-coût est dans le rasterizer (edges only) et pas dans un blur fullscreen. -Les post-processes (bloom, TM) ne sont **pas** affectés — ils tournent sur la -texture résolue (1 échantillon/pixel). +--- -## Limitations / non-goals +## Plan d'implémentation -- **Pas de TAA** (temporal AA) — nécessiterait un history buffer + motion vectors, - bien plus complexe. MSAA 4× couvre 90% du besoin pour un lib "simple". -- **Pas de MSAA sur les post-processes** — le bloom/TM lissent déjà l'image. -- **Pas de coverage sampling** (DX12-only) — WebGPU expose seulement MSAA. -- **Resize = recréation** des textures MSAA (pas de resize in-place). +| # | Tâche | Dépend | +|---|-------|--------| +| 1 | `core/dof.rs` : `DoFConfig` + tests | — | +| 2 | `core/mod.rs` + `lib.rs` + `prelude.rs` : exports | 1 | +| 3 | `shaders/dof_coc.wgsl` + `shaders/dof_blur.wgsl` | — | +| 4 | `tests/wgsl_validate.rs` : ajouter les 2 shaders | 3 | +| 5 | `core/dof.rs` : `DoFPipeline` (textures, pipelines, BGL, bind groups) | 3 | +| 6 | `core/renderer.rs` : fields + `new` + `set_dof` + `render_scene` + `resize` | 5 | +| 7 | `app.rs` : `with_dof()` + plumbage | 6 | +| 8 | `examples/dof.rs` | 6 | +| 9 | Docs : examples README + user docs + ROADMAP | 8 | +| 10 | Vérification : `cargo check` + tests + examples | all | -## Tests +--- -- [ ] `MsaaConfig` Default = 4 -- [ ] Validation `sample_count` (rejette 3, 5, 16) -- [ ] Build avec `with_msaa(4)` + HDR + Bloom → compile -- [ ] Exemple `msaa.rs` compile -- [ ] WGSL validation inchangé (pas de nouveau shader) -- [ ] Test unit : `Renderer::new` avec `msaa_config=Some(4)` ne panique pas - (nécessite un device mock — peut être un test intégration ignoré) +## Estimation -## Critères d'acceptation - -1. `cargo check` 0 errors, 0 warnings -2. `cargo test` tous verts -3. `cargo run -p wsg-lib --example msaa` → bords lisses avec M, escaliers sans M -4. `cargo run -p wsg-lib --example demo` → inchangé (pas de `with_msaa`) -5. Combinaison HDR + Bloom + MSAA fonctionne (demo avec `.with_msaa(4)`) +- **Effort** : Moyen (~200 lignes Rust + ~80 lignes WGSL) +- **Risque** : Bas (pattern identique à bloom, 2 passes simples) +- **Gain visuel** : ⭐⭐⭐ (effet cinématique immédiat) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1257d26..f04af45 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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) diff --git a/docs/user/README.md b/docs/user/README.md index 2861d0c..9742db5 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -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 | diff --git a/docs/user/fog.md b/docs/user/fog.md new file mode 100644 index 0000000..231ed56 --- /dev/null +++ b/docs/user/fog.md @@ -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" +``` diff --git a/lib/Cargo.toml b/lib/Cargo.toml index ac43a92..c935560 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -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"] diff --git a/lib/examples/README.md b/lib/examples/README.md index 194c547..d85640b 100644 --- a/lib/examples/README.md +++ b/lib/examples/README.md @@ -18,6 +18,7 @@ cargo run -p wsg-lib --example | `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`, diff --git a/lib/examples/dof.rs b/lib/examples/dof.rs new file mode 100644 index 0000000..45f80cb --- /dev/null +++ b/lib/examples/dof.rs @@ -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(), + }) +} diff --git a/lib/examples/fog.rs b/lib/examples/fog.rs new file mode 100644 index 0000000..c43ad58 --- /dev/null +++ b/lib/examples/fog.rs @@ -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(), + }) +} diff --git a/lib/examples/manual.rs b/lib/examples/manual.rs index d543ae2..9e699a9 100644 --- a/lib/examples/manual.rs +++ b/lib/examples/manual.rs @@ -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() diff --git a/lib/src/app.rs b/lib/src/app.rs index e024d7d..e7c1022 100644 --- a/lib/src/app.rs +++ b/lib/src/app.rs @@ -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, + /// Fog configuration (Étape 25). `None` = no fog (default, zero overhead). + fog: Option, + /// DoF configuration (Étape 26). `None` = no DoF (default, zero overhead). Requires HDR. + dof: Option, /// Winit event loop for window management. Set to None after run() consumes it. event_loop: Option>, // 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, + /// Fog configuration (Étape 25). `None` = no fog (default). + fog: Option, + /// DoF configuration (Étape 26). `None` = no DoF (default). Requires HDR. + dof: Option, } 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 { exposure: f32, /// MSAA config (Étape 24); passed to `Renderer::new` in `resumed`. msaa: Option, + /// Fog config (Étape 25); passed to `Renderer::new` in `resumed`. + fog: Option, + /// DoF config (Étape 26); passed to `Renderer::new` in `resumed`. Only active with HDR. + dof: Option, /// The user-provided game logic. handler: H, /// The fully-built App facade, populated on the first `resumed` event. @@ -409,7 +444,7 @@ impl ApplicationHandler for AppRunner { .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 ApplicationHandler for AppRunner { 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), diff --git a/lib/src/core/dof.rs b/lib/src/core/dof.rs new file mode 100644 index 0000000..4dc6699 --- /dev/null +++ b/lib/src/core/dof.rs @@ -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); + } +} diff --git a/lib/src/core/fog.rs b/lib/src/core/fog.rs new file mode 100644 index 0000000..91a77a9 --- /dev/null +++ b/lib/src/core/fog.rs @@ -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 + } +} diff --git a/lib/src/core/mod.rs b/lib/src/core/mod.rs index 6d645a0..27abade 100644 --- a/lib/src/core/mod.rs +++ b/lib/src/core/mod.rs @@ -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}; diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index 6d1d06d..a885d1a 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -170,6 +170,12 @@ pub struct Renderer { msaa_depth_texture: Option, /// MSAA depth view used as the main pass depth attachment when MSAA is active. msaa_depth_view: Option, + /// Fog configuration (Étape 25). `None` = fog disabled (zero overhead). + fog: Option, + /// DoF configuration (Étape 26). `None` = DoF disabled (zero overhead). + dof: Option, + /// DoF pipeline (Étape 26). Present only when DoF + HDR are both active. + dof_pipeline: Option, } /// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline. @@ -213,6 +219,8 @@ impl Renderer { hdr: Option, bloom_config: Option, msaa_config: Option, + fog: Option, + dof_config: Option, ) -> 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) { + 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) { + 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()); diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 82ac768..5b9b592 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -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; diff --git a/lib/src/prelude.rs b/lib/src/prelude.rs index 0069fc8..a509582 100644 --- a/lib/src/prelude.rs +++ b/lib/src/prelude.rs @@ -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 diff --git a/lib/src/resources/uniform.rs b/lib/src/resources/uniform.rs index 757765c..394fc5e 100644 --- a/lib/src/resources/uniform.rs +++ b/lib/src/resources/uniform.rs @@ -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::(), 784); - assert_eq!(size_of::(), 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::(), 816); + assert_eq!(size_of::(), 160 + 512 + 112 + 32); assert_eq!(align_of::(), 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] diff --git a/lib/src/shaders/dof_blur.wgsl b/lib/src/shaders/dof_blur.wgsl new file mode 100644 index 0000000..ff1f938 --- /dev/null +++ b/lib/src/shaders/dof_blur.wgsl @@ -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 u: DoFUniform; +@group(0) @binding(1) var color_tex: texture_2d; +@group(0) @binding(2) var coc_tex: texture_2d; +@group(0) @binding(3) var s: sampler; + +// 12-tap disc pattern (Poisson-disc-like) for natural bokeh. +const TAPS: array, 12> = array, 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 { + switch vid { + case 0u { + return vec4(-1.0, -1.0, 0.0, 1.0); + } + case 1u { + return vec4(3.0, -1.0, 0.0, 1.0); + } + default { + return vec4(-1.0, 3.0, 0.0, 1.0); + } + } +} + +@fragment +fn fs_main(@builtin(position) frag_pos: vec4) -> @location(0) vec4 { + // 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(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; +} diff --git a/lib/src/shaders/dof_coc.wgsl b/lib/src/shaders/dof_coc.wgsl new file mode 100644 index 0000000..dd0e0e5 --- /dev/null +++ b/lib/src/shaders/dof_coc.wgsl @@ -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 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 { + switch vid { + case 0u { + return vec4(-1.0, -1.0, 0.0, 1.0); + } + case 1u { + return vec4(3.0, -1.0, 0.0, 1.0); + } + default { + return vec4(-1.0, 3.0, 0.0, 1.0); + } + } +} + +@fragment +fn fs_main(@builtin(position) frag_pos: vec4) -> @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; +} diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl index b3d9f4e..c5e2dcc 100644 --- a/lib/src/shaders/standard_shader.wgsl +++ b/lib/src/shaders/standard_shader.wgsl @@ -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) (Étape 10) @@ -93,6 +93,8 @@ struct FrameUniforms { light_view_proj: mat4x4, // world → shadow light clip space (Étape 14, D3) shadow_params: vec4, // .x = map size, .y = constant bias, .z = slope bias options: vec4, // .x = unlit flag ; .y = shadows on + fog_a: vec4, // .x=enabled .y=mode .z=near .w=far (Étape 25) + fog_b: vec4, // .x=density .y/.z/.w=fog color RGB (Étape 25) }; struct ObjectUniform { @@ -151,7 +153,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // 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(base + emissive_contrib, in.color.a); + let final_rgb = base + emissive_contrib; + return vec4(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 { // É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(lit + emissive_contrib, in.color.a); + let final_rgb = lit + emissive_contrib; + return vec4(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, world_pos: vec3) -> vec3 { + 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 diff --git a/lib/src/utils/conf.rs b/lib/src/utils/conf.rs index f707ab7..3263f95 100644 --- a/lib/src/utils/conf.rs +++ b/lib/src/utils/conf.rs @@ -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. diff --git a/lib/tests/wgsl_validate.rs b/lib/tests/wgsl_validate.rs index 2ffb064..0e9b6d3 100644 --- a/lib/tests/wgsl_validate.rs +++ b/lib/tests/wgsl_validate.rs @@ -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"]); +}