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

14 KiB
Raw Blame History

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