14 KiB
Étape 28 — Système de Particules : Étape A (Pool)
Objectif : Créer l'infrastructure GPU du pool de particules (buffer + pipeline render + draw). C'est la brique de base sur laquelle les drivers (GPU/CPU/Manual) seront construits. Référence :
docs/tech/ARCHI_PARTICULES.md(§2, §3, §6, §7, §8.2, §8.3, §12, §13)
Contexte
La phase 6 "Post-MVP" a couvert les effets post-process (bloom, DoF, fog, MSAA) et le PBR.
On passe maintenant au système de particules — une nouvelle catégorie de fonctionnalité
(simulation + rendu) qui suit l'architecture Pool ≠ Driver décrite dans ARCHI_PARTICULES.md.
Les effets restants de la phase 6 (6.6 CSM, 6.7 SSAO, 6.14-6.16 Area lights / Volumetric) seront repris après le système de particules (phase 7).
Scope de cette étape (A)
| Fait | Non fait (étapes suivantes) |
|---|---|
Struct Particle (64 bytes, Pod) |
Driver GPU (compute + spawn) — Étape B |
ParticlePoolConfig + BlendingMode |
Driver CPU (simulation Rust) — Étape C |
ParticlePool (buffer + pipeline + bind group) |
Driver Manual + handle — Étape D |
| Pipeline render (billboard instancé) | Intégration Renderer (frame loop) — Étape E |
| Vertex shader (quad via vertex_index + billboard) | Presets + Example — Étape F |
| Fragment shader (texture × color) | Tests WGSL + layout — Étape G |
| Texture par défaut (disque 16×16) | |
Méthodes Scene::create_particle_pool |
|
| Pool inactif par défaut (zéro draw sans driver) |
Cette étape produit un pool qui EXISTE mais ne draw rien (pas de driver = pas de count > 0). Le draw sera activé à l'étape E (intégration Renderer). On peut néanmoins tester le pipeline en forçant un count artificiel dans un test.
Décisions (rappel de ARCHI_PARTICULES.md)
| # | Décision | Détail |
|---|---|---|
| D1 | Pool ≠ Driver | Le pool est la ressource GPU. Le driver est swappable. |
| D2 | 64 bytes/particule | pos(12)+pad+vel(12)+pad+life+max_life+size+size_growth+angle+angular_vel+color(16) |
| D3 | Billboard camera-facing | Quad orienté vers la caméra (axes right/up de la view matrix) |
| D4 | Quad via @builtin(vertex_index) |
Pas de vertex buffer. 4 sommets générés en shader. |
| D5 | draw(4, max_count) + early-out |
Le vertex shader skip les instances au-delà de count_buffer |
| D6 | Blend figé au pipeline | 1 mode par pool (Additive ou Alpha) |
| D7 | Depth test oui, depth write non | Transparence correcte |
| D8 | Texture par défaut : disque 16×16 | Si texture: None |
| D9 | Pool inactif si pas de driver | Zéro compute, zéro draw |
Fichiers à créer / modifier
lib/src/
├── core/
│ ├── mod.rs # + pub mod particles
│ └── particles.rs # NOUVEAU : ParticlePool + ParticlePoolConfig + BlendingMode
├── resources/
│ ├── mod.rs # + re-export Particle
│ └── particle.rs # NOUVEAU : struct Particle (64 bytes, Pod)
├── shaders/
│ ├── mod.rs # + PARTICLE_BILLBOARD_SHADER
│ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main
├── scene/
│ └── scene.rs # + particle_pools: HashMap<String, Arc<ParticlePool>>
│ # + create_particle_pool()
└── prelude.rs # + re-exports
lib/tests/
└── wgsl_validate.rs # + test particle_billboard
lib/examples/
└── particles.rs # (Étape F, pas cette étape)
Détail des implémentations
1. resources/particle.rs
use bytemuck::{Pod, Zeroable};
/// 64 bytes per particle. Mirror of the WGSL `Particle` struct.
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable, Default)]
pub struct Particle {
pub pos: [f32; 3], // offset 0
pub _pad0: f32, // offset 12
pub vel: [f32; 3], // offset 16
pub _pad1: f32, // offset 28
pub life: f32, // offset 32
pub max_life: f32, // offset 36
pub size: f32, // offset 40
pub size_growth: f32, // offset 44
pub angle: f32, // offset 48
pub angular_vel: f32, // offset 52
pub color: [f32; 4], // offset 56
}
impl Particle {
pub const SIZE: u64 = std::mem::size_of::<Self>() as u64; // must be 64
}
Test : assert_eq!(size_of::<Particle>(), 64), assert_eq!(align_of::<Particle>(), 16).
2. core/particles.rs
pub enum BlendingMode {
Additive,
Alpha,
}
pub struct ParticlePoolConfig {
pub max_count: u32,
pub texture: Option<String>, // ID dans scene.textures
pub blending: BlendingMode,
}
pub struct ParticlePool {
pub(crate) buffer: wgpu::Buffer,
pub(crate) pipeline: wgpu::RenderPipeline,
pub(crate) bind_group: wgpu::BindGroup,
pub(crate) sampler: wgpu::Sampler,
pub(crate) count_buffer: wgpu::Buffer,
pub max_count: u32,
pub blending: BlendingMode,
// Driver (Étape B/C/D) :
pub(crate) driver: Option<Box<dyn ParticleDriver>>,
pub(crate) active: bool,
}
Construit par Scene::create_particle_pool qui a accès au device, queue,
format, et aux textures. Le pipeline est compilé immédiatement.
3. shaders/particle_billboard.wgsl
// Particle billboard shader (vertex + fragment).
// Quad generated via @builtin(vertex_index) — no vertex buffer.
// Instance data read from storage buffer.
struct Particle {
pos: vec3<f32>, pad0: f32,
vel: vec3<f32>, pad1: f32,
life: f32, max_life: f32,
size: f32, size_growth: f32,
angle: f32, angular_vel: f32,
color: vec4<f32>,
}
struct CameraParams {
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<uniform> count_buf: f32;
const QUAD: array<vec2<f32>, 4> = array<vec2<f32>, 4>(
vec2(-0.5, -0.5),
vec2( 0.5, -0.5),
vec2( 0.5, 0.5),
vec2(-0.5, 0.5),
);
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32,
) -> VsOut {
var out: VsOut;
if f32(ii) >= count_buf {
out.clip = vec4(0.0, 0.0, -2.0, 1.0);
out.frag_color = vec4(0.0);
out.uv = vec2(0.0);
return out;
}
let p = particles[ii];
let q = QUAD[vi];
let c = cos(p.angle);
let s = sin(p.angle);
let rot = vec2(q.x * c - q.y * s, q.x * s + q.y * c) * p.size;
let right = vec3(camera.view[0][0], camera.view[1][0], camera.view[2][0]);
let up = vec3(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
let world = p.pos + right * rot.x + up * rot.y;
out.clip = camera.proj * camera.view * vec4(world, 1.0);
out.frag_color = p.color;
out.uv = q + vec2(0.5);
return out;
}
@group(0) @binding(3) var samp: sampler;
@group(0) @binding(4) var tex: texture_2d<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)
| Binding | Type | Contenu | Visibility |
|---|---|---|---|
| 0 | Uniform (min 112 B) | CameraParams (view + proj) | VERTEX |
| 1 | Storage (RO) | particle_data | VERTEX |
| 2 | Uniform (min 4 B) | count_buffer | VERTEX |
| 3 | Sampler | Sampler | FRAGMENT |
| 4 | Texture (2D) | Texture particule | FRAGMENT |
5. Pipeline descriptor
wgpu::RenderPipelineDescriptor {
vertex: wgpu::VertexStage {
module: shader,
entry_point: "vs_main",
buffers: &[], // PAS de vertex buffer
},
fragment: Some(wgpu::FragmentStage {
module: shader,
entry_point: "fs_main",
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
// Indices : pas de index buffer → on utilise draw(4, N)
// MAIS : 4 sommets sans indices = 2 triangles ? NON.
// draw(4, N) drawe 4 triangles (4 indices implicites 0,1,2,3) = 1 triangle + 1 degénéré.
// IL FAUT un index buffer ! Ou utiliser draw_indexed.
// → Voir GOTCHA ci-dessous.
..Default::default()
},
color_states: [wgpu::ColorState {
format,
alpha_blend: blend_alpha,
color_blend: blend_color,
write_mask: wgpu::ColorWrites::ALL,
}],
depth_stencil: Some(wgpu::DepthStencilState {
format: depth_format,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
..Default::default()
}),
multisample,
..
}
⚠️ GOTCHA : Topologie du quad billboard
Problème : draw(4, N) sans index buffer drawe 4 vertices en TriangleList,
ce qui fait 4/3 = 1 triangle + 1 vertex orphelin. Ce n'est PAS un quad.
Solutions :
| Option | Pro | Contre |
|---|---|---|
A : draw(6, N) + 6 sommets (quad = 2 tris, 6 verts) |
Pas d'index buffer | 6 vertices au lieu de 4 (2 dupliqués) |
B : Index buffer (6 indices) + draw_indexed(6, N, 0, 0) |
4 vertices seulement | 1 petit buffer index (24 bytes) partagé |
C : @builtin(vertex_index) avec 6 values dans le const |
Pas d'index buffer, pas de vertex buffer | Le const a 6 entries au lieu de 4 |
Décision : Option C — 6 entries dans le const QUAD, draw(6, max_count).
// 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad
const QUAD: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
vec2(-0.5, -0.5), // 0
vec2( 0.5, -0.5), // 1
vec2( 0.5, 0.5), // 2
vec2(-0.5, -0.5), // 3
vec2( 0.5, 0.5), // 4
vec2(-0.5, 0.5), // 5
);
→ draw(6, max_count). Pas de vertex buffer, pas d'index buffer. Cohérent avec
le pattern fullscreen triangle du TM/bloom (qui utilise draw(3, 1)).
6. Texture par défaut (disque 16×16)
Générée en Rust au build du pool (si config.texture == None) :
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) as u8 * 255;
let i = (y * size + x) * 4;
data[i] = 255; // R
data[i+1] = 255; // G
data[i+2] = 255; // B
data[i+3] = alpha; // A
}
}
data
}
7. Scene::create_particle_pool
impl Scene {
pub fn create_particle_pool(&mut self, id: &str, config: ParticlePoolConfig) -> Result<(), String> {
if self.particle_pools.contains_key(id) {
return Err(format!("particle pool '{}' already exists", id));
}
// Résoudre la texture
let (texture_view, sampler, is_owned) = match &config.texture {
Some(tex_id) => {
let tex = self.textures.get(tex_id)
.ok_or_else(|| format!("texture '{}' not found", tex_id))?;
(tex.view.clone(), tex.sampler.clone(), false)
}
None => {
// Créer la texture disque 16×16
let (view, sampler) = self.gpu.create_default_disc_texture();
(view, sampler, true)
}
};
// Construire le pool (buffer + pipeline + bind group)
let pool = ParticlePool::new(
&self.gpu.device,
&self.gpu.queue,
self.gpu.format,
self.gpu.depth_format,
self.gpu.msaa,
&config,
texture_view,
sampler,
);
self.particle_pools.insert(id.to_string(), Arc::new(pool));
Ok(())
}
}
8. Prelude
// Dans prelude.rs :
pub use crate::core::particles::{ParticlePoolConfig, BlendingMode};
pub use crate::resources::particle::Particle;
Blend states
| Mode | color_ops.src | color_ops.dst | alpha_ops.src | alpha_ops.dst |
|---|---|---|---|---|
| Additive | One | One | One | One |
| Alpha | SrcAlpha | OneMinusSrcAlpha | One | OneMinusSrcAlpha |
Tests
Unit tests (particles.rs)
| Test | Vérifie |
|---|---|
particle_size_is_64 |
size_of::<Particle>() == 64 |
particle_align_is_16 |
align_of::<Particle>() == 16 |
particle_offsets |
Offsets de chaque champ |
pool_config_default_max_count |
Valeur raisonnable |
default_disc_texture_size |
16×16×4 bytes |
default_disc_center_is_opaque |
Center pixel alpha = 255 |
default_disc_corner_is_transparent |
Corner pixel alpha = 0 |
WGSL validation (wgsl_validate.rs)
| Test | Vérifie |
|---|---|
particle_billboard_compiles |
Naga compile le shader |
particle_billboard_entry_points |
Contient vs_main + fs_main |
particle_billboard_no_compute |
Pas d'entry point compute (cette étape) |
Vérification de non-régression
cargo check -p wsg-lib --all-targets→ 0 errors, 0 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_poolsmais il est vide par défaut → zéro coût
Critères d'acceptation
- ✅
Particlecompile, 64 bytes, Pod, offsets corrects - ✅
particle_billboard.wgslcompile par Naga (test WGSL) - ✅
ParticlePool::newcrée buffer + pipeline + bind group sans erreur - ✅ La texture disque 16×16 est générée correctement
- ✅
Scene::create_particle_poolfonctionne (test unitaire avec mock device) - ✅ Le pool est inactif (pas de draw) 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 + GpuEmitterConfig +
spawn CPU + dispatch + Scene::attach_gpu_emitter.