# Étape 28 — Système de Particules : Étape A (Pool) > **Objectif** : Créer l'infrastructure GPU du pool de particules (buffers + pipeline render + > bind group), **sans driver**. 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.2, §6, §7.2, §8.2, §12, §13, §18) --- ## 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` (**80 B, sans padding** [D15/D19]) | Driver GPU (compute + spawn + compaction) — Étape B | | `ParticlePoolConfig` + `BlendingMode` | Driver CPU (simulation Rust) — Étape C | | `ParticlePool` (4 buffers + pipeline + bind group) | Driver Manual + handle — Étape D | | Pipeline render (billboard instancé, layout vertex vide) | Intégration Renderer (frame loop, `draw_indirect`) — Étape E | | Vertex shader (quad via vertex_index + `compact_index[ii]` [D17/D19]) | Presets + Example — Étape F | | Fragment shader (texture × color, UV via `uv_rect` [D15]) | Tests WGSL compute — Étape B | | Texture par défaut (disque 16×16) | | | Méthode `Scene::create_particle_pool` (+ champs `SceneGpu`) | | | Pool inactif par défaut (args indirect = 0 → no-op) | | > **Cette étape produit un pool qui EXISTE mais ne draw rien** (pas de driver = > `indirect_args` à zéro = `drawIndirect` no-op, §12 ARCHI). Le draw sera activé à l'étape E > (intégration Renderer). On peut néanmoins tester le pipeline en forçant des args > artificiels dans un test. --- ## Décisions appliquées (rappel de ARCHI_PARTICULES.md §17/§18) | # | Décision | Détail | |---|----------|--------| | D4 | **80 bytes/particule, sans padding** [D15/D19] | pos@0, vel@12, life@24, max_life@28, size@32, size_growth@36, angle@40, angular_vel@44, color@48, uv_rect@64. Espace **storage** : vec3/vec4 align 4 → layout Rust = WGSL identique. | | D5 | Billboard camera-facing | Quad orienté vers la caméra (axes right/up de la view matrix) | | D6 | Quad via `@builtin(vertex_index)`, **layout vertex vide** [D17/D19] | Pas de vertex buffer. Le slot arrive par **storage** (`compact_index[ii]`), pas par attribut. | | D9 | Blend figé au pipeline | 1 mode par pool (Additive ou Alpha) | | D10 | Depth test oui, depth write non | Transparence correcte | | D11 | Texture par défaut : disque 16×16 | Si `texture: None` | | D12 | Pool inactif si pas de driver | Zéro compute ; `indirect_args` = 0 → draw no-op | | D15 | UV par particule (`uv_rect`) | `uv = uv_rect.xy + (q + 0.5) * uv_rect.zw` | | D17 | Compaction + indirect draw | Buffers `compact_index` (N × u32) + `indirect_args` (16 B) créés ici ; la compaction elle-même est dans le compute de l'étape B. | | D19 | Audit layout | Un seul struct `Particle` partagé (pas de `ParticleAlive`), 1 u32 par slot dans `compact_index`, args 16 B. | --- ## Fichiers à créer / modifier ``` lib/ ├── shaders/ │ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main (pas de compute) └── src/ ├── utils/ │ └── conf.rs # + pub const PARTICLE_BILLBOARD_SHADER (include_str!, pattern existant) ├── resources/ │ ├── mod.rs # + pub mod particle + re-export │ └── particle.rs # NOUVEAU : struct Particle (80 B, Pod, sans padding) ├── core/ │ ├── mod.rs # + pub mod particles │ └── particles.rs # NOUVEAU : ParticlePool + ParticlePoolConfig + BlendingMode ├── scene/ │ └── scene.rs # + SceneGpu { queue, sample_count } │ # + particle_pools: HashMap> │ # + create_particle_pool() └── prelude.rs # + re-exports lib/tests/ └── wgsl_validate.rs # + tests particle_billboard ``` > **Changement `SceneGpu`** : le struct actuel ne garde que `device`/`format`/`cache` > (le `queue` et le `sample_count` sont reçus par `init_gpu` puis jetés). Le pool a besoin > des deux pour créer son pipeline au moment du `create_particle_pool` → ajouter les deux > champs à `SceneGpu` (stokés au lieu d'être jetés). Aucun autre impact : `init_gpu` garde > sa signature. Le depth format vient de la constante `crate::pipeline::DEPTH_FORMAT` > (pas de champ à ajouter). --- ## Détail des implémentations ### 1. `resources/particle.rs` ```rust use bytemuck::{Pod, Zeroable}; /// 80 bytes per particle. Mirror of the WGSL `Particle` struct (ARCHI §2 / §8). /// Layout **sans padding** : espace storage (vec3/vec4 align 4) → Rust = WGSL identique [D19]. #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable, Default)] pub struct Particle { pub pos: [f32; 3], // offset 0 pub vel: [f32; 3], // offset 12 pub life: f32, // offset 24 pub max_life: f32, // offset 28 pub size: f32, // offset 32 pub size_growth: f32, // offset 36 pub angle: f32, // offset 40 pub angular_vel: f32, // offset 44 pub color: [f32; 4], // offset 48 pub uv_rect: [f32; 4], // offset 64 — zone UV (ox, oy, sx, sy) [D15] } impl Particle { /// Taille d'un élément du buffer storage : **80 bytes** (doit rester stable — testé). pub const SIZE: u64 = std::mem::size_of::() as u64; /// Particule nulle (life = 0 → morte). `Default`. pub const ZERO: Self = Self::default(); } ``` **Tests** : `size_of::() == 80`, `align_of::() == 4`, offsets de chaque champ (0/12/24/28/32/36/40/44/48/64), `ZERO.life == 0.0`. ### 2. `core/particles.rs` ```rust #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum BlendingMode { #[default] Alpha, Additive, } pub struct ParticlePoolConfig { /// Capacité max du pool (slots). Défaut : 1024. pub max_count: u32, /// ID d'une texture dans `scene.textures`. `None` → disque 16×16 par défaut [D11]. pub texture: Option, /// Mode de blending figé au pipeline [D9]. pub blending: BlendingMode, } pub struct ParticlePool { /// État des particules : N × 80 B. STORAGE | COPY_DST. Zéro initialisé (toutes mortes). pub(crate) buffer: wgpu::Buffer, /// Index compact : N × u32, 1 par slot [D17/D19]. STORAGE | COPY_DST. pub(crate) compact_index: wgpu::Buffer, /// Args indirect draw : 16 B (4 × u32) [D17/D19]. STORAGE | COPY_DST. Zéro initialisé. pub(crate) indirect_args: wgpu::Buffer, /// Camera params : 128 B (view + proj). UNIFORM | COPY_DST. /// Possédée par le pool ; écrite par le Renderer à chaque frame (étape E). pub(crate) camera_params: wgpu::Buffer, pub(crate) pipeline: wgpu::RenderPipeline, pub(crate) layout: wgpu::BindGroupLayout, /// Bind group construit une fois à la création (tout est possédé par le pool [D17/D19]). pub(crate) bind_group: wgpu::BindGroup, pub(crate) sampler: wgpu::Sampler, pub max_count: u32, pub blending: BlendingMode, // Driver (étapes B/C/D) : pub(crate) driver: Option>, pub(crate) active: bool, } ``` **Construit** par `Scene::create_particle_pool` (device + queue + format + textures). Le pipeline est compilé immédiatement. `ParticleDriver` (trait) est déclaré ici mais ses implémentations arrivent aux étapes B/C/D — le champ `driver` reste `None` à cette étape. ### 3. `shaders/particle_billboard.wgsl` ```wgsl // Particle billboard shader (vertex + fragment). Étape A — pas de compute. // Layout vertex VIDE : quad généré en shader, slot par storage [D17/D19]. struct Particle { // 80 B — espace storage, vec3/vec4 align 4, pas de padding [D19] pos: vec3, vel: vec3, life: f32, max_life: f32, size: f32, size_growth: f32, angle: f32, angular_vel: f32, color: vec4, uv_rect: vec4, // [D15] } struct CameraParams { // 128 B — préfixe de FrameUniforms (view + proj) 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 compact_index: array; // [D17/D19] // 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad — voir GOTCHA topologie. const QUAD: array, 6> = array, 6>( vec2(-0.5, -0.5), vec2( 0.5, -0.5), 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; // Pas d'early-out [D17] : instance_count vient des args indirect (exact = alive). let slot = compact_index[ii]; let p = particles[slot]; let q = QUAD[vi]; // Rotation 2D dans le plan du billboard 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; // Axes camera-facing (colonne/ligne 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 = p.uv_rect.xy + (q + vec2(0.5)) * p.uv_rect.zw; // [D15] 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) | Group | Binding | Type | Contenu | Visibility | |-------|---------|------|---------|------------| | 0 | 0 | Uniform RO | `camera_params` (view + proj, 128 B) | VERTEX | | 0 | 1 | Storage RO | `particle_data` | VERTEX | | 0 | 2 | Storage RO | `compact_index` [D17] | VERTEX | | 0 | 3 | Sampler | Sampler | FRAGMENT | | 0 | 4 | Texture | Texture particule | FRAGMENT | > Le slot de l'instance arrive par **storage** (`compact_index[ii]`), pas par attribut vertex > (layout vide, pattern TM) [D17/D19]. Le count exact vient des args indirect — pas d'early-out. ### 5. Pipeline descriptor ```rust wgpu::RenderPipelineDescriptor { vertex: wgpu::VertexStage { module: shader, entry_point: "vs_main", buffers: &[], // layout VIDE — quad via QUAD[vi], slot via storage binding 2 [D17/D19] }, fragment: Some(wgpu::FragmentStage { module: shader, entry_point: "fs_main", }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, color_states: [wgpu::ColorState { format, alpha_blend: blend_alpha, // selon BlendingMode color_blend: blend_color, write_mask: wgpu::ColorWrites::ALL, }], depth_stencil: Some(wgpu::DepthStencilState { format: DEPTH_FORMAT, // crate::pipeline::DEPTH_FORMAT (Depth32Float) depth_write_enabled: false, // [D10] depth_compare: wgpu::CompareFunction::LessEqual, ..Default::default() }), multisample: wgpu::MultisampleState { count: sample_count, ..Default::default() }, .. } ``` Draw (étape E) : `render_pass.draw_indirect(&pool.indirect_args, 0)` — vertexCount = 6 (dans les args), instanceCount = alive [D17]. ### ⚠️ 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` | 4 vertices seulement | 1 buffer index de plus à gérer | | 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 (ci-dessus). Pas de vertex buffer, pas d'index buffer. Cohérent avec le pattern fullscreen triangle du TM/bloom (`draw(3, 1)`). Avec l'indirect draw [D17], les args portent `vertex_count = 6, instance_count = alive`. ### 6. Texture par défaut (disque 16×16) Générée en Rust au build du pool (si `config.texture == None`) [D11] : ```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) * 255.0) as u8; 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 } ``` Format `Rgba8UnormSrgb` (convention du lib). Sampler : `MagFilter::Linear`, `AddressMode::ClampToEdge`. ### 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) = 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()) } None => { // Créer la texture disque 16×16 par défaut [D11] self.gpu.create_default_disc_texture() } }; // Construire le pool (buffers + pipeline + bind group) let gpu = self.gpu(); let pool = ParticlePool::new( &gpu.device, &gpu.queue, gpu.format, gpu.sample_count, &config, texture_view, sampler, ); self.particle_pools.insert(id.to_string(), Arc::new(pool)); Ok(()) } } ``` Champs ajoutés à `Scene` : `particle_pools: HashMap>` (vide par défaut → zéro coût [D12]). ### 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_80` | `size_of::() == 80` [D15/D19] | | `particle_align_is_4` | `align_of::() == 4` (storage, pas de padding) [D19] | | `particle_offsets` | Offsets 0/12/24/28/32/36/40/44/48/64 de chaque champ | | `particle_zero_is_dead` | `Particle::ZERO.life == 0.0` | | `pool_config_default_max_count` | Valeur raisonnable (1024) | | `pool_buffers_sizes` | particle_data = N×80, compact_index = N×4, indirect_args = 16, camera_params = 128 | | `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) | | `particle_billboard_layout_empty` | Le pipeline se construit avec `buffers: &[]` (layout vide) | --- ## 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` vide par défaut → zéro coût [D12] - [ ] `SceneGpu` gagne 2 champs (`queue`, `sample_count`) — `init_gpu` inchangé --- ## Critères d'acceptation 1. ✅ `Particle` compile : **80 bytes**, align 4, Pod, offsets corrects (sans padding) 2. ✅ `particle_billboard.wgsl` compile par Naga (test WGSL) 3. ✅ `ParticlePool::new` crée les 4 buffers + pipeline + bind group sans erreur 4. ✅ `indirect_args` initialisée à zéro → un `draw_indirect` forcé est un no-op 5. ✅ La texture disque 16×16 est générée correctement 6. ✅ `Scene::create_particle_pool` fonctionne (test unitaire avec device réel) 7. ✅ Le pool est inactif (pas de driver, args = 0) tant qu'aucun driver n'est attaché 8. ✅ Zéro warning, tous les tests verts 9. ✅ Prelude expose les types --- ## Étape suivante (B) Driver GPU : compute shader `particle_update.wgsl` (intégration + **compaction fused** [§18 D17] : `compact_index` + `indirect_args` écrits par le compute) + `GpuEmitterConfig` (**`color_range` + `uv_rects` + `alpha_scale`** [D18]) + spawn CPU + dispatch + `Scene::attach_gpu_emitter`.