Files
wsg/docs/DRAFT.md
T
Jérôme Bousquié 35aeb769a8 refactor examples
2026-09-25 10:19:24 +02:00

13 KiB
Raw Blame History

Étape 23 — Bloom (post-process HDR)

Statut : ✅ Terminé Prérequis : HDR + Tone Mapping (Étape 20 ✅), Emissive (Étape 22 ✅)


Objectif

Ajouter un effet bloom : les zones très brillantes de la scène (emissive > 1.0, spéculaires, overbright lighting) diffusent une lueur vers les zones voisines. C'est l'effet "glow" qui rend les néons et les sources de lumière visuellement impactants.

Le bloom est un post-process qui opère sur la texture HDR, entre le rendu de la scène et le tone mapping. Il est opt-in (AppBuilder::with_bloom(...)) et n'a zéro coût quand désactivé (aucune texture/pipeline allouée).


Pipeline

Scene render → HDR texture (Rgba16Float, full res)
                 │
                 ├─[bloom actif?]─→ 1. Threshold (half res)  : extrait les pixels > threshold
                 │                    2. Blur H (half res)    : Gaussian 9 taps
                 │                    3. Blur V (half res)    : Gaussian 9 taps
                 │                    4. Composite (full res) : HDR += bloom × intensity
                 │
                 ▼
              TM pass → surface

Quand bloom est désactivé : Scene → HDR → TM → surface (comme aujourd'hui, zéro overhead).

4 passes fullscreen supplémentaires (seulement si HDR + bloom actifs).


Composants

BloomConfig (pub, dans core/bloom.rs)

pub struct BloomConfig {
    /// Seuil de luminance (en unités HDR linéaires). Au-dessus → contribue au bloom.
    /// Défaut : 1.0 (seul ce qui dépasse 1.0 "bloom" — les emissives > 1.0, les spéculaires).
    pub threshold: f32,
    /// Intensité du bloom (multiplicateur sur le résultat du blur). Défaut : 0.8.
    pub intensity: f32,
    /// Rayon du blur en pixels (à la résolution half-res). Défaut : 4.0.
    pub radius: f32,
}
impl Default for BloomConfig { /* threshold=1.0, intensity=0.8, radius=4.0 */ }

BloomPipeline (interne, dans core/bloom.rs)

struct BloomPipeline {
    /// Texture half-res pour le bloom (Rgba16Float).
    bright_texture: wgpu::Texture,
    bright_view: wgpu::TextureView,
    /// Texture half-res pour le blur ping-pong (2nd buffer).
    blur_texture: wgpu::Texture,
    blur_view: wgpu::TextureView,
    /// Sampler linear pour le blur.
    sampler: wgpu::Sampler,
    /// Pipeline threshold (fullscreen → half-res).
    threshold_pipeline: wgpu::RenderPipeline,
    /// Pipeline blur (fullscreen half-res, direction via uniform).
    blur_pipeline: wgpu::RenderPipeline,
    /// Pipeline composite (full-res: HDR += bloom).
    composite_pipeline: wgpu::RenderPipeline,
    /// Bind groups pré-alloués.
    threshold_bg: wgpu::BindGroup,
    blur_bg_a: wgpu::BindGroup,   // reads bright, writes blur
    blur_bg_b: wgpu::BindGroup,   // reads blur, writes bright (ping-pong)
    composite_bg: wgpu::BindGroup, // reads HDR + bright
    /// Uniform buffer pour le blur (direction + radius).
    blur_uniform: wgpu::Buffer,
    /// Uniform buffer pour le threshold (threshold value).
    threshold_uniform: wgpu::Buffer,
    /// Half-res dimensions.
    width: u32,
    height: u32,
}

Shaders (3 fichiers WGSL)

bloom_threshold.wgsl

  • Vertex : fullscreen triangle
  • Fragment : lit la texture HDR (full res), calcule la luminance, sort color × smoothstep(threshold, threshold+knee, lum) ou max(color - threshold, 0) si lum > threshold, sinon 0
  • Écrit dans la texture half-res

bloom_blur.wgsl

  • Vertex : fullscreen triangle (à la résolution half-res)
  • Fragment : 9-tap Gaussian séparable. L'offset est texel_size × radius × i dans la direction donnée par l'uniform.
  • Uniform : vec2<f32> direction (dx, dy), f32 radius
  • Weights Gaussian : [0.227027, 0.194595, 0.121622, 0.054054, 0.016216] (symétrique)

bloom_composite.wgsl

  • Vertex : fullscreen triangle (full res)
  • Fragment : result = hdr_color + bloom_color × intensity
  • Uniform : f32 intensity
  • Lit les 2 textures (HDR full-res + bloom half-res, upscalé par le sampler linear)

Shaders

bloom_threshold.wgsl

// Fullscreen triangle vertex (même pattern que tonemap)
struct VsOut {
    @builtin(position) pos: vec4<f32>,
    @location(0) uv: vec2<f32>,
};

@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
    var pos: vec2<f32>;
    pos.x = f32((vi << 1) & 2) * 2.0 - 1.0;
    pos.y = f32(vi & 2) * 2.0 - 1.0;
    var out: VsOut;
    out.pos = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
    out.uv = vec2<f32>(pos.x * 0.5 + 0.5, 0.5 - pos.y * 0.5);
    return out;
}

