Files
wsg/docs/DRAFT.md
T
Jérôme Bousquié 7e88390006 archi particules
2026-09-25 16:00:17 +02:00

437 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# É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`
```rust
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`
```rust
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`
```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
```rust
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)`.
```wgsl
// 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`) :
```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) 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`
```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, 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
```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_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 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` mais il est vide par défaut → zéro coût
---
## Critères d'acceptation
1. ✅ `Particle` compile, 64 bytes, Pod, offsets corrects
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
---
## Étape suivante (B)
Driver GPU : compute shader `particle_update.wgsl` + `GpuEmitterConfig` +
spawn CPU + dispatch + `Scene::attach_gpu_emitter`.