Creates the GPU resource layer for particles per ARCHI §3.2/§6, without simulation: the pool owns all buffers/pipeline/bind group but draws nothing (indirect args zeroed → no-op) until a driver is attached (Étape B). New: - resources/particle.rs: Particle (80 B, #[repr(C)], no padding — D19), SIZE/ZERO consts + offset/layout unit tests - shaders/particle_billboard.wgsl: camera-facing billboard, empty vertex layout (quad via vertex_index), instance slot via storage binding compact_index (D17), uv_rect atlas support (D15/D18) - core/particles.rs: ParticlePoolConfig, BlendingMode, ParticleDriver trait (D3), ParticlePool (4 buffers + pipeline + bind group), default disc texture (D11), unit tests Wired: - Scene: SceneGpu keeps queue/sample_count; particle_pools registry + create_particle_pool() (default disc when no texture given) - utils::conf PARTICLE_BILLBOARD_SHADER, module re-exports, prelude - tests/wgsl_validate.rs: particle billboard naga validation (2 entry points) Docs: DRAFT call-site/tree synced with the final code; AGENTS.md test count (138) + wgpu 30 API drift gotcha (contents/DeviceExt/ALPHA_BLENDING, DepthStencilState no Default, NonZero min_binding_size, const Zeroable). cargo test -p wsg-lib: 138 pass (121 lib + 10 wgsl + 7), 0 warnings.
18 KiB
É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 =drawIndirectno-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/
└── src/
├── shaders/
│ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main (pas de compute)
├── 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 quedevice/format/cache(lequeueet lesample_countsont reçus parinit_gpupuis jetés). Le pool a besoin des deux pour créer son pipeline au moment ducreate_particle_pool→ ajouter les deux champs àSceneGpu(stokés au lieu d'être jetés). Aucun autre impact :init_gpugarde sa signature. Le depth format vient de la constantecrate::pipeline::DEPTH_FORMAT(pas de champ à ajouter).
Détail des implémentations
1. resources/particle.rs
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
#[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
// 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
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] :
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
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.as_ref(),
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
// 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 warningscargo 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) Scenea un nouveau champparticle_poolsvide par défaut → zéro coût [D12]SceneGpugagne 2 champs (queue,sample_count) —init_gpuinchangé
Critères d'acceptation
- ✅
Particlecompile : 80 bytes, align 4, Pod, offsets corrects (sans padding) - ✅
particle_billboard.wgslcompile par Naga (test WGSL) - ✅
ParticlePool::newcrée les 4 buffers + pipeline + bind group sans erreur - ✅
indirect_argsinitialisée à zéro → undraw_indirectforcé est un no-op - ✅ La texture disque 16×16 est générée correctement
- ✅
Scene::create_particle_poolfonctionne (test unitaire avec device réel) - ✅ Le pool est inactif (pas de driver, args = 0) tant qu'aucun driver n'est attaché
- ✅ Zéro warning, tous les tests verts
- ✅ 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.