425 lines
12 KiB
Markdown
425 lines
12 KiB
Markdown
# É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<f32> {
|
||
// même triangle que TM : (-1,-1), (3,-1), (-1,3)
|
||
}
|
||
|
||
struct DoFUniform {
|
||
focus_distance: f32,
|
||
aperture: f32,
|
||
max_blur: f32,
|
||
near: f32,
|
||
far: f32,
|
||
inv_width: f32,
|
||
inv_height: f32,
|
||
_pad: f32,
|
||
};
|
||
|
||
@group(0) @binding(0) var<uniform> u: DoFUniform;
|
||
@group(0) @binding(1) var depth_tex: texture_depth_2d;
|
||
@group(0) @binding(2) var sampler: sampler;
|
||
|
||
@fragment
|
||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) f32 {
|
||
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
|
||
let ndc_z = textureSample(depth_tex, sampler, uv);
|
||
|
||
// Linearize: NDC [0,1] → world distance
|
||
let dist = u.near * u.far / (u.far - ndc_z * (u.far - u.near));
|
||
|
||
// CoC in pixels
|
||
var coc = u.max_blur * u.aperture * abs(dist - u.focus_distance)
|
||
/ max(u.focus_distance, 1e-4);
|
||
coc = min(coc, u.max_blur);
|
||
|
||
// Edge case: depth = 1.0 (far plane) → no blur
|
||
if (ndc_z >= 0.9999) { coc = 0.0; }
|
||
|
||
return coc;
|
||
}
|
||
```
|
||
|
||
#### `dof_blur.wgsl`
|
||
|
||
```wgsl
|
||
// Vertex : fullscreen triangle (id)
|
||
|
||
struct DoFUniform { /* idem */ };
|
||
|
||
@group(0) @binding(0) var<uniform> u: DoFUniform;
|
||
@group(0) @binding(1) var color_tex: texture_2d<f32>;
|
||
@group(0) @binding(2) var coc_tex: texture_2d<f32>;
|
||
@group(0) @binding(3) var sampler: sampler;
|
||
|
||
const TAPS: array<vec2<f32>, 12> = array<vec2<f32>, 12>(
|
||
vec2(0.0, 0.0),
|
||
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(-1.0, 0.0),
|
||
vec2(0.707, 0.707), vec2(0.707, -0.707),
|
||
vec2(-0.707, 0.707), vec2(-0.707, -0.707),
|
||
vec2(0.383, 0.924), vec2(-0.383, 0.924), vec2(0.383, -0.924),
|
||
);
|
||
|
||
@fragment
|
||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
|
||
let coc = textureSample(coc_tex, sampler, uv).r;
|
||
|
||
if (coc < 0.5) {
|
||
// Below 0.5px: no blur needed
|
||
return textureSample(color_tex, sampler, uv);
|
||
}
|
||
|
||
let radius = coc; // in pixels
|
||
var sum = vec4<f32>(0.0);
|
||
for (var i = 0u; i < 12u; i++) {
|
||
let offset = TAPS[i] * radius * vec2(u.inv_width, u.inv_height);
|
||
sum += textureSample(color_tex, sampler, uv + offset);
|
||
}
|
||
return sum / 12.0;
|
||
}
|
||
```
|
||
|
||
### D9 — Bind group layouts
|
||
|
||
**CoC pipeline** (3 bindings) :
|
||
| Binding | Type | Description |
|
||
|---------|------|-------------|
|
||
| 0 | Uniform (32B) | DoF params |
|
||
| 1 | Texture (depth) | Depth buffer de la scène |
|
||
| 2 | Sampler | Linear, clamp |
|
||
|
||
**Blur pipeline** (4 bindings) :
|
||
| Binding | Type | Description |
|
||
|---------|------|-------------|
|
||
| 0 | Uniform (32B) | DoF params |
|
||
| 1 | Texture (color) | HDR/bloom color |
|
||
| 2 | Texture (color) | CoC texture |
|
||
| 3 | Sampler | Linear, clamp |
|
||
|
||
Chaque pipeline a **son propre** pipeline layout (règle wgpu 30).
|
||
|
||
### D10 — `DoFPipeline` struct
|
||
|
||
```rust
|
||
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)
|