Files
wsg/docs/DRAFT.md
T
Jérôme Bousquié 8ece89ccba dof
2026-09-25 13:43:59 +02:00

425 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# É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)