diff --git a/docs/DRAFT.md b/docs/DRAFT.md index 3ee5bbb..cd1e889 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -1,354 +1,436 @@ -# DRAFT — Étape 27 : PBR Metallic/Roughness + Normal Mapping (Phase 6.5) +# É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 -Le shader actuel (`standard_shader.wgsl`) utilise un modèle d'éclairage simpliste : -- Diffuse Lambert (`N·L`) + ambient hémisphérique -- **Aucun terme spéculaire** (pas de Blinn-Phong, pas de Cook-Torrance) -- Pas de normal mapping -- Pas d'IBL (Image-Based Lighting) +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`. -Résultat : les matériaux métalliques ne brillent pas, les surfaces rugueuses ne -s'assombrissent pas correctement, et les normales ne peuvent pas être sculptées -via texture. Le saut vers PBR est le plus grand gain visuel restant. +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). -## Objectif +--- -Remplacer le modèle Lambert par un **PBR Metalness/Roughness** complet : -- BRDF Cook-Torrance (GGX distribution + Smith visibility + Schlick Fresnel) -- Workflow Metallic/Roughness (industriel : Unreal, Unity, Blender) -- Normal mapping (tangent space, tangente dérivée — pas d'attribut tangent) -- IBL analytique (hémisphère ciel/sol, pas de cubemap) -- **Rétrocompatibilité** : les matériaux existants (metallic=0, roughness=0.5) - rendent à peu près comme avant (diffuse + léger spéculaire) +## Scope de cette étape (A) -## Décisions +| 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) | | -### D1 — Workflow Metallic/Roughness +> **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 ``` -F0 = mix(vec3(0.04), base_color, metallic) // diélectrique: 4% reflexion, métal: albedo -R = roughness² (GGX alpha) +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> +│ # + create_particle_pool() +└── prelude.rs # + re-exports + +lib/tests/ +└── wgsl_validate.rs # + test particle_billboard + +lib/examples/ +└── particles.rs # (Étape F, pas cette étape) ``` -- `metallic ∈ [0, 1]` : 0 = diélectrique (dielectric), 1 = métal pur -- `roughness ∈ [0, 1]` : 0 = miroir, 1 = totalement rugueux -- Le `base_color` existant sert d'albedo (déjà présent via texture + vertex color) -- **Pas de Specular/Glossiness** (workflow obsolète) +--- -### D2 — Où stocker metallic/roughness +## Détail des implémentations -Dans le **padding de `ObjectUniform`** (offset 80-87, juste après `emissive` à 64-79) : +### 1. `resources/particle.rs` ```rust -// WGSL: -struct ObjectUniform { - model: mat4x4, // 64 bytes (offset 0) - emissive: vec4, // 16 bytes (offset 64) - pbr: vec4, // 16 bytes (offset 80): (metallic, roughness, 0, 0) - // ... padding jusqu'à 256 bytes -}; -``` +use bytemuck::{Pod, Zeroable}; -- **Aucune modification du compute shader** (il n'écrit que bytes 0-63) -- Ecrit via `queue.write_buffer` au moment du frame update (comme l'emissive) -- `Material` gagne 2 champs : `metallic: f32`, `roughness: f32` - -### D3 — Nouveau point d'entrée shader `fs_pbr` - -Le PBR est **plus complexe** que le Lambert actuel. Plutôt que de modifier -`fs_main` en place (risque de régression), on ajoute un **deuxième point -d'entrée fragment** `fs_pbr` dans le même module WGSL : - -``` -@fragment fn fs_main(...) → Lambert (existant, pour rétrocompatibilité) -@fragment fn fs_pbr(...) → PBR Cook-Torrance (nouveau) -``` - -La sélection est **compile-time** via le `shader_id` : -- `Material::new(format, "standard", cache)` → pipeline avec `fs_main` (Lambert) -- `Material::pbr(format, cache)` → pipeline avec `fs_pbr` (PBR) - -Le vertex shader est **partagé** entre les deux (même `vs_main`). - -### D4 — Normal mapping par tangente dérivée - -**Pas d'attribut tangent** dans le vertex buffer (casserait tous les meshes existants). -On utilise la méthode des **dérivées ecran-space** (mipmapped derivative tangent) : - -```wgsl -// Dans le fragment shader : -let dpdx = dFdx(world_pos); -let dpdy = dFdy(world_pos); -let dwdx = dFdx(uv); -let dwdy = dFdy(uv); - -let tangent = normalize(dpdx * dwdy.y - dpdy * dwdx.y); -let bitangent = normalize(cross(n, tangent)); -let tbn = mat3x3(tangent, bitangent, n); -``` - -Avantages : -- Zéro changement de format vertex -- Fonctionne avec n'importe quel mesh existant -- Moins précis qu'un tangent explicite (artefacts possibles sur UV dégénérés) -- Suffisant pour un premier PBR - -Le normal map est échantillonné dans `@group(2) @binding(2)` (nouveau binding) : -``` -vec3 nmap = textureSample(normal_texture, normal_sampler, uv).rgb * 2.0 - 1.0; -vec3 n_pbr = normalize(tbn * nmap); -``` - -Sans normal map → placeholder blanc (128,128,255) → `nmap = (0,0,1)` → `n_pbr = n` (aucun changement). - -### D5 — IBL analytique (hémisphère) - -Pas de cubemap pour cette étape. L'IBL est approximé par un **hémisphère 2 couleurs** : - -```wgsl -// Sky/ground colors from frame.ambient (déjà présent) -let ibl_dir = n; // direction de la normale (view space ou world) -let ibl_sky = frame.ambient.rgb; // couleur "ciel" -let ibl_ground = frame.ambient.rgb * 0.3; // couleur "sol" (assombrie) -let ibl_color = mix(ibl_ground, ibl_sky, ibl_dir.y * 0.5 + 0.5); - -// Specular IBL : approximation pré-filtrée par roughness -// (réalité : cubemap pré-filtrée par mip ; ici : simple interpolation) -let spec_ibl = mix(ibl_color, vec3(1.0), 0.5 * (1.0 - roughness)); -``` - -C'est une approximation grossière mais suffisante pour : -- Donner du "remplissage" aux zones non éclairées par les lumières ponctuelles -- Faire varier le spéculaire IBL selon la roughness (mirroir = brillant, rugueux = mat) - -### D6 — BRDF Cook-Torrance (GGX) - -```wgsl -fn distribution_ggx(n: vec3, h: vec3, roughness: f32) -> f32 { - let a = roughness * roughness; - let a2 = a * a; - let ndh = max(dot(n, h), 0.0); - let d = ndh * ndh * (a2 - 1.0) + 1.0; - return a2 / (3.14159 * d * d); +/// 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 } -fn geometry_smith(n: vec3, v: vec3, l: vec3, roughness: f32) -> f32 { - let a = roughness * roughness; - let kv = vec2(0.5, 0.5); - let gv = n.y / (n.y * (1.0 - kv.y) + kv.x); // note: n.y ≈ |N·V| pour hémisphère local - let kv2 = vec2(0.5, 0.5); - let gl = n.y / (n.y * (1.0 - kv2.y) + kv2.x); - return gv * gl; -} - -fn fresnel_schlick(cos_theta: f32, f0: vec3) -> vec3 { - return f0 + (vec3(1.0) - f0) * pow(1.0 - cos_theta, 5.0); -} - -fn brdf_pbr(n: vec3, v: vec3, l: vec3, - base: vec3, metallic: f32, roughness: f32) -> vec3 { - let h = normalize(v + l); - let f0 = mix(vec3(0.04), base, metallic); - - let d = distribution_ggx(n, h, roughness); - let g = geometry_smith(n, v, l, roughness); - let f = fresnel_schlick(max(dot(h, v), 0.0), f0); - - let ndl = max(dot(n, l), 0.0); - let ndv = max(dot(n, v), 0.0); - let ndh = max(dot(n, h), 0.0); - let hv = max(dot(h, v), 0.0); - - // Diffuse : Lambert × (1 - metallic) × (1 - F_D90) - let kd = (vec3(1.0) - f) * (1.0 - metallic); - let diffuse = kd * base / 3.14159; - - // Speculaire : D × G × F / (4 × N·V × N·L) - let denom = 4.0 * ndv * ndl + 1e-4; - let specular = d * g * f / denom; - - let radiance = (diffuse + specular) * base * ndl; // base = light color × intensity - return radiance; +impl Particle { + pub const SIZE: u64 = std::mem::size_of::() as u64; // must be 64 } ``` -### D7 — Structure du fragment PBR +**Test** : `assert_eq!(size_of::(), 64)`, `assert_eq!(align_of::(), 16)`. + +### 2. `core/particles.rs` + +```rust +pub enum BlendingMode { + Additive, + Alpha, +} + +pub struct ParticlePoolConfig { + pub max_count: u32, + pub texture: Option, // 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>, + 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, pad0: f32, + vel: vec3, pad1: f32, + life: f32, max_life: f32, + size: f32, size_growth: f32, + angle: f32, angular_vel: f32, + color: vec4, +} + +struct CameraParams { + view: mat4x4, + proj: mat4x4, +} + +struct VsOut { + @builtin(position) clip: vec4, + @location(0) frag_color: vec4, + @location(1) uv: vec2, +} + +@group(0) @binding(0) var camera: CameraParams; +@group(0) @binding(1) var particles: array; +@group(0) @binding(2) var count_buf: f32; + +const QUAD: array, 4> = array, 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; + @fragment -fn fs_pbr(in: VertexOutput) -> @location(0) vec4 { - let texel = textureSample(diffuse_texture, texture_sampler, in.uv); - let base = texel.rgb * in.color.rgb; - - // Unlit mode (même que fs_main) - if (frame.options.x != 0u) { - let emissive_contrib = base * object.emissive.rgb * object.emissive.a; - return vec4(apply_fog(base + emissive_contrib, in.world_pos), in.color.a); - } - - let metallic = object.pbr.x; - let roughness = clamp(object.pbr.y, 0.045, 1.0); // min 0.045 (évite division par 0) - - // Normal mapping (derivative tangent) - let n = compute_pbr_normal(in); // inclut le normal map si présent - - let v = normalize(frame.cam_pos - in.world_pos); - var color = vec3(0.0); - - // IBL (hémisphère analytique) - let ibl = compute_ibl(n, roughness, base, metallic); - color += ibl; - - // Lumières directionnelles - for (var i = 0u; i < frame.num_directional; i++) { - let l = normalize(frame.lights[i].position_dir.xyz); - let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a; - color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color - * compute_shadow(in.world_pos, n); - } - - // Lumières ponctuelles + spots (même pattern, avec falloff) - // ... - - // Emissive - let emissive_contrib = base * object.emissive.rgb * object.emissive.a; - let final_rgb = color + emissive_contrib; - return vec4(apply_fog(final_rgb, in.world_pos), in.color.a); +fn fs_main(in: VsOut) -> @location(0) vec4 { + let t = textureSample(tex, samp, in.uv); + return in.frag_color * t; } ``` -### D8 — Texture normal map : nouveau binding `@group(2) @binding(2)` +### 4. Bind group layout (render) -Le `@group(2)` actuel a 2 bindings (sampler + diffuse texture). On ajoute : -``` -@group(2) @binding(2) var normal_texture: texture_2d; -@group(2) @binding(3) var normal_sampler: sampler; -``` +| 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 | -- Sans normal map → placeholder (128,128,255) = normale neutre → aucun effet -- Le `Material` gagne un champ `normal_texture: Option>` -- Le bind group group-2 est reconstruit avec la normal map (ou le placeholder) -- **Le pipeline layout est le même** pour `fs_main` et `fs_pbr` (mêmes bindings) - → la PipelineCache peut partager le layout - -### D9 — `Material::pbr()` constructor +### 5. Pipeline descriptor ```rust -impl Material { - /// Crée un matériau PBR avec metallic/roughness. - pub fn pbr( - format: wgpu::TextureFormat, - shader_id: &str, // "pbr" - metallic: f32, - roughness: f32, - cache: &mut PipelineCache, - ) -> Self { ... } - - /// Avec texture albedo + normal map. - pub fn pbr_textured( - format: wgpu::TextureFormat, - shader_id: &str, - metallic: f32, - roughness: f32, - albedo: Option>, - normal_map: Option>, - cache: &mut PipelineCache, - ) -> Self { ... } +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, + .. } ``` -### D10 — Rétrocompatibilité +### ⚠️ GOTCHA : Topologie du quad billboard -- `Material::new()` (existant) → pipeline `fs_main` (Lambert) → **inchangé** -- `Material::pbr()` (nouveau) → pipeline `fs_pbr` (PBR) → nouveau -- Les deux pipelines coexistent dans la PipelineCache -- Les examples existants (demo, bloom, fog, dof, etc.) continuent à utiliser `Material::new()` -- **Aucune régression** : le shader `fs_main` n'est pas modifié +**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. -### D11 — Pipeline layout : 1 seul layout pour les 2 entry points +**Solutions** : -`fs_main` et `fs_pbr` lisent les **mêmes bindings** : -- `@group(0)`: FrameUniforms -- `@group(1)`: ObjectUniform -- `@group(2)`: sampler + diffuse + normal_sampler + normal_texture +| 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 | -Un seul `BindGroupLayout` couvre les deux. La PipelineCache crée 2 pipelines -(même layout, entry points différents) → partage du layout = zéro overhead supplémentaire. +**Décision : Option C** — 6 entries dans le const QUAD, `draw(6, max_count)`. -### D12 — ObjectUniform : écriture du PBR data +```wgsl +// 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad +const QUAD: array, 6> = array, 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 +); +``` -Dans `render_scene`, l'écriture de l'emissive est déjà faite par `queue.write_buffer` -à l'offset 64. On ajoute l'écriture de `pbr` à l'offset 80 : +→ `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 -// Étape 27 : PBR params (metallic, roughness) dans le padding de ObjectUniform. -if mat.metallic != 0.0 || mat.roughness != 0.5 { - let pbr_data: [f32; 4] = [mat.metallic, mat.roughness, 0.0, 0.0]; - let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 80) as u64; - self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&pbr_data)); +fn default_disc_texture() -> Vec { + 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 } ``` -Par défaut (metallic=0, roughness=0.5) → pas d'écriture → le buffer contient 0.0 -(le buffer est alloué avec `COPY_DST` et initialisé à zéro) → **c'est correct** : -metallic=0 (diélectrique) et roughness=0.0... +### 7. `Scene::create_particle_pool` -Hmm, roughness=0.0 est un problème (GGX avec alpha=0 → division par zéro). -**Solution** : clamer `roughness = max(roughness, 0.045)` dans le shader (déjà prévu en D7). -Le buffer initialisé à 0 → roughness=0 → clampé à 0.045 dans le shader → OK. +```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(()) + } +} +``` -### D13 — Example `pbr.rs` +### 8. Prelude -Scène de démonstration : -- **Sol** : plan 20×20, PBR (metallic=0, roughness=0.8) — surface matte -- **Cube métal** : metallic=1.0, roughness=0.1 — miroir chromé -- **Cube plastique** : metallic=0.0, roughness=0.4 — plastique lisse -- **Cube rouillé** : metallic=0.8, roughness=0.7 — métal rugueux -- **Sphere** : metallic=0.3, roughness=0.3 — céramique -- **Cube normal map** : avec une normal map procédurale (bump) -- 1 lumière directionnelle + 1 spot -- Clavier : `R` = reset, `1` = varier roughness, `2` = varier metallic +```rust +// Dans prelude.rs : +pub use crate::core::particles::{ParticlePoolConfig, BlendingMode}; +pub use crate::resources::particle::Particle; +``` -### D14 — Normal map procédurale pour l'exemple +--- -Générer une texture normal map 256×256 en code (pas de fichier externe) : -- Pattern "bump" : sin(x*freq) * sin(y*freq) → normale perturbée -- Ou pattern "bricks" : normales plates avec arêtes -- Stockée dans un `wgpu::Texture` via `queue.write_texture` +## Blend states -## Étapes d'implémentation +| Mode | color_ops.src | color_ops.dst | alpha_ops.src | alpha_ops.dst | +|------|--------------|--------------|---------------|---------------| +| **Additive** | One | One | One | One | +| **Alpha** | SrcAlpha | OneMinusSrcAlpha | One | OneMinusSrcAlpha | -| # | Tâche | Fichiers | -|---|-------|----------| -| 1 | `Material` : ajouter `metallic`, `roughness`, `normal_texture` + constructors `pbr()`/`pbr_textured()` | `resources/material.rs` | -| 2 | `ObjectUniform` WGSL : ajouter `pbr: vec4` (offset 80) | `shaders/standard_shader.wgsl` | -| 3 | Écrire le BRDF Cook-Torrance (GGX + Smith + Schlick) en WGSL | `shaders/standard_shader.wgsl` | -| 4 | Écrire `fs_pbr` (IBL + boucle lumières + normal map) | `shaders/standard_shader.wgsl` | -| 5 | Normal map bindings `@group(2) @binding(2,3)` + placeholder | `shaders/standard_shader.wgsl` + `pipeline_cache.rs` | -| 6 | PipelineCache : créer pipeline `fs_pbr` (même layout, entry point différent) | `pipeline/pipeline_cache.rs` | -| 7 | Renderer : écrire `pbr` data dans ObjectUniform (offset 80) | `core/renderer.rs` | -| 8 | Bind group group-2 : inclure normal map (ou placeholder) | `resources/material.rs` | -| 9 | WGSL validation test : vérifier que `fs_pbr` parsse | `tests/wgsl_validate.rs` | -| 10 | Example `pbr.rs` : scène de démo + normal map procédurale | `examples/pbr.rs` | -| 11 | Docs : examples/README.md + docs/user/pbr.md + ROADMAP | divers | +--- -## Risques et mitigations +## Tests -| Risque | Mitigation | -|--------|-----------| -| GGX avec roughness≈0 → NaN | Clamp `roughness ≥ 0.045` dans le shader | -| Dérivées ecran-space instables sur UV dégénérés (poles, seams) | Acceptable pour v1 ; tangent explicite en v2 | -| Le PBR est "trop sombre" vs Lambert | Le `base/π` dans le diffuse PBR assombrit ; compenser par lumière plus intense ou exposure | -| Normal map placeholder (128,128,255) → artefacts sur certains angles | Le mat3 TBN est orthonormalisé par `normalize` ; acceptable | -| 2 pipelines (fs_main + fs_pbr) → mémoire GPU | ~2 pipelines × ~50KB = négligeable | +### Unit tests (`particles.rs`) + +| Test | Vérifie | +|------|---------| +| `particle_size_is_64` | `size_of::() == 64` | +| `particle_align_is_16` | `align_of::() == 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 -- [ ] `Material::pbr(format, "pbr", metallic, roughness, cache)` compile et rend -- [ ] Un cube metallic=1, roughness=0.1 a un reflet spéculaire net (miroir) -- [ ] Un cube metallic=0, roughness=0.9 a un spéculaire large et diffus (mat) -- [ ] Un cube avec normal map montre des bumps visibles -- [ ] Les examples existants (demo, bloom, fog, dof) sont **inchangés** (fs_main) -- [ ] `cargo test --workspace` : 0 failures -- [ ] `cargo check -p wsg-lib --all-targets` : 0 warnings +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`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c9aaf73..d9d2ef6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -57,9 +57,11 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT --- -## Phase 6 — Post-MVP ⬜ +## Phase 6 — Post-MVP (effets) 🔶 > Au-delà du scope initial. Chaque item est opt-in et indépendant. +> **Note** : la phase 6 est mise en pause pendant la phase 7 (particules). +> Les items restants (6.6, 6.7, 6.14-6.16) seront repris après. | # | Item | Impact visuel | Effort | Statut | |---|------|:---:|:---:|:---:| @@ -88,6 +90,26 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT --- +## Phase 7 — Système de Particules 🔄 + +> Nouvelle catégorie : simulation + rendu de particules (VFX). +> Architecture : **Pool ≠ Driver** (`docs/tech/ARCHI_PARTICULES.md`). +> 3 drivers possibles : GPU (compute), CPU (simulation Rust), Manual (full control). + +| # | Item | Impact visuel | Effort | Statut | +|---|------|:---:|:---:|:---:| +| 7.1 | **Pool** (buffer 64B×N + pipeline billboard + texture + draw) | — | Moyen | ⬜ ← **en cours** | +| 7.2 | **Driver GPU** (compute update + spawn CPU + GpuEmitterConfig) | ⭐⭐⭐⭐ | Élevé | ⬜ | +| 7.3 | **Driver CPU** (simulation Rust + upload + custom_force) | ⭐⭐⭐ | Moyen | ⬜ | +| 7.4 | **Driver Manual** (handle direct sur le buffer) | ⭐⭐ | Faible | ⬜ | +| 7.5 | **Intégration Renderer** (frame loop, order, multi-pools) | — | Moyen | ⬜ | +| 7.6 | **Presets + Example** (fire, smoke, rain, explosion, snow, sparkles) | ⭐⭐⭐⭐ | Moyen | ⬜ | +| 7.7 | **Tests** (WGSL validate + layout + pool + drivers) | — | Faible | ⬜ | + +> **Après la phase 7** : reprise des items phase 6 restants (6.6 CSM, 6.7 SSAO, 6.14-6.16). + +--- + ## Liens - **Prochaine étape** : [DRAFT.md](DRAFT.md) (détail de l'étape en cours, remplacée à chaque itération) diff --git a/docs/tech/ARCHI_PARTICULES.md b/docs/tech/ARCHI_PARTICULES.md new file mode 100644 index 0000000..16b036a --- /dev/null +++ b/docs/tech/ARCHI_PARTICULES.md @@ -0,0 +1,959 @@ +# Architecture du Système de Particules (GPU) + +> Document de référence pour l'implémentation. +> Statut : **DESIGN FINAL** — base pour les DRAFT d'implémentation. +> Principe directeur : **Pool ≠ Driver**. La ressource GPU (pool) est séparée du mécanisme de simulation (driver). + +--- + +## 1. Vue d'ensemble + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SCÈNE │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ PARTICLE POOL (ressource GPU, créée une fois) │ │ +│ │ │ │ +│ │ • Buffer storage (N × 64 bytes) │ │ +│ │ • Pipeline render (billboard instancé) │ │ +│ │ • Texture + Sampler + Blending mode │ │ +│ │ • Draw call (instanced, 1 par frame) │ │ +│ │ │ │ +│ │ Rôle : STOCKER + AFFICHER │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ DRIVER (mécanisme de mise à jour, attaché au pool) │ │ +│ │ │ │ +│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐ │ │ +│ │ │ GPU Emitter │ │ CPU Emitter │ │ Manual │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ Compute pass │ │ write_buffer │ │ write_range │ │ │ +│ │ │ (simulation │ │ (full upload │ │ (user writes │ │ │ +│ │ │ en shader) │ │ par frame) │ │ directement) │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ Rôle : │ │ Rôle : │ │ Rôle : │ │ │ +│ │ │ SIMULER (GPU) │ │ SIMULER (CPU) │ │ CONTRÔLE TOTAL │ │ │ +│ │ └───────────────┘ └───────────────┘ └───────────────────┘ │ │ +│ │ │ │ +│ │ Un pool a UN SEUL driver actif à la fois. │ │ +│ │ Changer de driver = swap (pas de reallocation GPU). │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Séparation des responsabilités + +| Couche | Sait | Ne sait pas | +|--------|------|-------------| +| **Pool** | Comment afficher (pipeline, texture, blending, draw) | Comment les particules bougent | +| **Driver** | Comment mettre à jour le buffer (compute / upload / écriture directe) | Comment afficher | +| **User** | Quelle intention (feu, pluie, explosion, custom) | Les détails wgpu | + +--- + +## 2. État par particule (64 bytes) + +```rust +/// Miroir du struct WGSL `Particle`. +/// 64 bytes, `#[repr(C)]`, `Pod + Zeroable`. +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +pub struct Particle { + pub pos: [f32; 3], // offset 0 — position monde (xyz) + pub _pad0: f32, // offset 12 + pub vel: [f32; 3], // offset 16 — vélocité (xyz) + pub _pad1: f32, // offset 28 + pub life: f32, // offset 32 — vie restante (seconds) + pub max_life: f32, // offset 36 — vie initiale (pour fade normalisé) + pub size: f32, // offset 40 — taille courante (world units) + pub size_growth: f32, // offset 44 — croissance par seconde (+ = grandir, - = rétrécir) + pub angle: f32, // offset 48 — rotation 2D courante (radians) + pub angular_vel: f32, // offset 52 — vitesse angulaire (rad/s) + pub color: [f32; 4], // offset 56 — RGBA (alpha modulée par le driver) + // Total : 64 bytes +} +``` + +### Transform par particule + +Chaque particule porte un **transform complet** : + +| Composante | Stockage | Mise à jour (GPU compute) | Effet visuel | +|-----------|----------|---------------------------|--------------| +| Translation 3D | `pos` | `pos += vel * dt` | Déplacement | +| Trajectoire | `vel` | `vel += gravity * dt; vel *= (1 - drag * dt)` | Arc, chute, flottement | +| Scale 2D | `size` + `size_growth` | `size += size_growth * dt` | Croissance (fumée) / rétrécissement (feu) | +| Rotation 2D | `angle` + `angular_vel` | `angle += angular_vel * dt` | Spin (turbulence, pétale) | +| Opacité | `color.a` | `color.a = life / max_life` | Fade out progressif | + +> Le RGB de `color` est fixé au spawn (CPU). Seule l'alpha est modulée par le driver. +> **V2** : color ramp (`mix(color_start, color_end, 1.0 - life/max_life)`). + +### Taille du pool + +| `max_count` | Buffer size | Upload CPU (si driver CPU) @60fps | Verdict | +|-------------|-------------|-----------------------------------|---------| +| 1 024 | 64 KB | 3.8 MB/s | Trivial | +| 10 000 | 640 KB | 38 MB/s | OK | +| 50 000 | 3.2 MB | 190 MB/s | Correct (PC) | +| 65 536 | 4 MB | 245 MB/s | Max recommandé pour CPU | +| 100 000 | 6.4 MB | 380 MB/s | → Préférer driver GPU | + +--- + +## 3. Le Pool (ressource GPU) + +### 3.1 Création + +Le pool est créé **une fois** au setup. Il alloue : +- Le buffer storage (N × 64 bytes) +- Le pipeline render (billboard instancé) +- Le bind group layout +- La texture + sampler (si fournie) +- Le blending state + +```rust +pub struct ParticlePoolConfig { + /// Nombre maximum de particules dans le pool. + pub max_count: u32, + /// Texture de la particule (ID dans scene.textures). + /// `None` = texture disque doux intégrée (16×16, alpha radiale). + pub texture: Option, + /// Mode de blending. + pub blending: BlendingMode, +} + +pub enum BlendingMode { + /// `src × 1 + dst × 1` — feu, sparkles, magie. + Additive, + /// `src × src.a + dst × (1 - src.a)` — fumée, neige, vapeur. + Alpha, +} +``` + +### 3.2 Composantes internes du pool + +```rust +pub struct ParticlePool { + /// Buffer storage (N × 64 bytes). COPY_DST | STORAGE. + pub(crate) buffer: wgpu::Buffer, + /// Pipeline render (billboard instancé). + pub(crate) pipeline: wgpu::RenderPipeline, + /// Pipeline layout du render. + pub(crate) layout: wgpu::BindGroupLayout, + /// Bind group (buffer + texture + sampler + camera uniform ref). + pub(crate) bind_group: wgpu::BindGroup, + /// Sampler (Linear, ClampToEdge). + pub(crate) sampler: wgpu::Sampler, + /// Buffer uniform 4 bytes : alive_count (écrit par le driver, lu par le vertex shader). + pub(crate) count_buffer: wgpu::Buffer, + /// Taille max du pool. + pub max_count: u32, + /// Driver actuellement attaché (None = pool inactif). + pub(crate) driver: Option>, + /// Mode de blending (déterminé au build du pipeline). + pub blending: BlendingMode, +} +``` + +### 3.3 Comportement + +| État du pool | Compute | Upload CPU | Draw | +|-------------|---------|------------|------| +| Pas de driver | ❌ | ❌ | ❌ (count = 0) | +| Driver GPU actif | ✅ (1 dispatch) | Spawns seulement | ✅ | +| Driver CPU actif | ❌ | Full range alive | ✅ | +| Driver Manual actif | ❌ | Ce que l'utilisateur envoie | ✅ | +| Driver actif mais `paused = true` | ❌ | ❌ | ❌ (count = 0) | + +**Coût zéro** : un pool sans driver (ou pausé) n'apparaît pas dans le render pass. + +--- + +## 4. Les Drivers + +### 4.1 Trait commun + +```rust +/// Trait implémenté par chaque type de driver. +/// Le pool appelle ces méthodes dans l'ordre à chaque frame. +pub trait ParticleDriver: Send { + /// Appelé AVANT le compute (si driver GPU) ou avant le draw. + /// Le driver peut écrire dans le pool buffer (spawns, updates). + fn pre_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool, dt: f32); + + /// Appelé APRÈS le compute (driver GPU uniquement). + /// Permet de mettre à jour des uniforms post-simulation. + fn post_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool); + + /// Le driver veut-il qu'on fasse un compute dispatch cette frame ? + fn needs_compute(&self) -> bool; + + /// Le driver veut qu'on drawe le pool ? + fn needs_draw(&self) -> bool; +} +``` + +### 4.2 Driver GPU (GpuEmitter) + +**Rôle** : la simulation est faite par un compute shader. Le CPU ne fait que les spawns. + +``` +Frame loop (driver GPU) : + 1. pre_compute : + a. Accumulateur : acc += rate * dt + b. n_spawn = floor(acc); acc -= n_spawn + c. Trouver n_spawn slots morts dans le pool + d. Pour chaque slot : tirer pos/vel/life/size/angle/color (random CPU) + e. queue.write_buffer(&pool.buffer, offset_morts, new_particles) + f. Écrire alive_count estimé dans count_buffer + 2. Compute dispatch (le pool fait le dispatch) : + - workgroups = ceil(max_count / 64) + - Le shader intègre TOUS les slots alive + - Le shader écrit alive_count exact dans count_buffer (atomic ou 2e pass) + 3. post_compute : (optionnel, ex: sync alive_count) + 4. Draw (si alive_count > 0) +``` + +**Config** : + +```rust +pub struct GpuEmitterConfig { + /// Taux d'émission (particules / seconde). + pub rate: f32, + /// Forme de spawn (où naît la particule). + pub shape: SpawnShape, + /// Pattern de vitesse (dans quelle direction). + pub velocity: VelocityPattern, + /// Plage de vitesse (pour Fixed/Cone/Sphere). Ignoré pour Box. + pub speed_range: (f32, f32), + /// Gravité (m/s²) appliquée par le compute. + pub gravity: [f32; 3], + /// Friction linéaire (0.0 = aucune, 1.0 = stop immédiat). + pub drag: f32, + /// Plage de vie (seconds). + pub life_range: (f32, f32), + /// Plage de taille initiale. + pub size_range: (f32, f32), + /// Croissance de taille par seconde (négatif = rétrécir). + pub size_growth: f32, + /// Plage de vitesse angulaire initiale (rad/s). + pub angular_vel_range: (f32, f32), + /// Couleur de base RGBA (alpha initiale = 1.0, modulée par life/max_life). + pub color: [f32; 4], +} +``` + +**Position de l'émetteur** : mise à jour par l'utilisateur à tout moment : +```rust +scene.set_emitter_position(pool_id, [x, y, z]); +``` +Le driver lit cette position à chaque spawn. + +### 4.3 Driver CPU (CpuEmitter) + +**Rôle** : la simulation est faite en Rust (CPU). Le résultat est uploadé dans le pool à chaque frame. Pas de compute pass. + +``` +Frame loop (driver CPU) : + 1. pre_compute (ici : simulation + upload) : + a. Pour chaque slot alive : intégrer (pos += vel*dt, vel += grav*dt, life -= dt, ...) + b. Tuer les particules mortes (life <= 0 → alive = 0) + c. Accumulateur de spawn : acc += rate * dt + d. Pour chaque nouveau spawn : écrire dans un slot mort + e. queue.write_buffer(&pool.buffer, 0, &all_alive_particles) + f. Écrire alive_count dans count_buffer + 2. Pas de compute dispatch + 3. post_compute : (rien) + 4. Draw (si alive_count > 0) +``` + +**Config** : identique à `GpuEmitterConfig` (mêmes paramètres de simulation). +La différence est **où** l'intégration se fait. + +**Avantage du CPU** : possibilité d'interactions (collision avec objets de la scène, +champs de force locaux, attracteurs). L'utilisateur peut implémenter une closure +`fn custom_force(&self, particle: &Particle, scene: &Scene) -> Vec3`. + +```rust +pub struct CpuEmitterConfig { + // ... même champs que GpuEmitterConfig ... + + /// Force custom (appelée par particule par frame). None = aucune. + /// Permet : collision, attracteur, champ de vent, etc. + pub custom_force: Option [f32; 3] + Send + Sync>>, +} +``` + +**Limite** : bandwidth. Au-delà de ~10K particules, l'upload full buffer devient coûteux. +Pour 50K+, préférer le driver GPU. + +### 4.4 Driver Manual + +**Rôle** : l'utilisateur contrôle **directement** le contenu du buffer. Le pool ne fait que draw. + +``` +Frame loop (driver Manual) : + 1. L'utilisateur appelle pool.upload(queue) quand il veut + (ou le driver fait un upload automatique de la "dirty range") + 2. Pas de compute dispatch + 3. Draw (si alive_count > 0) +``` + +**API** : + +```rust +/// Handle exposé à l'utilisateur pour le driver manual. +pub struct ManualPoolHandle<'a> { + pool: &'a mut ParticlePool, + queue: &'a wgpu::Queue, +} + +impl ManualPoolHandle<'_> { + /// Lire une particule (retourne une copie). + pub fn get(&self, index: u32) -> Particle; + + /// Écrire une particule (marque le pool dirty). + pub fn set(&mut self, index: u32, particle: Particle); + + /// Écrire un range de particules. + pub fn write_range(&mut self, start: u32, count: u32, particles: &[Particle]); + + /// Définir manuellement le alive_count. + pub fn set_count(&mut self, count: u32); + + /// Uploader le buffer dans le GPU (à appeler avant le draw). + pub fn upload(&mut self); +} +``` + +**Cas d'usage** : +- Animation de particules le long d'une courbe (splines) +- Transfert de particules entre pools +- Import d'une animation de particules pré-calculée +- Debug (visualiser l'état interne) +- Effets très custom (morphing, shader-like en CPU) + +--- + +## 5. Modèles d'émission + +### 5.1 Shape de spawn + +```rust +pub enum SpawnShape { + /// Position exacte de l'émetteur (point). + Point, + /// Boîte centrée sur l'émetteur. `extent` = demi-extents xyz. + Box { extent: [f32; 3] }, + /// Sphère centrée sur l'émetteur. + Sphere { radius: f32 }, + /// Plan perpendiculaire à `normal`, centré sur l'émetteur. + /// `size` = demi-largeur x/y dans le plan local. + Plane { normal: [f32; 3], size: [f32; 2] }, +} +``` + +### 5.2 Pattern de vitesse + +```rust +pub enum VelocityPattern { + /// Vélocité fixe : `base_dir * speed`. (balles, rayons) + Fixed, + /// Cône : direction aléatoire dans un cône de demi-angle `spread_deg` autour de `base_dir`. + Cone { spread_deg: f32 }, + /// Sphère : direction aléatoire uniforme sur la sphère. (explosion) + Sphere, + /// Boîte : chaque composante tirée indépendamment dans [min_i, max_i]. (pluie) + Box { min: [f32; 3], max: [f32; 3] }, +} +``` + +### 5.3 Direction base + +La direction base est implicitement dérivée : +- Pour `Cone` / `Fixed` : l'utilisateur donne un `base_dir: [f32; 3]` dans le config +- Pour `Sphere` : pas de direction base (isotrope) +- Pour `Box` : pas de direction base (les min/max définissent tout) + +```rust +// Dans GpuEmitterConfig / CpuEmitterConfig : +pub base_dir: [f32; 3], // utilisé par Fixed et Cone, ignoré par Sphere et Box +``` + +### 5.4 Table des presets + +| Preset | Shape | Velocity | base_dir | speed | gravity | drag | life | size | growth | ang_vel | color | blending | +|--------|-------|----------|----------|-------|---------|------|------|------|--------|---------|-------|----------| +| **Explosion** | Point | Sphere | — | 2-8 | (0,-9.8,0) | 0.1 | 0.5-1.5 | 0.05-0.15 | -0.05 | ±5 | [1,0.8,0.3,1] | Additive | +| **Jet d'eau** | Point | Cone 10° | (0,1,0) | 3-5 | (0,-9.8,0) | 0.01 | 1.0-2.0 | 0.03-0.05 | 0 | 0 | [0.3,0.6,1,0.8] | Alpha | +| **Pluie** | Plan (0,1,0) 20×20 | Box | — | vy:-5, vx/z:±0.3 | (0,0,0) | 0 | 3.0-5.0 | 0.01-0.02 | 0 | 0 | [1,1,1,0.3] | Alpha | +| **Fumée** | Box [0.15,0,0.15] | Cone 45° | (0,1,0) | 0.3-1.0 | (0,0.3,0) | 0.3 | 2.0-4.0 | 0.2-0.4 | +0.3 | ±2 | [0.5,0.5,0.5,0.4] | Alpha | +| **Feu** | Point | Cone 20° | (0,1,0) | 1-3 | (0,0.5,0) | 0.2 | 0.3-0.8 | 0.15-0.3 | -0.2 | ±8 | [1,0.5,0,1] | Additive | +| **Neige** | Plan (0,1,0) 10×10 | Box | — | vy:-0.5, vx/z:±0.2 | (0,0,0) | 0 | 5-10 | 0.02-0.04 | 0 | ±3 | [1,1,1,0.8] | Alpha | +| **Sparkles** | Sphere r=0.3 | Sphere | — | 0.1-0.5 | (0,0,0) | 0.5 | 1-3 | 0.02-0.05 | -0.01 | 0 | [1,1,0.8,1] | Additive | +| **Débris** | Point | Sphere | — | 1-6 | (0,-9.8,0) | 0.02 | 1-3 | 0.03-0.08 | 0 | ±10 | [0.6,0.4,0.2,1] | Alpha | + +Disponibles comme constructors : `GpuEmitterConfig::fire()`, `::smoke()`, `::rain()`, etc. + +--- + +## 6. Buffers GPU + +| Buffer | Type | Taille | Écrit par | Lu par | +|--------|------|--------|-----------|--------| +| `particle_data` | Storage (RW) | N × 64 B | Driver (spawn/update) | Compute + Render | +| `count_buffer` | Uniform (RW) | 4 B | Driver / Compute | Vertex shader (instance_count) | +| `emitter_params` | Uniform (RO) | 64 B | Driver GPU (par frame) | Compute shader | +| `camera_params` | Uniform (RO) | 160 B | Renderer (existant) | Vertex shader (view, proj) | + +### `emitter_params` (uniform, 64 bytes) + +```wgsl +struct EmitterParams { + dt: f32, // delta time de la frame + gravity: vec3, // accélération + drag: f32, // friction + alive_count: f32, // count courant (écrit par le compute via atomic) + pool_size: f32, // taille max du pool + _pad: vec2, // alignment +} +// Total : 4+12+4+4+4+8 = 36 → pad à 48 (align 16) +``` + +### Bind groups + +**Compute pass (driver GPU uniquement)** : + +| Group | Binding | Type | Contenu | Visibility | +|-------|---------|------|---------|------------| +| 0 | 0 | Storage RW | `particle_data` | COMPUTE | +| 0 | 1 | Uniform RO | `emitter_params` | COMPUTE | + +**Render pass (toujours)** : + +| Group | Binding | Type | Contenu | Visibility | +|-------|---------|------|---------|------------| +| 0 | 0 | Uniform RO | `camera_params` (view + proj) | VERTEX | +| 0 | 1 | Storage RO | `particle_data` | VERTEX | +| 0 | 2 | Uniform RO | `count_buffer` (alive_count) | VERTEX | +| 0 | 3 | Sampler | Sampler | FRAGMENT | +| 0 | 4 | Texture | Texture particule | FRAGMENT | + +> **Note** : le `count_buffer` est lu par le vertex shader pour déterminer +> `instance_count` (via `@builtin(instance_index)` et un early-out si `ii >= count`). + +--- + +## 7. Pipelines + +### 7.1 Pipeline Compute (driver GPU) + +```rust +wgpu::ComputePipeline { + layout: ComputePipelineLayout { + bind_group_layouts: [bgl_compute], // storage RW + params + }, + // entry point : "cs_update" +} +``` + +Dispatch : `workgroups = ceil(max_count / 64)`, un workgroup de 64 threads. + +### 7.2 Pipeline Render (billboard) + +```rust +wgpu::RenderPipeline { + layout: RenderPipelineLayout { + bind_group_layouts: [bgl_render], // camera + storage RO + count + sampler + texture + }, + vertex: vs_main (billboard), + fragment: fs_main (texture × color), + primitive: TriangleList, + vertex_buffer_layouts: [], // PAS de vertex buffer ! (quad généré en shader) + multisample: sample_count du contexte, + color_states: [blending mode du pool], + depth_stencil: Some(LessEqual, ALWAYS), // depth test oui, depth write non (transparence) +} +``` + +> **Pas de vertex buffer** : le quad billboard est généré dans le vertex shader +> via `@builtin(vertex_index)` (4 vertices) — même pattern que le fullscreen triangle +> du tone mapping, mais avec 4 sommets au lieu de 3. + +### 7.3 Blend states + +| Mode | color_ops | Formula | +|------|-----------|---------| +| **Additive** | src: One, dst: One | `output = src × 1 + dst × 1` | +| **Alpha** | src: SrcAlpha, dst: OneMinusSrcAlpha | `output = src × a + dst × (1-a)` | + +Le blending est **figé au build du pipeline** (2 pipelines par pool si on veut les 2 modes — mais v1 : 1 mode par pool). + +### 7.4 Depth + +- **Depth test** : `CompareFunction::LessEqual` (les particules derrière les objets opaques sont masquées) +- **Depth write** : `STENCIL_WRITE_ONLY` → en pratique `depth_write_enabled: false` (les particules ne créent pas d'ombre depth entre elles) +- **Stencil** : `Always` (pas de stencil) + +--- + +## 8. Shaders (WGSL) + +### 8.1 Compute shader (`particle_update.wgsl`) + +```wgsl +// particle_update.wgsl +// Compute pass : intègre toutes les particules alive. + +struct Particle { + pos: vec3, + pad0: f32, + vel: vec3, + pad1: f32, + life: f32, + max_life: f32, + size: f32, + size_growth: f32, + angle: f32, + angular_vel: f32, + color: vec4, +} + +struct EmitterParams { + dt: f32, + gravity: vec3, + drag: f32, + alive_count: f32, + pool_size: f32, + pad: vec2, +} + +@group(0) @binding(0) var particles: array; +@group(0) @binding(1) var params: EmitterParams; + +@compute @workgroup_size(64) +fn cs_update() { + let idx = u32(InvocationIndex); + if idx >= u32(params.pool_size) { return; } + + var p = particles[idx]; + + // Skip les particules mortes + if p.life <= 0.0 { return; } + + // Intégration (semi-implicit Euler) + p.vel += params.gravity * params.dt; + p.vel *= max(0.0, 1.0 - params.drag * params.dt); + p.pos += p.vel * params.dt; + + // Vie + p.life -= params.dt; + + // Size + p.size = max(0.0, p.size + p.size_growth * params.dt); + + // Rotation + p.angle += p.angular_vel * params.dt; + + // Fade out + p.color.a = clamp(p.life / p.max_life, 0.0, 1.0); + + // Kill + if p.life <= 0.0 { + p.life = 0.0; + p.color.a = 0.0; + } + + particles[idx] = p; +} +``` + +> **alive_count** : pour la v1, le count est géré par le CPU (le driver compte les +> spawns/kills). Le compute ne fait que l'intégration. Un atomic dans le compute +> serait plus "pur GPU" mais ajoute de la complexité sans gain visible en v1. + +### 8.2 Vertex shader (billboard) + +```wgsl +// particle_billboard.wgsl (vertex) + +struct Particle { + pos: vec3, pad0: f32, + vel: vec3, pad1: f32, + life: f32, max_life: f32, + size: f32, size_growth: f32, + angle: f32, angular_vel: f32, + color: vec4, +} + +struct CameraParams { + view: mat4x4, + proj: mat4x4, +} + +struct VsOut { + @builtin(position) clip: vec4, + @location(0) frag_color: vec4, + @location(1) uv: vec2, +} + +@group(0) @binding(0) var camera: CameraParams; +@group(0) @binding(1) var particles: array; +@group(0) @binding(2) var count_buf: f32; + +const QUAD: array, 4> = array, 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; + + // Early-out si au-delà du count alive + if f32(ii) >= count_buf { + out.clip = vec4(0.0, 0.0, -1.0, 1.0); // hors écran + out.frag_color = vec4(0.0); + out.uv = vec2(0.0); + return out; + } + + let p = particles[ii]; + + // Quad unitaire + let q = QUAD[vi]; + + // Rotation 2D + 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; + + // Billboard : axes caméra (right, up) extraits 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 = q + vec2(0.5); // 0..1 + + return out; +} +``` + +### 8.3 Fragment shader + +```wgsl +// particle_billboard.wgsl (fragment) + +struct FsIn { + @builtin(position) clip: vec4, + @location(0) frag_color: vec4, + @location(1) uv: vec2, +} + +@group(0) @binding(3) var samp: sampler; +@group(0) @binding(4) var tex: texture_2d; + +@fragment +fn fs_main(in: FsIn) -> @location(0) vec4 { + let t = textureSample(tex, samp, in.uv); + return in.frag_color * t; +} +``` + +> Pour le blending **Additive**, le hardware fait `src × 1 + dst × 1` → le résultat +> s'additive naturally. Pour **Alpha**, `src × src.a + dst × (1-src.a)`. +> Le shader est identique dans les 2 cas — le blending est dans le pipeline. + +--- + +## 9. Frame loop (ordre exact) + +``` +App::render_scene(frame) : + │ + ├─ 1. Shadow pass (si ombres) + │ + ├─ 2. Opaque pass (entities, indirect draws) + │ + ├─ 3. PARTICLES (nouveau) + │ │ + │ ├─ Pour chaque pool actif, dans l'ordre : + │ │ + │ │ a. driver.pre_compute(queue, pool, dt) + │ │ → GPU : écrit spawns + params + │ │ → CPU : simule + upload full + │ │ → Manual : upload si dirty + │ │ + │ │ b. Si driver.needs_compute() : + │ │ → encoder.dispatch_workgroups(ceil(N/64)) + │ │ + │ │ c. driver.post_compute(queue, pool) + │ │ + │ │ d. Si driver.needs_draw() : + │ │ → render_pass.set_pipeline(pool.pipeline) + │ │ → render_pass.set_bind_group(0, pool.bind_group) + │ │ → render_pass.draw(4, pool.max_count) // early-out en shader + │ │ + │ └─ (fin pools) + │ + ├─ 4. Post-process (bloom → DoF → fog → tone mapping) + │ + └─ 5. Present +``` + +> **Note sur `draw(4, max_count)`** : on drawe TOUS les slots du pool (max_count +> instances). Le vertex shader fait un early-out (`if ii >= count → clip hors écran`). +> C'est moins élégant qu'un indirect draw mais : +> - Évite un buffer indirect + un compute de réduction +> - Le GPU skip les instances mortes très tôt (vertex shader) +> - Pour 50K instances "mortes", le coût est ~0 (vertex shader trivial) +> +> **V2** : indirect draw avec `alive_count` exact (nécessite un buffer indirect +> écrit par le compute ou un 2e dispatch de réduction). + +--- + +## 10. API utilisateur (résumé) + +### 10.1 Setup + +```rust +impl AppHandler for MyScene { + fn setup(&mut self, app: &mut wsg_lib::App) { + // 1. Créer la texture (optionnel — None = disque blanc) + app.scene.add_texture("fire_tex", fire_texture); + + // 2. Créer le pool + app.scene.create_particle_pool("fire_pool", ParticlePoolConfig { + max_count: 10_000, + texture: Some("fire_tex"), + blending: BlendingMode::Additive, + }).unwrap(); + + // 3. Attacher un driver + app.scene.attach_gpu_emitter("fire_pool", GpuEmitterConfig::fire()).unwrap(); + app.scene.set_emitter_position("fire_pool", [0.0, 0.5, 0.0]); + + // Autre pool avec driver CPU + app.scene.create_particle_pool("debris_pool", ParticlePoolConfig { + max_count: 5_000, + texture: None, + blending: BlendingMode::Alpha, + }).unwrap(); + app.scene.attach_cpu_emitter("debris_pool", CpuEmitterConfig { + // ... + custom_force: Some(Arc::new(|p, scene| { + // Attraction vers le centre + -p.pos.map(|c| c * 2.0) + })), + ..CpuEmitterConfig::explosion() + }).unwrap(); + + // Pool manual + app.scene.create_particle_pool("custom_pool", ParticlePoolConfig { + max_count: 1_000, + texture: None, + blending: BlendingMode::Alpha, + }).unwrap(); + app.scene.attach_manual("custom_pool").unwrap(); + } +} +``` + +### 10.2 Per-frame + +```rust +impl AppHandler for MyScene { + fn update(&mut self, app: &mut wsg_lib::App) { + // GPU emitter : rien à faire (le driver gère tout) + + // CPU emitter : rien à faire non plus (le driver simule) + + // Manual : l'utilisateur écrit + if let Some(handle) = app.scene.manual_handle("custom_pool") { + for i in 0..100 { + let t = (i as f32 / 100.0) * std::f32::consts::TAU; + handle.set(i, Particle { + pos: [t.cos() * 2.0, 0.0, t.sin() * 2.0], + vel: [0.0; 3], + life: 1.0, max_life: 1.0, + size: 0.1, size_growth: 0.0, + angle: t, angular_vel: 1.0, + color: [1.0, 1.0, 1.0, 1.0], + ..Particle::ZERO + }); + } + handle.set_count(100); + handle.upload(); + } + } +} +``` + +### 10.3 Runtime control + +```rust +// Pauser/reprendre un pool +scene.set_pool_active("fire_pool", false); +scene.set_pool_active("fire_pool", true); + +// Repositionner un émetteur +scene.set_emitter_position("fire_pool", [1.0, 2.0, 3.0]); + +// Burst (émettre N particules d'un coup, en plus du rate continu) +scene.emitter_burst("fire_pool", 200); + +// Changer le driver (swap, pas de reallocation) +scene.detach_driver("fire_pool"); +scene.attach_cpu_emitter("fire_pool", CpuEmitterConfig::smoke()).unwrap(); +``` + +--- + +## 11. Méthodes Scene (nouveaux) + +```rust +impl Scene { + /// Créer un pool de particules (alloue le buffer + pipeline). + /// Le pool est inactif jusqu'à l'attachement d'un driver. + pub fn create_particle_pool(&mut self, id: &str, config: ParticlePoolConfig) -> Result<(), String>; + + /// Attacher un driver GPU (compute emitter) au pool. + /// Remplace tout driver précédemment attaché. + pub fn attach_gpu_emitter(&mut self, pool_id: &str, config: GpuEmitterConfig) -> Result<(), String>; + + /// Attacher un driver CPU (simulation Rust + upload) au pool. + pub fn attach_cpu_emitter(&mut self, pool_id: &str, config: CpuEmitterConfig) -> Result<(), String>; + + /// Attacher le mode manual (l'utilisateur écrit le buffer directement). + pub fn attach_manual(&mut self, pool_id: &str) -> Result<(), String>; + + /// Détacher le driver (le pool devient inactif). + pub fn detach_driver(&mut self, pool_id: &str); + + /// Définir la position d'un émetteur (driver GPU/CPU). + pub fn set_emitter_position(&mut self, pool_id: &str, pos: [f32; 3]); + + /// Pauser/reprendre un pool. + pub fn set_pool_active(&mut self, pool_id: &str, active: bool); + + /// Burst : émettre N particules immédiatement (en plus du rate). + pub fn emitter_burst(&mut self, pool_id: &str, count: u32); + + /// Obtenir un handle pour le driver manual (None si pas en mode manual). + pub fn manual_handle(&mut self, pool_id: &str) -> Option>; +} +``` + +--- + +## 12. Non-régression et opt-in + +| Condition | Coût | +|-----------|------| +| Aucun pool créé | **Zéro**. Pas de buffer, pas de pipeline, pas de draw. | +| Pool créé, pas de driver | Buffer alloué (N × 64 B). Pas de compute, pas d'upload, pas de draw. | +| Pool + driver, `active = false` | Idem ci-dessus. | +| Pool + driver GPU actif | 1 compute dispatch + 1 draw. CPU : spawns seulement. | +| Pool + driver CPU actif | 1 write_buffer + 1 draw. CPU : O(N) simulation. | +| Pool + driver Manual actif | 1 write_buffer (si dirty) + 1 draw. | + +**Zéro impact** sur les scènes existantes sans particules. + +--- + +## 13. Texture par défaut (si `texture: None`) + +Texture **16×16** générée au build : + +``` + . . . . . . . . + . + + + + + + . + . + # # # # + . + . + # # # # + . + . + # # # # + . + . + # # # # + . + . + + + + + + . + . . . . . . . . +``` + +Disque doux (alpha radiale : 1.0 au centre → 0.0 au bord). RGBA blanc. +Utilisée par défaut si l'utilisateur ne fournit pas de texture. + +--- + +## 14. Limitations v1 + +| Limitation | Justification | V2 | +|------------|---------------|-----| +| 1 driver par pool | Simplifie la gestion du buffer | Multi-drivers (rangs du pool) | +| 1 blending par pool | Pipeline figé | Multi-pipelines par pool | +| Fade alpha linéaire | Simple, couvre 90% | Color ramp (mix start→end) | +| Pas de collision particule↔objet | O(n×m) | Driver CPU + custom_force | +| Pas de collision particule↔particule | O(n²) | Spatial hash (V3) | +| `draw(4, max_count)` + early-out | Simple, pas de buffer indirect | Indirect draw (V2) | +| Spawn CPU uniquement | Le random au spawn est CPU | GPU spawn (curated random) | +| Pas de texture sheet (UV animation) | 1 UV par particule | UV offset + animation | +| Pas de sorting (alpha) | Additive = commutatif. Alpha = approximation | Back-to-front sort (V2) | + +--- + +## 15. Questions ouvertes (à trancher par DRAFT) + +| # | Question | Tendance | Impact | +|---|----------|----------|--------| +| 1 | `draw(4, max_count)` vs indirect draw | v1 : early-out en shader. v2 : indirect. | Complexité du pipeline | +| 2 | alive_count : CPU count vs atomic GPU | v1 : CPU (driver compte). Simple. | Précision du count | +| 3 | Multi-pools même texture : partager le pipeline ? | Oui (Arc partagé) | Mémoire | +| 4 | Le pool est-il `Send` ? (multi-thread spawn) | Oui — le buffer est GPU, le driver est `Send` | Flexibilité | +| 5 | `emitter_burst` : overwrite les plus vieilles si pool saturé ? | Oui (ring buffer sémantique) | Robustesse | +| 6 | Le driver CPU fait-il un upload **full** ou **dirty range** ? | v1 : full (simple). V2 : dirty range. | Bandwidth | +| 7 | Les pools sont-ils rendus dans un seul render pass ou un par pool ? | Un seul pass, `set_pipeline` + `set_bind_group` par pool | Draw calls | +| 8 | Faut-il un `ParticlePool::alive_count()` public (pour l'UI) ? | Oui (le driver maintient le count) | Debug | + +--- + +## 16. Estimation de complexité (par DRAFT) + +| DRAFT | Contenu | Lignes estimées | +|-------|---------|-----------------| +| **Étape A** | Struct `Particle` + `ParticlePoolConfig` + enums + `ParticlePool` (buffer + pipeline + draw) | ~250 | +| **Étape B** | Compute shader + driver GPU (spawn + dispatch) | ~200 | +| **Étape C** | Driver CPU (simulation Rust + upload) + `custom_force` | ~150 | +| **Étape D** | Driver Manual + `ManualPoolHandle` | ~100 | +| **Étape E** | Intégration Renderer (frame loop, order) + `Scene` methods | ~150 | +| **Étape F** | Presets + Example `particles.rs` | ~150 | +| **Étape G** | Tests (WGSL validate + layout + pool) | ~80 | +| **Total** | | **~1 080 lines** | + +Chaque étape est un DRAFT séparé, testable indépendamment. + +--- + +## 17. Résumé des décisions + +| # | Décision | Justification | +|---|----------|---------------| +| D1 | **Pool ≠ Driver** (séparation stricte) | Flexibilité, swappability, zéro waste | +| D2 | **3 drivers** : GPU, CPU, Manual | Gradient de contrôle | +| D3 | **1 driver par pool** (v1) | Simplicité. Multi-drivers = V2. | +| D4 | **64 bytes/particule** | Couvre pos/vel/life/size/angle/color. Align 16. | +| D5 | **Billboard camera-facing** (pas world-facing) | Standard pour les VFX. Plus simple. | +| D6 | **Quad via vertex_index** (pas de vertex buffer) | Cohérent avec TM/bloom. 0 buffer vertices. | +| D7 | **`draw(4, max_count)` + early-out** (v1) | Pas de buffer indirect. GPU skip les mortes. | +| D8 | **Spawn toujours CPU** (même driver GPU) | Le random + la décision "qui spawn" est CPU. Le GPU intègre. | +| D9 | **Blend figé au pipeline** (1 mode par pool) | Pas de switch de pipeline par frame. | +| D10 | **Depth test oui, depth write non** | Transparence correcte sans artefacts. | +| D11 | **Texture par défaut : disque 16×16** | Zéro config pour un résultat acceptable. | +| D12 | **Pool inactif si pas de driver** | Zéro coût. L'allocation buffer est le seul coût. | +| D13 | **Preset methods** (`GpuEmitterConfig::fire()`) | UX : 1 ligne pour un effet correct. | +| D14 | **`custom_force` (driver CPU)** | Le seul cas où CPU > GPU : interactions. |