Files
wsg/docs/DRAFT.md
T
Jérôme Bousquié 8606aba510 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
2026-09-26 10:49:13 +02:00

470 lines
18 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 (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`.