From 8606aba5103e039e92c866a0ded2b31bdc3357f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Sat, 26 Sep 2026 10:49:13 +0200 Subject: [PATCH] particules: integrate decisions D15-D19 in ARCHI, rewrite DRAFT Step A ARCHI_PARTICULES.md: - D15 uv_rect, D16 single fixed layout, D17 compaction+indirect draw - D18 color_range + uv_rects + alpha_scale (spawn randomization, EmitterParams 48 B) - D19 layout audit: 80 B without padding (storage space, vec3/vec4 align 4), single shared Particle struct (no ParticleAlive prefix), compact_index 1 u32 per slot via storage binding, indirect_args 16 B - apply to sections 2/3/4/5/6/7/8/9/10/14/15/17 + new section 18 DRAFT.md (Step A) full rewrite against final decisions: - Particle 80 B no-padding, 4 pool buffers (data, compact_index, indirect_args, camera_params), 5-binding layout, empty vertex layout, draw_indirect, no early-out, SceneGpu gains queue+sample_count --- docs/DRAFT.md | 313 +++++++++++++++++++--------------- docs/tech/ARCHI_PARTICULES.md | 181 ++++++++++++-------- 2 files changed, 284 insertions(+), 210 deletions(-) diff --git a/docs/DRAFT.md b/docs/DRAFT.md index cd1e889..e0d4984 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -1,8 +1,9 @@ # É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) +> **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) --- @@ -21,63 +22,72 @@ seront repris **après** le système de particules (phase 7). | Fait | Non fait (étapes suivantes) | |------|---------------------------| -| Struct `Particle` (64 bytes, Pod) | Driver GPU (compute + spawn) — Étape B | +| Struct `Particle` (**80 B, sans padding** [D15/D19]) | Driver GPU (compute + spawn + compaction) — É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 | +| `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éthodes `Scene::create_particle_pool` | | -| Pool inactif par défaut (zéro draw sans driver) | | +| 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 = 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. +> **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 (rappel de ARCHI_PARTICULES.md) +## Décisions appliquées (rappel de ARCHI_PARTICULES.md §17/§18) | # | 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 | +| 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/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) +lib/ ├── 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 +│ └── 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 # + test particle_billboard - -lib/examples/ -└── particles.rs # (Étape F, pas cette étape) +└── 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 @@ -87,78 +97,100 @@ lib/examples/ ```rust use bytemuck::{Pod, Zeroable}; -/// 64 bytes per particle. Mirror of the WGSL `Particle` struct. +/// 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 _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 + 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 { - pub const SIZE: u64 = std::mem::size_of::() as u64; // must be 64 + /// 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(); } ``` -**Test** : `assert_eq!(size_of::(), 64)`, `assert_eq!(align_of::(), 16)`. +**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 { - Additive, + #[default] Alpha, + Additive, } pub struct ParticlePoolConfig { + /// Capacité max du pool (slots). Défaut : 1024. pub max_count: u32, - pub texture: Option, // ID dans scene.textures + /// 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(crate) count_buffer: wgpu::Buffer, pub max_count: u32, pub blending: BlendingMode, - // Driver (Étape B/C/D) : + // Driver (étapes 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. +**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). -// Quad generated via @builtin(vertex_index) — no vertex buffer. -// Instance data read from storage buffer. +// 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 { - pos: vec3, pad0: f32, - vel: vec3, pad1: f32, - life: f32, max_life: f32, - size: f32, size_growth: f32, - angle: f32, angular_vel: f32, +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 { +struct CameraParams { // 128 B — préfixe de FrameUniforms (view + proj) view: mat4x4, proj: mat4x4, } @@ -171,12 +203,15 @@ struct VsOut { @group(0) @binding(0) var camera: CameraParams; @group(0) @binding(1) var particles: array; -@group(0) @binding(2) var count_buf: f32; +@group(0) @binding(2) var compact_index: array; // [D17/D19] -const QUAD: array, 4> = array, 4>( +// 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), ); @@ -187,27 +222,25 @@ fn vs_main( ) -> 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; - } + // Pas d'early-out [D17] : instance_count vient des args indirect (exact = alive). + let slot = compact_index[ii]; + let p = particles[slot]; - let p = particles[ii]; 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 = q + vec2(0.5); + out.uv = p.uv_rect.xy + (q + vec2(0.5)) * p.uv_rect.zw; // [D15] return out; } @@ -223,13 +256,16 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { ### 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 | +| 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 @@ -238,7 +274,7 @@ wgpu::RenderPipelineDescriptor { vertex: wgpu::VertexStage { module: shader, entry_point: "vs_main", - buffers: &[], // PAS de vertex buffer + buffers: &[], // layout VIDE — quad via QUAD[vi], slot via storage binding 2 [D17/D19] }, fragment: Some(wgpu::FragmentStage { module: shader, @@ -246,30 +282,28 @@ wgpu::RenderPipelineDescriptor { }), 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, + alpha_blend: blend_alpha, // selon BlendingMode color_blend: blend_color, write_mask: wgpu::ColorWrites::ALL, }], depth_stencil: Some(wgpu::DepthStencilState { - format: depth_format, - depth_write_enabled: false, + format: DEPTH_FORMAT, // crate::pipeline::DEPTH_FORMAT (Depth32Float) + depth_write_enabled: false, // [D10] depth_compare: wgpu::CompareFunction::LessEqual, ..Default::default() }), - multisample, + 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`, @@ -280,29 +314,17 @@ ce qui fait 4/3 = 1 triangle + 1 vertex orphelin. Ce n'est PAS un quad. | 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é | +| 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, `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)`). +**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`) : +Générée en Rust au build du pool (si `config.texture == None`) [D11] : ```rust fn default_disc_texture() -> Vec { @@ -314,7 +336,7 @@ fn default_disc_texture() -> Vec { 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 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 @@ -326,6 +348,8 @@ fn default_disc_texture() -> Vec { } ``` +Format `Rgba8UnormSrgb` (convention du lib). Sampler : `MagFilter::Linear`, `AddressMode::ClampToEdge`. + ### 7. `Scene::create_particle_pool` ```rust @@ -335,25 +359,24 @@ impl Scene { return Err(format!("particle pool '{}' already exists", id)); } // Résoudre la texture - let (texture_view, sampler, is_owned) = match &config.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(), false) + (tex.view.clone(), tex.sampler.clone()) } None => { - // Créer la texture disque 16×16 - let (view, sampler) = self.gpu.create_default_disc_texture(); - (view, sampler, true) + // Créer la texture disque 16×16 par défaut [D11] + self.gpu.create_default_disc_texture() } }; - // Construire le pool (buffer + pipeline + bind group) + // Construire le pool (buffers + pipeline + bind group) + let gpu = self.gpu(); let pool = ParticlePool::new( - &self.gpu.device, - &self.gpu.queue, - self.gpu.format, - self.gpu.depth_format, - self.gpu.msaa, + &gpu.device, + &gpu.queue, + gpu.format, + gpu.sample_count, &config, texture_view, sampler, @@ -364,6 +387,9 @@ impl Scene { } ``` +Champs ajoutés à `Scene` : `particle_pools: HashMap>` +(vide par défaut → zéro coût [D12]). + ### 8. Prelude ```rust @@ -389,10 +415,12 @@ pub use crate::resources::particle::Particle; | 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 | +| `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 | @@ -404,6 +432,7 @@ pub use crate::resources::particle::Particle; | `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) | --- @@ -413,24 +442,28 @@ pub use crate::resources::particle::Particle; - [ ] `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 +- [ ] `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, 64 bytes, Pod, offsets corrects +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 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 +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` + `GpuEmitterConfig` + -spawn CPU + dispatch + `Scene::attach_gpu_emitter`. +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`. diff --git a/docs/tech/ARCHI_PARTICULES.md b/docs/tech/ARCHI_PARTICULES.md index 087d6f9..37784d7 100644 --- a/docs/tech/ARCHI_PARTICULES.md +++ b/docs/tech/ARCHI_PARTICULES.md @@ -15,7 +15,7 @@ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ PARTICLE POOL (ressource GPU, créée une fois) │ │ │ │ │ │ -│ │ • Buffer storage (N × 64 bytes) │ │ +│ │ • Buffer storage (N × 80 bytes [D15/D19]) │ │ │ │ • Pipeline render (billboard instancé) │ │ │ │ • Texture + Sampler + Blending mode │ │ │ │ • Draw call (instanced, 1 par frame) │ │ @@ -52,27 +52,25 @@ --- -## 2. État par particule (80 bytes avec `uv_rect` [D15]) +## 2. État par particule (80 bytes : `uv_rect` [D15], sans padding [D19]) ```rust -/// Miroir du struct WGSL `Particle`. -/// 80 bytes avec `uv_rect` [D15], `#[repr(C)]`, `Pod + Zeroable`. +/// Miroir du struct WGSL `Particle` (§8.1/§8.2 — un seul struct partagé compute + render, [D19]). +/// 80 bytes avec `uv_rect` [D15] : espace **storage** → vec3/vec4 align 4 → **pas de padding** [D19]. #[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) + pub vel: [f32; 3], // offset 12 — vélocité (xyz) + pub life: f32, // offset 24 — vie restante (seconds) + pub max_life: f32, // offset 28 — vie initiale (pour fade normalisé) + pub size: f32, // offset 32 — taille courante (world units) + pub size_growth: f32, // offset 36 — croissance par seconde (+ = grandir, - = rétrécir) + pub angle: f32, // offset 40 — rotation 2D courante (radians) + pub angular_vel: f32, // offset 44 — vitesse angulaire (rad/s) + pub color: [f32; 4], // offset 48 — RGBA (alpha : `alpha_scale` [D18]) pub uv_rect: [f32; 4], // offset 64 — zone UV (ox, oy, sx, sy) [D15] - // Total : 80 bytes + // Total : 80 bytes (layout identique Rust = WGSL storage, align 4) [D19] } ``` @@ -141,18 +139,24 @@ pub enum BlendingMode { ```rust pub struct ParticlePool { - /// Buffer storage (N × 64 bytes). COPY_DST | STORAGE. + /// Buffer storage (N × 80 B [D15/D19]). 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). + /// Bind group (particles + compact_index + camera_params + texture + sampler — tout est + /// possédé par le pool, [D17/D19]). 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, + /// Index compact : 1 u32 par slot [D17/D19], lu en VS par storage (binding 2). + /// STORAGE | COPY_DST. + pub(crate) compact_index: wgpu::Buffer, + /// Args indirect draw (16 B : 4 × u32, `drawIndirect`) [D17/D19]. STORAGE | COPY_DST. + pub(crate) indirect_args: wgpu::Buffer, + /// Camera params (128 B : view + proj), possédée par le pool, écrite par le renderer chaque frame. + pub(crate) camera_params: wgpu::Buffer, /// Taille max du pool. pub max_count: u32, /// Driver actuellement attaché (None = pool inactif). @@ -212,11 +216,11 @@ Frame loop (driver GPU) : 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 + f. [D17] rien ici — les args indirect sortent de la compaction (fused dans le compute, §18) 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) + - Le shader écrit `compact_index` + `indirect_args` (compaction fused, [D17]) 3. post_compute : (optionnel, ex: sync alive_count) 4. Draw (si alive_count > 0) ``` @@ -245,8 +249,15 @@ pub struct GpuEmitterConfig { 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], + /// Couleur de base : [min, max] par canal **RGB** (le canal alpha est ignoré → `alpha_scale`). + /// Chaque canal est tiré uniformément dans [min, max] au spawn [D18]. + pub color_range: ([f32; 4], [f32; 4]), + /// Palette de zones UV : chaque spawn tire un rect uniformément au hasard [D15/D18]. + /// `uv_rects_count = 1` = toute la texture (comportement d'avant D15). + pub uv_rects: [[f32; 4]; UV_RECTS_MAX], // UV_RECTS_MAX = 8 + pub uv_rects_count: u8, // ≤ UV_RECTS_MAX + /// Alpha cible (0..1, défaut 1.0) : l'intégration écrit `color.a = alpha_scale × life/max_life` [D18]. + pub alpha_scale: f32, } ``` @@ -268,13 +279,13 @@ Frame loop (driver CPU) : 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 + f. [D17] Construire `compact_index` + `indirect_args` au CPU (compaction exacte) 2. Pas de compute dispatch 3. post_compute : (rien) 4. Draw (si alive_count > 0) ``` -**Config** : identique à `GpuEmitterConfig` (mêmes paramètres de simulation). +**Config** : identique à `GpuEmitterConfig` (mêmes paramètres de simulation, y compris `color_range`/`uv_rects`/`alpha_scale` [D18] — les tirages se font en Rust au spawn). La différence est **où** l'intégration se fait. **Avantage du CPU** : possibilité d'interactions (collision avec objets de la scène, @@ -389,16 +400,18 @@ pub base_dir: [f32; 3], // utilisé par Fixed et Cone, ignoré par Sphere et Bo ### 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 | +| Preset | Shape | Velocity | base_dir | speed | gravity | drag | life | size | growth | ang_vel | color_range (RGB min→max) [D18] | α_scale [D18] | blending | +|--------|-------|----------|----------|-------|---------|------|------|------|--------|---------|---------------------------|---------|----------| +| **Explosion** | Point | Sphere | — | 2-8 | (0,-9.8,0) | 0.1 | 0.5-1.5 | 0.05-0.15 | -0.05 | ±5 | [0.8,0.3,0]→[1,0.8,0.3] | 1.0 | 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.5,0.8]→[0.5,0.8,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 | [0.7,0.8,1]→[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.3,0.3,0.3]→[0.7,0.7,0.7] | 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.3,0]→[1,0.9,0.2] | 1.0 | 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 | [0.9,0.9,1]→[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,0.8,0.4]→[1,1,1] | 1.0 | Additive | +| **Débris** | Point | Sphere | — | 1-6 | (0,-9.8,0) | 0.02 | 1-3 | 0.03-0.08 | 0 | ±10 | [0.4,0.3,0.15]→[0.8,0.6,0.3] | 1.0 | Alpha | + +Par défaut `uv_rects = [(0,0,1,1)]` (toute la texture) [D15]. Exemple d'atlas : `Neige` avec 3 flocons → `uv_rects: [(0,0,⅓,1), (⅓,0,⅓,1), (⅔,0,⅓,1)]` — chaque spawn tire un flocon [D18]. Disponibles comme constructors : `GpuEmitterConfig::fire()`, `::smoke()`, `::rain()`, etc. @@ -408,10 +421,11 @@ Disponibles comme constructors : `GpuEmitterConfig::fire()`, `::smoke()`, `::rai | 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) | +| `particle_data` | Storage (RW) | N × 80 B [D15/D19] | Driver (spawn/update) | Compute + Render | +| `compact_index` | Storage (RO) | N × u32 [D17/D19] | Compute (compaction) / Driver | Vertex shader (slot via `compact_index[ii]`) | +| `indirect_args` | Storage (RO) | 16 B [D17/D19] | Compute (compaction) / Driver | `drawIndirect` | | `emitter_params` | Uniform (RO) | 64 B | Driver GPU (par frame) | Compute shader | -| `camera_params` | Uniform (RO) | 160 B | Renderer (existant) | Vertex shader (view, proj) | +| `camera_params` | Uniform (RO) | 128 B (view + proj) | Renderer (par frame) | Vertex shader | ### `emitter_params` (uniform, 64 bytes) @@ -422,9 +436,10 @@ struct EmitterParams { drag: f32, // friction alive_count: f32, // count courant (écrit par le compute via atomic) pool_size: f32, // taille max du pool + alpha_scale: f32, // [D18] alpha cible (modulée par life/max_life dans le compute) _pad: vec2, // alignment } -// Total : 4+12+4+4+4+8 = 36 → pad à 48 (align 16) +// Total : 48 B (espace uniform, vec3 align 16) [D18] ``` ### Bind groups @@ -440,14 +455,14 @@ struct EmitterParams { | Group | Binding | Type | Contenu | Visibility | |-------|---------|------|---------|------------| -| 0 | 0 | Uniform RO | `camera_params` (view + proj) | VERTEX | +| 0 | 0 | Uniform RO | `camera_params` (view + proj, 128 B) | VERTEX | | 0 | 1 | Storage RO | `particle_data` | VERTEX | -| 0 | 2 | Uniform RO | `count_buffer` (alive_count) | VERTEX | +| 0 | 2 | Storage RO | `compact_index` [D17] | 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`). +> **Note [D17/D19]** : le slot de l'instance arrive par **storage** (`compact_index[ii]`), pas par +> attribut vertex (layout vide, pattern TM). Le count exact vient des args indirect — plus d'early-out. --- @@ -471,21 +486,21 @@ Dispatch : `workgroups = ceil(max_count / 64)`, un workgroup de 64 threads. ```rust wgpu::RenderPipeline { layout: RenderPipelineLayout { - bind_group_layouts: [bgl_render], // camera + storage RO + count + sampler + texture + bind_group_layouts: [bgl_render], // camera + particles + compact_index + sampler + texture (5 bindings) [D17] }, vertex: vs_main (billboard), fragment: fs_main (texture × color), primitive: TriangleList, - vertex_buffer_layouts: [], // PAS de vertex buffer ! (quad généré en shader) + vertex_buffer_layouts: [], // layout VIDE — quad via QUAD[vi], slot via storage binding 2 [D17/D19] 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. +> **Vertex layout vide** (comme le fullscreen du tone mapping) : le quad est généré en shader +> (`QUAD[vi]`), et le slot de l'instance arrive par **storage** (`compact_index[ii]`) — pas +> d'attribut vertex [D17/D19]. ### 7.3 Blend states @@ -512,11 +527,9 @@ Le blending est **figé au build du pipeline** (2 pipelines par pool si on veut // particle_update.wgsl // Compute pass : intègre toutes les particules alive. -struct Particle { +struct Particle { // 80 B — espace storage, vec3/vec4 align 4, pas de padding [D19] pos: vec3, - pad0: f32, vel: vec3, - pad1: f32, life: f32, max_life: f32, size: f32, @@ -527,13 +540,14 @@ struct Particle { uv_rect: vec4, // [D15] } -struct EmitterParams { +struct EmitterParams { // espace uniform : vec3 align 16 → padding conservé [D19] dt: f32, gravity: vec3, drag: f32, alive_count: f32, pool_size: f32, - pad: vec2, + alpha_scale: f32, // [D18] + _pad: vec2, // → total 48 B } @group(0) @binding(0) var particles: array; @@ -563,8 +577,8 @@ fn cs_update() { // Rotation p.angle += p.angular_vel * params.dt; - // Fade out - p.color.a = clamp(p.life / p.max_life, 0.0, 1.0); + // Fade out [D18] + p.color.a = params.alpha_scale * clamp(p.life / p.max_life, 0.0, 1.0); // Kill if p.life <= 0.0 { @@ -587,9 +601,9 @@ fn cs_update() { ```wgsl // particle_billboard.wgsl (vertex) -struct Particle { - pos: vec3, pad0: f32, - vel: vec3, pad1: f32, +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, @@ -712,7 +726,7 @@ App::render_scene(frame) : │ │ 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 + │ │ → render_pass.draw_indirect(pool.indirect_args, 0) // [D17] args écrits par le compute/driver │ │ │ └─ (fin pools) │ @@ -802,6 +816,7 @@ impl AppHandler for MyScene { size: 0.1, size_growth: 0.0, angle: t, angular_vel: 1.0, color: [1.0, 1.0, 1.0, 1.0], + uv_rect: [0.0, 0.0, 1.0, 1.0], // [D15] ..Particle::ZERO }); } @@ -874,7 +889,7 @@ impl Scene { | 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 créé, pas de driver | Buffer alloué (N × 80 B). Pas de compute, pas d'upload, pas de draw (args = 0 → no-op). | | 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. | @@ -959,9 +974,9 @@ Chaque étape est un DRAFT séparé, testable indépendamment. | 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 | **80 bytes/particule** (avec `uv_rect`, [D15]) | Couvre pos/vel/life/size/angle/color/uv. Align 16. | +| D4 | **80 bytes/particule** (avec `uv_rect` [D15] ; **confirmé sans padding** [D19]) | Couvre pos/vel/life/size/angle/color/uv. Storage align 4 — Rust = WGSL [D19]. | | D5 | **Billboard camera-facing** (pas world-facing) | Standard pour les VFX. Plus simple. | -| D6 | **Quad via vertex_index** (⚠️ [D17] ajoute un vertex buffer d'index 1 u32/vertex ; le quad reste généré en shader) | Cohérent avec TM/bloom. | +| D6 | **Quad via vertex_index** (vertex layout vide ; le slot arrive par storage `compact_index[ii]` [D17/D19]) | Cohérent avec TM/bloom. | | D7 | ~~**`draw(4, max_count)` + early-out**~~ → **supplanté par D17** (§18) | — | | 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. | @@ -972,22 +987,24 @@ Chaque étape est un DRAFT séparé, testable indépendamment. | D14 | **`custom_force` (driver CPU)** | Le seul cas où CPU > GPU : interactions. | | D15 | **UV par particule** (`uv_rect` vec4 → struct 80 B) | WebGPU : pas d'indexage dynamique de textures → multi-motifs via UV. §18 | | D16 | **Layout complet figé, pas d'opt-in par attribut** | Gain nul, explosion de variants, coupling pool/émetteur. §18 | -| D17 | **Compaction + indirect draw** (fused dans le compute) | Scale avec count actif, pas capacity. Count exact GPU. §18 | +| D17 | **Compaction + indirect draw** (fused dans le compute ; détails corrigés par D19) | Scale avec count actif, pas capacity. Count exact GPU. §18 | +| D18 | **Randomisation par particule au spawn** (`color_range` + `uv_rects` + `alpha_scale`) | Principe D16 : les plages vivent dans la config (coût GPU zéro), le résultat dans l'état. §18 | +| D19 | **Audit layout/détails** : 80 B sans padding, un seul struct partagé, `compact_index` ×1 par storage, args 16 B | L'align uniform space du draft avait fuité en storage space. §18 | --- ## 18. Décisions de la session de design (à appliquer par DRAFT) -Ces 3 décisions tranchent les questions §15 (Q1, Q2) et supplantent D4/D6/D7. +Ces 5 décisions tranchent les questions §15 (Q1, Q2), supplantent D4/D6/D7 et corrigent l'application initiale de D15/D17 (audit D19). Le reste du doc décrit la version initiale : appliquer les impacts listés ici. ### D15 — UV par particule (`uv_rect`) -- `uv_rect: vec4` (ox, oy, sx, sy en UV texture) ajouté à `Particle` → struct 64 → **80 B** (déjà appliqué dans §2, §8.1, §8.2). +- `uv_rect: vec4` (ox, oy, sx, sy en UV texture) ajouté à `Particle` → struct 64 → **80 B** (appliqué dans §2, §8.1, §8.2 — D19 a corrigé l'application initiale : padding résiduel de l'espace uniform). - VS : `out.uv = uv_rect.xy + (q + 0.5) * uv_rect.zw` (au lieu de `q + 0.5`). Défaut (0,0,1,1) = comportement d'avant. - **Pourquoi** : WebGPU interdit l'indexage dynamique de textures en uniform (1 texture = 1 bind group = 1 pipeline). La variété de motifs ne peut venir QUE des UV → multi-motifs depuis UNE texture (atlas partagé entre presets). - Statique par vie (fixé au spawn). Animation d'atlas (uv = f(t)) = **v2** (cf §14). -- Impact restant : §5.4 (`uv_rect` dans `GpuEmitterConfig`, défaut (0,0,1,1)), §10.2 (exemple manual : `uv_rect` à poser), §3.2/§6 (tailles × 1.25). +- Impact : §2/§3.2/§6/§8.1/§8.2 appliqués ; §5.2/§5.4 appliqués par D18 (la zone unique devient la palette `uv_rects`) ; §10.2 appliqué. ### D16 — Layout COMPLET et figé, PAS d'opt-in par attribut @@ -1001,11 +1018,35 @@ Le reste du doc décrit la version initiale : appliquer les impacts listés ici. ### D17 — Compaction + indirect draw (supplante D7, tranche Q1+Q2) -- Emulation d'instancing standard WebGPU : **index buffer** (`compact_index`, 1 u32 par vertex = 4 copies du slot par instance, vertex buffer layout 1 × u32 stride 4) + storage load en VS (`particles[idx]` au lieu de `particles[ii]`). -- La **compaction est FUSIONNÉE dans le compute d'intégration** (~15 lignes WGSL, pas de 3e pass) : chaque slot alive (life > 0 après intégration) fait `atomicAdd(&compact_count)` et écrit son slot 4× dans `compact_index` ; le dernier workgroup (détection par atomic global vs nb de workgroups) écrit les args indirect. -- Draw : **`drawIndirect`** (vertexCount = 4 × alive, instanceCount = alive) au lieu de `draw(4, max_count)` + early-out. Le VS n'a plus besoin du count → `count_buffer` remplacé par `indirect_args` (20 B : 5 × u32). +- Emulation d'instancing standard WebGPU : buffer `compact_index` (**1 u32 par slot** [D19], pas 4 copies) lié en **storage** (binding 2) — le VS fait `let slot = compact_index[ii]` puis `particles[slot]`. Le vertex layout reste **vide** (quad généré en shader via `QUAD[vi]`, pattern TM). +- La **compaction est FUSIONNÉE dans le compute d'intégration** (~15 lignes WGSL, pas de 3e pass) : chaque slot alive (life > 0 après intégration) fait `atomicAdd(&compact_count)` et écrit son slot **une fois** dans `compact_index` [D19] ; le dernier workgroup (détection par atomic global vs nb de workgroups) écrit les args indirect. +- Draw : **`drawIndirect`** (vertexCount = 4, instanceCount = alive → 4 × alive vertices au total) au lieu de `draw(4, max_count)` + early-out. Le VS n'a plus besoin du count → `count_buffer` remplacé par `indirect_args` (**16 B : 4 × u32**, draw non-indexé [D19]). - **Pourquoi** : la compaction scale avec le count **actif** ; le template statique (early-out) taxe la **capacity** à CHAQUE frame — scène idle avec grand pool = 0.3–1 ms/frame de VS inutile. Coût compaction : pire cas ~0.3 ms sur iGPU faible (scène active). - Conséquence : le count est **exact et GPU** (sortie de la compaction) → tranche Q2 (plus de count CPU estimé). - Par driver : **GPU** = compaction fused (ci-dessus). **CPU** = sait exactement les slots alive → build lui-même l'index (4 copies) + args au CPU. **Manual** = `set_count` remplit l'index identité (l'utilisateur pack ses alive en tête de buffer) + args. - Non-régression : pool sans driver → args indirect = 0 → `drawIndirect` no-op (§12 inchangé). -- Impact restant : §3.2/§6 (ajouter `compact_index` N × 4 × u32 + `indirect_args` 20 B), §7.2 (vertex buffer layout), §8.1 (compaction fused), §8.2 (supprimer early-out + `count_buf`, lire l'index), §9 (d = `drawIndirect`), §4.2/§4.3/§4.4 (chacun produit index + args). +- Impact : §3.2/§6/§7.2/§9 appliqués (buffers + bind group + pipeline + frame loop, détails corrigés par D19) ; §8.1 (compaction fused) + §8.2 (supprimer early-out + `count_buf`, lire l'index) + §4.2/§4.3/§4.4 (chaque driver produit index + args) → à appliquer par le DRAFT B (les corps v0 restent, notes ci-dessus). + +--- + +### D18 — Randomisation par particule au spawn : `color_range` + `uv_rects` + `alpha_scale` + +Demande : « randomiser un peu la couleur, et randomiser un peu le rect UV ». Principe général (extension de D16) : **tout** champ de l'état 80 B randomisable au spawn a sa **plage** dans la config d'émetteur (coût GPU zéro — les tirages sont CPU au spawn [D8]) et son **résultat** dans l'état (par particule). Trois paramètres ajoutés aux configs d'émetteurs : + +- `color_range: ([f32; 4], [f32; 4])` — [min, max] par canal **RGB** (le canal alpha est ignoré → `alpha_scale`). Spawn : `c = lerp(min, max, rand)` par canal. +- `uv_rects: [[f32; 4]; UV_RECTS_MAX]` + `uv_rects_count: u8` (UV_RECTS_MAX = 8) — **palette** de zones UV (généralise la zone unique de D15). Spawn : tirage uniforme d'un rect. `count = 1` → défaut D15 (toute la texture, comportement inchangé). +- `alpha_scale: f32` (0..1, défaut 1.0) — alpha cible, modulé par la vie normalisée. **Source unique de l'alpha** : l'intégration (compute GPU ou simulation CPU) réécrit à chaque frame `color.a = alpha_scale × clamp(life/max_life, 0, 1)` (remplace l'« alpha initiale » de `color`). Driver Manual : pas d'intégration → l'utilisateur écrit `color` (avec alpha) directement, `alpha_scale` est inopérant. + +`EmitterParams` (uniform lu par le compute) gagne `alpha_scale` → struct **48 B** (toujours dans le buffer 64 B déclaré). + +Impact : §4.2/§4.3 (configs — appliqué), §5.4 (presets : colonne `color` → `color_range` + `α_scale` — appliqué), §6/§8.1 (EmitterParams + formule de fade — appliqué), §10.2 (manual : pas de config, écriture directe — inchangé). **Pas dans l'étape A du DRAFT** (étapes B/C/D). + +### D19 — Audit layout/détails (préparation DRAFT) : 80 B sans padding, un seul struct partagé, `compact_index` ×1 + +Audit fait en écrivant le DRAFT. Trois corrections, toutes dans le même sens : **l'alignement de l'espace uniform du draft avait fuité dans l'espace storage**. + +1. **Pas de padding dans les structs particule.** `Particle` vit en **espace storage**, où `vec3` a **align 4** (l'espace uniform a align 16 — d'où venaient `pad0`/`pad1` du draft). Le Rust `[f32; 3]` est aussi align 4 → layout naturellement identique dans les deux langages : pos@0, vel@12, life@24, max_life@28, size@32, size_growth@36, angle@40, angular_vel@44, color@48, uv_rect@64 → **80 B exactement, sans padding**. L'état précédent (avec padding) faisait en réalité **88 B** — en contradiction avec la mention « 80 bytes » (le commentaire d'offset 64 de `uv_rect` incohérent avec color@56+16=72). Appliqué : §2, §8.1, §8.2. +2. **Un seul struct `Particle` partagé** entre les entry points compute et render : le double struct `ParticleAlive` du draft (64 B, pad) était un **préfixe incompatible** du `Particle` padé (life@32 vs life@24) → le cull aurait lu les mauvais offsets. Supprimé : un storage load ne dépend pas de la taille du struct déclaré, la vue « sous-ensemble » n'a aucun gain. (`EmitterParams` **conserve** son padding — espace uniform, vec3 align 16.) +3. **`compact_index` : 1 u32 par slot (pas 4 copies), lu par **storage binding** (pas d'attribut vertex).** Le buffer est lié en storage (binding 2) et le VS fait `compact_index[ii]` — le vertex layout du pipeline reste vide (pattern TM). La compaction écrit **un** u32 par particule alive (pas quatre). `indirect_args` = **16 B** (4 × u32, `drawIndirect` non-indexé) et non 20 B. Usage du buffer : STORAGE | COPY_DST (pas de VERTEX). Appliqué : §3.2, §6, §7.2, §17 (D6/D17), §18 (D17). + +Règle future : ajouter un champ à `Particle` → alignement storage (vec3/vec4 = align 4, pas de padding) + mettre à jour les deux miroirs (Rust §2 + WGSL §8) + les tests de layout (DRAFT A).