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:
@@ -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 -->
|
||||
|
||||
+2
-3
@@ -60,9 +60,9 @@ seront repris **après** le système de particules (phase 7).
|
||||
|
||||
```
|
||||
lib/
|
||||
└── src/
|
||||
├── shaders/
|
||||
│ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main (pas de compute)
|
||||
└── src/
|
||||
├── 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user