From eeea3f3a4a8239bdb964b4dffd14c25214dc9f44 Mon Sep 17 00:00:00 2001 From: Julian Ramirez Ruiseco Date: Tue, 14 Jul 2026 11:16:12 -0400 Subject: [PATCH 1/4] feat: gate CPIC data and per-node particle lists behind features Two new default-on features let a pipeline drop grid data it doesn't use. cpic gates Node's incompatible-momentum lane and cdf collision field. With it off the GPU node shrinks from 48 to 16 bytes and the CDF kernels are not compiled. node_particle_lists gates the per-node particle linked lists built during the sort. With it off the per-particle atomic head-exchange and the per-node clears go away. register_shaders mirrors each feature into a shader macro (SLOSH_CPIC, SLOSH_NODE_PARTICLE_LISTS) so the slang Node layout and sort kernels stay in lockstep with GpuGridNode and GridArgs. Struct fields, kernel params and bindings are gated together, and the default features keep behavior identical to v0.6.2. --- Cargo.toml | 2 +- crates/slosh2d/Cargo.toml | 4 +++- crates/slosh3d/Cargo.toml | 4 +++- shaders/slosh/grid/grid.slang | 22 ++++++++++++++++++++++ shaders/slosh/grid/sort.slang | 14 ++++++++++++++ shaders/slosh/solver/grid_update.slang | 10 +++++++++- src/grid/grid.rs | 21 +++++++++++++++++++-- src/grid/sort.rs | 4 ++++ src/lib.rs | 12 ++++++++++++ src/pipeline.rs | 15 +++++++++++++-- src/solver/mod.rs | 6 ++++++ 11 files changed, 106 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9276d02..0946f4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ stensor = "0.4.2" [workspace.lints] rust.unexpected_cfgs = { level = "warn", check-cfg = [ - 'cfg(feature, values("dim2", "dim3"))', + 'cfg(feature, values("dim2", "dim3", "cpic", "node_particle_lists"))', ] } [patch.crates-io] diff --git a/crates/slosh2d/Cargo.toml b/crates/slosh2d/Cargo.toml index b73f94e..21c9b32 100644 --- a/crates/slosh2d/Cargo.toml +++ b/crates/slosh2d/Cargo.toml @@ -16,8 +16,10 @@ path = "../../src/lib.rs" required-features = ["dim2"] [features] -default = ["dim2"] +default = ["dim2", "cpic", "node_particle_lists"] dim2 = [] +cpic = [] +node_particle_lists = [] comptime = ["slosh_testbed2d/comptime", "stensor/comptime"] runtime = ["slosh_testbed2d/runtime", "stensor/runtime"] diff --git a/crates/slosh3d/Cargo.toml b/crates/slosh3d/Cargo.toml index b0c0b2d..251f757 100644 --- a/crates/slosh3d/Cargo.toml +++ b/crates/slosh3d/Cargo.toml @@ -16,8 +16,10 @@ path = "../../src/lib.rs" required-features = ["dim3"] [features] -default = ["dim3"] +default = ["dim3", "cpic", "node_particle_lists"] dim3 = [] +cpic = [] +node_particle_lists = [] comptime = ["stensor/comptime", "slosh_testbed3d/comptime"] runtime = ["stensor/runtime", "slosh_testbed3d/runtime"] diff --git a/shaders/slosh/grid/grid.slang b/shaders/slosh/grid/grid.slang index 5d9aa3f..c96d2e2 100644 --- a/shaders/slosh/grid/grid.slang +++ b/shaders/slosh/grid/grid.slang @@ -2,6 +2,20 @@ module grid; import slosh.aliases; +// SLOSH_CPIC gates the CPIC parts of Node: the incompatible-momentum lane and the cdf collision +// field. register_shaders sets it from the cpic cargo feature so this layout matches the Rust +// GpuGridNode. Default on so a compile that never sets the macro still builds the full layout. +#ifndef SLOSH_CPIC +#define SLOSH_CPIC 1 +#endif + +// SLOSH_NODE_PARTICLE_LISTS gates the per-node particle linked lists (built in +// finalize_particles_sort, cleared in reset). Set from the node_particle_lists cargo feature; the +// bound buffers and the Rust GridArgs fields share it so everything stays in sync. +#ifndef SLOSH_NODE_PARTICLE_LISTS +#define SLOSH_NODE_PARTICLE_LISTS 1 +#endif + // TODO: a lot if what exposed from this module should be methods of Grid (or other structs) // rather than free function. (For now they have just been converted from WGSL to Slang.) @@ -309,12 +323,14 @@ public struct Node { /// (depending on the context). The fourth component contains the cell’s mass. // TODO: maybe we don’t really need to pack ourself. public vector momentum_velocity_mass; +#if SLOSH_CPIC // If a particle is incompatible with this node (as pepr CPIC’s concept of compatibility // based on affinities), it will contribute to this field instead of `momentum_velocity_mass`. // That way the P2G/G2P transfers on incompatible nodes still work properly and we don’t // loose the contributions of other compatible particles influencing this incompatible node too. public vector momentum_velocity_mass_incompatible; public NodeCdf cdf; +#endif } #if DIM == 2 @@ -442,8 +458,10 @@ func reset( uint3 invocation_id: SV_DispatchThreadID, StructuredBuffer grid, RWStructuredBuffer nodes, +#if SLOSH_NODE_PARTICLE_LISTS RWStructuredBuffer nodes_linked_lists, RWStructuredBuffer rigid_nodes_linked_lists, +#endif ) { // TODO: slang doesn’t have a way to retrieve the number of workgroups? // let num_threads = num_workgroups.x * GRID_WORKGROUP_SIZE * num_workgroups.y * num_workgroups.z; @@ -452,12 +470,16 @@ func reset( // for (var i = invocation_id.x; i < num_nodes; i += num_threads) { if (i < num_nodes) { nodes[i].momentum_velocity_mass = vector(0.0); +#if SLOSH_CPIC nodes[i].momentum_velocity_mass_incompatible = vector(0.0); nodes[i].cdf = NodeCdf(0.0, 0, NONE); +#endif +#if SLOSH_NODE_PARTICLE_LISTS nodes_linked_lists[i].head = NONE; nodes_linked_lists[i].len = 0u; rigid_nodes_linked_lists[i].head = NONE; rigid_nodes_linked_lists[i].len = 0u; +#endif } // } } diff --git a/shaders/slosh/grid/sort.slang b/shaders/slosh/grid/sort.slang index 37f9664..c25389d 100644 --- a/shaders/slosh/grid/sort.slang +++ b/shaders/slosh/grid/sort.slang @@ -3,6 +3,12 @@ module sort; import slosh.grid.grid; import slosh.solver.particle; +// SLOSH_NODE_PARTICLE_LISTS gates the per-node particle linked-list build (see grid.slang). +// Default on so a standalone compile keeps the full behavior. +#ifndef SLOSH_NODE_PARTICLE_LISTS +#define SLOSH_NODE_PARTICLE_LISTS 1 +#endif + // Returns the within-block sort bucket for a particle counted/inserted into its primary // block: one bucket per associated-cell slab along the slowest-varying node axis (y in 2D, // z in 3D). @@ -303,8 +309,10 @@ func finalize_particles_sort( StructuredBuffer hmap_entries, StructuredBuffer particles_pos, ConstantBuffer particles_len, +#if SLOSH_NODE_PARTICLE_LISTS RWStructuredBuffer nodes_linked_lists, RWStructuredBuffer particle_node_linked_lists, +#endif RWStructuredBuffer sorted_particle_ids, RWStructuredBuffer active_blocks, @@ -337,12 +345,14 @@ func finalize_particles_sort( } } +#if SLOSH_NODE_PARTICLE_LISTS // Setup the per-node particle linked-list (still consumed by the gather P2G and the // rigid/CDF paths). let node_global_id = node_id(block_header_id_to_physical_id(active_block_id_0), assoc); let prev_head = nodes_linked_lists[node_global_id.id].head.exchange(id); nodes_linked_lists[node_global_id.id].len.add(1u); particle_node_linked_lists[id] = prev_head; +#endif } } @@ -353,8 +363,10 @@ func sort_rigid_particles( StructuredBuffer grid, StructuredBuffer hmap_entries, StructuredBuffer rigid_particles_pos, +#if SLOSH_NODE_PARTICLE_LISTS RWStructuredBuffer rigid_nodes_linked_lists, RWStructuredBuffer rigid_particle_node_linked_lists, +#endif ) { let id = invocation_id.x; if (id < rigid_particles_pos.getCount()) { @@ -367,6 +379,7 @@ func sort_rigid_particles( // NOTE: if the rigid particle doesn’t map to any block, we can just ignore it // is it won’t affect the simulation. +#if SLOSH_NODE_PARTICLE_LISTS if (active_block_id.id != NONE) { // Setup the per-node rigid particle linked-list. let node_local_id = associated_cell_index_in_block_off_by_one(particle, cell_width); @@ -375,5 +388,6 @@ func sort_rigid_particles( rigid_nodes_linked_lists[node_global_id.id].len.add(1u); rigid_particle_node_linked_lists[id] = prev_head; } +#endif } } \ No newline at end of file diff --git a/shaders/slosh/solver/grid_update.slang b/shaders/slosh/solver/grid_update.slang index 23f3c7a..d818cb5 100644 --- a/shaders/slosh/solver/grid_update.slang +++ b/shaders/slosh/solver/grid_update.slang @@ -7,6 +7,12 @@ import slosh.solver.params; import slosh.collision.collide; import slosh.solver.boundary_condition; +// SLOSH_CPIC gates the incompatible-momentum lane on Node (see grid.slang). Default on so a +// standalone compile matches the crate's default layout. +#ifndef SLOSH_CPIC +#define SLOSH_CPIC 1 +#endif + #if DIM == 2 static const uint WORKGROUP_SIZE_X = 8; static const uint WORKGROUP_SIZE_Y = 8; @@ -73,10 +79,12 @@ func grid_update( * Step 1: momentum -> velocity update (gravity + clamping). */ let momentum_velocity_mass = nodes[global_id].momentum_velocity_mass; - let momentum_velocity_mass_incompatible = nodes[global_id].momentum_velocity_mass_incompatible; var velocity_mass = update_single_cell(sim_params, cell_width, cell_pos, momentum_velocity_mass); +#if SLOSH_CPIC + let momentum_velocity_mass_incompatible = nodes[global_id].momentum_velocity_mass_incompatible; let velocity_mass_incompatible = update_single_cell(sim_params, cell_width, cell_pos, momentum_velocity_mass_incompatible); nodes[global_id].momentum_velocity_mass_incompatible = velocity_mass_incompatible; +#endif /* * Step 2: rigid-body collision boundary condition. diff --git a/src/grid/grid.rs b/src/grid/grid.rs index f4250f5..9934f18 100644 --- a/src/grid/grid.rs +++ b/src/grid/grid.rs @@ -34,7 +34,11 @@ struct GridArgs<'a, B: Backend> { n_block_groups: &'a GpuVector<[u32; 3], B>, n_g2p_p2g_groups: &'a GpuVector<[u32; 3], B>, nodes: &'a GpuVector, + // Per-node particle linked lists, bound only under the node_particle_lists feature (the + // shader params are #if SLOSH_NODE_PARTICLE_LISTS). + #[cfg(feature = "node_particle_lists")] nodes_linked_lists: &'a GpuVector<[u32; 2], B>, + #[cfg(feature = "node_particle_lists")] rigid_nodes_linked_lists: &'a GpuVector<[u32; 2], B>, scan_values: &'a GpuVector, // Snapshot of `num_active_blocks` after the primary-block touch pass (the base-block count), @@ -47,6 +51,7 @@ struct GridArgs<'a, B: Backend> { rigid_particles_pos: &'a GpuVector, rigid_particle_needs_block: &'a GpuVector, sorted_particle_ids: &'a GpuVector, + #[cfg(feature = "node_particle_lists")] particle_node_linked_lists: &'a GpuVector, } @@ -87,7 +92,9 @@ impl WgGrid { n_block_groups: &grid.indirect_n_blocks_groups, n_g2p_p2g_groups: &grid.indirect_n_g2p_p2g_groups, nodes: &grid.nodes, + #[cfg(feature = "node_particle_lists")] nodes_linked_lists: &grid.nodes_linked_lists, + #[cfg(feature = "node_particle_lists")] rigid_nodes_linked_lists: &grid.rigid_nodes_linked_lists, scan_values: &grid.scan_values, num_base_blocks: &grid.active_blocks_snapshot, @@ -97,6 +104,7 @@ impl WgGrid { rigid_particles_pos: &rigid_particles.sample_points, rigid_particle_needs_block: &rigid_particles.rigid_particle_needs_block, sorted_particle_ids: particles.sorted_ids(), + #[cfg(feature = "node_particle_lists")] particle_node_linked_lists: particles.node_linked_lists(), }; @@ -241,7 +249,12 @@ pub struct GpuGridMetadata { #[repr(C)] pub struct GpuGridNode { momentum_velocity_mass: glam::Vec4, + // Gated by the cpic feature (default on). Turning cpic off drops the node from 48 to 16 + // bytes, cutting grid traffic ~3x, and gives up collision-detection-field support. Keep in + // lockstep with Node in grid.slang, which SLOSH_CPIC gates the same way. + #[cfg(feature = "cpic")] momentum_velocity_mass_incompatible: glam::Vec4, + #[cfg(feature = "cpic")] cdf: GpuGridNodeCdf, } @@ -343,6 +356,7 @@ pub struct GpuNodeCollision { /// Collision detection field data for a grid node. /// /// Stores signed distance and affinity information for MPM-rigid body coupling. +#[cfg(feature = "cpic")] #[derive(Copy, Clone, PartialEq, Default, Debug, ShaderType)] #[repr(C)] pub struct GpuGridNodeCdf { @@ -386,9 +400,12 @@ pub struct GpuGrid { pub active_blocks_snapshot: GpuVector, /// Workspace for prefix sum operations. pub scan_values: GpuVector, - /// Per-node linked lists for MPM particles. + /// Per-node linked lists for MPM particles. Only built/reset under the node_particle_lists + /// feature, but always allocated: the P2G launchers still bind this buffer (the gather P2G + /// reads it; the scatter-style P2G binds it without reading). Gating the allocation means + /// dropping that unused scatter-style binding first. pub nodes_linked_lists: GpuVector<[u32; 2], B>, - /// Per-node linked lists for rigid body particles. + /// Per-node linked lists for rigid body particles. See `nodes_linked_lists`. pub rigid_nodes_linked_lists: GpuVector<[u32; 2], B>, /// Indirect dispatch arguments for block-parallel kernels. pub indirect_n_blocks_groups: Arc>, diff --git a/src/grid/sort.rs b/src/grid/sort.rs index 1b3329b..fe20be4 100644 --- a/src/grid/sort.rs +++ b/src/grid/sort.rs @@ -38,8 +38,10 @@ pub struct WgSort { struct SortArgs<'a, B: Backend> { grid: &'a GpuScalar, hmap_entries: &'a GpuScalar, + #[cfg(feature = "node_particle_lists")] rigid_nodes_linked_lists: &'a GpuScalar<[u32; 2], B>, rigid_particles_pos: &'a GpuScalar, + #[cfg(feature = "node_particle_lists")] rigid_particle_node_linked_lists: &'a GpuScalar, } @@ -64,8 +66,10 @@ impl WgSort { let args = SortArgs { grid: &grid.meta, hmap_entries: &grid.hmap_entries, + #[cfg(feature = "node_particle_lists")] rigid_nodes_linked_lists: &grid.rigid_nodes_linked_lists, rigid_particles_pos: &rigid_particles.sample_points, + #[cfg(feature = "node_particle_lists")] rigid_particle_node_linked_lists: &rigid_particles.node_linked_lists, }; diff --git a/src/lib.rs b/src/lib.rs index ee509d6..e978134 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,6 +89,18 @@ pub const SLANG_SRC_DIR: include_dir::Dir<'_> = pub fn register_shaders(compiler: &mut SlangCompiler) { stensor::register_shaders(compiler); compiler.add_dir(SLANG_SRC_DIR.clone()); + // Mirror the cpic and node_particle_lists cargo features into shader macros so the shader + // Node layout and sort kernels match GpuGridNode and GridArgs on the Rust side. slosh sets + // these itself (unlike DIM, which the consumer picks) since they're crate-layout invariants. + compiler.set_global_macro("SLOSH_CPIC", if cfg!(feature = "cpic") { 1 } else { 0 }); + compiler.set_global_macro( + "SLOSH_NODE_PARTICLE_LISTS", + if cfg!(feature = "node_particle_lists") { + 1 + } else { + 0 + }, + ); } /// Mathematical types and utilities for physics simulation. diff --git a/src/pipeline.rs b/src/pipeline.rs index aaebd47..e0679e1 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -12,9 +12,12 @@ use crate::rbd::dynamics::body::{BodyCoupling, BodyCouplingEntry}; use crate::solver::{ GpuBoundaryCondition, GpuImpulses, GpuMaterials, GpuParticleModelData, GpuParticles, GpuRigidParticles, GpuSimulationParams, GpuTimestepBounds, Particle, SimulationParams, WgG2P, - WgG2PCdf, WgGridUpdate, WgGridUpdateCdf, WgP2G, WgP2GCdf, WgP2GScatterStyle, WgParticleUpdate, - WgRigidImpulses, WgRigidParticleUpdate, WgTimestepBounds, + WgGridUpdate, WgP2G, WgP2GScatterStyle, WgParticleUpdate, WgRigidImpulses, + WgRigidParticleUpdate, WgTimestepBounds, }; +// The CDF kernel wrappers read the gated `Node.cdf`, so they only exist under the `cpic` feature. +#[cfg(feature = "cpic")] +use crate::solver::{WgG2PCdf, WgGridUpdateCdf, WgP2GCdf}; use rapier::dynamics::RigidBodySet; use rapier::geometry::{ColliderHandle, ColliderSet}; use slang_hal::backend::{Backend, Encoder, GpuTimestamps}; @@ -47,13 +50,18 @@ pub struct MpmPipeline { #[allow(dead_code)] p2g: WgP2G, p2g_scatter_style: WgP2GScatterStyle, + // The CDF kernels read `Node.cdf`, so gate them behind `cpic`: a build without the feature + // (slim 16 B node) must not construct a kernel referencing the removed field. + #[cfg(feature = "cpic")] #[allow(dead_code)] p2g_cdf: WgP2GCdf, + #[cfg(feature = "cpic")] #[allow(dead_code)] grid_update_cdf: WgGridUpdateCdf, grid_update: WgGridUpdate, particles_update: WgParticleUpdate, g2p: WgG2P, + #[cfg(feature = "cpic")] #[allow(dead_code)] g2p_cdf: WgG2PCdf, #[allow(dead_code)] @@ -383,8 +391,10 @@ impl MpmPipeline { sort: WgSort::from_backend(backend, compiler)?, p2g: WgP2G::from_backend(backend, compiler)?, p2g_scatter_style: WgP2GScatterStyle::from_backend(backend, compiler)?, + #[cfg(feature = "cpic")] p2g_cdf: WgP2GCdf::from_backend(backend, compiler)?, grid_update: WgGridUpdate::from_backend(backend, compiler)?, + #[cfg(feature = "cpic")] grid_update_cdf: WgGridUpdateCdf::from_backend(backend, compiler)?, #[cfg(feature = "comptime")] particles_update: WgParticleUpdate::from_backend(backend, compiler)?, @@ -396,6 +406,7 @@ impl MpmPipeline { )?, rigid_particles_update: WgRigidParticleUpdate::from_backend(backend, compiler)?, g2p: WgG2P::from_backend(backend, compiler)?, + #[cfg(feature = "cpic")] g2p_cdf: WgG2PCdf::from_backend(backend, compiler)?, impulses: WgRigidImpulses::from_backend(backend, compiler)?, #[cfg(feature = "comptime")] diff --git a/src/solver/mod.rs b/src/solver/mod.rs index 41e99ef..c275352 100644 --- a/src/solver/mod.rs +++ b/src/solver/mod.rs @@ -34,8 +34,10 @@ //! - [`SimulationParams`]: Global parameters (gravity, timestep) pub use g2p::WgG2P; +#[cfg(feature = "cpic")] pub use g2p_cdf::WgG2PCdf; pub use p2g::{WgP2G, WgP2GScatterStyle}; +#[cfg(feature = "cpic")] pub use p2g_cdf::WgP2GCdf; pub use params::{GpuSimulationParams, SimulationParams}; pub use particle::*; @@ -43,6 +45,7 @@ pub use particle_model::*; // pub use particle_update::WgParticleUpdate; pub use boundary_condition::{GpuBoundaryCondition, GpuMaterials}; pub use grid_update::WgGridUpdate; +#[cfg(feature = "cpic")] pub use grid_update_cdf::WgGridUpdateCdf; pub use particle_update::WgParticleUpdate; pub use rigid_impulses::{GpuImpulses, RigidImpulse, WgRigidImpulses}; @@ -51,8 +54,10 @@ pub use timestep_bound::{GpuTimestepBounds, WgTimestepBounds}; mod boundary_condition; mod g2p; +#[cfg(feature = "cpic")] mod g2p_cdf; mod p2g; +#[cfg(feature = "cpic")] mod p2g_cdf; mod params; mod particle_update; @@ -60,6 +65,7 @@ mod rigid_impulses; mod rigid_particle_update; mod grid_update; +#[cfg(feature = "cpic")] mod grid_update_cdf; mod particle; mod particle_model; From 672ec9454501147f1f3f84fd9f8e25753870cb70 Mon Sep 17 00:00:00 2001 From: Julian Ramirez Ruiseco Date: Tue, 14 Jul 2026 11:21:42 -0400 Subject: [PATCH 2/4] perf: opt-in kernel compilation and lighter grid clears MpmPipelineKernels lets a pipeline compile only the kernel families it will dispatch. The transfer, CDF, rigid-particle, grid_update and particles_update kernels become Option, built via new_with_kernels, and launch_step skips the ones left out. hooks_only() is the common case: run_p2g/run_g2p hooks replace the built-in transfers. This is also what lets a build without cpic avoid compiling the CDF kernels, which reference the gated-out Node.cdf. Two clears on the hot path get trimmed: - reset_hmap now clears only the entry state, not key/value. Neither is read before being rewritten, so this drops 44 of 48 bytes per entry on a clear that sweeps the whole hashmap every substep. - node_reset gates the per-node reset pass. Its only unconditional job is zeroing Node.momentum_velocity_mass, which every P2G overwrites, so a lean pipeline can skip it. It stays forced whenever cpic or node_particle_lists is on, since reset also initializes the per-node data those features need. --- shaders/slosh/grid/grid.slang | 8 +- src/grid/grid.rs | 25 ++-- src/pipeline.rs | 216 +++++++++++++++++++++++++++------- 3 files changed, 192 insertions(+), 57 deletions(-) diff --git a/shaders/slosh/grid/grid.slang b/shaders/slosh/grid/grid.slang index c96d2e2..6b771a4 100644 --- a/shaders/slosh/grid/grid.slang +++ b/shaders/slosh/grid/grid.slang @@ -206,11 +206,11 @@ void reset_hmap( ) { let id = invocation_id.x; if (id < grid[0].hmap_capacity) { + // Only `state` needs clearing: insertion CAS-probes on `state` and the winner writes + // `key`, and `find_block_header_id` reads `value` only after matching `state` (which is + // always written before any lookup). Skipping key/value saves 44 of the 48 bytes/entry + // on this clear, which sweeps the whole fixed-size hashmap every substep. hmap_entries[id].state = NONE; - // Resetting the following two isn’t necessary for correctness, - // but it makes debugging easier. - hmap_entries[id].key = BlockVirtualId(vector(0)); - hmap_entries[id].value = BlockHeaderId(0); } if (id == 0) { grid[0].num_active_blocks = 0u; diff --git a/src/grid/grid.rs b/src/grid/grid.rs index 9934f18..58bdcce 100644 --- a/src/grid/grid.rs +++ b/src/grid/grid.rs @@ -85,6 +85,7 @@ impl WgGrid { prefix_sum: &mut PrefixSumWorkspace, sort_module: &'a WgSort, prefix_sum_module: &'a WgPrefixSum, + reset_nodes: bool, ) -> Result<(), B::Error> { let args = GridArgs { grid: &grid.meta, @@ -210,14 +211,22 @@ impl WgGrid { .copy_scan_values_to_first_particles_and_prepare_for_finalize .launch_indirect(backend, pass, &args, grid.indirect_n_blocks_groups.buffer())?; - // Reset here so the linked list heads get reset before `finalize_particles_sort` which - // also setups the per-node linked list. - self.reset.launch_indirect( - backend, - pass, - &args, - grid.indirect_n_g2p_p2g_groups.buffer(), - )?; + // `reset` zeroes the node momentum lane, but it's also the only thing that initializes + // the per-node data the features depend on: the cpic incompatible/cdf lanes that + // grid_update reads, and the linked-list heads/len that finalize_particles_sort builds + // onto. Those buffers start uninitialized, so we can only honor `reset_nodes: false` when + // both features are off and the momentum lane (overwritten by every P2G) is all that's + // left. It has to run before finalize_particles_sort, which populates the linked list. + let must_reset = + reset_nodes || cfg!(feature = "cpic") || cfg!(feature = "node_particle_lists"); + if must_reset { + self.reset.launch_indirect( + backend, + pass, + &args, + grid.indirect_n_g2p_p2g_groups.buffer(), + )?; + } sort_module.finalize_particles_sort.launch_capped( backend, pass, diff --git a/src/pipeline.rs b/src/pipeline.rs index e0679e1..b71063b 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -26,6 +26,72 @@ use std::any::Any; use std::marker::PhantomData; use stensor::tensor::{GpuScalar, GpuTensor, GpuVector}; +/// Selects which optional kernel families `MpmPipeline` compiles. +/// +/// A pipeline that never dispatches some kernels (say hooks replace the built-in P2G/G2P and the +/// CDF/rigid paths go unused) can skip compiling them. Skipping is mandatory once the `Node` +/// struct is slimmed: the CDF kernels read `Node.cdf`, so they cannot be compiled after `cpic` +/// gates that field out. +/// +/// The gather P2G and the CDF/rigid paths read the per-node linked lists, which only exist with +/// the `node_particle_lists` feature (on by default). Keep it enabled if you use +/// `builtin_transfers` (gather), `cdf`, or `rigid_particles`. +#[derive(Copy, Clone, Debug)] +pub struct MpmPipelineKernels { + /// Built-in P2G/G2P transfer kernels (gather + scatter). Skip only if hooks handle + /// `run_p2g`/`run_g2p` on every step. + pub builtin_transfers: bool, + /// CDF (rigid-body collision field) kernels. Read `Node.cdf`, so compiled only under the + /// `cpic` feature. Compile-only for now: `launch_step` doesn't dispatch the CDF passes yet. + pub cdf: bool, + /// Rigid-particle update kernel. Compile-only for now: `launch_step` doesn't dispatch the + /// rigid-particle pass yet. + pub rigid_particles: bool, + /// Run the per-node `reset` pass during the sort. Its only unconditional job is zeroing + /// `Node.momentum_velocity_mass`, which every P2G variant overwrites on every active node + /// anyway, so set `false` to skip it when the built-in P2G (or a `run_p2g` hook) is known to + /// write every active node. + /// + /// Only honored with both `cpic` and `node_particle_lists` off. With either on, `reset` also + /// does init those features rely on (the cpic incompatible/cdf lanes, the linked-list + /// heads/len), so `launch_sort` forces it regardless of this flag. + pub node_reset: bool, + /// Built-in grid_update pass (momentum to velocity, gravity, velocity clamp, rigid-collision + /// boundary condition). Disable only when a `run_p2g` hook folds this into its own kernel; + /// the grid must hold velocities by the time G2P runs. + pub grid_update: bool, + /// Built-in particles_update pass (advection, deformation-gradient update, constitutive + /// model, APIC affine). Disable only when a `run_g2p` hook folds this into its own kernel; + /// particle state must be fully advanced before `after_particles_update` hooks run. + pub particles_update: bool, +} + +impl Default for MpmPipelineKernels { + fn default() -> Self { + Self { + builtin_transfers: true, + cdf: true, + rigid_particles: true, + node_reset: true, + grid_update: true, + particles_update: true, + } + } +} + +impl MpmPipelineKernels { + /// Minimal set for pipelines whose hooks replace the built-in transfers and that don't use + /// the CDF or rigid-particle paths. + pub fn hooks_only() -> Self { + Self { + builtin_transfers: false, + cdf: false, + rigid_particles: false, + ..Self::default() + } + } +} + /// GPU compute pipeline for Material Point Method simulation. /// /// This struct holds all the compiled compute shaders needed to execute a complete @@ -48,28 +114,31 @@ pub struct MpmPipeline { sort: WgSort, // Kept for the alternative/CDF code paths that are currently commented out in `step`. #[allow(dead_code)] - p2g: WgP2G, - p2g_scatter_style: WgP2GScatterStyle, + p2g: Option>, + p2g_scatter_style: Option>, // The CDF kernels read `Node.cdf`, so gate them behind `cpic`: a build without the feature // (slim 16 B node) must not construct a kernel referencing the removed field. #[cfg(feature = "cpic")] #[allow(dead_code)] - p2g_cdf: WgP2GCdf, + p2g_cdf: Option>, #[cfg(feature = "cpic")] #[allow(dead_code)] - grid_update_cdf: WgGridUpdateCdf, - grid_update: WgGridUpdate, - particles_update: WgParticleUpdate, - g2p: WgG2P, + grid_update_cdf: Option>, + grid_update: Option>, + particles_update: Option>, + g2p: Option>, #[cfg(feature = "cpic")] #[allow(dead_code)] - g2p_cdf: WgG2PCdf, + g2p_cdf: Option>, #[allow(dead_code)] - rigid_particles_update: WgRigidParticleUpdate, + rigid_particles_update: Option>, /// Maximum timestep bound calculation. pub timestep_bounds: WgTimestepBounds, /// Rigid body impulse computation kernel (publicly accessible for external use). pub impulses: WgRigidImpulses, + /// Kernel families this pipeline was built with; also gates which built-in dispatches + /// `launch_step` runs. + kernels: MpmPipelineKernels, _phantom: PhantomData, } @@ -385,29 +454,71 @@ impl MpmPipeline { /// /// A ready-to-use MPM pipeline, or an error if shader compilation fails. pub fn new(backend: &B, compiler: &SlangCompiler) -> Result { + Self::new_with_kernels(backend, compiler, MpmPipelineKernels::default()) + } + + /// Like [`Self::new`], but only compiles the kernel families selected by `kernels`. Any + /// skipped kernel must never be dispatched. + pub fn new_with_kernels( + backend: &B, + compiler: &SlangCompiler, + kernels: MpmPipelineKernels, + ) -> Result { Ok(Self { grid: WgGrid::from_backend(backend, compiler)?, prefix_sum: WgPrefixSum::from_backend(backend, compiler)?, sort: WgSort::from_backend(backend, compiler)?, - p2g: WgP2G::from_backend(backend, compiler)?, - p2g_scatter_style: WgP2GScatterStyle::from_backend(backend, compiler)?, + p2g: kernels + .builtin_transfers + .then(|| WgP2G::from_backend(backend, compiler)) + .transpose()?, + p2g_scatter_style: kernels + .builtin_transfers + .then(|| WgP2GScatterStyle::from_backend(backend, compiler)) + .transpose()?, #[cfg(feature = "cpic")] - p2g_cdf: WgP2GCdf::from_backend(backend, compiler)?, - grid_update: WgGridUpdate::from_backend(backend, compiler)?, + p2g_cdf: kernels + .cdf + .then(|| WgP2GCdf::from_backend(backend, compiler)) + .transpose()?, + grid_update: kernels + .grid_update + .then(|| WgGridUpdate::from_backend(backend, compiler)) + .transpose()?, #[cfg(feature = "cpic")] - grid_update_cdf: WgGridUpdateCdf::from_backend(backend, compiler)?, + grid_update_cdf: kernels + .cdf + .then(|| WgGridUpdateCdf::from_backend(backend, compiler)) + .transpose()?, #[cfg(feature = "comptime")] - particles_update: WgParticleUpdate::from_backend(backend, compiler)?, + particles_update: kernels + .particles_update + .then(|| WgParticleUpdate::from_backend(backend, compiler)) + .transpose()?, #[cfg(feature = "runtime")] - particles_update: WgParticleUpdate::with_specializations( - backend, - compiler, - &GpuModel::specialization_modules(), - )?, - rigid_particles_update: WgRigidParticleUpdate::from_backend(backend, compiler)?, - g2p: WgG2P::from_backend(backend, compiler)?, + particles_update: kernels + .particles_update + .then(|| { + WgParticleUpdate::with_specializations( + backend, + compiler, + &GpuModel::specialization_modules(), + ) + }) + .transpose()?, + rigid_particles_update: kernels + .rigid_particles + .then(|| WgRigidParticleUpdate::from_backend(backend, compiler)) + .transpose()?, + g2p: kernels + .builtin_transfers + .then(|| WgG2P::from_backend(backend, compiler)) + .transpose()?, #[cfg(feature = "cpic")] - g2p_cdf: WgG2PCdf::from_backend(backend, compiler)?, + g2p_cdf: kernels + .cdf + .then(|| WgG2PCdf::from_backend(backend, compiler)) + .transpose()?, impulses: WgRigidImpulses::from_backend(backend, compiler)?, #[cfg(feature = "comptime")] timestep_bounds: WgTimestepBounds::from_backend(backend, compiler)?, @@ -417,6 +528,7 @@ impl MpmPipeline { compiler, &GpuModel::specialization_modules(), )?, + kernels, _phantom: PhantomData, }) } @@ -484,6 +596,7 @@ impl MpmPipeline { &mut data.prefix_sum, &self.sort, &self.prefix_sum, + self.kernels.node_reset, )?; // self.sort.launch_sort_rigid_particles( // backend, @@ -501,6 +614,9 @@ impl MpmPipeline { timestamps.as_deref_mut(), )?; + // CDF passes, not yet wired. The cdf kernels are now `#[cfg(feature = "cpic")] + // Option<..>`, so re-enabling this means gating the block on `cpic`, unwrapping with + // `.as_ref().expect(..)`, and setting `MpmPipelineKernels::cdf`. // { // let mut pass = encoder.begin_pass("grid_update_cdf", timestamps.as_deref_mut()); // self.grid_update_cdf @@ -539,15 +655,20 @@ impl MpmPipeline { timestamps.as_deref_mut(), )? { let mut pass = encoder.begin_pass("p2g", timestamps.as_deref_mut()); - self.p2g_scatter_style.launch( - backend, - &mut pass, - &data.grid, - &data.particles, - &data.impulses, - &data.bodies, - &data.body_materials, - )?; + self.p2g_scatter_style + .as_ref() + .expect( + "pipeline built without builtin transfer kernels; a hook must handle run_p2g", + ) + .launch( + backend, + &mut pass, + &data.grid, + &data.particles, + &data.impulses, + &data.bodies, + &data.body_materials, + )?; } hooks.after_p2g( @@ -558,9 +679,9 @@ impl MpmPipeline { timestamps.as_deref_mut(), )?; - { + if let Some(grid_update) = &self.grid_update { let mut pass = encoder.begin_pass("grid_update", timestamps.as_deref_mut()); - self.grid_update.launch( + grid_update.launch( backend, &mut pass, &data.sim_params, @@ -588,15 +709,20 @@ impl MpmPipeline { timestamps.as_deref_mut(), )? { let mut pass = encoder.begin_pass("g2p", timestamps.as_deref_mut()); - self.g2p.launch( - backend, - &mut pass, - &data.sim_params, - &data.grid, - &data.particles, - &data.bodies, - &data.body_materials, - )?; + self.g2p + .as_ref() + .expect( + "pipeline built without builtin transfer kernels; a hook must handle run_g2p", + ) + .launch( + backend, + &mut pass, + &data.sim_params, + &data.grid, + &data.particles, + &data.bodies, + &data.body_materials, + )?; } hooks.after_g2p( @@ -607,9 +733,9 @@ impl MpmPipeline { timestamps.as_deref_mut(), )?; - { + if let Some(particles_update) = &self.particles_update { let mut pass = encoder.begin_pass("particles_update", timestamps.as_deref_mut()); - self.particles_update.launch( + particles_update.launch( backend, &mut pass, &data.sim_params, From 1d29afb6aec9b5e1fdb084320646af3779ae8aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Wed, 15 Jul 2026 13:08:57 +0200 Subject: [PATCH 3/4] feat(testbed): make it possible to configure the webgpu limits externally --- src_testbed/lib.rs | 88 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/src_testbed/lib.rs b/src_testbed/lib.rs index 6a16a89..a12d6a5 100644 --- a/src_testbed/lib.rs +++ b/src_testbed/lib.rs @@ -33,8 +33,7 @@ use slang_hal::backend::{Backend, WebGpu}; use slang_hal::re_exports::include_dir; use slang_hal::BufferUsages; use slang_hal::SlangCompiler; -use slosh::math::Vector; -use slosh::pipeline::{MpmPipeline, MpmPipelineHooks}; +use slosh::pipeline::{MpmPipeline, MpmPipelineHooks, MpmPipelineKernels}; use slosh::rapier::geometry::Shape; use slosh::rapier::geometry::ShapeType; use slosh::rapier::prelude::ColliderHandle; @@ -46,6 +45,32 @@ use wgpu::Limits; type SceneBuilders = Vec<(String, SceneBuildFn)>; type SceneBuildFn = fn(&WebGpu, &mut AppState) -> PhysicsContext; +/// GPU-construction options for the testbed. +pub struct TestbedConfig { + /// Kernel families compiled into the `MpmPipeline`. Must match what the hooks expect: + /// hooks that *replace* built-in passes (e.g. fuse the grid update into P2G or the + /// particle update into G2P) require those passes disabled, or they run a second time. + pub kernels: MpmPipelineKernels, + /// Device limits requested from wgpu. Bump e.g. `max_storage_buffers_per_shader_stage` + /// when hook kernels bind more buffers than the built-in passes need. + pub limits: Limits, +} + +impl Default for TestbedConfig { + fn default() -> Self { + Self { + kernels: MpmPipelineKernels::default(), + limits: Limits { + max_storage_buffers_per_shader_stage: 13, + max_compute_workgroup_storage_size: 32768, // Why do we need this if wgsparkl didn’t? + max_buffer_size: 4_000_000_000, + max_storage_buffer_binding_size: 4_000_000_000, + ..Limits::default() + }, + } + } +} + #[cfg(feature = "dim2")] type RenderNode = PlanarSceneNode; #[cfg(feature = "dim3")] @@ -79,15 +104,9 @@ impl Stage { mut compiler: SlangCompiler, hooks: impl FnOnce(&WebGpu, &SlangCompiler) -> Box>, builders: SceneBuilders, + config: TestbedConfig, ) -> Stage { - let limits = Limits { - max_storage_buffers_per_shader_stage: 13, - max_compute_workgroup_storage_size: 32768, // Why do we need this if wgsparkl didn’t? - max_buffer_size: 4_000_000_000, - max_storage_buffer_binding_size: 4_000_000_000, - ..Limits::default() - }; - let mut gpu = WebGpu::new(wgpu::Features::TIMESTAMP_QUERY, limits) + let mut gpu = WebGpu::new(wgpu::Features::TIMESTAMP_QUERY, config.limits) .await .unwrap(); // TODO: this is a terrible, horrible, hack, to work around the fact that slang isn’t giving us access to @@ -109,7 +128,7 @@ impl Stage { compiler.set_global_macro("DIM", slosh::math::DIM); } - let mpm_pipeline = MpmPipeline::new(&gpu, &compiler).unwrap(); + let mpm_pipeline = MpmPipeline::new_with_kernels(&gpu, &compiler, config.kernels).unwrap(); let mut app_state = AppState { pipeline: mpm_pipeline, run_state: RunState::Paused, @@ -350,10 +369,33 @@ pub async fn run_with_hooks( hooks: impl FnOnce(&WebGpu, &SlangCompiler) -> Box>, scene_builders: SceneBuilders, #[cfg(feature = "dim3")] up_axis: Vector, +) { + run_with_hooks_and_config( + compiler, + hooks, + TestbedConfig::default(), + scene_builders, + #[cfg(feature = "dim3")] + up_axis, + ) + .await +} + +/// Like [`run_with_hooks`], but with explicit GPU-construction options (kernel set, device +/// limits). Required when the hooks *replace* built-in passes (e.g. fuse the grid update +/// into P2G or the particle update into G2P): the default kernel set would run those passes +/// a second time. +pub async fn run_with_hooks_and_config( + compiler: SlangCompiler, + hooks: impl FnOnce(&WebGpu, &SlangCompiler) -> Box>, + config: TestbedConfig, + scene_builders: SceneBuilders, + #[cfg(feature = "dim3")] up_axis: Vector3, ) { run_with_hooks_and_ui( compiler, hooks, + config, scene_builders, |_, _, _, _| None, #[cfg(feature = "dim3")] @@ -365,6 +407,7 @@ pub async fn run_with_hooks( pub async fn run_with_hooks_and_ui( compiler: SlangCompiler, hooks: impl FnOnce(&WebGpu, &SlangCompiler) -> Box>, + config: TestbedConfig, scene_builders: SceneBuilders, mut extra_ui: impl FnMut( &egui::Context, @@ -375,7 +418,7 @@ pub async fn run_with_hooks_and_ui( #[cfg(feature = "dim3")] up_axis: Vector, ) { let mut colliders_gfx = HashMap::new(); - let mut stage = Stage::new(compiler, hooks, scene_builders).await; + let mut stage = Stage::new(compiler, hooks, scene_builders, config).await; let mut window = Window::new("slosh - 3D testbed"); render_colliders(&mut window, &stage.physics, &mut colliders_gfx); @@ -604,9 +647,28 @@ pub async fn run_headless_with_hooks( compiler: SlangCompiler, hooks: impl FnOnce(&WebGpu, &SlangCompiler) -> Box>, scene_builders: SceneBuilders, + step_callback: impl FnMut(&PhysicsContext, &SimulationStepResult) -> bool, +) { + run_headless_with_hooks_and_config( + compiler, + hooks, + TestbedConfig::default(), + scene_builders, + step_callback, + ) + .await +} + +/// Like [`run_headless_with_hooks`], but with explicit GPU-construction options. +/// See [`run_with_hooks_and_config`]. +pub async fn run_headless_with_hooks_and_config( + compiler: SlangCompiler, + hooks: impl FnOnce(&WebGpu, &SlangCompiler) -> Box>, + config: TestbedConfig, + scene_builders: SceneBuilders, mut step_callback: impl FnMut(&PhysicsContext, &SimulationStepResult) -> bool, ) { - let mut stage = Stage::new(compiler, hooks, scene_builders).await; + let mut stage = Stage::new(compiler, hooks, scene_builders, config).await; stage.app_state.run_state = RunState::Running; loop { From bf92ad878b1b3912dc82f33a4ec2442413220c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Wed, 15 Jul 2026 17:33:39 +0200 Subject: [PATCH 4/4] fix 3D compilation --- src_testbed/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src_testbed/lib.rs b/src_testbed/lib.rs index a12d6a5..619404d 100644 --- a/src_testbed/lib.rs +++ b/src_testbed/lib.rs @@ -33,6 +33,7 @@ use slang_hal::backend::{Backend, WebGpu}; use slang_hal::re_exports::include_dir; use slang_hal::BufferUsages; use slang_hal::SlangCompiler; +use slosh::math::Vector; use slosh::pipeline::{MpmPipeline, MpmPipelineHooks, MpmPipelineKernels}; use slosh::rapier::geometry::Shape; use slosh::rapier::geometry::ShapeType; @@ -390,7 +391,7 @@ pub async fn run_with_hooks_and_config( hooks: impl FnOnce(&WebGpu, &SlangCompiler) -> Box>, config: TestbedConfig, scene_builders: SceneBuilders, - #[cfg(feature = "dim3")] up_axis: Vector3, + #[cfg(feature = "dim3")] up_axis: Vector, ) { run_with_hooks_and_ui( compiler,