refactor examples

This commit is contained in:
Jérôme Bousquié
2026-09-25 10:19:24 +02:00
parent ab3f056dbb
commit 35aeb769a8
37 changed files with 3430 additions and 457 deletions
+326 -82
View File
@@ -1,99 +1,343 @@
# Étape 21 — Module `mesh` : primitives optionnelles + import
# Étape 23 — Bloom (post-process HDR)
**Statut : ✅ TERMINÉE**
**Statut** : ✅ Terminé
**Prérequis** : HDR + Tone Mapping (Étape 20 ✅), Emissive (Étape 22 ✅)
## Résumé
---
Restructuration du module de géométrie :
- `math/` supprimé — types (`Geometry`, `Transform`, `BBox`, `Frustum`, LOD) déplacés vers `core/`
- `primitives.rs` (monolith) → `mesh/primitives/` (6 fichiers, un par famille)
- Nouveau module `wsg::mesh` : point d'entrée unique pour les sources de géométrie
- Features par primitive (`prim-cube`, `prim-sphere`, …) — zéro coût si désactivées
- Parser OBJ intégré (zéro dep externe), wrapper glTF en stub
- `prelude.rs` pour un glob import confortable
- Re-exports top-level : `Geometry`, `Transform`, `BBox`
## Objectif
## Structure finale
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
```
lib/src/
├── lib.rs # + pub mod mesh, pub mod prelude, re-exports Geometry/Transform/BBox
├── prelude.rs # glob re-exports (types quotidiens)
├── core/
│ ├── mod.rs # + geometry, transform, frustum, lod
│ ├── geometry.rs # ← déplacé de math/
│ ├── transform.rs # ← déplacé de math/
│ ├── frustum.rs # ← déplacé de math/
│ ├── lod.rs # ← déplacé de math/
│ ├── renderer.rs
│ ├── shadow.rs
│ ├── hdr.rs
│ ├── context.rs
│ ├── frame.rs
│ └── input.rs
├── mesh/
│ ├── mod.rs # re-exports flat (cube, plane, sphere, …, load_obj, …)
│ ├── primitives/
│ │ ├── mod.rs
│ │ ├── cube.rs
│ │ ├── plane.rs
│ │ ├── sphere.rs # uv_sphere + icosphere
│ │ ├── cylinder.rs
│ │ ├── cone.rs
│ │ └── torus.rs
│ └── import/
│ ├── mod.rs # MeshImportError
│ ├── obj.rs # parser OBJ (zéro dep)
│ └── gltf.rs # stub (wrapper gltf crate à implémenter)
├── app.rs
├── handler.rs
├── pipeline/
├── resources/
├── scene/
└── utils/
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
```
## Features (Cargo.toml)
Quand bloom est désactivé : `Scene → HDR → TM → surface` (comme aujourd'hui, zéro overhead).
| Feature | Default | Fournit |
|---------|---------|---------|
| `prim-cube` | ✅ (via all-prims) | `cube(size)` |
| `prim-plane` | ✅ | `plane(w, d, sx, sz)` |
| `prim-sphere` | ✅ | `uv_sphere(…)`, `icosphere(…)` |
| `prim-cylinder` | ✅ | `cylinder(…)` |
| `prim-cone` | ✅ | `cone(…)` |
| `prim-torus` | ✅ | `torus(…)` |
| `all-prims` | ✅ (default) | les 6 ci-dessus |
| `import-obj` | ⬜ | `load_obj(path)`, `parse_obj(str)` |
| `import-gltf` | ⬜ | `load_gltf(path)` (stub) |
**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 |
|---|----------|
| D1 | Un seul crate `wsg-lib` — pas de crate séparée |
| D2 | Feature par famille de primitives |
| D3 | Feature par format d'import |
| D4 | Pas de trait `MeshSource` — fonctions qui retournent `Geometry` |
| D5 | `Geometry::new()` / `Scene::add_mesh()` restent en core |
| D6 | Module `wsg::mesh` au même niveau que `core`, `app` |
| D7 | `primitives/` un fichier par famille |
| D8 | `import/` un fichier par format |
| D9 | Import retourne `Result<_, MeshImportError>` |
| D10 | `default = ["all-prims"]` |
| D11 | `all-prims` = les 6 primitives |
| D12 | `math` disparaît — types re-exportés par `core` / top-level |
| # | 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
- 107 unit tests (dont 7 tests OBJ parser)
- 4 WGSL validation
- 5 doctests
- **Total : 116 tests, 0 failures**
| 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 |
## Build vérifié
---
- `cargo check` (default = all-prims) ✅
- `cargo check --no-default-features --features "prim-cube"` ✅
- `cargo check --features "import-obj,import-gltf"` ✅
- `cargo check --examples --features "import-obj"` ✅
## 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