13 KiB
É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)oumax(color - threshold, 0)silum > threshold, sinon0 - É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 × idans 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
brightetblur(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ènebloom_composite_texture(full-res, allouée avec le bloom) : reçoit le résultat du composite- Le TM pass lit
bloom_composite_textureau lieu dehdr_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 testpasse (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