# É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 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`. 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). --- ## Scope de cette étape (A) | 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) | | > **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 ``` 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> │ # + create_particle_pool() └── prelude.rs # + re-exports lib/tests/ └── wgsl_validate.rs # + test particle_billboard lib/examples/ └── particles.rs # (Étape F, pas cette étape) ``` --- ## Détail des implémentations ### 1. `resources/particle.rs` ```rust use bytemuck::{Pod, Zeroable}; /// 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 } impl Particle { pub const SIZE: u64 = std::mem::size_of::() as u64; // must be 64 } ``` **Test** : `assert_eq!(size_of::(), 64)`, `assert_eq!(align_of::(), 16)`. ### 2. `core/particles.rs` ```rust pub enum BlendingMode { Additive, Alpha, } pub struct ParticlePoolConfig { pub max_count: u32, pub texture: Option, // 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>, 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, pad0: f32, vel: vec3, pad1: f32, life: f32, max_life: f32, size: f32, size_growth: f32, angle: f32, angular_vel: f32, color: vec4, } struct CameraParams { view: mat4x4, proj: mat4x4, } struct VsOut { @builtin(position) clip: vec4, @location(0) frag_color: vec4, @location(1) uv: vec2, } @group(0) @binding(0) var camera: CameraParams; @group(0) @binding(1) var particles: array; @group(0) @binding(2) var count_buf: f32; const QUAD: array, 4> = array, 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; @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { let t = textureSample(tex, samp, in.uv); return in.frag_color * t; } ``` ### 4. Bind group layout (render) | 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 | ### 5. Pipeline descriptor ```rust 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, .. } ``` ### ⚠️ GOTCHA : Topologie du quad billboard **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. **Solutions** : | 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 | **Décision : Option C** — 6 entries dans le const QUAD, `draw(6, max_count)`. ```wgsl // 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad const QUAD: array, 6> = array, 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 ); ``` → `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 fn default_disc_texture() -> Vec { 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 } ``` ### 7. `Scene::create_particle_pool` ```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(()) } } ``` ### 8. Prelude ```rust // Dans prelude.rs : pub use crate::core::particles::{ParticlePoolConfig, BlendingMode}; pub use crate::resources::particle::Particle; ``` --- ## Blend states | Mode | color_ops.src | color_ops.dst | alpha_ops.src | alpha_ops.dst | |------|--------------|--------------|---------------|---------------| | **Additive** | One | One | One | One | | **Alpha** | SrcAlpha | OneMinusSrcAlpha | One | OneMinusSrcAlpha | --- ## Tests ### Unit tests (`particles.rs`) | Test | Vérifie | |------|---------| | `particle_size_is_64` | `size_of::() == 64` | | `particle_align_is_16` | `align_of::() == 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 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`.