feat(particles): Étape 28 A — ParticlePool infrastructure (no driver)

Creates the GPU resource layer for particles per ARCHI §3.2/§6, without
simulation: the pool owns all buffers/pipeline/bind group but draws nothing
(indirect args zeroed → no-op) until a driver is attached (Étape B).

New:
- resources/particle.rs: Particle (80 B, #[repr(C)], no padding — D19),
  SIZE/ZERO consts + offset/layout unit tests
- shaders/particle_billboard.wgsl: camera-facing billboard, empty vertex
  layout (quad via vertex_index), instance slot via storage binding
  compact_index (D17), uv_rect atlas support (D15/D18)
- core/particles.rs: ParticlePoolConfig, BlendingMode, ParticleDriver trait
  (D3), ParticlePool (4 buffers + pipeline + bind group), default disc
  texture (D11), unit tests

Wired:
- Scene: SceneGpu keeps queue/sample_count; particle_pools registry +
  create_particle_pool() (default disc when no texture given)
- utils::conf PARTICLE_BILLBOARD_SHADER, module re-exports, prelude
- tests/wgsl_validate.rs: particle billboard naga validation (2 entry points)

Docs: DRAFT call-site/tree synced with the final code; AGENTS.md test count
(138) + wgpu 30 API drift gotcha (contents/DeviceExt/ALPHA_BLENDING,
DepthStencilState no Default, NonZero min_binding_size, const Zeroable).

cargo test -p wsg-lib: 138 pass (121 lib + 10 wgsl + 7), 0 warnings.
This commit is contained in:
Jérôme Bousquié
2026-09-26 12:34:21 +02:00
parent 8606aba510
commit 49aa9e48fd
12 changed files with 669 additions and 10 deletions
+2 -1
View File
@@ -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<NonZero<u64>>`. 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.
<!-- lean-ctx -->
+3 -4
View File
@@ -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,
+2
View File
@@ -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;
+361
View File
@@ -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<String>,
/// 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<Box<dyn ParticleDriver>>,
}
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<u8> {
let size = 16;
let mut data = vec![0u8; size * size * 4];
let center = (size as f32 - 1.0) / 2.0;
for y in 0..size {
for x in 0..size {
let dx = (x as f32 - center) / center;
let dy = (y as f32 - center) / center;
let dist = (dx * dx + dy * dy).sqrt();
let alpha = ((1.0 - dist).clamp(0.0, 1.0) * 255.0) as u8;
let i = (y * size + x) * 4;
data[i] = 255;
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)
}
}
+5 -2
View File
@@ -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};
+2
View File
@@ -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,
+104
View File
@@ -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<f32>`/`vec4<f32>` 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::<Self>() 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::<Particle>(), 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::<Particle>(), 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());
}
}
+65 -1
View File
@@ -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<wgpu::Device>,
/// 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<PipelineCache>,
}
@@ -82,6 +89,9 @@ pub struct Scene {
meshes: HashMap<String, Arc<Mesh>>,
/// Map of material identifiers to owned `Arc<Material>` instances. Populated via `add_material()`.
materials: HashMap<String, Arc<Material>>,
/// Map of particle pool identifiers to owned `Arc<ParticlePool>` instances (Étape 28 A).
/// Populated via `create_particle_pool()`.
particle_pools: HashMap<String, Arc<ParticlePool>>,
/// Map of diffuse texture identifiers to owned `Arc<Texture>` instances (Step 10, D4).
/// Populated via `add_texture()`; materials reference them via `add_material_texture()` by id.
textures: HashMap<String, Arc<Texture>>,
@@ -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
+86
View File
@@ -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<f32>, // offset 0
vel: vec3<f32>, // 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<f32>, // offset 48
uv_rect: vec4<f32>, // offset 64 — (D15) (ox, oy, sx, sy)
}
struct CameraParams { // 128 B — prefix of FrameUniforms (view + proj)
view: mat4x4<f32>,
proj: mat4x4<f32>,
}
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) frag_color: vec4<f32>,
@location(1) uv: vec2<f32>,
}
@group(0) @binding(0) var<uniform> camera: CameraParams;
@group(0) @binding(1) var<storage, read> particles: array<Particle>;
@group(0) @binding(2) var<storage, read> compact_index: array<u32>; // (D17/D19)
// 6 entries = 2 triangles (0-1-2, 3-4-5) forming one quad.
// Without an index buffer, draw(4, n) would yield a single triangle + 1 orphan vertex.
const QUAD: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
vec2(-0.5, -0.5),
vec2( 0.5, -0.5),
vec2( 0.5, 0.5),
vec2(-0.5, -0.5),
vec2( 0.5, 0.5),
vec2(-0.5, 0.5),
);
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32,
) -> VsOut {
var out: VsOut;
// 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<f32>;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let t = textureSample(tex, samp, in.uv);
return in.frag_color * t;
}
+6
View File
@@ -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.
+2 -2
View File
@@ -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;
+31
View File
@@ -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