# Étape 26 — Depth of Field (DoF) > **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é. --- ## Contexte & motivation 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é : - **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) ### 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). --- ## Décisions ### 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); ``` - `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) ### 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, }; ``` 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 · · · · · · · · · · · · · · · ``` 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. ### D5 — Textures | 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. ### D6 — API publique ```rust /// Configuration du Depth of Field. #[derive(Clone, Copy, Debug)] 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 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; } ``` **Builder** : ```rust AppBuilder::with_dof(DoFConfig::cinematic(5.0)) ``` **Runtime** : ```rust app.renderer_mut().set_dof(Some(DoFConfig::new(3.0, 0.5, 8.0))); app.renderer_mut().set_dof(None); // désactiver ``` ### D7 — Pipeline integration Dans `Renderer::render_scene` : ```rust // 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, .. }); 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) } ``` ### D8 — Shaders #### `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); } 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; } ``` ### 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 pub(crate) struct DoFPipeline { // Textures coc_texture: Texture, coc_view: TextureView, output_texture: Texture, output_view: TextureView, // 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, } ``` 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 if let Some(dof) = &mut self.dof_pipeline { dof.resize(device, queue, new_w, new_h, &new_depth_view, &new_color_view); } ``` ### D12 — Ordre des post-process ```text Main pass → HDR → Bloom (si actif) → bloom_composite → DoF (si actif) → dof_output → TM → surface ``` DoF **après** bloom : le glow du bloom est aussi flouté par le DoF → plus naturel. ### D13 — Compatibilité | 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. --- ## Fichiers modifiés / créés | 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 → ✅ | --- ## Plan d'implémentation | # | 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 | --- ## Estimation - **Effort** : Moyen (~200 lignes Rust + ~80 lignes WGSL) - **Risque** : Bas (pattern identique à bloom, 2 passes simples) - **Gain visuel** : ⭐⭐⭐ (effet cinématique immédiat)