12 KiB
É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)
Main pass → HDR texture (Rgba16Float)
↓
Bloom (si actif) → composite
↓
Tone Mapping → surface
Pipeline avec DoF
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
// 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 netteaperture: 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)
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 :
· ·
· ·
· ·
· · ·
· ·
· ·
· ·
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
/// 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 :
AppBuilder::with_dof(DoFConfig::cinematic(5.0))
Runtime :
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 :
// 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
// 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
// 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
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 toutDoFPipeline::update_uniform(&mut self, queue, config, near, far)→ écrit le bufferDoFPipeline::output_view(&self) -> &TextureView→ pour re-pointer le TMDoFPipeline::output_texture(&self) -> &Texture→ pour le bind group TMDoFPipeline::resize(...)→ recrée textures + bind groups
D11 — Resize
Dans resize_depth (ou équivalent) :
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
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)