struct ThresholdUniforms {
    threshold: f32,
    knee: f32,
    pad: vec2<f32>,
};

@group(0) @binding(0) var<uniform> tmu: ThresholdUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;
@group(0) @binding(3) var<atomic u32> pad; // placeholder — not needed, use texture_storage

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let color = textureSample(src_tex, src_sampler, in.uv).rgb;
    let lum = dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
    // Soft knee: smooth transition above threshold
    let soft = max(lum - tmu.threshold, 0.0);
    let contrib = soft / (soft + tmu.knee);  // 0..1 smooth
    return vec4<f32>(color * contrib, 1.0);
}

bloom_blur.wgsl

// Même VsOut / vs_main que threshold (fullscreen triangle)

struct BlurUniforms {
    direction: vec2<f32>,  // texel offset: (1/w, 0) or (0, 1/h)
    radius: f32,
    pad: vec2<f32>,
};

@group(0) @binding(0) var<uniform> bu: BlurUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;

const W: array<f32, 5> = array<f32, 5>(
    0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162
);

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let center = textureSample(src_tex, src_sampler, in.uv).rgb;
    var sum = center * W[0];
    for (var i: u32 = 1u; i < 5u; i = i + 1u) {
        let off = bu.direction * (f32(i) * bu.radius);
        let s = textureSample(src_tex, src_sampler, in.uv + off).rgb
              + textureSample(src_tex, src_sampler, in.uv - off).rgb;
        sum = sum + s * W[i];
    }
    return vec4<f32>(sum, 1.0);
}

bloom_composite.wgsl

// Même VsOut / vs_main

struct CompositeUniforms {
    intensity: f32,
    pad: vec3<f32>,
};

@group(0) @binding(0) var<uniform> cu: CompositeUniforms;
@group(0) @binding(1) var hdr_tex: texture_2d<f32>;
@group(0) @binding(2) var hdr_sampler: sampler;
@group(0) @binding(3) var bloom_tex: texture_2d<f32>;
@group(0) @binding(4) var bloom_sampler: sampler;

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let hdr = textureSample(hdr_tex, hdr_sampler, in.uv).rgb;
    let bloom = textureSample(bloom_tex, bloom_sampler, in.uv).rgb;
    return vec4<f32>(hdr + bloom * cu.intensity, 1.0);
}

Intégration dans Renderer::render_scene

Step 7: Main render pass → HDR texture (ou surface si pas HDR)
Step 8: [Bloom] Si HDR + bloom actifs :
         8a. Threshold pass (HDR full → bright half)
         8b. Blur H (bright half → blur half)
         8c. Blur V (blur half → bright half)  [ping-pong]
         8d. Composite (HDR full + bright half → HDR full)
         8e. write_buffer(exposure) — comme aujourd'hui
Step 9: TM pass (HDR full → surface)

