8606aba510
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
470 lines
18 KiB
Markdown
470 lines
18 KiB
Markdown
# É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<String, Arc<ParticlePool>>
|
||
│ # + 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::<Self>() as u64;
|
||
/// Particule nulle (life = 0 → morte). `Default`.
|
||
pub const ZERO: Self = Self::default();
|
||
}
|
||
```
|
||
|
||
**Tests** : `size_of::<Particle>() == 80`, `align_of::<Particle>() == 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<String>,
|
||
/// 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<Box<dyn ParticleDriver>>,
|
||
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<f32>,
|
||
vel: vec3<f32>,
|
||
life: f32,
|
||
max_life: f32,
|
||
size: f32,
|
||
size_growth: f32,
|
||
angle: f32,
|
||
angular_vel: f32,
|
||
color: vec4<f32>,
|
||
uv_rect: vec4<f32>, // [D15]
|
||
}
|
||
|
||
struct CameraParams { // 128 B — préfixe de FrameUniforms (view + proj)
|
||
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<storage, read> compact_index: array<u32>; // [D17/D19]
|
||
|
||
// 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad — voir GOTCHA topologie.
|
||
const QUAD: array<vec2<f32>, 6> = array<vec2<f32>, 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<f32>;
|
||
|
||
@fragment
|
||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||
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<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) * 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<String, Arc<ParticlePool>>`
|
||
(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::<Particle>() == 80` [D15/D19] |
|
||
| `particle_align_is_4` | `align_of::<Particle>() == 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`.
|