From 49aa9e48fda8dd3887579ad8485e785597d3fc75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Sat, 26 Sep 2026 12:34:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(particles):=20=C3=89tape=2028=20A=20?= =?UTF-8?q?=E2=80=94=20ParticlePool=20infrastructure=20(no=20driver)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 3 +- docs/DRAFT.md | 7 +- lib/src/core/mod.rs | 2 + lib/src/core/particles.rs | 361 ++++++++++++++++++++++++ lib/src/prelude.rs | 7 +- lib/src/resources/mod.rs | 2 + lib/src/resources/particle.rs | 104 +++++++ lib/src/scene/scene.rs | 66 ++++- lib/src/shaders/particle_billboard.wgsl | 86 ++++++ lib/src/utils/conf.rs | 6 + lib/src/utils/mod.rs | 4 +- lib/tests/wgsl_validate.rs | 31 ++ 12 files changed, 669 insertions(+), 10 deletions(-) create mode 100644 lib/src/core/particles.rs create mode 100644 lib/src/resources/particle.rs create mode 100644 lib/src/shaders/particle_billboard.wgsl diff --git a/AGENTS.md b/AGENTS.md index 73c7796..028c69c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,9 +43,10 @@ WGPU doesn't have a native "Context" object — this type groups them together f ## Gotchas - Rust 2024 edition is used. Ensure your Rust toolchain supports it (`rustup update`). - wgpu 30.0.0 is pinned in `lib/Cargo.toml`. The comment says "check the latest version" — verify compatibility before upgrading. -- Cargo features gate primitives (`prim-*`, `all-prims` is default) and importers (`import-obj`, `import-gltf`); the `import` example is `required-features = ["import-obj"]`. 127 tests exist (`cargo test --workspace`). +- Cargo features gate primitives (`prim-*`, `all-prims` is default) and importers (`import-obj`, `import-gltf`); the `import` example is `required-features = ["import-obj"]`. 138 tests exist (`cargo test --workspace`, incl. particle layout + billboard WGSL validation). - The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally. - **WGSL `select` argument order** (cost us a day): `select(reject, accept, cond)` returns the **second** arg when `cond` is true — the reverse of HLSL's `select(trueVal, falseVal, cond)`. In `shaders/gpu_driven.wgsl` the cull pass must stay `select(0u, u32(flags.z), visible)` (visible ⇒ full count, culled ⇒ 0). Swapped args silently zero the counts of every visible entity → black window. See the GOTCHA comment at the top of that shader. +- **wgpu 30 API drift** (verified this session): `BufferInitDescriptor` has a `contents: &[u8]` field (not `data`) and `create_buffer_init` comes from the `wgpu::util::DeviceExt` trait (import it, as in `mesh.rs`). `BlendState::ALPHA_BLENDING` is the alpha-blend constant (there is no `ALPHA`); `DepthStencilState` has **no** `Default` impl — write `stencil`/`bias` fields explicitly. `min_binding_size` is `Option>`. bytemuck 1.25: `Zeroable::zeroed()` is not `const` (const traits unstable) — use a const literal for `ZERO`-style constants. For layout-offset tests prefer `std::mem::offset_of!` (stable 1.77, no unsafe). - **LOD UV blending: never fold integer-tile jumps, freeze seam twins instead** (cost us a day, 2026-09-23): a UV *seam* is two copies of the same 3-D point on integer-apart UVs (u=0/u=1 columns) — it is NOT a mesh edge, so the decimation must record the weld's refused pairs and **freeze** those twins (any edge touching one is excluded from the PQ). A co-facial edge spanning a whole tile (cone apex v=1 ↔ base v=0) is a *legit* chart span — the chart is bilinear, so the UVs **blend linearly** (fold the integer jump to zero and the apex UV smears down the cone side). And the attribute-aware weld refuses a Δ of *exactly* 0.5 (ambiguous: seam at its widest vs legit half-tile jump — the cone's u=1 column vs the cap-disc chart sits exactly there). See the comments in `geometry.rs` (`welded`, `Collapse::collapse_edge`) and the cone/seam regression tests. diff --git a/docs/DRAFT.md b/docs/DRAFT.md index e0d4984..33db3c8 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -60,9 +60,9 @@ seront repris **après** le système de particules (phase 7). ``` lib/ -├── shaders/ -│ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main (pas de compute) └── 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/ @@ -373,8 +373,7 @@ impl Scene { // Construire le pool (buffers + pipeline + bind group) let gpu = self.gpu(); let pool = ParticlePool::new( - &gpu.device, - &gpu.queue, + gpu.device.as_ref(), gpu.format, gpu.sample_count, &config, diff --git a/lib/src/core/mod.rs b/lib/src/core/mod.rs index 27abade..6ea6739 100644 --- a/lib/src/core/mod.rs +++ b/lib/src/core/mod.rs @@ -19,6 +19,7 @@ pub mod geometry; pub mod hdr; pub mod lod; pub mod msaa; +pub mod particles; pub mod renderer; pub mod shadow; pub mod transform; @@ -34,6 +35,7 @@ pub use geometry::{BBox, Geometry, GeometryError}; pub use hdr::ToneMapper; pub use lod::{lod_level, projected_radius_px}; pub use msaa::MsaaConfig; +pub use particles::{BlendingMode, ParticleDriver, ParticlePool, ParticlePoolConfig}; pub use renderer::Renderer; pub use shadow::ShadowConfig; pub use transform::Transform; diff --git a/lib/src/core/particles.rs b/lib/src/core/particles.rs new file mode 100644 index 0000000..d20ac57 --- /dev/null +++ b/lib/src/core/particles.rs @@ -0,0 +1,361 @@ +//! # Particles — Pool Infrastructure (Étape 28 A) +//! +//! Implements the **pool** side of the Pool ≠ Driver architecture +//! (ARCHI_PARTICULES §1–§3, §6–§8): the pool owns the GPU resources — the +//! per-particle state buffer (80 B/slot, D15/D19), the compaction index buffer +//! and the indirect draw args (D17), the per-pool camera-params uniform, the +//! billboard render pipeline and its bind group — while *drivers* (GPU/CPU/Manual, +//! Étapes B/C/D) provide the simulation logic through the [`ParticleDriver`] trait. +//! +//! A pool without an attached driver costs nothing at render time: its indirect +//! args stay zero, so the draw is a no-op (D12). Pools are created through +//! [`Scene::create_particle_pool`](crate::scene::Scene::create_particle_pool). + +use crate::pipeline::DEPTH_FORMAT; +use crate::resources::Particle; +use crate::utils::PARTICLE_BILLBOARD_SHADER; +use wgpu::util::DeviceExt; + +/// Default pool capacity (particle slots). +pub const DEFAULT_POOL_CAPACITY: u32 = 1024; + +/// Size in bytes of the per-pool camera-params uniform (view + proj — a prefix +/// of `FrameUniforms`, so it can later be fed from the same buffer). +pub const CAMERA_PARAMS_SIZE: u64 = 128; + +/// Blend mode frozen at pipeline creation (D9): one mode per pool. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum BlendingMode { + /// Standard alpha blending (`SrcAlpha`/`OneMinusSrcAlpha` on color, + /// `One`/`OneMinusSrcAlpha` on alpha). + #[default] + Alpha, + /// Additive blending (`One`/`One` on both) — flames, sparks, glows. + Additive, +} + +impl BlendingMode { + /// The wgpu blend state corresponding to this mode. + fn state(&self) -> wgpu::BlendState { + match self { + BlendingMode::Alpha => wgpu::BlendState::ALPHA_BLENDING, + BlendingMode::Additive => wgpu::BlendState::ADDITIVE, + } + } +} + +/// Configuration for [`ParticlePool::new`]. +#[derive(Clone, Debug)] +pub struct ParticlePoolConfig { + /// Pool capacity (particle slots). Default: [`DEFAULT_POOL_CAPACITY`]. + pub max_count: u32, + /// Id of a texture already registered in the Scene. `None` → the built-in + /// 16×16 disc (D11). + pub texture: Option, + /// Blend mode frozen at pipeline creation (D9). + pub blending: BlendingMode, +} + +impl Default for ParticlePoolConfig { + fn default() -> Self { + Self { + max_count: DEFAULT_POOL_CAPACITY, + texture: None, + blending: BlendingMode::default(), + } + } +} + +/// Common trait implemented by every driver type (ARCHI §4.1). The pool calls +/// these methods in order each frame (wired into the render loop at Étape E). +pub trait ParticleDriver: Send { + /// Called BEFORE the compute (if the driver is GPU) or before the draw. + /// The driver may write into the pool's buffers (spawns, CPU updates). + fn pre_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool, dt: f32); + /// Called AFTER the compute (GPU driver only). Lets the driver update + /// post-simulation uniforms. + fn post_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool); + /// Does the driver want a compute dispatch this frame? + fn needs_compute(&self) -> bool; + /// Does the driver want the pool drawn? + fn needs_draw(&self) -> bool; +} + +/// The particle pool: all GPU resources needed to simulate and draw particles, +/// without the simulation logic (which is the driver's job, D1). +/// +/// `allow(dead_code)`: in Étape 28 A the resources are created but not yet consumed — +/// Étapes B/E wire the driver, the compaction dispatch and the indirect draw into the +/// render loop. The allow keeps the "zero warnings" acceptance criterion in the meantime. +#[allow(dead_code)] +pub struct ParticlePool { + /// Per-particle state: N × 80 B (D15/D19). STORAGE | COPY_DST, zeroed (all dead). + pub(crate) particle_data: wgpu::Buffer, + /// Compaction index: N × u32, one per slot (D17/D19). STORAGE | COPY_DST, zeroed. + pub(crate) compact_index: wgpu::Buffer, + /// Indirect draw args: 16 B (4 × u32) (D17). Zeroed → the draw is a no-op (D12). + pub(crate) indirect_args: wgpu::Buffer, + /// Per-pool camera params: 128 B (view + proj). UNIFORM | COPY_DST, owned by + /// the pool; the renderer writes it each frame (Étape E). + pub(crate) camera_params: wgpu::Buffer, + /// Billboard render pipeline (empty vertex layout, D6). + pub(crate) pipeline: wgpu::RenderPipeline, + /// The pool's bind group layout (5 bindings, ARCHI §6). + pub(crate) layout: wgpu::BindGroupLayout, + /// The single render bind group, built once at creation (D17/D19: the pool + /// owns all its buffers — self-contained). + pub(crate) bind_group: wgpu::BindGroup, + /// Texture view bound in the group (a scene texture, or the owned default disc, D11). + pub(crate) texture_view: wgpu::TextureView, + /// Pool capacity (particle slots). + pub max_count: u32, + /// Blend mode frozen at pipeline creation (D9). + pub blending: BlendingMode, + /// Attached driver (Étapes B/C/D). `None` → the pool is inactive (D12). + pub(crate) driver: Option>, +} + +impl ParticlePool { + /// Creates the pool's GPU resources: the 4 buffers, the shader module, the + /// bind group layout, the render pipeline (eagerly compiled) and the bind group. + pub fn new( + device: &wgpu::Device, + format: wgpu::TextureFormat, + sample_count: u32, + config: &ParticlePoolConfig, + texture_view: wgpu::TextureView, + sampler: wgpu::Sampler, + ) -> Self { + let n = config.max_count; + + // --- Buffers (ARCHI §3.2/§6) — zeroed: all particles dead, empty draw args. + let particle_data = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("particle pool: particle_data"), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + contents: &vec![0u8; n as usize * Particle::SIZE as usize], + }); + let compact_index = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("particle pool: compact_index"), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + contents: &vec![0u8; n as usize * 4], + }); + let indirect_args = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("particle pool: indirect_args"), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + contents: &[0u8; 16], + }); + let camera_params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("particle pool: camera_params"), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + contents: &vec![0u8; CAMERA_PARAMS_SIZE as usize], + }); + + // --- Shader + bind group layout (5 bindings, ARCHI §6). + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("particle_billboard"), + source: wgpu::ShaderSource::Wgsl(PARTICLE_BILLBOARD_SHADER.into()), + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("particle pool: bind group layout"), + entries: &[ + // 0: camera params (uniform, 128 B) + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // 1: particle state (storage read-only) + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // 2: compaction index (storage read-only, D17/D19) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // 3: sampler + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // 4: particle texture + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }); + + // --- Render pipeline: EMPTY vertex layout (D6/D17/D19) — the quad is + // generated in the shader (QUAD[vi]), the instance slot comes from + // storage binding 2. + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("particle pool: pipeline layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let blend = config.blending.state(); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("particle pool: billboard pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], // empty layout (D17/D19) + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), // D10 + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + ..Default::default() + }, + multiview_mask: None, + cache: None, + }); + + // --- Bind group (built once: the pool owns all its buffers, D17/D19). + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("particle pool: bind group"), + layout: &layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: particle_data.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: compact_index.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&texture_view), + }, + ], + }); + + Self { + particle_data, + compact_index, + indirect_args, + camera_params, + pipeline, + layout, + bind_group, + texture_view, + max_count: n, + blending: config.blending, + driver: None, + } + } +} + +/// RGBA bytes of the built-in 16×16 soft disc (D11), used when a pool has no texture. +/// A radial gradient (opaque center → transparent edge), white. +pub(crate) fn default_disc_rgba() -> 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) * 255.0) as u8; + let i = (y * size + x) * 4; + data[i] = 255; + data[i + 1] = 255; + data[i + 2] = 255; + data[i + 3] = alpha; + } + } + data +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pool_config_default() { + let c = ParticlePoolConfig::default(); + assert_eq!(c.max_count, DEFAULT_POOL_CAPACITY); + assert!(c.texture.is_none()); + assert_eq!(c.blending, BlendingMode::Alpha); + } + + #[test] + fn default_disc_size() { + assert_eq!(default_disc_rgba().len(), 16 * 16 * 4); + } + + #[test] + fn default_disc_center_opaque() { + let d = default_disc_rgba(); + // 16×16 is even: the exact center falls between the four middle pixels, so the + // highest alpha is 1 - sqrt(2)/15 ≈ 0.905 → 230, not 255. The soft disc must still + // be (nearly) opaque at its core. + let i = (7 * 16 + 7) * 4; + assert!(d[i + 3] >= 200, "center alpha was {}", d[i + 3]); + } + + #[test] + fn default_disc_corner_transparent() { + let d = default_disc_rgba(); + assert_eq!(d[3], 0); // pixel (0, 0) + assert_eq!(d[(15 * 16 + 15) * 4 + 3], 0); // pixel (15, 15) + } +} diff --git a/lib/src/prelude.rs b/lib/src/prelude.rs index a509582..ffc7edc 100644 --- a/lib/src/prelude.rs +++ b/lib/src/prelude.rs @@ -15,8 +15,11 @@ // Core types pub use crate::core::geometry::{BBox, Geometry}; pub use crate::core::transform::Transform; -pub use crate::core::{BloomConfig, DoFConfig, FogConfig, FogMode, MsaaConfig, ShadowConfig, ToneMapper}; -pub use crate::resources::Material; +pub use crate::core::{ + BlendingMode, BloomConfig, DoFConfig, FogConfig, FogMode, MsaaConfig, ParticleDriver, + ParticlePool, ParticlePoolConfig, ShadowConfig, ToneMapper, +}; +pub use crate::resources::{Material, Particle}; // Camera pub use crate::camera::{Camera, CameraController}; diff --git a/lib/src/resources/mod.rs b/lib/src/resources/mod.rs index b2926bf..3bc5aac 100644 --- a/lib/src/resources/mod.rs +++ b/lib/src/resources/mod.rs @@ -9,6 +9,7 @@ pub mod material; pub mod mesh; +pub mod particle; pub mod texture; pub mod uniform; pub mod vertex; @@ -16,6 +17,7 @@ pub mod vertex; // Re-exports pub use material::Material; pub use mesh::{LodMode, Mesh, PackError}; +pub use particle::Particle; pub use texture::{Texture, TextureError}; pub use uniform::{ BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, CullUniforms, DRAW_SLOT_SIZE, DrawSlot, diff --git a/lib/src/resources/particle.rs b/lib/src/resources/particle.rs new file mode 100644 index 0000000..a693c5b --- /dev/null +++ b/lib/src/resources/particle.rs @@ -0,0 +1,104 @@ +//! # Particle Resource — Per-Particle GPU State (Étape 28) +//! +//! CPU-side mirror of the WGSL `Particle` struct (particle system, Step 28 A): one +//! **80-byte** flat record per slot in the pool's storage buffer (ARCHI_PARTICULES §2, +//! decisions D15/D19). +//! +//! The layout is **padding-free**: the buffer lives in WGSL *storage* space where +//! `vec3`/`vec4` have alignment 4 (uniform space would enforce alignment 16), +//! so the Rust `#[repr(C)]` layout matches the WGSL struct field-for-field with no +//! padding. The size and the per-field offsets are pinned by tests below. + +use bytemuck::{Pod, Zeroable}; + +/// Per-particle state, 80 bytes — the CPU mirror of the WGSL `Particle` struct +/// (`shaders/particle_billboard.wgsl`, and later `particle_update.wgsl`). +/// +/// A slot is *dead* when `life == 0.0` (a fresh pool buffer is all-dead). +/// Drivers (GPU/CPU/Manual) write these records; the billboard render pass reads them. +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable, Default)] +pub struct Particle { + /// World position (xyz). Offset 0. + pub pos: [f32; 3], + /// Velocity (xyz). Offset 12. + pub vel: [f32; 3], + /// Remaining lifetime (seconds). Offset 24. `0.0` = dead. + pub life: f32, + /// Initial lifetime (seconds) — normalizes the fade (`life / max_life`). Offset 28. + pub max_life: f32, + /// Current size (world units). Offset 32. + pub size: f32, + /// Size growth (units per second; positive grows, negative shrinks). Offset 36. + pub size_growth: f32, + /// Current 2D rotation (radians). Offset 40. + pub angle: f32, + /// Angular velocity (radians per second). Offset 44. + pub angular_vel: f32, + /// RGBA color (the alpha component is driven by the integrator, D18). Offset 48. + pub color: [f32; 4], + /// UV rect `(ox, oy, sx, sy)` — atlas support (D15). Offset 64. + pub uv_rect: [f32; 4], +} + +impl Particle { + /// Size of one slot in the pool's storage buffer: **80 bytes** (pinned by tests). + pub const SIZE: u64 = std::mem::size_of::() as u64; + + /// The all-dead particle (`life == 0.0`). A fresh pool buffer is this record + /// repeated N times. + pub const ZERO: Self = Self { + pos: [0.0; 3], + vel: [0.0; 3], + life: 0.0, + max_life: 0.0, + size: 0.0, + size_growth: 0.0, + angle: 0.0, + angular_vel: 0.0, + color: [0.0; 4], + uv_rect: [0.0; 4], + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::size_of; + + /// D15/D19: the layout is exactly 80 bytes, without any padding. + #[test] + fn particle_size_is_80() { + assert_eq!(size_of::(), 80); + assert_eq!(Particle::SIZE, 80); + } + + /// Storage space: the maximum field alignment is 4 (vec3/vec4 are align 4 in + /// storage, not uniform space) — so no padding is needed anywhere. + #[test] + fn particle_align_is_4() { + assert_eq!(std::mem::align_of::(), 4); + } + + /// The offsets must match the WGSL `Particle` struct exactly (storage space, + /// no padding). A drift here silently corrupts the whole pool on the GPU. + #[test] + fn particle_field_offsets() { + assert_eq!(std::mem::offset_of!(Particle, pos), 0); + assert_eq!(std::mem::offset_of!(Particle, vel), 12); + assert_eq!(std::mem::offset_of!(Particle, life), 24); + assert_eq!(std::mem::offset_of!(Particle, max_life), 28); + assert_eq!(std::mem::offset_of!(Particle, size), 32); + assert_eq!(std::mem::offset_of!(Particle, size_growth), 36); + assert_eq!(std::mem::offset_of!(Particle, angle), 40); + assert_eq!(std::mem::offset_of!(Particle, angular_vel), 44); + assert_eq!(std::mem::offset_of!(Particle, color), 48); + assert_eq!(std::mem::offset_of!(Particle, uv_rect), 64); + } + + #[test] + fn particle_zero_is_dead() { + assert_eq!(Particle::ZERO.life, 0.0); + assert_eq!(Particle::ZERO, Particle::default()); + } +} diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index a808f10..c707f2a 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -15,7 +15,9 @@ //! instead of `App`. It can therefore build materials and meshes itself (`add_material_shader`, `create_mesh`) and inject //! a default material for meshes that carry none (`default_material`). -use crate::core::{Geometry, Transform}; +use crate::core::{ + particles::{default_disc_rgba, ParticlePool, ParticlePoolConfig}, Geometry, Transform, +}; use crate::pipeline::PipelineCache; use crate::camera::Camera; use crate::lights::Lights; use crate::resources::{BBoxSlot, Material, Mesh, Texture, TransformSlot}; use crate::scene::Entity; @@ -31,8 +33,13 @@ use std::sync::Arc; struct SceneGpu { /// Shared GPU device used to create mesh buffers and compile pipelines. device: Arc, + /// GPU command queue (uploads). Kept for resources built at creation time + /// (e.g. the default particle disc texture, Étape 28 A). + queue: wgpu::Queue, /// Surface texture output format, required to build fragment pipelines. format: wgpu::TextureFormat, + /// MSAA sample count (pipeline multisample state). + sample_count: u32, /// Shader compilation cache: compiles/caches RenderPipelines keyed by shader_id + format. cache: RefCell, } @@ -82,6 +89,9 @@ pub struct Scene { meshes: HashMap>, /// Map of material identifiers to owned `Arc` instances. Populated via `add_material()`. materials: HashMap>, + /// Map of particle pool identifiers to owned `Arc` instances (Étape 28 A). + /// Populated via `create_particle_pool()`. + particle_pools: HashMap>, /// Map of diffuse texture identifiers to owned `Arc` instances (Step 10, D4). /// Populated via `add_texture()`; materials reference them via `add_material_texture()` by id. textures: HashMap>, @@ -124,6 +134,7 @@ impl Scene { meshes: HashMap::new(), materials: HashMap::new(), textures: HashMap::new(), + particle_pools: HashMap::new(), entities: HashMap::new(), camera: Camera::default(), gpu: None, @@ -153,7 +164,9 @@ impl Scene { let cache = PipelineCache::new(device.clone(), queue.clone(), sample_count); self.gpu = Some(SceneGpu { device, + queue, format, + sample_count, cache: RefCell::new(cache), }); self @@ -262,6 +275,57 @@ impl Scene { self.textures.get(id) } + /// Étape 28 A : crée un pool de particules (infra GPU, sans driver). + /// + /// Le pool possède ses buffers (état 80 B/slot, index de compaction, args indirect, + /// camera params), son pipeline billboard et son bind group (ARCHI §3.2/§6). + /// `config.texture` est l'id d'une texture enregistrée via `add_texture` ; + /// `None` → disque 16×16 intégré (D11). Un pool sans driver ne draw rien (D12). + 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 '{id}' already exists")); + } + let gpu = self.gpu(); + let (texture_view, sampler) = match &config.texture { + Some(tex_id) => { + let tex = self + .textures + .get(tex_id) + .ok_or_else(|| { + format!("texture '{tex_id}' not found — register it via add_texture first") + })?; + (tex.view.clone(), tex.sampler.clone()) + } + None => { + let disc = Texture::from_rgba8( + gpu.device.as_ref(), + &gpu.queue, + 16, + 16, + &default_disc_rgba(), + "particle default disc", + ) + .map_err(|e| format!("failed to build default disc texture: {e}"))?; + (disc.view, disc.sampler) + } + }; + 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(()) + } + /// Builds and registers a Material from a shader id **and** a diffuse texture registered via /// [`Scene::add_texture`]. The material samples `texture_id` (Step 10, D4). Returns Ok(id) or /// Err(String) if the material id exists or the texture id does not. Inputs: id (material id to diff --git a/lib/src/shaders/particle_billboard.wgsl b/lib/src/shaders/particle_billboard.wgsl new file mode 100644 index 0000000..8b9a0b1 --- /dev/null +++ b/lib/src/shaders/particle_billboard.wgsl @@ -0,0 +1,86 @@ +// Particle billboard shader (Étape 28 A) — vertex + fragment, no compute yet. +// +// EMPTY vertex layout: the quad is generated in the shader (QUAD[vi], 6 vertices = +// 2 triangles) and each instance's pool slot arrives via a STORAGE binding +// (`compact_index[ii]`), not as a vertex attribute (ARCHI D17/D19 — WebGPU-safe, +// same pattern as the TM fullscreen triangle). +// The instance count comes from the indirect args written by the compaction (D17): +// no early-out, no count uniform — a zeroed args buffer makes the draw a no-op (D12). + +struct Particle { // 80 B — storage space: vec3/vec4 align 4, no padding (D19) + pos: vec3, // offset 0 + vel: vec3, // offset 12 + life: f32, // offset 24 — 0.0 = dead + max_life: f32, // offset 28 + size: f32, // offset 32 + size_growth: f32, // offset 36 + angle: f32, // offset 40 + angular_vel: f32, // offset 44 + color: vec4, // offset 48 + uv_rect: vec4, // offset 64 — (D15) (ox, oy, sx, sy) +} + +struct CameraParams { // 128 B — prefix of FrameUniforms (view + proj) + 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 compact_index: array; // (D17/D19) + +// 6 entries = 2 triangles (0-1-2, 3-4-5) forming one quad. +// Without an index buffer, draw(4, n) would yield a single triangle + 1 orphan vertex. +const QUAD: array, 6> = array, 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; + + // Instance slot via storage (D17/D19) — no early-out: the instance count is + // exactly the number of alive particles (indirect args). + let slot = compact_index[ii]; + let p = particles[slot]; + + let q = QUAD[vi]; + + // 2D rotation in the billboard plane + 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; + + // Camera-facing axes (columns 0 and 1 of the 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; + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let t = textureSample(tex, samp, in.uv); + return in.frag_color * t; +} diff --git a/lib/src/utils/conf.rs b/lib/src/utils/conf.rs index 3263f95..742ed99 100644 --- a/lib/src/utils/conf.rs +++ b/lib/src/utils/conf.rs @@ -64,6 +64,12 @@ pub const DOF_COC_SHADER: &str = include_str!("../shaders/dof_coc.wgsl"); /// DoF blur shader (Étape 26). pub const DOF_BLUR_SHADER: &str = include_str!("../shaders/dof_blur.wgsl"); +/// Particle billboard shader (Étape 28 A), embedded at compile time. +/// Carries one vertex entry point (`vs_main` — EMPTY layout: quad via +/// `@builtin(vertex_index)`, instance slot via storage binding, D17/D19) and one fragment +/// entry point (`fs_main`). Compiled directly by `ParticlePool` (library-internal pipeline). +pub const PARTICLE_BILLBOARD_SHADER: &str = include_str!("../shaders/particle_billboard.wgsl"); + /// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and /// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU /// rewrites only the transform slots and the cull uniforms. diff --git a/lib/src/utils/mod.rs b/lib/src/utils/mod.rs index 92be4eb..07478ba 100644 --- a/lib/src/utils/mod.rs +++ b/lib/src/utils/mod.rs @@ -14,7 +14,7 @@ pub mod error; // Re-exports pub use conf::{ - SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, SHADOW_SHADER, SHADOW_SHADER_PATH, - STANDARD_SHADER, STANDARD_SHADER_PATH, + PARTICLE_BILLBOARD_SHADER, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, + SHADOW_SHADER, SHADOW_SHADER_PATH, STANDARD_SHADER, STANDARD_SHADER_PATH, }; pub use error::WsgError; diff --git a/lib/tests/wgsl_validate.rs b/lib/tests/wgsl_validate.rs index 6aac9f0..8075ac3 100644 --- a/lib/tests/wgsl_validate.rs +++ b/lib/tests/wgsl_validate.rs @@ -86,6 +86,37 @@ fn gpu_driven_shader_is_valid_wgsl() { ); } +/// Parses and fully validates the embedded `particle_billboard.wgsl` shader (Étape 28 A) via naga. +/// The particle pool compiles it into one `RenderPipeline` (empty vertex layout — quad via +/// `@builtin(vertex_index)`, instance slot via storage binding, D17/D19), so this offline +/// validation is the guarantee of its validity. The contract expects two entry points: +/// `vs_main` + `fs_main` (no compute in this step). +#[test] +fn particle_billboard_shader_is_valid_wgsl() { + let src = include_str!("../src/shaders/particle_billboard.wgsl"); + let module = naga::front::wgsl::parse_str(src) + .unwrap_or_else(|e| panic!("particle_billboard.wgsl: parsing error: {e:?}")); + + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .unwrap_or_else(|e| panic!("particle_billboard.wgsl: validation failed: {e:?}")); + + let entry_names: Vec<&str> = module + .entry_points + .iter() + .map(|ep| ep.name.as_str()) + .collect(); + assert_eq!( + entry_names, + vec!["vs_main", "fs_main"], + "the two entry points (vs + fs) are expected" + ); +} + /// Parses and fully validates the embedded `tonemap.wgsl` shader (Étape 20) via naga. /// The renderer compiles it into one `RenderPipeline` (vertex `vs_main` + one of the two /// fragment entry points `fs_aces` / `fs_reinhard`), so this offline validation is the