Le composite modifie la texture HDR in-place (rend dans une 2ème texture puis swap, ou rend directement dans la HDR texture si on utilise un ping-pong). En pratique : le composite rend dans la HDR texture elle-même (le bind group lit la HDR comme input ET écrit dedans — NON, c'est undefined behavior en wgpu).

Solution : le composite écrit dans un 3ème buffer full-res (ou on swap les rôles : le bloom écrit dans la HDR texture en lisant une copie). La solution la plus simple :

  • Le threshold lit la HDR texture et écrit dans bright (half res)
  • Le blur ping-ponge entre bright et blur (half res)
  • Le composite lit la HDR texture + bright (half res) et écrit dans la HDR texture (c'est OK car le composite est une pass séparée qui commence APRÈS que le threshold/blur ont fini d'écrire — et le composite lit la HDR texture en input mais écrit aussi dedans)

Attendez — non, en wgpu/WebGPU, on ne peut PAS lire et écrire la même texture dans la même render pass. Mais on peut le faire dans des passes différentes (le composite est une pass séparée du threshold). Le problème est que le composite lit la HDR texture (qui n'a pas été modifiée par threshold/blur — ils ont écrit dans bright/blur) et écrit dans la HDR texture. C'est valide car c'est dans une render pass unique : le GPU ne permet pas de lire ET écrire la même texture attachment dans la même pass.

Solution propre : utiliser un ping-pong full-res :

  • hdr_texture (existante) : contient le rendu de la scène
  • bloom_composite_texture (full-res, allouée avec le bloom) : reçoit le résultat du composite
  • Le TM pass lit bloom_composite_texture au lieu de hdr_texture

Quand bloom est inactif : le TM lit hdr_texture directement (comme aujourd'hui).


API utilisateur

Composant Changement
AppBuilder with_bloom(config: BloomConfig) — active le bloom
App set_bloom_config(config), bloom_enabled() -> bool
Renderer Champ bloom: Option<BloomPipeline>, bloom_config: BloomConfig
core/mod.rs pub mod bloom; + re-export BloomConfig
lib.rs Re-export BloomConfig
prelude.rs Re-export BloomConfig

Règle : le bloom n'a d'effet que si HDR est actif. with_bloom() sans with_hdr() est un no-op (log un warning).


Resize

Au resize, si le bloom est actif :

  • Recréer les textures half-res (bright, blur)
  • Recréer le composite texture full-res
  • Recréer les bind groups
  • Mettre à jour les uniforms (dimensions)

Décisions

# Décision Justification
D1 4 passes (threshold + blur H + blur V + composite) Bonne qualité/performances. Un seul niveau de mip suffit pour un bloom "soft"
D2 Résolution half-res pour le bloom Standard. Le blur à half-res est 4× moins coûteux et le résultat upscalé par le sampler linear est lisse
D3 Soft-knee threshold (pas un cutoff dur) soft/(soft+knee) donne une transition douce, pas d'aliasing au seuil
D4 Composite via ping-pong full-res (3ème texture) Évite le conflit read/write sur la même texture dans une même pass
D5 Bloom seulement si HDR actif Le bloom opère en espace linéaire HDR. Sans HDR, les valeurs sont déjà clampées [0,1] → pas de "bright" à extraire
D6 BloomConfig avec 3 champs (threshold, intensity, radius) Minimum utile. Pas de multi-mip, pas de directional bloom pour MVP
D7 Sampler Linear + ClampToEdge pour le blur Les bords ne doivent pas sampler hors-texture (artefacts noirs)
D8 Le TM pass lit la texture composite (si bloom) ou la HDR (si pas bloom) Le TM est agnostique de la source — il lit juste une texture full-res Rgba16Float
D9 Uniform threshold : 16 bytes (threshold + knee + 2 pad) Aligned 16, simple
D10 Uniform blur : 16 bytes (direction vec2 + radius + pad) Aligned 16
D11 Uniform composite : 16 bytes (intensity + 3 pad) Aligned 16

Fichiers modifiés / créés

Fichier Changement
lib/src/core/bloom.rs Nouveau : BloomConfig, BloomPipeline, allocation + bind groups
lib/src/core/renderer.rs + bloom: Option<BloomPipeline>, bloom_config ; passes 8a-8d ; TM lit composite ou HDR ; resize
lib/src/core/hdr.rs create_hdr_bind_group accepte une texture arbitraire (pas seulement self.texture)
lib/src/core/mod.rs + pub mod bloom; + re-exports
lib/src/shaders/bloom_threshold.wgsl Nouveau
lib/src/shaders/bloom_blur.wgsl Nouveau
lib/src/shaders/bloom_composite.wgsl Nouveau
lib/src/shaders/conf.rs + BLOOM_THRESHOLD_SHADER, BLOOM_BLUR_SHADER, BLOOM_COMPOSITE_SHADER
lib/src/app.rs + bloom_config, bloom_enabled, set_bloom_config, builder with_bloom
lib/src/lib.rs Re-export BloomConfig
lib/src/prelude.rs Re-export BloomConfig
lib/tests/wgsl_validate.rs + 3 tests (threshold, blur, composite)
lib/examples/demo.rs + with_bloom(BloomConfig::default())
docs/user/bloom.md Nouveau : doc utilisateur
docs/ROADMAP.md 6.3 → ✅

Tests

Test Vérifie
bloom_config_default threshold=1.0, intensity=0.8, radius=4.0
bloom_requires_hdr with_bloom sans with_hdr → warning, bloom inactif
bloom_pipeline_allocates_half_res dimensions = (w/2, h/2)
bloom_zero_intensity_is_noop intensity=0 → composite = HDR (pas de changement)
WGSL threshold compile avec naga
WGSL blur compile avec naga
WGSL composite compile avec naga

Critères d'acceptation

  • cargo test passe (tous tests existants + nouveaux)
  • cargo run --example demo : le glow sphere produit un halo visible
  • Sans bloom : rendu identique à avant (zéro régression)
  • Sans HDR + avec bloom : pas de crash (bloom ignoré, warning)
  • Resize : le bloom continue de fonctionner
  • 0 warnings