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

344 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# É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<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`
```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`
```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`
```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