# É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`) ```rust 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`) ```rust 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 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` ```wgsl // Fullscreen triangle vertex (même pattern que tonemap) struct VsOut { @builtin(position) pos: vec4, @location(0) uv: vec2, }; @vertex fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { var pos: vec2; 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(pos.x, -pos.y, 0.0, 1.0); out.uv = vec2(pos.x * 0.5 + 0.5, 0.5 - pos.y * 0.5); return out; } struct ThresholdUniforms { threshold: f32, knee: f32, pad: vec2, }; @group(0) @binding(0) var tmu: ThresholdUniforms; @group(0) @binding(1) var src_tex: texture_2d; @group(0) @binding(2) var src_sampler: sampler; @group(0) @binding(3) var pad; // placeholder — not needed, use texture_storage @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { let color = textureSample(src_tex, src_sampler, in.uv).rgb; let lum = dot(color, vec3(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(color * contrib, 1.0); } ``` ### `bloom_blur.wgsl` ```wgsl // Même VsOut / vs_main que threshold (fullscreen triangle) struct BlurUniforms { direction: vec2, // texel offset: (1/w, 0) or (0, 1/h) radius: f32, pad: vec2, }; @group(0) @binding(0) var bu: BlurUniforms; @group(0) @binding(1) var src_tex: texture_2d; @group(0) @binding(2) var src_sampler: sampler; const W: array = array( 0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162 ); @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { 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(sum, 1.0); } ``` ### `bloom_composite.wgsl` ```wgsl // Même VsOut / vs_main struct CompositeUniforms { intensity: f32, pad: vec3, }; @group(0) @binding(0) var cu: CompositeUniforms; @group(0) @binding(1) var hdr_tex: texture_2d; @group(0) @binding(2) var hdr_sampler: sampler; @group(0) @binding(3) var bloom_tex: texture_2d; @group(0) @binding(4) var bloom_sampler: sampler; @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { let hdr = textureSample(hdr_tex, hdr_sampler, in.uv).rgb; let bloom = textureSample(bloom_tex, bloom_sampler, in.uv).rgb; return vec4(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`, `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`, `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