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

12 KiB
Raw Blame History

É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 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)

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 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) :

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)