archi particules

This commit is contained in:
Jérôme Bousquié
2026-09-25 16:00:17 +02:00
parent 83daeb4c7d
commit 7e88390006
3 changed files with 1360 additions and 297 deletions
+378 -296
View File
@@ -1,354 +1,436 @@
# DRAFT — Étape 27 : PBR Metallic/Roughness + Normal Mapping (Phase 6.5)
# Étape 28 — Système de Particules : Étape A (Pool)
> **Objectif** : Créer l'infrastructure GPU du pool de particules (buffer + pipeline render + draw).
> C'est la brique de base sur laquelle les drivers (GPU/CPU/Manual) seront construits.
> **Référence** : `docs/tech/ARCHI_PARTICULES.md` (§2, §3, §6, §7, §8.2, §8.3, §12, §13)
---
## Contexte
Le shader actuel (`standard_shader.wgsl`) utilise un modèle d'éclairage simpliste :
- Diffuse Lambert (`N·L`) + ambient hémisphérique
- **Aucun terme spéculaire** (pas de Blinn-Phong, pas de Cook-Torrance)
- Pas de normal mapping
- Pas d'IBL (Image-Based Lighting)
La phase 6 "Post-MVP" a couvert les effets post-process (bloom, DoF, fog, MSAA) et le PBR.
On passe maintenant au **système de particules** — une nouvelle catégorie de fonctionnalité
(simulation + rendu) qui suit l'architecture Pool ≠ Driver décrite dans `ARCHI_PARTICULES.md`.
Résultat : les matériaux métalliques ne brillent pas, les surfaces rugueuses ne
s'assombrissent pas correctement, et les normales ne peuvent pas être sculptées
via texture. Le saut vers PBR est le plus grand gain visuel restant.
Les effets restants de la phase 6 (6.6 CSM, 6.7 SSAO, 6.14-6.16 Area lights / Volumetric)
seront repris **après** le système de particules (phase 7).
## Objectif
---
Remplacer le modèle Lambert par un **PBR Metalness/Roughness** complet :
- BRDF Cook-Torrance (GGX distribution + Smith visibility + Schlick Fresnel)
- Workflow Metallic/Roughness (industriel : Unreal, Unity, Blender)
- Normal mapping (tangent space, tangente dérivée — pas d'attribut tangent)
- IBL analytique (hémisphère ciel/sol, pas de cubemap)
- **Rétrocompatibilité** : les matériaux existants (metallic=0, roughness=0.5)
rendent à peu près comme avant (diffuse + léger spéculaire)
## Scope de cette étape (A)
## Décisions
| Fait | Non fait (étapes suivantes) |
|------|---------------------------|
| Struct `Particle` (64 bytes, Pod) | Driver GPU (compute + spawn) — Étape B |
| `ParticlePoolConfig` + `BlendingMode` | Driver CPU (simulation Rust) — Étape C |
| `ParticlePool` (buffer + pipeline + bind group) | Driver Manual + handle — Étape D |
| Pipeline render (billboard instancé) | Intégration Renderer (frame loop) — Étape E |
| Vertex shader (quad via vertex_index + billboard) | Presets + Example — Étape F |
| Fragment shader (texture × color) | Tests WGSL + layout — Étape G |
| Texture par défaut (disque 16×16) | |
| Méthodes `Scene::create_particle_pool` | |
| Pool inactif par défaut (zéro draw sans driver) | |
### D1 — Workflow Metallic/Roughness
> **Cette étape produit un pool qui EXISTE mais ne draw rien** (pas de driver = pas de count > 0).
> Le draw sera activé à l'étape E (intégration Renderer). On peut néanmoins tester le pipeline
> en forçant un count artificiel dans un test.
---
## Décisions (rappel de ARCHI_PARTICULES.md)
| # | Décision | Détail |
|---|----------|--------|
| D1 | Pool ≠ Driver | Le pool est la ressource GPU. Le driver est swappable. |
| D2 | 64 bytes/particule | pos(12)+pad+vel(12)+pad+life+max_life+size+size_growth+angle+angular_vel+color(16) |
| D3 | Billboard camera-facing | Quad orienté vers la caméra (axes right/up de la view matrix) |
| D4 | Quad via `@builtin(vertex_index)` | Pas de vertex buffer. 4 sommets générés en shader. |
| D5 | `draw(4, max_count)` + early-out | Le vertex shader skip les instances au-delà de `count_buffer` |
| D6 | Blend figé au pipeline | 1 mode par pool (Additive ou Alpha) |
| D7 | Depth test oui, depth write non | Transparence correcte |
| D8 | Texture par défaut : disque 16×16 | Si `texture: None` |
| D9 | Pool inactif si pas de driver | Zéro compute, zéro draw |
---
## Fichiers à créer / modifier
```
F0 = mix(vec3(0.04), base_color, metallic) // diélectrique: 4% reflexion, métal: albedo
R = roughness² (GGX alpha)
lib/src/
├── core/
│ ├── mod.rs # + pub mod particles
│ └── particles.rs # NOUVEAU : ParticlePool + ParticlePoolConfig + BlendingMode
├── resources/
│ ├── mod.rs # + re-export Particle
│ └── particle.rs # NOUVEAU : struct Particle (64 bytes, Pod)
├── shaders/
│ ├── mod.rs # + PARTICLE_BILLBOARD_SHADER
│ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main
├── scene/
│ └── scene.rs # + particle_pools: HashMap<String, Arc<ParticlePool>>
│ # + create_particle_pool()
└── prelude.rs # + re-exports
lib/tests/
└── wgsl_validate.rs # + test particle_billboard
lib/examples/
└── particles.rs # (Étape F, pas cette étape)
```
- `metallic ∈ [0, 1]` : 0 = diélectrique (dielectric), 1 = métal pur
- `roughness ∈ [0, 1]` : 0 = miroir, 1 = totalement rugueux
- Le `base_color` existant sert d'albedo (déjà présent via texture + vertex color)
- **Pas de Specular/Glossiness** (workflow obsolète)
---
### D2 — Où stocker metallic/roughness
## Détail des implémentations
Dans le **padding de `ObjectUniform`** (offset 80-87, juste après `emissive` à 64-79) :
### 1. `resources/particle.rs`
```rust
// WGSL:
struct ObjectUniform {
model: mat4x4<f32>, // 64 bytes (offset 0)
emissive: vec4<f32>, // 16 bytes (offset 64)
pbr: vec4<f32>, // 16 bytes (offset 80): (metallic, roughness, 0, 0)
// ... padding jusqu'à 256 bytes
};
```
use bytemuck::{Pod, Zeroable};
- **Aucune modification du compute shader** (il n'écrit que bytes 0-63)
- Ecrit via `queue.write_buffer` au moment du frame update (comme l'emissive)
- `Material` gagne 2 champs : `metallic: f32`, `roughness: f32`
### D3 — Nouveau point d'entrée shader `fs_pbr`
Le PBR est **plus complexe** que le Lambert actuel. Plutôt que de modifier
`fs_main` en place (risque de régression), on ajoute un **deuxième point
d'entrée fragment** `fs_pbr` dans le même module WGSL :
```
@fragment fn fs_main(...) → Lambert (existant, pour rétrocompatibilité)
@fragment fn fs_pbr(...) → PBR Cook-Torrance (nouveau)
```
La sélection est **compile-time** via le `shader_id` :
- `Material::new(format, "standard", cache)` → pipeline avec `fs_main` (Lambert)
- `Material::pbr(format, cache)` → pipeline avec `fs_pbr` (PBR)
Le vertex shader est **partagé** entre les deux (même `vs_main`).
### D4 — Normal mapping par tangente dérivée
**Pas d'attribut tangent** dans le vertex buffer (casserait tous les meshes existants).
On utilise la méthode des **dérivées ecran-space** (mipmapped derivative tangent) :
```wgsl
// Dans le fragment shader :
let dpdx = dFdx(world_pos);
let dpdy = dFdy(world_pos);
let dwdx = dFdx(uv);
let dwdy = dFdy(uv);
let tangent = normalize(dpdx * dwdy.y - dpdy * dwdx.y);
let bitangent = normalize(cross(n, tangent));
let tbn = mat3x3<f32>(tangent, bitangent, n);
```
Avantages :
- Zéro changement de format vertex
- Fonctionne avec n'importe quel mesh existant
- Moins précis qu'un tangent explicite (artefacts possibles sur UV dégénérés)
- Suffisant pour un premier PBR
Le normal map est échantillonné dans `@group(2) @binding(2)` (nouveau binding) :
```
vec3 nmap = textureSample(normal_texture, normal_sampler, uv).rgb * 2.0 - 1.0;
vec3 n_pbr = normalize(tbn * nmap);
```
Sans normal map → placeholder blanc (128,128,255) → `nmap = (0,0,1)` → `n_pbr = n` (aucun changement).
### D5 — IBL analytique (hémisphère)
Pas de cubemap pour cette étape. L'IBL est approximé par un **hémisphère 2 couleurs** :
```wgsl
// Sky/ground colors from frame.ambient (déjà présent)
let ibl_dir = n; // direction de la normale (view space ou world)
let ibl_sky = frame.ambient.rgb; // couleur "ciel"
let ibl_ground = frame.ambient.rgb * 0.3; // couleur "sol" (assombrie)
let ibl_color = mix(ibl_ground, ibl_sky, ibl_dir.y * 0.5 + 0.5);
// Specular IBL : approximation pré-filtrée par roughness
// (réalité : cubemap pré-filtrée par mip ; ici : simple interpolation)
let spec_ibl = mix(ibl_color, vec3<f32>(1.0), 0.5 * (1.0 - roughness));
```
C'est une approximation grossière mais suffisante pour :
- Donner du "remplissage" aux zones non éclairées par les lumières ponctuelles
- Faire varier le spéculaire IBL selon la roughness (mirroir = brillant, rugueux = mat)
### D6 — BRDF Cook-Torrance (GGX)
```wgsl
fn distribution_ggx(n: vec3<f32>, h: vec3<f32>, roughness: f32) -> f32 {
let a = roughness * roughness;
let a2 = a * a;
let ndh = max(dot(n, h), 0.0);
let d = ndh * ndh * (a2 - 1.0) + 1.0;
return a2 / (3.14159 * d * d);
/// 64 bytes per particle. Mirror of the WGSL `Particle` struct.
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable, Default)]
pub struct Particle {
pub pos: [f32; 3], // offset 0
pub _pad0: f32, // offset 12
pub vel: [f32; 3], // offset 16
pub _pad1: f32, // offset 28
pub life: f32, // offset 32
pub max_life: f32, // offset 36
pub size: f32, // offset 40
pub size_growth: f32, // offset 44
pub angle: f32, // offset 48
pub angular_vel: f32, // offset 52
pub color: [f32; 4], // offset 56
}
fn geometry_smith(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>, roughness: f32) -> f32 {
let a = roughness * roughness;
let kv = vec2<f32>(0.5, 0.5);
let gv = n.y / (n.y * (1.0 - kv.y) + kv.x); // note: n.y ≈ |N·V| pour hémisphère local
let kv2 = vec2<f32>(0.5, 0.5);
let gl = n.y / (n.y * (1.0 - kv2.y) + kv2.x);
return gv * gl;
}
fn fresnel_schlick(cos_theta: f32, f0: vec3<f32>) -> vec3<f32> {
return f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - cos_theta, 5.0);
}
fn brdf_pbr(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>,
base: vec3<f32>, metallic: f32, roughness: f32) -> vec3<f32> {
let h = normalize(v + l);
let f0 = mix(vec3<f32>(0.04), base, metallic);
let d = distribution_ggx(n, h, roughness);
let g = geometry_smith(n, v, l, roughness);
let f = fresnel_schlick(max(dot(h, v), 0.0), f0);
let ndl = max(dot(n, l), 0.0);
let ndv = max(dot(n, v), 0.0);
let ndh = max(dot(n, h), 0.0);
let hv = max(dot(h, v), 0.0);
// Diffuse : Lambert × (1 - metallic) × (1 - F_D90)
let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
let diffuse = kd * base / 3.14159;
// Speculaire : D × G × F / (4 × N·V × N·L)
let denom = 4.0 * ndv * ndl + 1e-4;
let specular = d * g * f / denom;
let radiance = (diffuse + specular) * base * ndl; // base = light color × intensity
return radiance;
impl Particle {
pub const SIZE: u64 = std::mem::size_of::<Self>() as u64; // must be 64
}
```
### D7 — Structure du fragment PBR
**Test** : `assert_eq!(size_of::<Particle>(), 64)`, `assert_eq!(align_of::<Particle>(), 16)`.
### 2. `core/particles.rs`
```rust
pub enum BlendingMode {
Additive,
Alpha,
}
pub struct ParticlePoolConfig {
pub max_count: u32,
pub texture: Option<String>, // ID dans scene.textures
pub blending: BlendingMode,
}
pub struct ParticlePool {
pub(crate) buffer: wgpu::Buffer,
pub(crate) pipeline: wgpu::RenderPipeline,
pub(crate) bind_group: wgpu::BindGroup,
pub(crate) sampler: wgpu::Sampler,
pub(crate) count_buffer: wgpu::Buffer,
pub max_count: u32,
pub blending: BlendingMode,
// Driver (Étape B/C/D) :
pub(crate) driver: Option<Box<dyn ParticleDriver>>,
pub(crate) active: bool,
}
```
**Construit** par `Scene::create_particle_pool` qui a accès au `device`, `queue`,
`format`, et aux textures. Le pipeline est compilé immédiatement.
### 3. `shaders/particle_billboard.wgsl`
```wgsl
// Particle billboard shader (vertex + fragment).
// Quad generated via @builtin(vertex_index) — no vertex buffer.
// Instance data read from storage buffer.
struct Particle {
pos: vec3<f32>, pad0: f32,
vel: vec3<f32>, pad1: f32,
life: f32, max_life: f32,
size: f32, size_growth: f32,
angle: f32, angular_vel: f32,
color: vec4<f32>,
}
struct CameraParams {
view: mat4x4<f32>,
proj: mat4x4<f32>,
}
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) frag_color: vec4<f32>,
@location(1) uv: vec2<f32>,
}
@group(0) @binding(0) var<uniform> camera: CameraParams;
@group(0) @binding(1) var<storage, read> particles: array<Particle>;
@group(0) @binding(2) var<uniform> count_buf: f32;
const QUAD: array<vec2<f32>, 4> = array<vec2<f32>, 4>(
vec2(-0.5, -0.5),
vec2( 0.5, -0.5),
vec2( 0.5, 0.5),
vec2(-0.5, 0.5),
);
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32,
) -> VsOut {
var out: VsOut;
if f32(ii) >= count_buf {
out.clip = vec4(0.0, 0.0, -2.0, 1.0);
out.frag_color = vec4(0.0);
out.uv = vec2(0.0);
return out;
}
let p = particles[ii];
let q = QUAD[vi];
let c = cos(p.angle);
let s = sin(p.angle);
let rot = vec2(q.x * c - q.y * s, q.x * s + q.y * c) * p.size;
let right = vec3(camera.view[0][0], camera.view[1][0], camera.view[2][0]);
let up = vec3(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
let world = p.pos + right * rot.x + up * rot.y;
out.clip = camera.proj * camera.view * vec4(world, 1.0);
out.frag_color = p.color;
out.uv = q + vec2(0.5);
return out;
}
@group(0) @binding(3) var samp: sampler;
@group(0) @binding(4) var tex: texture_2d<f32>;
@fragment
fn fs_pbr(in: VertexOutput) -> @location(0) vec4<f32> {
let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
let base = texel.rgb * in.color.rgb;
// Unlit mode (même que fs_main)
if (frame.options.x != 0u) {
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
return vec4<f32>(apply_fog(base + emissive_contrib, in.world_pos), in.color.a);
}
let metallic = object.pbr.x;
let roughness = clamp(object.pbr.y, 0.045, 1.0); // min 0.045 (évite division par 0)
// Normal mapping (derivative tangent)
let n = compute_pbr_normal(in); // inclut le normal map si présent
let v = normalize(frame.cam_pos - in.world_pos);
var color = vec3<f32>(0.0);
// IBL (hémisphère analytique)
let ibl = compute_ibl(n, roughness, base, metallic);
color += ibl;
// Lumières directionnelles
for (var i = 0u; i < frame.num_directional; i++) {
let l = normalize(frame.lights[i].position_dir.xyz);
let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a;
color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color
* compute_shadow(in.world_pos, n);
}
// Lumières ponctuelles + spots (même pattern, avec falloff)
// ...
// Emissive
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
let final_rgb = color + emissive_contrib;
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let t = textureSample(tex, samp, in.uv);
return in.frag_color * t;
}
```
### D8 — Texture normal map : nouveau binding `@group(2) @binding(2)`
### 4. Bind group layout (render)
Le `@group(2)` actuel a 2 bindings (sampler + diffuse texture). On ajoute :
```
@group(2) @binding(2) var normal_texture: texture_2d<f32>;
@group(2) @binding(3) var normal_sampler: sampler;
```
| Binding | Type | Contenu | Visibility |
|---------|------|---------|------------|
| 0 | Uniform (min 112 B) | CameraParams (view + proj) | VERTEX |
| 1 | Storage (RO) | particle_data | VERTEX |
| 2 | Uniform (min 4 B) | count_buffer | VERTEX |
| 3 | Sampler | Sampler | FRAGMENT |
| 4 | Texture (2D) | Texture particule | FRAGMENT |
- Sans normal map → placeholder (128,128,255) = normale neutre → aucun effet
- Le `Material` gagne un champ `normal_texture: Option<Arc<Texture>>`
- Le bind group group-2 est reconstruit avec la normal map (ou le placeholder)
- **Le pipeline layout est le même** pour `fs_main` et `fs_pbr` (mêmes bindings)
→ la PipelineCache peut partager le layout
### D9 — `Material::pbr()` constructor
### 5. Pipeline descriptor
```rust
impl Material {
/// Crée un matériau PBR avec metallic/roughness.
pub fn pbr(
format: wgpu::TextureFormat,
shader_id: &str, // "pbr"
metallic: f32,
roughness: f32,
cache: &mut PipelineCache,
) -> Self { ... }
/// Avec texture albedo + normal map.
pub fn pbr_textured(
format: wgpu::TextureFormat,
shader_id: &str,
metallic: f32,
roughness: f32,
albedo: Option<Arc<Texture>>,
normal_map: Option<Arc<Texture>>,
cache: &mut PipelineCache,
) -> Self { ... }
wgpu::RenderPipelineDescriptor {
vertex: wgpu::VertexStage {
module: shader,
entry_point: "vs_main",
buffers: &[], // PAS de vertex buffer
},
fragment: Some(wgpu::FragmentStage {
module: shader,
entry_point: "fs_main",
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
// Indices : pas de index buffer → on utilise draw(4, N)
// MAIS : 4 sommets sans indices = 2 triangles ? NON.
// draw(4, N) drawe 4 triangles (4 indices implicites 0,1,2,3) = 1 triangle + 1 degénéré.
// IL FAUT un index buffer ! Ou utiliser draw_indexed.
// → Voir GOTCHA ci-dessous.
..Default::default()
},
color_states: [wgpu::ColorState {
format,
alpha_blend: blend_alpha,
color_blend: blend_color,
write_mask: wgpu::ColorWrites::ALL,
}],
depth_stencil: Some(wgpu::DepthStencilState {
format: depth_format,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
..Default::default()
}),
multisample,
..
}
```
### D10 — Rétrocompatibilité
### ⚠️ GOTCHA : Topologie du quad billboard
- `Material::new()` (existant) → pipeline `fs_main` (Lambert) → **inchangé**
- `Material::pbr()` (nouveau) → pipeline `fs_pbr` (PBR) → nouveau
- Les deux pipelines coexistent dans la PipelineCache
- Les examples existants (demo, bloom, fog, dof, etc.) continuent à utiliser `Material::new()`
- **Aucune régression** : le shader `fs_main` n'est pas modifié
**Problème** : `draw(4, N)` sans index buffer drawe 4 **vertices** en `TriangleList`,
ce qui fait 4/3 = 1 triangle + 1 vertex orphelin. Ce n'est PAS un quad.
### D11 — Pipeline layout : 1 seul layout pour les 2 entry points
**Solutions** :
`fs_main` et `fs_pbr` lisent les **mêmes bindings** :
- `@group(0)`: FrameUniforms
- `@group(1)`: ObjectUniform
- `@group(2)`: sampler + diffuse + normal_sampler + normal_texture
| Option | Pro | Contre |
|--------|-----|--------|
| A : `draw(6, N)` + 6 sommets (quad = 2 tris, 6 verts) | Pas d'index buffer | 6 vertices au lieu de 4 (2 dupliqués) |
| B : Index buffer (6 indices) + `draw_indexed(6, N, 0, 0)` | 4 vertices seulement | 1 petit buffer index (24 bytes) partagé |
| C : `@builtin(vertex_index)` avec 6 values dans le const | Pas d'index buffer, pas de vertex buffer | Le const a 6 entries au lieu de 4 |
Un seul `BindGroupLayout` couvre les deux. La PipelineCache crée 2 pipelines
(même layout, entry points différents) → partage du layout = zéro overhead supplémentaire.
**Décision : Option C** — 6 entries dans le const QUAD, `draw(6, max_count)`.
### D12 — ObjectUniform : écriture du PBR data
```wgsl
// 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad
const QUAD: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
vec2(-0.5, -0.5), // 0
vec2( 0.5, -0.5), // 1
vec2( 0.5, 0.5), // 2
vec2(-0.5, -0.5), // 3
vec2( 0.5, 0.5), // 4
vec2(-0.5, 0.5), // 5
);
```
Dans `render_scene`, l'écriture de l'emissive est déjà faite par `queue.write_buffer`
à l'offset 64. On ajoute l'écriture de `pbr` à l'offset 80 :
→ `draw(6, max_count)`. Pas de vertex buffer, pas d'index buffer. Cohérent avec
le pattern fullscreen triangle du TM/bloom (qui utilise `draw(3, 1)`).
### 6. Texture par défaut (disque 16×16)
Générée en Rust au build du pool (si `config.texture == None`) :
```rust
// Étape 27 : PBR params (metallic, roughness) dans le padding de ObjectUniform.
if mat.metallic != 0.0 || mat.roughness != 0.5 {
let pbr_data: [f32; 4] = [mat.metallic, mat.roughness, 0.0, 0.0];
let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 80) as u64;
self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&pbr_data));
fn default_disc_texture() -> Vec<u8> {
let size = 16;
let mut data = vec![0u8; size * size * 4];
let center = (size as f32 - 1.0) / 2.0;
for y in 0..size {
for x in 0..size {
let dx = (x as f32 - center) / center;
let dy = (y as f32 - center) / center;
let dist = (dx * dx + dy * dy).sqrt();
let alpha = (1.0 - dist).clamp(0.0, 1.0) as u8 * 255;
let i = (y * size + x) * 4;
data[i] = 255; // R
data[i+1] = 255; // G
data[i+2] = 255; // B
data[i+3] = alpha; // A
}
}
data
}
```
Par défaut (metallic=0, roughness=0.5) → pas d'écriture → le buffer contient 0.0
(le buffer est alloué avec `COPY_DST` et initialisé à zéro) → **c'est correct** :
metallic=0 (diélectrique) et roughness=0.0...
### 7. `Scene::create_particle_pool`
Hmm, roughness=0.0 est un problème (GGX avec alpha=0 → division par zéro).
**Solution** : clamer `roughness = max(roughness, 0.045)` dans le shader (déjà prévu en D7).
Le buffer initialisé à 0 → roughness=0 → clampé à 0.045 dans le shader → OK.
```rust
impl Scene {
pub fn create_particle_pool(&mut self, id: &str, config: ParticlePoolConfig) -> Result<(), String> {
if self.particle_pools.contains_key(id) {
return Err(format!("particle pool '{}' already exists", id));
}
// Résoudre la texture
let (texture_view, sampler, is_owned) = match &config.texture {
Some(tex_id) => {
let tex = self.textures.get(tex_id)
.ok_or_else(|| format!("texture '{}' not found", tex_id))?;
(tex.view.clone(), tex.sampler.clone(), false)
}
None => {
// Créer la texture disque 16×16
let (view, sampler) = self.gpu.create_default_disc_texture();
(view, sampler, true)
}
};
// Construire le pool (buffer + pipeline + bind group)
let pool = ParticlePool::new(
&self.gpu.device,
&self.gpu.queue,
self.gpu.format,
self.gpu.depth_format,
self.gpu.msaa,
&config,
texture_view,
sampler,
);
self.particle_pools.insert(id.to_string(), Arc::new(pool));
Ok(())
}
}
```
### D13 — Example `pbr.rs`
### 8. Prelude
Scène de démonstration :
- **Sol** : plan 20×20, PBR (metallic=0, roughness=0.8) — surface matte
- **Cube métal** : metallic=1.0, roughness=0.1 — miroir chromé
- **Cube plastique** : metallic=0.0, roughness=0.4 — plastique lisse
- **Cube rouillé** : metallic=0.8, roughness=0.7 — métal rugueux
- **Sphere** : metallic=0.3, roughness=0.3 — céramique
- **Cube normal map** : avec une normal map procédurale (bump)
- 1 lumière directionnelle + 1 spot
- Clavier : `R` = reset, `1` = varier roughness, `2` = varier metallic
```rust
// Dans prelude.rs :
pub use crate::core::particles::{ParticlePoolConfig, BlendingMode};
pub use crate::resources::particle::Particle;
```
### D14 — Normal map procédurale pour l'exemple
---
Générer une texture normal map 256×256 en code (pas de fichier externe) :
- Pattern "bump" : sin(x*freq) * sin(y*freq) → normale perturbée
- Ou pattern "bricks" : normales plates avec arêtes
- Stockée dans un `wgpu::Texture` via `queue.write_texture`
## Blend states
## Étapes d'implémentation
| Mode | color_ops.src | color_ops.dst | alpha_ops.src | alpha_ops.dst |
|------|--------------|--------------|---------------|---------------|
| **Additive** | One | One | One | One |
| **Alpha** | SrcAlpha | OneMinusSrcAlpha | One | OneMinusSrcAlpha |
| # | Tâche | Fichiers |
|---|-------|----------|
| 1 | `Material` : ajouter `metallic`, `roughness`, `normal_texture` + constructors `pbr()`/`pbr_textured()` | `resources/material.rs` |
| 2 | `ObjectUniform` WGSL : ajouter `pbr: vec4<f32>` (offset 80) | `shaders/standard_shader.wgsl` |
| 3 | Écrire le BRDF Cook-Torrance (GGX + Smith + Schlick) en WGSL | `shaders/standard_shader.wgsl` |
| 4 | Écrire `fs_pbr` (IBL + boucle lumières + normal map) | `shaders/standard_shader.wgsl` |
| 5 | Normal map bindings `@group(2) @binding(2,3)` + placeholder | `shaders/standard_shader.wgsl` + `pipeline_cache.rs` |
| 6 | PipelineCache : créer pipeline `fs_pbr` (même layout, entry point différent) | `pipeline/pipeline_cache.rs` |
| 7 | Renderer : écrire `pbr` data dans ObjectUniform (offset 80) | `core/renderer.rs` |
| 8 | Bind group group-2 : inclure normal map (ou placeholder) | `resources/material.rs` |
| 9 | WGSL validation test : vérifier que `fs_pbr` parsse | `tests/wgsl_validate.rs` |
| 10 | Example `pbr.rs` : scène de démo + normal map procédurale | `examples/pbr.rs` |
| 11 | Docs : examples/README.md + docs/user/pbr.md + ROADMAP | divers |
---
## Risques et mitigations
## Tests
| Risque | Mitigation |
|--------|-----------|
| GGX avec roughness≈0 → NaN | Clamp `roughness ≥ 0.045` dans le shader |
| Dérivées ecran-space instables sur UV dégénérés (poles, seams) | Acceptable pour v1 ; tangent explicite en v2 |
| Le PBR est "trop sombre" vs Lambert | Le `base/π` dans le diffuse PBR assombrit ; compenser par lumière plus intense ou exposure |
| Normal map placeholder (128,128,255) → artefacts sur certains angles | Le mat3 TBN est orthonormalisé par `normalize` ; acceptable |
| 2 pipelines (fs_main + fs_pbr) → mémoire GPU | ~2 pipelines × ~50KB = négligeable |
### Unit tests (`particles.rs`)
| Test | Vérifie |
|------|---------|
| `particle_size_is_64` | `size_of::<Particle>() == 64` |
| `particle_align_is_16` | `align_of::<Particle>() == 16` |
| `particle_offsets` | Offsets de chaque champ |
| `pool_config_default_max_count` | Valeur raisonnable |
| `default_disc_texture_size` | 16×16×4 bytes |
| `default_disc_center_is_opaque` | Center pixel alpha = 255 |
| `default_disc_corner_is_transparent` | Corner pixel alpha = 0 |
### WGSL validation (`wgsl_validate.rs`)
| Test | Vérifie |
|------|---------|
| `particle_billboard_compiles` | Naga compile le shader |
| `particle_billboard_entry_points` | Contient `vs_main` + `fs_main` |
| `particle_billboard_no_compute` | Pas d'entry point compute (cette étape) |
---
## Vérification de non-régression
- [ ] `cargo check -p wsg-lib --all-targets` → 0 errors, 0 warnings
- [ ] `cargo test -p wsg-lib` → tous les tests existants passent (127+)
- [ ] Les examples existants (demo, pbr, bloom, etc.) compilent et fonctionnent
- [ ] Aucun changement dans `renderer.rs` (le pool n'est pas encore intégré au frame loop)
- [ ] `Scene` a un nouveau champ `particle_pools` mais il est vide par défaut → zéro coût
---
## Critères d'acceptation
- [ ] `Material::pbr(format, "pbr", metallic, roughness, cache)` compile et rend
- [ ] Un cube metallic=1, roughness=0.1 a un reflet spéculaire net (miroir)
- [ ] Un cube metallic=0, roughness=0.9 a un spéculaire large et diffus (mat)
- [ ] Un cube avec normal map montre des bumps visibles
- [ ] Les examples existants (demo, bloom, fog, dof) sont **inchangés** (fs_main)
- [ ] `cargo test --workspace` : 0 failures
- [ ] `cargo check -p wsg-lib --all-targets` : 0 warnings
1. ✅ `Particle` compile, 64 bytes, Pod, offsets corrects
2. ✅ `particle_billboard.wgsl` compile par Naga (test WGSL)
3. ✅ `ParticlePool::new` crée buffer + pipeline + bind group sans erreur
4. ✅ La texture disque 16×16 est générée correctement
5. ✅ `Scene::create_particle_pool` fonctionne (test unitaire avec mock device)
6. ✅ Le pool est inactif (pas de draw) tant qu'aucun driver n'est attaché
7. ✅ Zéro warning, tous les tests verts
8. ✅ Prelude expose les types
---
## Étape suivante (B)
Driver GPU : compute shader `particle_update.wgsl` + `GpuEmitterConfig` +
spawn CPU + dispatch + `Scene::attach_gpu_emitter`.
+23 -1
View File
@@ -57,9 +57,11 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT
---
## Phase 6 — Post-MVP ⬜
## Phase 6 — Post-MVP (effets) 🔶
> Au-delà du scope initial. Chaque item est opt-in et indépendant.
> **Note** : la phase 6 est mise en pause pendant la phase 7 (particules).
> Les items restants (6.6, 6.7, 6.14-6.16) seront repris après.
| # | Item | Impact visuel | Effort | Statut |
|---|------|:---:|:---:|:---:|
@@ -88,6 +90,26 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT
---
## Phase 7 — Système de Particules 🔄
> Nouvelle catégorie : simulation + rendu de particules (VFX).
> Architecture : **Pool ≠ Driver** (`docs/tech/ARCHI_PARTICULES.md`).
> 3 drivers possibles : GPU (compute), CPU (simulation Rust), Manual (full control).
| # | Item | Impact visuel | Effort | Statut |
|---|------|:---:|:---:|:---:|
| 7.1 | **Pool** (buffer 64B×N + pipeline billboard + texture + draw) | — | Moyen | ⬜ ← **en cours** |
| 7.2 | **Driver GPU** (compute update + spawn CPU + GpuEmitterConfig) | ⭐⭐⭐⭐ | Élevé | ⬜ |
| 7.3 | **Driver CPU** (simulation Rust + upload + custom_force) | ⭐⭐⭐ | Moyen | ⬜ |
| 7.4 | **Driver Manual** (handle direct sur le buffer) | ⭐⭐ | Faible | ⬜ |
| 7.5 | **Intégration Renderer** (frame loop, order, multi-pools) | — | Moyen | ⬜ |
| 7.6 | **Presets + Example** (fire, smoke, rain, explosion, snow, sparkles) | ⭐⭐⭐⭐ | Moyen | ⬜ |
| 7.7 | **Tests** (WGSL validate + layout + pool + drivers) | — | Faible | ⬜ |
> **Après la phase 7** : reprise des items phase 6 restants (6.6 CSM, 6.7 SSAO, 6.14-6.16).
---
## Liens
- **Prochaine étape** : [DRAFT.md](DRAFT.md) (détail de l'étape en cours, remplacée à chaque itération)
+959
View File
@@ -0,0 +1,959 @@
# Architecture du Système de Particules (GPU)
> Document de référence pour l'implémentation.
> Statut : **DESIGN FINAL** — base pour les DRAFT d'implémentation.
> Principe directeur : **Pool ≠ Driver**. La ressource GPU (pool) est séparée du mécanisme de simulation (driver).
---
## 1. Vue d'ensemble
```
┌─────────────────────────────────────────────────────────────────────────┐
│ SCÈNE │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ PARTICLE POOL (ressource GPU, créée une fois) │ │
│ │ │ │
│ │ • Buffer storage (N × 64 bytes) │ │
│ │ • Pipeline render (billboard instancé) │ │
│ │ • Texture + Sampler + Blending mode │ │
│ │ • Draw call (instanced, 1 par frame) │ │
│ │ │ │
│ │ Rôle : STOCKER + AFFICHER │ │
│ ├───────────────────────────────────────────────────────────────────┤ │
│ │ DRIVER (mécanisme de mise à jour, attaché au pool) │ │
│ │ │ │
│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐ │ │
│ │ │ GPU Emitter │ │ CPU Emitter │ │ Manual │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Compute pass │ │ write_buffer │ │ write_range │ │ │
│ │ │ (simulation │ │ (full upload │ │ (user writes │ │ │
│ │ │ en shader) │ │ par frame) │ │ directement) │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Rôle : │ │ Rôle : │ │ Rôle : │ │ │
│ │ │ SIMULER (GPU) │ │ SIMULER (CPU) │ │ CONTRÔLE TOTAL │ │ │
│ │ └───────────────┘ └───────────────┘ └───────────────────┘ │ │
│ │ │ │
│ │ Un pool a UN SEUL driver actif à la fois. │ │
│ │ Changer de driver = swap (pas de reallocation GPU). │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### Séparation des responsabilités
| Couche | Sait | Ne sait pas |
|--------|------|-------------|
| **Pool** | Comment afficher (pipeline, texture, blending, draw) | Comment les particules bougent |
| **Driver** | Comment mettre à jour le buffer (compute / upload / écriture directe) | Comment afficher |
| **User** | Quelle intention (feu, pluie, explosion, custom) | Les détails wgpu |
---
## 2. État par particule (64 bytes)
```rust
/// Miroir du struct WGSL `Particle`.
/// 64 bytes, `#[repr(C)]`, `Pod + Zeroable`.
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct Particle {
pub pos: [f32; 3], // offset 0 — position monde (xyz)
pub _pad0: f32, // offset 12
pub vel: [f32; 3], // offset 16 — vélocité (xyz)
pub _pad1: f32, // offset 28
pub life: f32, // offset 32 — vie restante (seconds)
pub max_life: f32, // offset 36 — vie initiale (pour fade normalisé)
pub size: f32, // offset 40 — taille courante (world units)
pub size_growth: f32, // offset 44 — croissance par seconde (+ = grandir, - = rétrécir)
pub angle: f32, // offset 48 — rotation 2D courante (radians)
pub angular_vel: f32, // offset 52 — vitesse angulaire (rad/s)
pub color: [f32; 4], // offset 56 — RGBA (alpha modulée par le driver)
// Total : 64 bytes
}
```
### Transform par particule
Chaque particule porte un **transform complet** :
| Composante | Stockage | Mise à jour (GPU compute) | Effet visuel |
|-----------|----------|---------------------------|--------------|
| Translation 3D | `pos` | `pos += vel * dt` | Déplacement |
| Trajectoire | `vel` | `vel += gravity * dt; vel *= (1 - drag * dt)` | Arc, chute, flottement |
| Scale 2D | `size` + `size_growth` | `size += size_growth * dt` | Croissance (fumée) / rétrécissement (feu) |
| Rotation 2D | `angle` + `angular_vel` | `angle += angular_vel * dt` | Spin (turbulence, pétale) |
| Opacité | `color.a` | `color.a = life / max_life` | Fade out progressif |
> Le RGB de `color` est fixé au spawn (CPU). Seule l'alpha est modulée par le driver.
> **V2** : color ramp (`mix(color_start, color_end, 1.0 - life/max_life)`).
### Taille du pool
| `max_count` | Buffer size | Upload CPU (si driver CPU) @60fps | Verdict |
|-------------|-------------|-----------------------------------|---------|
| 1 024 | 64 KB | 3.8 MB/s | Trivial |
| 10 000 | 640 KB | 38 MB/s | OK |
| 50 000 | 3.2 MB | 190 MB/s | Correct (PC) |
| 65 536 | 4 MB | 245 MB/s | Max recommandé pour CPU |
| 100 000 | 6.4 MB | 380 MB/s | → Préférer driver GPU |
---
## 3. Le Pool (ressource GPU)
### 3.1 Création
Le pool est créé **une fois** au setup. Il alloue :
- Le buffer storage (N × 64 bytes)
- Le pipeline render (billboard instancé)
- Le bind group layout
- La texture + sampler (si fournie)
- Le blending state
```rust
pub struct ParticlePoolConfig {
/// Nombre maximum de particules dans le pool.
pub max_count: u32,
/// Texture de la particule (ID dans scene.textures).
/// `None` = texture disque doux intégrée (16×16, alpha radiale).
pub texture: Option<String>,
/// Mode de blending.
pub blending: BlendingMode,
}
pub enum BlendingMode {
/// `src × 1 + dst × 1` — feu, sparkles, magie.
Additive,
/// `src × src.a + dst × (1 - src.a)` — fumée, neige, vapeur.
Alpha,
}
```
### 3.2 Composantes internes du pool
```rust
pub struct ParticlePool {
/// Buffer storage (N × 64 bytes). COPY_DST | STORAGE.
pub(crate) buffer: wgpu::Buffer,
/// Pipeline render (billboard instancé).
pub(crate) pipeline: wgpu::RenderPipeline,
/// Pipeline layout du render.
pub(crate) layout: wgpu::BindGroupLayout,
/// Bind group (buffer + texture + sampler + camera uniform ref).
pub(crate) bind_group: wgpu::BindGroup,
/// Sampler (Linear, ClampToEdge).
pub(crate) sampler: wgpu::Sampler,
/// Buffer uniform 4 bytes : alive_count (écrit par le driver, lu par le vertex shader).
pub(crate) count_buffer: wgpu::Buffer,
/// Taille max du pool.
pub max_count: u32,
/// Driver actuellement attaché (None = pool inactif).
pub(crate) driver: Option<Box<dyn ParticleDriver>>,
/// Mode de blending (déterminé au build du pipeline).
pub blending: BlendingMode,
}
```
### 3.3 Comportement
| État du pool | Compute | Upload CPU | Draw |
|-------------|---------|------------|------|
| Pas de driver | ❌ | ❌ | ❌ (count = 0) |
| Driver GPU actif | ✅ (1 dispatch) | Spawns seulement | ✅ |
| Driver CPU actif | ❌ | Full range alive | ✅ |
| Driver Manual actif | ❌ | Ce que l'utilisateur envoie | ✅ |
| Driver actif mais `paused = true` | ❌ | ❌ | ❌ (count = 0) |
**Coût zéro** : un pool sans driver (ou pausé) n'apparaît pas dans le render pass.
---
## 4. Les Drivers
### 4.1 Trait commun
```rust
/// Trait implémenté par chaque type de driver.
/// Le pool appelle ces méthodes dans l'ordre à chaque frame.
pub trait ParticleDriver: Send {
/// Appelé AVANT le compute (si driver GPU) ou avant le draw.
/// Le driver peut écrire dans le pool buffer (spawns, updates).
fn pre_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool, dt: f32);
/// Appelé APRÈS le compute (driver GPU uniquement).
/// Permet de mettre à jour des uniforms post-simulation.
fn post_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool);
/// Le driver veut-il qu'on fasse un compute dispatch cette frame ?
fn needs_compute(&self) -> bool;
/// Le driver veut qu'on drawe le pool ?
fn needs_draw(&self) -> bool;
}
```
### 4.2 Driver GPU (GpuEmitter)
**Rôle** : la simulation est faite par un compute shader. Le CPU ne fait que les spawns.
```
Frame loop (driver GPU) :
1. pre_compute :
a. Accumulateur : acc += rate * dt
b. n_spawn = floor(acc); acc -= n_spawn
c. Trouver n_spawn slots morts dans le pool
d. Pour chaque slot : tirer pos/vel/life/size/angle/color (random CPU)
e. queue.write_buffer(&pool.buffer, offset_morts, new_particles)
f. Écrire alive_count estimé dans count_buffer
2. Compute dispatch (le pool fait le dispatch) :
- workgroups = ceil(max_count / 64)
- Le shader intègre TOUS les slots alive
- Le shader écrit alive_count exact dans count_buffer (atomic ou 2e pass)
3. post_compute : (optionnel, ex: sync alive_count)
4. Draw (si alive_count > 0)
```
**Config** :
```rust
pub struct GpuEmitterConfig {
/// Taux d'émission (particules / seconde).
pub rate: f32,
/// Forme de spawn (où naît la particule).
pub shape: SpawnShape,
/// Pattern de vitesse (dans quelle direction).
pub velocity: VelocityPattern,
/// Plage de vitesse (pour Fixed/Cone/Sphere). Ignoré pour Box.
pub speed_range: (f32, f32),
/// Gravité (m/s²) appliquée par le compute.
pub gravity: [f32; 3],
/// Friction linéaire (0.0 = aucune, 1.0 = stop immédiat).
pub drag: f32,
/// Plage de vie (seconds).
pub life_range: (f32, f32),
/// Plage de taille initiale.
pub size_range: (f32, f32),
/// Croissance de taille par seconde (négatif = rétrécir).
pub size_growth: f32,
/// Plage de vitesse angulaire initiale (rad/s).
pub angular_vel_range: (f32, f32),
/// Couleur de base RGBA (alpha initiale = 1.0, modulée par life/max_life).
pub color: [f32; 4],
}
```
**Position de l'émetteur** : mise à jour par l'utilisateur à tout moment :
```rust
scene.set_emitter_position(pool_id, [x, y, z]);
```
Le driver lit cette position à chaque spawn.
### 4.3 Driver CPU (CpuEmitter)
**Rôle** : la simulation est faite en Rust (CPU). Le résultat est uploadé dans le pool à chaque frame. Pas de compute pass.
```
Frame loop (driver CPU) :
1. pre_compute (ici : simulation + upload) :
a. Pour chaque slot alive : intégrer (pos += vel*dt, vel += grav*dt, life -= dt, ...)
b. Tuer les particules mortes (life <= 0 → alive = 0)
c. Accumulateur de spawn : acc += rate * dt
d. Pour chaque nouveau spawn : écrire dans un slot mort
e. queue.write_buffer(&pool.buffer, 0, &all_alive_particles)
f. Écrire alive_count dans count_buffer
2. Pas de compute dispatch
3. post_compute : (rien)
4. Draw (si alive_count > 0)
```
**Config** : identique à `GpuEmitterConfig` (mêmes paramètres de simulation).
La différence est **où** l'intégration se fait.
**Avantage du CPU** : possibilité d'interactions (collision avec objets de la scène,
champs de force locaux, attracteurs). L'utilisateur peut implémenter une closure
`fn custom_force(&self, particle: &Particle, scene: &Scene) -> Vec3`.
```rust
pub struct CpuEmitterConfig {
// ... même champs que GpuEmitterConfig ...
/// Force custom (appelée par particule par frame). None = aucune.
/// Permet : collision, attracteur, champ de vent, etc.
pub custom_force: Option<Arc<dyn Fn(&Particle, &Scene) -> [f32; 3] + Send + Sync>>,
}
```
**Limite** : bandwidth. Au-delà de ~10K particules, l'upload full buffer devient coûteux.
Pour 50K+, préférer le driver GPU.
### 4.4 Driver Manual
**Rôle** : l'utilisateur contrôle **directement** le contenu du buffer. Le pool ne fait que draw.
```
Frame loop (driver Manual) :
1. L'utilisateur appelle pool.upload(queue) quand il veut
(ou le driver fait un upload automatique de la "dirty range")
2. Pas de compute dispatch
3. Draw (si alive_count > 0)
```
**API** :
```rust
/// Handle exposé à l'utilisateur pour le driver manual.
pub struct ManualPoolHandle<'a> {
pool: &'a mut ParticlePool,
queue: &'a wgpu::Queue,
}
impl ManualPoolHandle<'_> {
/// Lire une particule (retourne une copie).
pub fn get(&self, index: u32) -> Particle;
/// Écrire une particule (marque le pool dirty).
pub fn set(&mut self, index: u32, particle: Particle);
/// Écrire un range de particules.
pub fn write_range(&mut self, start: u32, count: u32, particles: &[Particle]);
/// Définir manuellement le alive_count.
pub fn set_count(&mut self, count: u32);
/// Uploader le buffer dans le GPU (à appeler avant le draw).
pub fn upload(&mut self);
}
```
**Cas d'usage** :
- Animation de particules le long d'une courbe (splines)
- Transfert de particules entre pools
- Import d'une animation de particules pré-calculée
- Debug (visualiser l'état interne)
- Effets très custom (morphing, shader-like en CPU)
---
## 5. Modèles d'émission
### 5.1 Shape de spawn
```rust
pub enum SpawnShape {
/// Position exacte de l'émetteur (point).
Point,
/// Boîte centrée sur l'émetteur. `extent` = demi-extents xyz.
Box { extent: [f32; 3] },
/// Sphère centrée sur l'émetteur.
Sphere { radius: f32 },
/// Plan perpendiculaire à `normal`, centré sur l'émetteur.
/// `size` = demi-largeur x/y dans le plan local.
Plane { normal: [f32; 3], size: [f32; 2] },
}
```
### 5.2 Pattern de vitesse
```rust
pub enum VelocityPattern {
/// Vélocité fixe : `base_dir * speed`. (balles, rayons)
Fixed,
/// Cône : direction aléatoire dans un cône de demi-angle `spread_deg` autour de `base_dir`.
Cone { spread_deg: f32 },
/// Sphère : direction aléatoire uniforme sur la sphère. (explosion)
Sphere,
/// Boîte : chaque composante tirée indépendamment dans [min_i, max_i]. (pluie)
Box { min: [f32; 3], max: [f32; 3] },
}
```
### 5.3 Direction base
La direction base est implicitement dérivée :
- Pour `Cone` / `Fixed` : l'utilisateur donne un `base_dir: [f32; 3]` dans le config
- Pour `Sphere` : pas de direction base (isotrope)
- Pour `Box` : pas de direction base (les min/max définissent tout)
```rust
// Dans GpuEmitterConfig / CpuEmitterConfig :
pub base_dir: [f32; 3], // utilisé par Fixed et Cone, ignoré par Sphere et Box
```
### 5.4 Table des presets
| Preset | Shape | Velocity | base_dir | speed | gravity | drag | life | size | growth | ang_vel | color | blending |
|--------|-------|----------|----------|-------|---------|------|------|------|--------|---------|-------|----------|
| **Explosion** | Point | Sphere | — | 2-8 | (0,-9.8,0) | 0.1 | 0.5-1.5 | 0.05-0.15 | -0.05 | ±5 | [1,0.8,0.3,1] | Additive |
| **Jet d'eau** | Point | Cone 10° | (0,1,0) | 3-5 | (0,-9.8,0) | 0.01 | 1.0-2.0 | 0.03-0.05 | 0 | 0 | [0.3,0.6,1,0.8] | Alpha |
| **Pluie** | Plan (0,1,0) 20×20 | Box | — | vy:-5, vx/z:±0.3 | (0,0,0) | 0 | 3.0-5.0 | 0.01-0.02 | 0 | 0 | [1,1,1,0.3] | Alpha |
| **Fumée** | Box [0.15,0,0.15] | Cone 45° | (0,1,0) | 0.3-1.0 | (0,0.3,0) | 0.3 | 2.0-4.0 | 0.2-0.4 | +0.3 | ±2 | [0.5,0.5,0.5,0.4] | Alpha |
| **Feu** | Point | Cone 20° | (0,1,0) | 1-3 | (0,0.5,0) | 0.2 | 0.3-0.8 | 0.15-0.3 | -0.2 | ±8 | [1,0.5,0,1] | Additive |
| **Neige** | Plan (0,1,0) 10×10 | Box | — | vy:-0.5, vx/z:±0.2 | (0,0,0) | 0 | 5-10 | 0.02-0.04 | 0 | ±3 | [1,1,1,0.8] | Alpha |
| **Sparkles** | Sphere r=0.3 | Sphere | — | 0.1-0.5 | (0,0,0) | 0.5 | 1-3 | 0.02-0.05 | -0.01 | 0 | [1,1,0.8,1] | Additive |
| **Débris** | Point | Sphere | — | 1-6 | (0,-9.8,0) | 0.02 | 1-3 | 0.03-0.08 | 0 | ±10 | [0.6,0.4,0.2,1] | Alpha |
Disponibles comme constructors : `GpuEmitterConfig::fire()`, `::smoke()`, `::rain()`, etc.
---
## 6. Buffers GPU
| Buffer | Type | Taille | Écrit par | Lu par |
|--------|------|--------|-----------|--------|
| `particle_data` | Storage (RW) | N × 64 B | Driver (spawn/update) | Compute + Render |
| `count_buffer` | Uniform (RW) | 4 B | Driver / Compute | Vertex shader (instance_count) |
| `emitter_params` | Uniform (RO) | 64 B | Driver GPU (par frame) | Compute shader |
| `camera_params` | Uniform (RO) | 160 B | Renderer (existant) | Vertex shader (view, proj) |
### `emitter_params` (uniform, 64 bytes)
```wgsl
struct EmitterParams {
dt: f32, // delta time de la frame
gravity: vec3<f32>, // accélération
drag: f32, // friction
alive_count: f32, // count courant (écrit par le compute via atomic)
pool_size: f32, // taille max du pool
_pad: vec2<f32>, // alignment
}
// Total : 4+12+4+4+4+8 = 36 → pad à 48 (align 16)
```
### Bind groups
**Compute pass (driver GPU uniquement)** :
| Group | Binding | Type | Contenu | Visibility |
|-------|---------|------|---------|------------|
| 0 | 0 | Storage RW | `particle_data` | COMPUTE |
| 0 | 1 | Uniform RO | `emitter_params` | COMPUTE |
**Render pass (toujours)** :
| Group | Binding | Type | Contenu | Visibility |
|-------|---------|------|---------|------------|
| 0 | 0 | Uniform RO | `camera_params` (view + proj) | VERTEX |
| 0 | 1 | Storage RO | `particle_data` | VERTEX |
| 0 | 2 | Uniform RO | `count_buffer` (alive_count) | VERTEX |
| 0 | 3 | Sampler | Sampler | FRAGMENT |
| 0 | 4 | Texture | Texture particule | FRAGMENT |
> **Note** : le `count_buffer` est lu par le vertex shader pour déterminer
> `instance_count` (via `@builtin(instance_index)` et un early-out si `ii >= count`).
---
## 7. Pipelines
### 7.1 Pipeline Compute (driver GPU)
```rust
wgpu::ComputePipeline {
layout: ComputePipelineLayout {
bind_group_layouts: [bgl_compute], // storage RW + params
},
// entry point : "cs_update"
}
```
Dispatch : `workgroups = ceil(max_count / 64)`, un workgroup de 64 threads.
### 7.2 Pipeline Render (billboard)
```rust
wgpu::RenderPipeline {
layout: RenderPipelineLayout {
bind_group_layouts: [bgl_render], // camera + storage RO + count + sampler + texture
},
vertex: vs_main (billboard),
fragment: fs_main (texture × color),
primitive: TriangleList,
vertex_buffer_layouts: [], // PAS de vertex buffer ! (quad généré en shader)
multisample: sample_count du contexte,
color_states: [blending mode du pool],
depth_stencil: Some(LessEqual, ALWAYS), // depth test oui, depth write non (transparence)
}
```
> **Pas de vertex buffer** : le quad billboard est généré dans le vertex shader
> via `@builtin(vertex_index)` (4 vertices) — même pattern que le fullscreen triangle
> du tone mapping, mais avec 4 sommets au lieu de 3.
### 7.3 Blend states
| Mode | color_ops | Formula |
|------|-----------|---------|
| **Additive** | src: One, dst: One | `output = src × 1 + dst × 1` |
| **Alpha** | src: SrcAlpha, dst: OneMinusSrcAlpha | `output = src × a + dst × (1-a)` |
Le blending est **figé au build du pipeline** (2 pipelines par pool si on veut les 2 modes — mais v1 : 1 mode par pool).
### 7.4 Depth
- **Depth test** : `CompareFunction::LessEqual` (les particules derrière les objets opaques sont masquées)
- **Depth write** : `STENCIL_WRITE_ONLY` → en pratique `depth_write_enabled: false` (les particules ne créent pas d'ombre depth entre elles)
- **Stencil** : `Always` (pas de stencil)
---
## 8. Shaders (WGSL)
### 8.1 Compute shader (`particle_update.wgsl`)
```wgsl
// particle_update.wgsl
// Compute pass : intègre toutes les particules alive.
struct Particle {
pos: vec3<f32>,
pad0: f32,
vel: vec3<f32>,
pad1: f32,
life: f32,
max_life: f32,
size: f32,
size_growth: f32,
angle: f32,
angular_vel: f32,
color: vec4<f32>,
}
struct EmitterParams {
dt: f32,
gravity: vec3<f32>,
drag: f32,
alive_count: f32,
pool_size: f32,
pad: vec2<f32>,
}
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@group(0) @binding(1) var<uniform> params: EmitterParams;
@compute @workgroup_size(64)
fn cs_update() {
let idx = u32(InvocationIndex);
if idx >= u32(params.pool_size) { return; }
var p = particles[idx];
// Skip les particules mortes
if p.life <= 0.0 { return; }
// Intégration (semi-implicit Euler)
p.vel += params.gravity * params.dt;
p.vel *= max(0.0, 1.0 - params.drag * params.dt);
p.pos += p.vel * params.dt;
// Vie
p.life -= params.dt;
// Size
p.size = max(0.0, p.size + p.size_growth * params.dt);
// Rotation
p.angle += p.angular_vel * params.dt;
// Fade out
p.color.a = clamp(p.life / p.max_life, 0.0, 1.0);
// Kill
if p.life <= 0.0 {
p.life = 0.0;
p.color.a = 0.0;
}
particles[idx] = p;
}
```
> **alive_count** : pour la v1, le count est géré par le CPU (le driver compte les
> spawns/kills). Le compute ne fait que l'intégration. Un atomic dans le compute
> serait plus "pur GPU" mais ajoute de la complexité sans gain visible en v1.
### 8.2 Vertex shader (billboard)
```wgsl
// particle_billboard.wgsl (vertex)
struct Particle {
pos: vec3<f32>, pad0: f32,
vel: vec3<f32>, pad1: f32,
life: f32, max_life: f32,
size: f32, size_growth: f32,
angle: f32, angular_vel: f32,
color: vec4<f32>,
}
struct CameraParams {
view: mat4x4<f32>,
proj: mat4x4<f32>,
}
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) frag_color: vec4<f32>,
@location(1) uv: vec2<f32>,
}
@group(0) @binding(0) var<uniform> camera: CameraParams;
@group(0) @binding(1) var<storage, read> particles: array<Particle>;
@group(0) @binding(2) var<uniform> count_buf: f32;
const QUAD: array<vec2<f32>, 4> = array<vec2<f32>, 4>(
vec2(-0.5, -0.5),
vec2( 0.5, -0.5),
vec2( 0.5, 0.5),
vec2(-0.5, 0.5),
);
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32,
) -> VsOut {
var out: VsOut;
// Early-out si au-delà du count alive
if f32(ii) >= count_buf {
out.clip = vec4(0.0, 0.0, -1.0, 1.0); // hors écran
out.frag_color = vec4(0.0);
out.uv = vec2(0.0);
return out;
}
let p = particles[ii];
// Quad unitaire
let q = QUAD[vi];
// Rotation 2D
let c = cos(p.angle);
let s = sin(p.angle);
let rot = vec2(q.x * c - q.y * s, q.x * s + q.y * c) * p.size;
// Billboard : axes caméra (right, up) extraits de la view matrix
let right = vec3(camera.view[0][0], camera.view[1][0], camera.view[2][0]);
let up = vec3(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
let world = p.pos + right * rot.x + up * rot.y;
out.clip = camera.proj * camera.view * vec4(world, 1.0);
out.frag_color = p.color;
out.uv = q + vec2(0.5); // 0..1
return out;
}
```
### 8.3 Fragment shader
```wgsl
// particle_billboard.wgsl (fragment)
struct FsIn {
@builtin(position) clip: vec4<f32>,
@location(0) frag_color: vec4<f32>,
@location(1) uv: vec2<f32>,
}
@group(0) @binding(3) var samp: sampler;
@group(0) @binding(4) var tex: texture_2d<f32>;
@fragment
fn fs_main(in: FsIn) -> @location(0) vec4<f32> {
let t = textureSample(tex, samp, in.uv);
return in.frag_color * t;
}
```
> Pour le blending **Additive**, le hardware fait `src × 1 + dst × 1` → le résultat
> s'additive naturally. Pour **Alpha**, `src × src.a + dst × (1-src.a)`.
> Le shader est identique dans les 2 cas — le blending est dans le pipeline.
---
## 9. Frame loop (ordre exact)
```
App::render_scene(frame) :
│
├─ 1. Shadow pass (si ombres)
│
├─ 2. Opaque pass (entities, indirect draws)
│
├─ 3. PARTICLES (nouveau)
│ │
│ ├─ Pour chaque pool actif, dans l'ordre :
│ │
│ │ a. driver.pre_compute(queue, pool, dt)
│ │ → GPU : écrit spawns + params
│ │ → CPU : simule + upload full
│ │ → Manual : upload si dirty
│ │
│ │ b. Si driver.needs_compute() :
│ │ → encoder.dispatch_workgroups(ceil(N/64))
│ │
│ │ c. driver.post_compute(queue, pool)
│ │
│ │ d. Si driver.needs_draw() :
│ │ → render_pass.set_pipeline(pool.pipeline)
│ │ → render_pass.set_bind_group(0, pool.bind_group)
│ │ → render_pass.draw(4, pool.max_count) // early-out en shader
│ │
│ └─ (fin pools)
│
├─ 4. Post-process (bloom → DoF → fog → tone mapping)
│
└─ 5. Present
```
> **Note sur `draw(4, max_count)`** : on drawe TOUS les slots du pool (max_count
> instances). Le vertex shader fait un early-out (`if ii >= count → clip hors écran`).
> C'est moins élégant qu'un indirect draw mais :
> - Évite un buffer indirect + un compute de réduction
> - Le GPU skip les instances mortes très tôt (vertex shader)
> - Pour 50K instances "mortes", le coût est ~0 (vertex shader trivial)
>
> **V2** : indirect draw avec `alive_count` exact (nécessite un buffer indirect
> écrit par le compute ou un 2e dispatch de réduction).
---
## 10. API utilisateur (résumé)
### 10.1 Setup
```rust
impl AppHandler for MyScene {
fn setup(&mut self, app: &mut wsg_lib::App) {
// 1. Créer la texture (optionnel — None = disque blanc)
app.scene.add_texture("fire_tex", fire_texture);
// 2. Créer le pool
app.scene.create_particle_pool("fire_pool", ParticlePoolConfig {
max_count: 10_000,
texture: Some("fire_tex"),
blending: BlendingMode::Additive,
}).unwrap();
// 3. Attacher un driver
app.scene.attach_gpu_emitter("fire_pool", GpuEmitterConfig::fire()).unwrap();
app.scene.set_emitter_position("fire_pool", [0.0, 0.5, 0.0]);
// Autre pool avec driver CPU
app.scene.create_particle_pool("debris_pool", ParticlePoolConfig {
max_count: 5_000,
texture: None,
blending: BlendingMode::Alpha,
}).unwrap();
app.scene.attach_cpu_emitter("debris_pool", CpuEmitterConfig {
// ...
custom_force: Some(Arc::new(|p, scene| {
// Attraction vers le centre
-p.pos.map(|c| c * 2.0)
})),
..CpuEmitterConfig::explosion()
}).unwrap();
// Pool manual
app.scene.create_particle_pool("custom_pool", ParticlePoolConfig {
max_count: 1_000,
texture: None,
blending: BlendingMode::Alpha,
}).unwrap();
app.scene.attach_manual("custom_pool").unwrap();
}
}
```
### 10.2 Per-frame
```rust
impl AppHandler for MyScene {
fn update(&mut self, app: &mut wsg_lib::App) {
// GPU emitter : rien à faire (le driver gère tout)
// CPU emitter : rien à faire non plus (le driver simule)
// Manual : l'utilisateur écrit
if let Some(handle) = app.scene.manual_handle("custom_pool") {
for i in 0..100 {
let t = (i as f32 / 100.0) * std::f32::consts::TAU;
handle.set(i, Particle {
pos: [t.cos() * 2.0, 0.0, t.sin() * 2.0],
vel: [0.0; 3],
life: 1.0, max_life: 1.0,
size: 0.1, size_growth: 0.0,
angle: t, angular_vel: 1.0,
color: [1.0, 1.0, 1.0, 1.0],
..Particle::ZERO
});
}
handle.set_count(100);
handle.upload();
}
}
}
```
### 10.3 Runtime control
```rust
// Pauser/reprendre un pool
scene.set_pool_active("fire_pool", false);
scene.set_pool_active("fire_pool", true);
// Repositionner un émetteur
scene.set_emitter_position("fire_pool", [1.0, 2.0, 3.0]);
// Burst (émettre N particules d'un coup, en plus du rate continu)
scene.emitter_burst("fire_pool", 200);
// Changer le driver (swap, pas de reallocation)
scene.detach_driver("fire_pool");
scene.attach_cpu_emitter("fire_pool", CpuEmitterConfig::smoke()).unwrap();
```
---
## 11. Méthodes Scene (nouveaux)
```rust
impl Scene {
/// Créer un pool de particules (alloue le buffer + pipeline).
/// Le pool est inactif jusqu'à l'attachement d'un driver.
pub fn create_particle_pool(&mut self, id: &str, config: ParticlePoolConfig) -> Result<(), String>;
/// Attacher un driver GPU (compute emitter) au pool.
/// Remplace tout driver précédemment attaché.
pub fn attach_gpu_emitter(&mut self, pool_id: &str, config: GpuEmitterConfig) -> Result<(), String>;
/// Attacher un driver CPU (simulation Rust + upload) au pool.
pub fn attach_cpu_emitter(&mut self, pool_id: &str, config: CpuEmitterConfig) -> Result<(), String>;
/// Attacher le mode manual (l'utilisateur écrit le buffer directement).
pub fn attach_manual(&mut self, pool_id: &str) -> Result<(), String>;
/// Détacher le driver (le pool devient inactif).
pub fn detach_driver(&mut self, pool_id: &str);
/// Définir la position d'un émetteur (driver GPU/CPU).
pub fn set_emitter_position(&mut self, pool_id: &str, pos: [f32; 3]);
/// Pauser/reprendre un pool.
pub fn set_pool_active(&mut self, pool_id: &str, active: bool);
/// Burst : émettre N particules immédiatement (en plus du rate).
pub fn emitter_burst(&mut self, pool_id: &str, count: u32);
/// Obtenir un handle pour le driver manual (None si pas en mode manual).
pub fn manual_handle(&mut self, pool_id: &str) -> Option<ManualPoolHandle<'_>>;
}
```
---
## 12. Non-régression et opt-in
| Condition | Coût |
|-----------|------|
| Aucun pool créé | **Zéro**. Pas de buffer, pas de pipeline, pas de draw. |
| Pool créé, pas de driver | Buffer alloué (N × 64 B). Pas de compute, pas d'upload, pas de draw. |
| Pool + driver, `active = false` | Idem ci-dessus. |
| Pool + driver GPU actif | 1 compute dispatch + 1 draw. CPU : spawns seulement. |
| Pool + driver CPU actif | 1 write_buffer + 1 draw. CPU : O(N) simulation. |
| Pool + driver Manual actif | 1 write_buffer (si dirty) + 1 draw. |
**Zéro impact** sur les scènes existantes sans particules.
---
## 13. Texture par défaut (si `texture: None`)
Texture **16×16** générée au build :
```
. . . . . . . .
. + + + + + + .
. + # # # # + .
. + # # # # + .
. + # # # # + .
. + # # # # + .
. + + + + + + .
. . . . . . . .
```
Disque doux (alpha radiale : 1.0 au centre → 0.0 au bord). RGBA blanc.
Utilisée par défaut si l'utilisateur ne fournit pas de texture.
---
## 14. Limitations v1
| Limitation | Justification | V2 |
|------------|---------------|-----|
| 1 driver par pool | Simplifie la gestion du buffer | Multi-drivers (rangs du pool) |
| 1 blending par pool | Pipeline figé | Multi-pipelines par pool |
| Fade alpha linéaire | Simple, couvre 90% | Color ramp (mix start→end) |
| Pas de collision particule↔objet | O(n×m) | Driver CPU + custom_force |
| Pas de collision particule↔particule | O(n²) | Spatial hash (V3) |
| `draw(4, max_count)` + early-out | Simple, pas de buffer indirect | Indirect draw (V2) |
| Spawn CPU uniquement | Le random au spawn est CPU | GPU spawn (curated random) |
| Pas de texture sheet (UV animation) | 1 UV par particule | UV offset + animation |
| Pas de sorting (alpha) | Additive = commutatif. Alpha = approximation | Back-to-front sort (V2) |
---
## 15. Questions ouvertes (à trancher par DRAFT)
| # | Question | Tendance | Impact |
|---|----------|----------|--------|
| 1 | `draw(4, max_count)` vs indirect draw | v1 : early-out en shader. v2 : indirect. | Complexité du pipeline |
| 2 | alive_count : CPU count vs atomic GPU | v1 : CPU (driver compte). Simple. | Précision du count |
| 3 | Multi-pools même texture : partager le pipeline ? | Oui (Arc<RenderPipeline> partagé) | Mémoire |
| 4 | Le pool est-il `Send` ? (multi-thread spawn) | Oui — le buffer est GPU, le driver est `Send` | Flexibilité |
| 5 | `emitter_burst` : overwrite les plus vieilles si pool saturé ? | Oui (ring buffer sémantique) | Robustesse |
| 6 | Le driver CPU fait-il un upload **full** ou **dirty range** ? | v1 : full (simple). V2 : dirty range. | Bandwidth |
| 7 | Les pools sont-ils rendus dans un seul render pass ou un par pool ? | Un seul pass, `set_pipeline` + `set_bind_group` par pool | Draw calls |
| 8 | Faut-il un `ParticlePool::alive_count()` public (pour l'UI) ? | Oui (le driver maintient le count) | Debug |
---
## 16. Estimation de complexité (par DRAFT)
| DRAFT | Contenu | Lignes estimées |
|-------|---------|-----------------|
| **Étape A** | Struct `Particle` + `ParticlePoolConfig` + enums + `ParticlePool` (buffer + pipeline + draw) | ~250 |
| **Étape B** | Compute shader + driver GPU (spawn + dispatch) | ~200 |
| **Étape C** | Driver CPU (simulation Rust + upload) + `custom_force` | ~150 |
| **Étape D** | Driver Manual + `ManualPoolHandle` | ~100 |
| **Étape E** | Intégration Renderer (frame loop, order) + `Scene` methods | ~150 |
| **Étape F** | Presets + Example `particles.rs` | ~150 |
| **Étape G** | Tests (WGSL validate + layout + pool) | ~80 |
| **Total** | | **~1 080 lines** |
Chaque étape est un DRAFT séparé, testable indépendamment.
---
## 17. Résumé des décisions
| # | Décision | Justification |
|---|----------|---------------|
| D1 | **Pool ≠ Driver** (séparation stricte) | Flexibilité, swappability, zéro waste |
| D2 | **3 drivers** : GPU, CPU, Manual | Gradient de contrôle |
| D3 | **1 driver par pool** (v1) | Simplicité. Multi-drivers = V2. |
| D4 | **64 bytes/particule** | Couvre pos/vel/life/size/angle/color. Align 16. |
| D5 | **Billboard camera-facing** (pas world-facing) | Standard pour les VFX. Plus simple. |
| D6 | **Quad via vertex_index** (pas de vertex buffer) | Cohérent avec TM/bloom. 0 buffer vertices. |
| D7 | **`draw(4, max_count)` + early-out** (v1) | Pas de buffer indirect. GPU skip les mortes. |
| D8 | **Spawn toujours CPU** (même driver GPU) | Le random + la décision "qui spawn" est CPU. Le GPU intègre. |
| D9 | **Blend figé au pipeline** (1 mode par pool) | Pas de switch de pipeline par frame. |
| D10 | **Depth test oui, depth write non** | Transparence correcte sans artefacts. |
| D11 | **Texture par défaut : disque 16×16** | Zéro config pour un résultat acceptable. |
| D12 | **Pool inactif si pas de driver** | Zéro coût. L'allocation buffer est le seul coût. |
| D13 | **Preset methods** (`GpuEmitterConfig::fire()`) | UX : 1 ligne pour un effet correct. |
| D14 | **`custom_force` (driver CPU)** | Le seul cas où CPU > GPU : interactions. |