Files
wsg/docs/DRAFT.md
T
Jérôme Bousquié 49aa9e48fd feat(particles): Étape 28 A — ParticlePool infrastructure (no driver)
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.
2026-09-26 12:34:21 +02:00

18 KiB
Raw Blame History

É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/
└── 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 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

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 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.