From fa1d163590ab9e9dd6019dccd1d171abe7054c34 Mon Sep 17 00:00:00 2001 From: Mark Nefedov Date: Fri, 14 Aug 2026 22:34:05 +0300 Subject: [PATCH 1/2] Add criterion benchmark for steady-state pipeline stepping Two scenes: a 210-box pyramid (persistent convex contacts, contact constraint update paths) and 300 mixed convex bodies on a trimesh terrain (contact clustering, trimesh manifolds). Bodies have sleeping disabled and each scene settles before the timed region. --- crates/rapier3d/Cargo.toml | 6 + crates/rapier3d/benches/pipeline.rs | 210 ++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 crates/rapier3d/benches/pipeline.rs diff --git a/crates/rapier3d/Cargo.toml b/crates/rapier3d/Cargo.toml index cfbf8362a..32eab0d08 100644 --- a/crates/rapier3d/Cargo.toml +++ b/crates/rapier3d/Cargo.toml @@ -123,3 +123,9 @@ bincode.workspace = true serde_json.workspace = true serde = { workspace = true, features = ["std"] } oorandom.workspace = true +criterion = "0.8" + +[[bench]] +name = "pipeline" +path = "benches/pipeline.rs" +harness = false diff --git a/crates/rapier3d/benches/pipeline.rs b/crates/rapier3d/benches/pipeline.rs new file mode 100644 index 000000000..51f0769be --- /dev/null +++ b/crates/rapier3d/benches/pipeline.rs @@ -0,0 +1,210 @@ +//! Criterion benchmarks for steady-state `PhysicsPipeline::step` on scenes +//! chosen to exercise the hot paths from the 2026-08 optimization audit: +//! +//! - `box_pyramid`: many persistent convex contacts — contact-constraint +//! update (R1), narrow/broad-phase per-step scratch allocations (R4). +//! - `terrain_debris`: convex shapes on a trimesh — contact clustering +//! warm-start carry (R5) and the trimesh manifold path in parry. +//! +//! Every scene runs a settle phase outside the timed region and all dynamic +//! bodies have sleeping disabled so each timed step does full solver work. + +use criterion::{criterion_group, criterion_main, Criterion}; +use rapier3d::prelude::*; +use std::hint::black_box; +use std::time::Duration; + +struct World { + bodies: RigidBodySet, + colliders: ColliderSet, + impulse_joints: ImpulseJointSet, + multibody_joints: MultibodyJointSet, + pipeline: PhysicsPipeline, + bf: BroadPhaseBvh, + nf: NarrowPhase, + islands: IslandManager, + ccd: CCDSolver, + params: IntegrationParameters, + gravity: Vector, +} + +impl World { + fn new() -> Self { + Self { + bodies: RigidBodySet::new(), + colliders: ColliderSet::new(), + impulse_joints: ImpulseJointSet::new(), + multibody_joints: MultibodyJointSet::new(), + pipeline: PhysicsPipeline::new(), + bf: BroadPhaseBvh::new(), + nf: NarrowPhase::new(), + islands: IslandManager::new(), + ccd: CCDSolver::new(), + params: IntegrationParameters::default(), + gravity: Vector::new(0.0, -9.81, 0.0), + } + } + + fn step(&mut self) { + self.pipeline.step( + self.gravity, + &self.params, + &mut self.islands, + &mut self.bf, + &mut self.nf, + &mut self.bodies, + &mut self.colliders, + &mut self.impulse_joints, + &mut self.multibody_joints, + &mut self.ccd, + &(), + &(), + ); + } + + fn settle(&mut self, steps: usize) { + for _ in 0..steps { + self.step(); + } + } +} + +fn add_ground(w: &mut World) { + let ground = w.bodies.insert(RigidBodyBuilder::fixed()); + w.colliders.insert_with_parent( + ColliderBuilder::cuboid(50.0, 0.5, 50.0).translation(Vector::new(0.0, -0.5, 0.0)), + ground, + &mut w.bodies, + ); +} + +/// A pyramid of boxes: `base * (base + 1) / 2` dynamic bodies in persistent +/// resting contact. +fn box_pyramid(base: usize) -> World { + let mut w = World::new(); + add_ground(&mut w); + + let half = 0.5; + let spacing = half * 2.0 * 1.001; + for layer in 0..base { + let count = base - layer; + let y = half + layer as f32 * spacing; + let x0 = -(count as f32) * half + layer as f32 * half; + for i in 0..count { + let body = w.bodies.insert( + RigidBodyBuilder::dynamic() + .translation(Vector::new(x0 + i as f32 * spacing, y, 0.0)) + .can_sleep(false), + ); + w.colliders.insert_with_parent( + ColliderBuilder::cuboid(half, half, half), + body, + &mut w.bodies, + ); + } + } + w +} + +fn terrain_mesh(subdivisions: usize) -> (Vec, Vec<[u32; 3]>) { + let n = subdivisions; + let mut vertices = Vec::with_capacity((n + 1) * (n + 1)); + let mut indices = Vec::with_capacity(n * n * 2); + for iz in 0..=n { + for ix in 0..=n { + let x = ix as f32 / n as f32 * 40.0 - 20.0; + let z = iz as f32 / n as f32 * 40.0 - 20.0; + let y = (x * 0.5).sin() * 0.6 + (z * 0.4).cos() * 0.6; + vertices.push(Vector::new(x, y, z)); + } + } + let stride = n + 1; + for iz in 0..n { + for ix in 0..n { + let a = (iz * stride + ix) as u32; + let b = (iz * stride + ix + 1) as u32; + let c = ((iz + 1) * stride + ix) as u32; + let d = ((iz + 1) * stride + ix + 1) as u32; + indices.push([a, b, c]); + indices.push([b, d, c]); + } + } + (vertices, indices) +} + +/// Mixed convex debris resting on a trimesh terrain: exercises the trimesh +/// manifold path and contact clustering. +fn terrain_debris(count: usize) -> World { + let mut w = World::new(); + + let terrain_body = w.bodies.insert(RigidBodyBuilder::fixed()); + let (vertices, indices) = terrain_mesh(64); + w.colliders.insert_with_parent( + ColliderBuilder::trimesh(vertices, indices).unwrap(), + terrain_body, + &mut w.bodies, + ); + + // Deterministic pseudo-random placement (no RNG dependency). + let mut seed = 0x9e3779b9u32; + let mut next = || { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + (seed >> 8) as f32 / (1u32 << 24) as f32 + }; + + for i in 0..count { + let x = next() * 30.0 - 15.0; + let z = next() * 30.0 - 15.0; + let y = 2.0 + next() * 3.0; + let body = w.bodies.insert( + RigidBodyBuilder::dynamic() + .translation(Vector::new(x, y, z)) + .can_sleep(false), + ); + let collider = match i % 3 { + 0 => ColliderBuilder::ball(0.4), + 1 => ColliderBuilder::cuboid(0.35, 0.35, 0.35), + _ => ColliderBuilder::capsule_y(0.3, 0.25), + }; + w.colliders.insert_with_parent(collider, body, &mut w.bodies); + } + w +} + +fn bench_pipeline(c: &mut Criterion) { + let mut g = c.benchmark_group("pipeline_step"); + g.sample_size(30); + + g.bench_function("box_pyramid_20", |b| { + let mut w = box_pyramid(20); // 210 boxes + w.settle(150); + b.iter(|| { + w.step(); + black_box(w.bodies.len()) + }) + }); + + g.bench_function("terrain_debris_300", |b| { + let mut w = terrain_debris(300); + w.settle(200); + b.iter(|| { + w.step(); + black_box(w.bodies.len()) + }) + }); + + g.finish(); +} + +fn config() -> Criterion { + Criterion::default() + .warm_up_time(Duration::from_secs(1)) + .measurement_time(Duration::from_secs(5)) +} + +criterion_group! { + name = benches; + config = config(); + targets = bench_pipeline +} +criterion_main!(benches); From 8d02ab5d0626c267a6d2506f021cce93e5f08f5d Mon Sep 17 00:00:00 2001 From: Mark Nefedov Date: Fri, 14 Aug 2026 23:20:56 +0300 Subject: [PATCH 2/2] Extend criterion pipeline benchmarks to rapier2d and the f64 variants The rapier3d bench body is parameterized over Real and reused verbatim by rapier3d-f64 through an extern-crate alias shim; rapier2d gets a 2D-native body (polyline terrain, 2D pyramid) shared the same way with rapier2d-f64. --- crates/rapier2d-f64/Cargo.toml | 6 + crates/rapier2d-f64/benches/pipeline.rs | 4 + crates/rapier2d/Cargo.toml | 6 + crates/rapier2d/benches/pipeline.rs | 181 ++++++++++++++++++++++++ crates/rapier3d-f64/Cargo.toml | 6 + crates/rapier3d-f64/benches/pipeline.rs | 4 + crates/rapier3d/benches/pipeline.rs | 32 ++--- 7 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 crates/rapier2d-f64/benches/pipeline.rs create mode 100644 crates/rapier2d/benches/pipeline.rs create mode 100644 crates/rapier3d-f64/benches/pipeline.rs diff --git a/crates/rapier2d-f64/Cargo.toml b/crates/rapier2d-f64/Cargo.toml index d9792065e..c3f67503e 100644 --- a/crates/rapier2d-f64/Cargo.toml +++ b/crates/rapier2d-f64/Cargo.toml @@ -112,9 +112,15 @@ nalgebra = { workspace = true } glamx = { workspace = true, features = ["nalgebra"] } [dev-dependencies] +criterion = "0.8" bincode.workspace = true serde_json.workspace = true serde = { workspace = true, features = ["std"] } oorandom.workspace = true [target.'cfg(target_vendor = "apple")'.dependencies] + +[[bench]] +name = "pipeline" +path = "benches/pipeline.rs" +harness = false diff --git a/crates/rapier2d-f64/benches/pipeline.rs b/crates/rapier2d-f64/benches/pipeline.rs new file mode 100644 index 000000000..05b035c00 --- /dev/null +++ b/crates/rapier2d-f64/benches/pipeline.rs @@ -0,0 +1,4 @@ +// f64 variant of the rapier2d pipeline benchmarks (shared body, aliased crate). +extern crate rapier2d_f64 as rapier2d; + +include!("../../rapier2d/benches/pipeline.rs"); diff --git a/crates/rapier2d/Cargo.toml b/crates/rapier2d/Cargo.toml index 706a05ca6..cb2f443fb 100644 --- a/crates/rapier2d/Cargo.toml +++ b/crates/rapier2d/Cargo.toml @@ -119,7 +119,13 @@ nalgebra = { workspace = true } glamx = { workspace = true, features = ["nalgebra"] } [dev-dependencies] +criterion = "0.8" bincode.workspace = true serde_json.workspace = true serde = { workspace = true, features = ["std"] } oorandom.workspace = true + +[[bench]] +name = "pipeline" +path = "benches/pipeline.rs" +harness = false diff --git a/crates/rapier2d/benches/pipeline.rs b/crates/rapier2d/benches/pipeline.rs new file mode 100644 index 000000000..3eac2bb2a --- /dev/null +++ b/crates/rapier2d/benches/pipeline.rs @@ -0,0 +1,181 @@ +// Criterion benchmarks for steady-state `PhysicsPipeline::step` in 2D, +// mirroring the rapier3d suite: a box pyramid (persistent convex contacts) +// and mixed debris on a polyline terrain. Written against `Real` so the +// rapier2d-f64 crate reuses the body verbatim. + +use criterion::{criterion_group, criterion_main, Criterion}; +use rapier2d::prelude::*; +use std::hint::black_box; +use std::time::Duration; + +struct World { + bodies: RigidBodySet, + colliders: ColliderSet, + impulse_joints: ImpulseJointSet, + multibody_joints: MultibodyJointSet, + pipeline: PhysicsPipeline, + bf: BroadPhaseBvh, + nf: NarrowPhase, + islands: IslandManager, + ccd: CCDSolver, + params: IntegrationParameters, + gravity: Vector, +} + +impl World { + fn new() -> Self { + Self { + bodies: RigidBodySet::new(), + colliders: ColliderSet::new(), + impulse_joints: ImpulseJointSet::new(), + multibody_joints: MultibodyJointSet::new(), + pipeline: PhysicsPipeline::new(), + bf: BroadPhaseBvh::new(), + nf: NarrowPhase::new(), + islands: IslandManager::new(), + ccd: CCDSolver::new(), + params: IntegrationParameters::default(), + gravity: Vector::new(0.0, -9.81), + } + } + + fn step(&mut self) { + self.pipeline.step( + self.gravity, + &self.params, + &mut self.islands, + &mut self.bf, + &mut self.nf, + &mut self.bodies, + &mut self.colliders, + &mut self.impulse_joints, + &mut self.multibody_joints, + &mut self.ccd, + &(), + &(), + ); + } + + fn settle(&mut self, steps: usize) { + for _ in 0..steps { + self.step(); + } + } +} + +fn add_ground(w: &mut World) { + let ground = w.bodies.insert(RigidBodyBuilder::fixed()); + w.colliders.insert_with_parent( + ColliderBuilder::cuboid(50.0, 0.5).translation(Vector::new(0.0, -0.5)), + ground, + &mut w.bodies, + ); +} + +/// A pyramid of boxes: `base * (base + 1) / 2` dynamic bodies in persistent +/// resting contact. +fn box_pyramid(base: usize) -> World { + let mut w = World::new(); + add_ground(&mut w); + + let half = 0.5; + let spacing = half * 2.0 * 1.001; + for layer in 0..base { + let count = base - layer; + let y = half + layer as Real * spacing; + let x0 = -(count as Real) * half + layer as Real * half; + for i in 0..count { + let body = w.bodies.insert( + RigidBodyBuilder::dynamic() + .translation(Vector::new(x0 + i as Real * spacing, y)) + .can_sleep(false), + ); + w.colliders + .insert_with_parent(ColliderBuilder::cuboid(half, half), body, &mut w.bodies); + } + } + w +} + +fn terrain_vertices(segments: usize) -> Vec { + (0..=segments) + .map(|i| { + let x = i as Real / segments as Real * 60.0 - 30.0; + Vector::new(x, (x * 0.5).sin() * 0.6) + }) + .collect() +} + +/// Mixed convex debris resting on a polyline terrain. +fn terrain_debris(count: usize) -> World { + let mut w = World::new(); + + let terrain_body = w.bodies.insert(RigidBodyBuilder::fixed()); + w.colliders.insert_with_parent( + ColliderBuilder::polyline(terrain_vertices(256), None), + terrain_body, + &mut w.bodies, + ); + + // Deterministic pseudo-random placement (no RNG dependency). + let mut seed = 0x9e3779b9u32; + let mut next = || { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + (seed >> 8) as Real / (1u32 << 24) as Real + }; + + for i in 0..count { + let x = next() * 40.0 - 20.0; + let y = 2.0 + next() * 6.0; + let body = w.bodies.insert( + RigidBodyBuilder::dynamic() + .translation(Vector::new(x, y)) + .can_sleep(false), + ); + let collider = match i % 3 { + 0 => ColliderBuilder::ball(0.4), + 1 => ColliderBuilder::cuboid(0.35, 0.35), + _ => ColliderBuilder::capsule_y(0.3, 0.25), + }; + w.colliders.insert_with_parent(collider, body, &mut w.bodies); + } + w +} + +fn bench_pipeline(c: &mut Criterion) { + let mut g = c.benchmark_group("pipeline_step"); + g.sample_size(30); + + g.bench_function("box_pyramid_20", |b| { + let mut w = box_pyramid(20); // 210 boxes + w.settle(150); + b.iter(|| { + w.step(); + black_box(w.bodies.len()) + }) + }); + + g.bench_function("terrain_debris_300", |b| { + let mut w = terrain_debris(300); + w.settle(200); + b.iter(|| { + w.step(); + black_box(w.bodies.len()) + }) + }); + + g.finish(); +} + +fn config() -> Criterion { + Criterion::default() + .warm_up_time(Duration::from_secs(1)) + .measurement_time(Duration::from_secs(5)) +} + +criterion_group! { + name = benches; + config = config(); + targets = bench_pipeline +} +criterion_main!(benches); diff --git a/crates/rapier3d-f64/Cargo.toml b/crates/rapier3d-f64/Cargo.toml index 1b31f1330..316cbb65c 100644 --- a/crates/rapier3d-f64/Cargo.toml +++ b/crates/rapier3d-f64/Cargo.toml @@ -113,9 +113,15 @@ nalgebra = { workspace = true } glamx = { workspace = true, features = ["nalgebra"] } [dev-dependencies] +criterion = "0.8" bincode.workspace = true serde_json.workspace = true serde = { workspace = true, features = ["std"] } oorandom.workspace = true [target.'cfg(target_vendor = "apple")'.dependencies] + +[[bench]] +name = "pipeline" +path = "benches/pipeline.rs" +harness = false diff --git a/crates/rapier3d-f64/benches/pipeline.rs b/crates/rapier3d-f64/benches/pipeline.rs new file mode 100644 index 000000000..d94eea048 --- /dev/null +++ b/crates/rapier3d-f64/benches/pipeline.rs @@ -0,0 +1,4 @@ +// f64 variant of the rapier3d pipeline benchmarks (shared body, aliased crate). +extern crate rapier3d_f64 as rapier3d; + +include!("../../rapier3d/benches/pipeline.rs"); diff --git a/crates/rapier3d/benches/pipeline.rs b/crates/rapier3d/benches/pipeline.rs index 51f0769be..436d26162 100644 --- a/crates/rapier3d/benches/pipeline.rs +++ b/crates/rapier3d/benches/pipeline.rs @@ -1,13 +1,13 @@ -//! Criterion benchmarks for steady-state `PhysicsPipeline::step` on scenes -//! chosen to exercise the hot paths from the 2026-08 optimization audit: -//! -//! - `box_pyramid`: many persistent convex contacts — contact-constraint -//! update (R1), narrow/broad-phase per-step scratch allocations (R4). -//! - `terrain_debris`: convex shapes on a trimesh — contact clustering -//! warm-start carry (R5) and the trimesh manifold path in parry. -//! -//! Every scene runs a settle phase outside the timed region and all dynamic -//! bodies have sleeping disabled so each timed step does full solver work. +// Criterion benchmarks for steady-state `PhysicsPipeline::step` on scenes +// chosen to exercise the hot paths from the 2026-08 optimization audit: +// +// - `box_pyramid`: many persistent convex contacts — contact-constraint +// update (R1), narrow/broad-phase per-step scratch allocations (R4). +// - `terrain_debris`: convex shapes on a trimesh — contact clustering +// warm-start carry (R5) and the trimesh manifold path in parry. +// +// Every scene runs a settle phase outside the timed region and all dynamic +// bodies have sleeping disabled so each timed step does full solver work. use criterion::{criterion_group, criterion_main, Criterion}; use rapier3d::prelude::*; @@ -88,12 +88,12 @@ fn box_pyramid(base: usize) -> World { let spacing = half * 2.0 * 1.001; for layer in 0..base { let count = base - layer; - let y = half + layer as f32 * spacing; - let x0 = -(count as f32) * half + layer as f32 * half; + let y = half + layer as Real * spacing; + let x0 = -(count as Real) * half + layer as Real * half; for i in 0..count { let body = w.bodies.insert( RigidBodyBuilder::dynamic() - .translation(Vector::new(x0 + i as f32 * spacing, y, 0.0)) + .translation(Vector::new(x0 + i as Real * spacing, y, 0.0)) .can_sleep(false), ); w.colliders.insert_with_parent( @@ -112,8 +112,8 @@ fn terrain_mesh(subdivisions: usize) -> (Vec, Vec<[u32; 3]>) { let mut indices = Vec::with_capacity(n * n * 2); for iz in 0..=n { for ix in 0..=n { - let x = ix as f32 / n as f32 * 40.0 - 20.0; - let z = iz as f32 / n as f32 * 40.0 - 20.0; + let x = ix as Real / n as Real * 40.0 - 20.0; + let z = iz as Real / n as Real * 40.0 - 20.0; let y = (x * 0.5).sin() * 0.6 + (z * 0.4).cos() * 0.6; vertices.push(Vector::new(x, y, z)); } @@ -149,7 +149,7 @@ fn terrain_debris(count: usize) -> World { let mut seed = 0x9e3779b9u32; let mut next = || { seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); - (seed >> 8) as f32 / (1u32 << 24) as f32 + (seed >> 8) as Real / (1u32 << 24) as Real }; for i in 0..count {