diff --git a/.github/scripts/run-wasi.mjs b/.github/scripts/run-wasi.mjs
new file mode 100644
index 000000000..2508c315d
--- /dev/null
+++ b/.github/scripts/run-wasi.mjs
@@ -0,0 +1,24 @@
+// Runs a `wasm32-wasip1` binary under Node's WASI, forwarding argv, env and stdout.
+//
+// Used as `CARGO_TARGET_WASM32_WASIP1_RUNNER` so `cargo test --target wasm32-wasip1` can
+// execute the test binary it builds. Node is used rather than a standalone runtime because
+// it is already on every GitHub runner; any WASI preview1 runtime (wasmtime, wasmer) works
+// just as well if you prefer one locally.
+//
+// Requires Node >= 22, where `node:wasi` is importable without a command-line flag.
+import { WASI } from 'node:wasi';
+import { readFile } from 'node:fs/promises';
+import { argv, env } from 'node:process';
+
+const [, , wasmPath, ...args] = argv;
+const wasi = new WASI({
+ version: 'preview1',
+ // argv[0] is conventional and unused by libtest; the rest are the harness's own flags.
+ args: ['test', ...args],
+ env,
+ returnOnExit: true,
+});
+
+const wasm = await WebAssembly.compile(await readFile(wasmPath));
+const instance = await WebAssembly.instantiate(wasm, wasi.getImportObject());
+process.exitCode = wasi.start(instance);
diff --git a/.github/workflows/rapier-ci-build.yml b/.github/workflows/rapier-ci-build.yml
index 9115cbb48..6805d2fb1 100644
--- a/.github/workflows/rapier-ci-build.yml
+++ b/.github/workflows/rapier-ci-build.yml
@@ -23,7 +23,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Cargo doc
- run: cargo doc --features parallel,simd-stable,serde-serialize,debug-render -p rapier3d -p rapier2d -p rapier3d-meshloader -p rapier3d-urdf && cargo doc -p mjcf-rs --features msh && cargo doc -p rapier3d-mjcf --features stl,wavefront,msh
+ run: cargo doc --features parallel,serde-serialize,debug-render -p rapier3d -p rapier2d -p rapier3d-meshloader -p rapier3d-urdf && cargo doc -p mjcf-rs --features msh && cargo doc -p rapier3d-mjcf --features stl,wavefront,msh
build-native:
runs-on: ubuntu-latest
env:
@@ -34,9 +34,9 @@ jobs:
- name: Clippy
run: cargo clippy
- name: Clippy rapier2d
- run: cargo clippy -p rapier-examples-2d --features parallel,simd-stable
+ run: cargo clippy -p rapier-examples-2d --features parallel
- name: Clippy rapier3d
- run: cargo clippy -p rapier-examples-3d --features parallel,simd-stable
+ run: cargo clippy -p rapier-examples-3d --features parallel
- name: Clippy mjcf-rs
run: cargo clippy -p mjcf-rs --all-targets --features msh
- name: Clippy rapier3d-mjcf
@@ -45,16 +45,46 @@ jobs:
run: cargo build --verbose -p rapier2d;
- name: Build rapier3d
run: cargo build --verbose -p rapier3d;
- - name: Build rapier2d SIMD
- run: cd crates/rapier2d; cargo build --verbose --features simd-stable;
- - name: Build rapier3d SIMD
- run: cd crates/rapier3d; cargo build --verbose --features simd-stable;
- - name: Build rapier2d SIMD Parallel
- run: cd crates/rapier2d; cargo build --verbose --features simd-stable --features parallel;
- - name: Build rapier3d SIMD Parallel
- run: cd crates/rapier3d; cargo build --verbose --features simd-stable --features parallel;
+ - name: Build rapier2d Parallel
+ run: cd crates/rapier2d; cargo build --verbose --features parallel;
+ - name: Build rapier3d Parallel
+ run: cd crates/rapier3d; cargo build --verbose --features parallel;
+ - name: Build rapier3d 8-lanes SIMD
+ run: cd crates/rapier3d; cargo build --verbose --features simd8;
- name: Run tests
run: cargo test
+ - name: Test determinism (SIMD backend)
+ run: cargo test -p rapier3d --release --features enhanced-determinism --test simd_backend_determinism
+ - name: Test SIMD backend op-level parity
+ run: cargo test -p rapier3d --release --features enhanced-determinism --test simd_backend_parity
+ - name: Test parallel-path parity (feature off)
+ run: cargo test -p rapier3d --release --features enhanced-determinism,serde-serialize --test parallel_path_parity
+ - name: Test parallel-path parity (feature on)
+ run: cargo test -p rapier3d --release --features enhanced-determinism,serde-serialize,parallel --test parallel_path_parity
+ - name: Test thread-count determinism
+ run: cargo test -p rapier3d --release --features parallel,serde-serialize --test thread_count_determinism
+ - name: Test snapshot round-trip (3D)
+ run: cargo test -p rapier3d --release --features serde-serialize --test snapshot_roundtrip
+ - name: Test snapshot round-trip (2D)
+ run: cargo test -p rapier2d --release --features serde-serialize --test snapshot_roundtrip
+ # The native half of the cross-target check: the `wasm-determinism` job below runs
+ # these same two tests, against the same goldens, on 32-bit pointers.
+ - name: Test snapshot portability (3D, native)
+ run: cargo test -p rapier3d --release --features enhanced-determinism,serde-serialize --test snapshot_portability
+ - name: Test snapshot portability (2D, native)
+ run: cargo test -p rapier2d --release --features enhanced-determinism,serde-serialize --test snapshot_portability
+ - name: Test single-worker deferred BVH
+ run: cargo test -p rapier3d --release --features parallel --test single_worker_deferred_bvh
+ # `unsync-callbacks` drops the `Sync` bound off the hooks/event traits; the test's
+ # callbacks hold a `Cell`, so it only compiles while that holds.
+ - name: Test unsync callbacks
+ run: cargo test -p rapier3d --release --features parallel,unsync-callbacks --test unsync_callbacks
+ # The bound is keyed on `unsync-callbacks` alone, so the no-`parallel` build is its own
+ # configuration rather than one that trivially has no bound.
+ - name: Test unsync callbacks (no parallel)
+ run: cargo test -p rapier3d --release --features unsync-callbacks --test unsync_callbacks
+ - name: Test parallel-path parity (unsync-callbacks)
+ run: cargo test -p rapier3d --release --features enhanced-determinism,serde-serialize,parallel,unsync-callbacks --test parallel_path_parity
- name: Test mjcf-rs
run: cargo test -p mjcf-rs --features msh
- name: Test rapier3d-mjcf
@@ -106,6 +136,31 @@ jobs:
run: cargo check --verbose -p rapier2d --no-default-features --features dim2,f32,alloc,debug-render --target=thumbv7em-none-eabihf
- name: Check rapier3d thumbv7em-none-eabihf + alloc + debug-render
run: cargo check --verbose -p rapier3d --no-default-features --features dim3,f32,alloc,debug-render --target=thumbv7em-none-eabihf
+ # Runs the tiny-scene tests under Miri to check the solver's unsafe hot paths
+ # (manifold store, solver-graph buckets, raw color-mask slices) for UB.
+ # x86_64 only: glam's aarch64 NEON backend hits foreign intrinsics Miri does
+ # not implement (on Apple Silicon, use --target x86_64-unknown-linux-gnu).
+ miri:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install nightly Rust with Miri
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: nightly
+ components: miri
+ - name: Miri test rapier3d (tiny scenes)
+ run: cargo miri test -p rapier3d --test miri_scenes
+ - name: Miri test rapier2d (tiny scenes)
+ run: cargo miri test -p rapier2d --test miri_scenes
+ # The SIMD solver paths lean harder on transmutes and raw slices; worth the
+ # extra interpretation time (~3.5x the scalar run). `parallel` also passes,
+ # but only manually (not in CI): crossbeam-epoch's container_of pattern
+ # violates Stacked Borrows in rayon's steal path, so it needs
+ # MIRIFLAGS="-Zmiri-tree-borrows -Zmiri-ignore-leaks" (ignore-leaks for
+ # rayon's never-joined global pool) and RAYON_NUM_THREADS=2.
+ - name: Miri test rapier3d (tiny scenes)
+ run: cargo miri test -p rapier3d --test miri_scenes
build-wasm:
runs-on: ubuntu-latest
env:
@@ -117,6 +172,32 @@ jobs:
run: cd crates/rapier2d && cargo build --verbose --target wasm32-unknown-unknown;
- name: build rapier3d
run: cd crates/rapier3d && cargo build --verbose --target wasm32-unknown-unknown;
+ # A snapshot taken in a browser must be byte-identical to one taken on the server, so the
+ # same golden the native jobs check is checked again on a 32-bit-pointer target with its
+ # own libm and codegen backend. `wasm32-wasip1` rather than `wasm32-unknown-unknown`
+ # because it can run a test binary; the arithmetic and the serialized layout are the same
+ # for both wasm targets.
+ wasm-determinism:
+ runs-on: ubuntu-latest
+ env:
+ # Absolute: cargo runs a test binary from its own package directory, not the
+ # workspace root.
+ CARGO_TARGET_WASM32_WASIP1_RUNNER: node ${{ github.workspace }}/.github/scripts/run-wasi.mjs
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install stable Rust with wasm32-wasip1
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: stable
+ targets: wasm32-wasip1
+ # `node:wasi` needs no command-line flag from 22 on.
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - name: Test snapshot portability (3D, wasm32)
+ run: cargo test -p rapier3d --release --features enhanced-determinism,serde-serialize --target wasm32-wasip1 --test snapshot_portability
+ - name: Test snapshot portability (2D, wasm32)
+ run: cargo test -p rapier2d --release --features enhanced-determinism,serde-serialize --target wasm32-wasip1 --test snapshot_portability
# If this fails, consider changing your text or adding something to .typos.toml
# You can find typos here: https://crates.io/crates/typos'
typos:
diff --git a/.run/Build_rapier2d__wasm32_unknown_unknown_.run.xml b/.run/Build_rapier2d__wasm32_unknown_unknown_.run.xml
new file mode 100644
index 000000000..49c3366da
--- /dev/null
+++ b/.run/Build_rapier2d__wasm32_unknown_unknown_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Build_rapier3d__wasm32_unknown_unknown_.run.xml b/.run/Build_rapier3d__wasm32_unknown_unknown_.run.xml
new file mode 100644
index 000000000..aa91a6d32
--- /dev/null
+++ b/.run/Build_rapier3d__wasm32_unknown_unknown_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Check_typescript_bindings__offline_.run.xml b/.run/Check_typescript_bindings__offline_.run.xml
new file mode 100644
index 000000000..5b0ee15a6
--- /dev/null
+++ b/.run/Check_typescript_bindings__offline_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Check_website_docs_examples.run.xml b/.run/Check_website_docs_examples.run.xml
new file mode 100644
index 000000000..b4fe270a6
--- /dev/null
+++ b/.run/Check_website_docs_examples.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Run all_examples3 (parallel, enhanced-determinism).run.xml b/.run/Run all_examples3 (parallel, enhanced-determinism).run.xml
new file mode 100644
index 000000000..19695a2cd
--- /dev/null
+++ b/.run/Run all_examples3 (parallel, enhanced-determinism).run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/Run_all_examples2.run.xml b/.run/Run_all_examples2.run.xml
new file mode 100644
index 000000000..a658a677a
--- /dev/null
+++ b/.run/Run_all_examples2.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Run_all_examples2__parallel_.run.xml b/.run/Run_all_examples2__parallel_.run.xml
new file mode 100644
index 000000000..5160409ae
--- /dev/null
+++ b/.run/Run_all_examples2__parallel_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Run_all_examples2__parallel__enhanced_determinism_.run.xml b/.run/Run_all_examples2__parallel__enhanced_determinism_.run.xml
new file mode 100644
index 000000000..0807f6c1e
--- /dev/null
+++ b/.run/Run_all_examples2__parallel__enhanced_determinism_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Run_all_examples3.run.xml b/.run/Run_all_examples3.run.xml
new file mode 100644
index 000000000..e98204e53
--- /dev/null
+++ b/.run/Run_all_examples3.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Run_all_examples3__parallel_.run.xml b/.run/Run_all_examples3__parallel_.run.xml
new file mode 100644
index 000000000..6e5f51474
--- /dev/null
+++ b/.run/Run_all_examples3__parallel_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Run_all_examples3_f64.run.xml b/.run/Run_all_examples3_f64.run.xml
new file mode 100644
index 000000000..ce565c3a3
--- /dev/null
+++ b/.run/Run_all_examples3_f64.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Test_parallel_path_parity.run.xml b/.run/Test_parallel_path_parity.run.xml
new file mode 100644
index 000000000..2b04eeb14
--- /dev/null
+++ b/.run/Test_parallel_path_parity.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Test_snapshot_portability__2D__wasm32_wasip1_.run.xml b/.run/Test_snapshot_portability__2D__wasm32_wasip1_.run.xml
new file mode 100644
index 000000000..2632f0ba9
--- /dev/null
+++ b/.run/Test_snapshot_portability__2D__wasm32_wasip1_.run.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Test_snapshot_portability__3D_.run.xml b/.run/Test_snapshot_portability__3D_.run.xml
new file mode 100644
index 000000000..269590f25
--- /dev/null
+++ b/.run/Test_snapshot_portability__3D_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Test_snapshot_portability__3D__wasm32_wasip1_.run.xml b/.run/Test_snapshot_portability__3D__wasm32_wasip1_.run.xml
new file mode 100644
index 000000000..aa5e49c8d
--- /dev/null
+++ b/.run/Test_snapshot_portability__3D__wasm32_wasip1_.run.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Test_snapshot_roundtrip__3D_.run.xml b/.run/Test_snapshot_roundtrip__3D_.run.xml
new file mode 100644
index 000000000..f6ce3b07a
--- /dev/null
+++ b/.run/Test_snapshot_roundtrip__3D_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Test_thread_count_determinism.run.xml b/.run/Test_thread_count_determinism.run.xml
new file mode 100644
index 000000000..b8c0c7b65
--- /dev/null
+++ b/.run/Test_thread_count_determinism.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Test_unsync_callbacks.run.xml b/.run/Test_unsync_callbacks.run.xml
new file mode 100644
index 000000000..2b4e11332
--- /dev/null
+++ b/.run/Test_unsync_callbacks.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Test_workspace.run.xml b/.run/Test_workspace.run.xml
new file mode 100644
index 000000000..1791cf3ec
--- /dev/null
+++ b/.run/Test_workspace.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/check_no_std__rapier3d_.run.xml b/.run/check_no_std__rapier3d_.run.xml
new file mode 100644
index 000000000..68bb3b604
--- /dev/null
+++ b/.run/check_no_std__rapier3d_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/check_simd8__rapier3d_.run.xml b/.run/check_simd8__rapier3d_.run.xml
new file mode 100644
index 000000000..42f72d7aa
--- /dev/null
+++ b/.run/check_simd8__rapier3d_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/clippy.run.xml b/.run/clippy.run.xml
new file mode 100644
index 000000000..0ce9e65d4
--- /dev/null
+++ b/.run/clippy.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/clippy_examples2d__parallel_.run.xml b/.run/clippy_examples2d__parallel_.run.xml
new file mode 100644
index 000000000..814639270
--- /dev/null
+++ b/.run/clippy_examples2d__parallel_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/clippy_examples3d__parallel_.run.xml b/.run/clippy_examples3d__parallel_.run.xml
new file mode 100644
index 000000000..b472aeea3
--- /dev/null
+++ b/.run/clippy_examples3d__parallel_.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/doc.run.xml b/.run/doc.run.xml
new file mode 100644
index 000000000..feb378621
--- /dev/null
+++ b/.run/doc.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/run_ci_checks_sh.run.xml b/.run/run_ci_checks_sh.run.xml
new file mode 100644
index 000000000..cbd3b4a0d
--- /dev/null
+++ b/.run/run_ci_checks_sh.run.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ed1f6590..856f9e4be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,95 @@
+## v0.35.0-beta.0 (02 August 2026)
+
+### Breaking changes
+
+- ⚠ Removed the `simd-stable`, `simd-nightly` and `simd-is-enabled` features: SIMD is now
+ always on, backed by `wide` (which falls back to scalar code where unsupported); the
+ nightly `core::simd` backend is gone (we will support it when it get stabilized).
+ Just drop these features from your `Cargo.toml`.
+- ⚠ `PhysicsHooks` and `EventHandler` now require `Sync` instead of `Send + Sync` (through
+ the blanket-implemented `utils::MaybeSync`). Existing implementations keep compiling.
+- ⚠ Sleeping was rewritten around persistent islands: an island sleeps and wakes strictly
+ as a unit, so a body is never frozen while something it touches still moves. The
+ `IslandManager` and `RigidBodyActivation` serialization formats changed.
+- ⚠ Sleep thresholds changed: `normalized_linear_threshold` defaults to `0.05` (was `0.4`,
+ and is now measured at the body’s farthest point) and `time_until_sleep` to `0.5` seconds
+ (was `2.0`).
+- ⚠ Awake bodies are solved as a single active set: `IntegrationParameters::min_island_size`
+ was removed.
+- ⚠ The CCD solver was rewritten around sweep-based time of impact: each fast body is
+ clamped to its earliest impact (pose only; velocities resolve through speculative
+ contacts), and already-touching pairs no longer pin the body in place.
+ `CCDSolver::predict_impacts_at_next_positions`/`clamp_motions`, `PredictedImpacts` and
+ `TOIEntry` were replaced by `CCDSolver::solve_continuous`.
+- ⚠ Fast dynamic bodies now always run CCD against fixed colliders; `ccd_enabled` upgrades
+ a body to a "bullet" that also sweeps kinematic and dynamic bodies. Set
+ `IntegrationParameters::max_ccd_substeps` to `0` to disable CCD entirely.
+- ⚠ Body velocities are now capped each substep: linear speed at
+ `IntegrationParameters::max_linear_velocity()` (default 400 units/s) and rotation at
+ ~45° per step (`RigidBody::set_allow_fast_rotation` bypasses the angular cap).
+ `RigidBodyCcd::ccd_max_dist` became `RigidBody::max_extent()`, which `max_point_velocity`
+ now takes as an argument.
+- ⚠ Contact defaults changed: `normalized_prediction_distance` is `0.02` (was `0.002`),
+ `normalized_max_corrective_velocity` is `3.0` (was `10.0`) and
+ `normalized_allowed_linear_error` is `0.005` (was `0.001`), greatly reducing tunneling
+ through thin walls. Contacts against fixed bodies use a stiffer spring
+ (`IntegrationParameters::static_contact_softness`).
+- ⚠ `SolverContact` stores per-body anchors instead of a single world point, and friction
+ and restitution became per-manifold (new `friction`/`restitution` fields on
+ `ContactModificationContext`, instead of per-contact). Inside
+ `PhysicsHooks::modify_solver_contacts` the anchors still hold fresh world-space points,
+ and `dist` edits are still honored. `ContactModificationContext::solver_contacts` is now
+ a `SolverContacts` (an inline `ArrayVec` in 2D, still a `Vec` in 3D).
+- ⚠ `ContactPair` can now store reduced *solver clusters* alongside its manifolds; use
+ `ContactPair::solver_manifolds()` to read what the solver actually sees.
+- ⚠ `RigidBody::additional_solver_iterations` now adds whole solver substeps for the
+ body’s constraint-connected component, converging much better on high mass ratios.
+
+### Added
+
+- NaN/infinity quarantine: bodies and colliders whose pose, velocity or geometry goes
+ non-finite are rolled back to their last valid pose, disabled, and reported through
+ `PhysicsPipeline::quarantine()`/`PhysicsWorld::quarantine()` (new `Quarantine` type),
+ instead of corrupting the rest of the simulation.
+- Thread-pool API on `PhysicsPipeline`/`PhysicsWorld` (`parallel` feature):
+ `configure_thread_pool`, `set_thread_pool`, `thread_pool`, `clear_thread_pool` and
+ `num_threads`. When set, the whole step runs inside that pool.
+- `enhanced-determinism` can now be combined with `parallel`, with results bitwise
+ identical for any thread-pool size, and costs no measurable performance anymore
+ (cross-platform determinism now rests on glam’s shared scalar core plus `libm`-pinned
+ transcendentals). It remains incompatible with `simd8`, and cross-platform reproducibility
+ still requires matching numeric features (`block-solver`, f32 vs f64).
+- New `simd8` feature: widens the solver’s SIMD from 4 to 8 lanes (f32 only; needs an
+ AVX2-capable target to emit 256-bit instructions).
+- New `block-solver` feature (on by default in 2D): solves adjacent contact points as
+ coupled 2x2 blocks for better resting-stack stability.
+- New `unsync-callbacks` feature: drops the `Sync` requirement from `PhysicsHooks` and
+ `EventHandler` for thread-affine callbacks (a JS closure, a GUI handle). Callbacks then
+ all run on the thread driving `step()`, and the thread-pool API above is compiled out
+ (install a pool by stepping from inside it). Parallelism is kept: only the pairs that can
+ reach a callback are held back for the driving thread.
+- New `solver-bounds-checks` feature: validates the solver’s body indices at constraint
+ generation, turning a stale index into a clean panic instead of an unchecked SIMD gather.
+- `ColliderBuilder::oriented_polyline` (2D): a one-sided polyline that only collides on the
+ side determined by vertex winding.
+- `IntegrationParameters::contact_clustering` and `contact_recycling` (both on by default,
+ plus `normalized_contact_recycle_distance`): reduce narrow-phase and solver work on
+ composite-shape and resting contacts.
+- `IntegrationParameters::friction_in_bias_pass` and `warmstart_joints` solver tuning knobs.
+
+### Modified
+
+- The narrow phase and constraint solver were reworked around a persistent contact graph and
+ a staged multithreaded solver (parallelism within a single island), for lower per-step
+ overhead and steadier timings; multithreaded trajectories may shift within solver
+ tolerance.
+- The broad phase was reworked for large, mostly-static worlds: scenes with very large static
+ collider counts now pay near-zero per-step broad-phase cost.
+- Gyroscopic forces are applied per-substep in the principal inertia frame, improving
+ fast-spinning asymmetric bodies.
+- Removed the x86 flush-to-zero/denormals-are-zero floating-point flag: it mutated a
+ process-wide control register behind the user’s back.
+
## v0.34.0 (04 July 2026)
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3df065358..8cb3ffa56 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -22,8 +22,8 @@ The Rust source code of the Rapier physics engines is available on our `rapier`
- Run the tests `cargo test`
- Run the 2D examples and see if they behave as expected: `cargo run --release --bin all_examples2`
- Run the 3D examples and see if they behave as expected: `cargo run --release --bin all_examples3`
- - Run the 2D examples with the `parallel` and `simd-stable` features enabled: `cargo run --release --bin all_examples2 --features parallel,simd-stable`
- - Run the 3D examples with the `parallel` and `simd-stable` features enabled: `cargo run --release --bin all_examples3 --features parallel,simd-stable`
+ - Run the 2D examples with the `parallel` feature enabled: `cargo run --release --bin all_examples2 --features parallel`
+ - Run the 3D examples with the `parallel` feature enabled: `cargo run --release --bin all_examples3 --features parallel`
4. Once you are satisfied with your changes, submit them by [opening a Pull Request](https://github.com/dimforge/rapier/pulls) on GitHub.
5. If that Pull Request does something you need urgently, or if you think it has been forgotten, don't hesitate
to ask **@sebcrozet** directly [on Discord][discord] for a review.
diff --git a/Cargo.toml b/Cargo.toml
index e881c8049..86003ab51 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -45,7 +45,7 @@ exclude = ["typescript"]
resolver = "2"
[workspace.package]
-version = "0.34.0"
+version = "0.35.0-beta.0"
authors = ["Sébastien Crozet "]
homepage = "https://rapier.rs"
repository = "https://github.com/dimforge/rapier"
@@ -67,17 +67,18 @@ needless_lifetimes = "allow"
# Core math
nalgebra = { version = "0.35", default-features = false, features = ["macros"] }
glamx = { version = "0.3", default-features = false }
-simba = { version = "0.10", default-features = false }
+simba = { version = "0.10.1", default-features = false }
num-traits = { version = "0.2", default-features = false }
approx = { version = "0.5", default-features = false }
# Parry (each crate picks its own variant)
-parry2d = { version = "0.29", default-features = false, features = ["required-features"] }
-parry3d = { version = "0.29", default-features = false, features = ["required-features"] }
-parry2d-f64 = { version = "0.29", default-features = false, features = ["required-features"] }
-parry3d-f64 = { version = "0.29", default-features = false, features = ["required-features"] }
+parry2d = { version = "0.30", default-features = false, features = ["required-features"] }
+parry3d = { version = "0.30", default-features = false, features = ["required-features"] }
+parry2d-f64 = { version = "0.30", default-features = false, features = ["required-features"] }
+parry3d-f64 = { version = "0.30", default-features = false, features = ["required-features"] }
# Utilities
+arrayvec = { version = "0.7", default-features = false }
bitflags = "2"
bytemuck = { version = "1", features = ["derive"] }
log = "0.4"
@@ -95,6 +96,7 @@ serde = { version = "1", default-features = false, features = ["derive"] }
rand = "0.10"
rand_pcg = "0.10"
num_cpus = "1"
+libc = "0.2"
Inflector = "0.11"
md5 = "0.8"
egui = "0.34"
@@ -114,23 +116,26 @@ lyon = "0.17"
dot_vox = "5"
usvg = "0.14"
obj-rs = { version = "0.7", default-features = false }
-glam = { version = "0.33", features = ["fast-math"] }
+# NOTE: no `fast-math` — cargo feature unification would switch it on for every
+# build whose graph includes the examples, and it re-enables FMA contraction in
+# glam's sse2 backend (a cross-platform determinism hazard).
+glam = { version = "0.33" }
getrandom = { version = "0.2", features = ["js"] }
wasm-bindgen = "0.2"
# Internal crates (paths are relative to the workspace root)
-rapier2d = { version = "0.34.0", path = "crates/rapier2d" }
-rapier2d-f64 = { version = "0.34.0", path = "crates/rapier2d-f64" }
-rapier3d = { version = "0.34.0", path = "crates/rapier3d" }
-rapier3d-f64 = { version = "0.34.0", path = "crates/rapier3d-f64" }
-rapier_testbed2d = { version = "0.34.0", path = "crates/rapier_testbed2d" }
-rapier_testbed2d-f64 = { version = "0.34.0", path = "crates/rapier_testbed2d-f64" }
-rapier_testbed3d = { version = "0.34.0", path = "crates/rapier_testbed3d" }
-rapier_testbed3d-f64 = { version = "0.34.0", path = "crates/rapier_testbed3d-f64" }
-rapier3d-urdf = { version = "0.34.0", path = "crates/rapier3d-urdf" }
-rapier3d-meshloader = { version = "0.34.0", path = "crates/rapier3d-meshloader", default-features = false }
-mjcf-rs = { version = "0.34.0", path = "crates/mjcf-rs" }
-rapier3d-mjcf = { version = "0.34.0", path = "crates/rapier3d-mjcf" }
+rapier2d = { version = "0.35.0-beta.0", path = "crates/rapier2d" }
+rapier2d-f64 = { version = "0.35.0-beta.0", path = "crates/rapier2d-f64" }
+rapier3d = { version = "0.35.0-beta.0", path = "crates/rapier3d" }
+rapier3d-f64 = { version = "0.35.0-beta.0", path = "crates/rapier3d-f64" }
+rapier_testbed2d = { version = "0.35.0-beta.0", path = "crates/rapier_testbed2d" }
+rapier_testbed2d-f64 = { version = "0.35.0-beta.0", path = "crates/rapier_testbed2d-f64" }
+rapier_testbed3d = { version = "0.35.0-beta.0", path = "crates/rapier_testbed3d" }
+rapier_testbed3d-f64 = { version = "0.35.0-beta.0", path = "crates/rapier_testbed3d-f64" }
+rapier3d-urdf = { version = "0.35.0-beta.0", path = "crates/rapier3d-urdf" }
+rapier3d-meshloader = { version = "0.35.0-beta.0", path = "crates/rapier3d-meshloader", default-features = false }
+mjcf-rs = { version = "0.35.0-beta.0", path = "crates/mjcf-rs" }
+rapier3d-mjcf = { version = "0.35.0-beta.0", path = "crates/rapier3d-mjcf" }
# Dev
bincode = "1"
diff --git a/README.md b/README.md
index fb4d1184e..40ab5ffe8 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,24 @@ The easiest way to get started with Rapier is to:
Their source code are available on the `examples2d/` and `examples3d/` directory.
3. Don't hesitate to ask for help on [Discord](https://discord.gg/vt9DJSW), or by opening an issue on GitHub.
+## Performance
+
+SIMD-batched constraint solving and contact processing are always on: the
+solver processes 4 contact manifolds per instruction, falling back to scalar
+code on targets without SIMD support. For performance-sensitive applications,
+also enable:
+
+- **`parallel`** — multithreading of the whole physics step (broad phase,
+ narrow phase, solver) through rayon. On CPUs with heterogeneous cores
+ (Apple silicon, Intel hybrid), also call
+ `PhysicsPipeline::set_dedicated_thread_pool(None)` to run the step on a
+ pool sized to the performance cores only: the solver's barrier-paced stages
+ otherwise run at the speed of the slowest (efficiency) core.
+
+```toml
+rapier3d = { version = "*", features = ["parallel"] }
+```
+
## Python bindings
Python bindings are under development. They ship as four PyPI packages —
diff --git a/crates/rapier2d-f64/Cargo.toml b/crates/rapier2d-f64/Cargo.toml
index 21bd733f1..d9792065e 100644
--- a/crates/rapier2d-f64/Cargo.toml
+++ b/crates/rapier2d-f64/Cargo.toml
@@ -19,14 +19,19 @@ maintenance = { status = "actively-developed" }
[lints]
clippy = { needless_lifetimes = "allow" }
rust.unexpected_cfgs = { level = "warn", check-cfg = [
- 'cfg(feature, values("dim3", "f32", "std", "alloc", "bytemuck", "simd-is-enabled", "simd-stable", "simd-nightly"))',
+ 'cfg(feature, values("dim3", "f32", "std", "alloc", "bytemuck", "simd8"))',
'cfg(target_arch, values("spirv"))',
] }
[features]
-default = ["dim2", "f64", "std"]
+default = ["dim2", "f64", "std", "block-solver"]
dim2 = []
f64 = []
+# Enables the 2x2 block solver for contact manifolds (couples the two normal
+# constraints of a contact pair into a single 2x2 MLCP solve). Disabling it
+# also removes the extra per-contact `r_mat_elts` field from the solver's
+# normal-constraint struct.
+block-solver = []
alloc = [
"nalgebra/alloc",
"parry2d-f64/alloc",
@@ -42,14 +47,13 @@ std = [
"thiserror/std",
"serde?/std",
]
-parallel = ["dep:rayon", "std"]
-# SoA SIMD not supported yet on f64
-#simd-stable = ["simba/wide", "simd-is-enabled"]
-#simd-nightly = ["simba/portable_simd", "simd-is-enabled"]
-## Do not enable this feature directly. It is automatically
-## enabled with the "simd-stable" or "simd-nightly" feature.
-#simd-is-enabled = []
+parallel = ["dep:rayon", "std", "parry2d-f64/parallel"]
+# Drops the `Sync` requirement from callbacks (`PhysicsHooks`, `EventHandler`).
+# As a side effect, this also removes the dedicated threadpool API from the physics world
+# and pipeline.
+unsync-callbacks = []
serde-serialize = [
+ "arrayvec/serde",
"nalgebra/serde-serialize",
"parry2d-f64/serde-serialize",
"dep:serde",
@@ -67,8 +71,16 @@ debug-disable-legitimate-fe-exceptions = []
# Do not enable this unless you are working on the engine internals.
dev-remove-slow-accessors = []
+# Opt-in bounds checks on the solver's otherwise-unchecked SIMD body gathers
+# (`SolverBodies::gather_*` use `get_unchecked`). Validates the per-constraint
+# solver-body ids once at constraint generation — off the per-iteration solve
+# loop — so a stale id from a solver-graph maintenance bug becomes a clean
+# panic instead of an out-of-bounds gather (UB / segfault in the SIMD build).
+# Cheap but non-zero; off by default.
+solver-bounds-checks = []
+
[package.metadata.docs.rs]
-features = ["parallel", "simd-stable", "serde-serialize", "debug-render"]
+features = ["parallel", "serde-serialize", "debug-render"]
[lib]
name = "rapier2d_f64"
@@ -82,6 +94,7 @@ parry2d-f64.workspace = true
simba.workspace = true
num-traits.workspace = true
approx.workspace = true
+arrayvec.workspace = true
bitflags.workspace = true
log.workspace = true
thiserror.workspace = true
@@ -103,3 +116,5 @@ bincode.workspace = true
serde_json.workspace = true
serde = { workspace = true, features = ["std"] }
oorandom.workspace = true
+
+[target.'cfg(target_vendor = "apple")'.dependencies]
diff --git a/crates/rapier2d/Cargo.toml b/crates/rapier2d/Cargo.toml
index 63f2c5e1e..706a05ca6 100644
--- a/crates/rapier2d/Cargo.toml
+++ b/crates/rapier2d/Cargo.toml
@@ -24,9 +24,14 @@ rust.unexpected_cfgs = { level = "warn", check-cfg = [
] }
[features]
-default = ["dim2", "f32", "std"]
+default = ["dim2", "f32", "std", "block-solver"]
dim2 = []
f32 = []
+# Enables the 2x2 block solver for contact manifolds (couples the two normal
+# constraints of a contact pair into a single 2x2 MLCP solve). Disabling it
+# also removes the extra per-contact `r_mat_elts` field from the solver's
+# normal-constraint struct.
+block-solver = []
alloc = [
"nalgebra/alloc",
"parry2d/alloc",
@@ -43,13 +48,18 @@ std = [
"wide/std",
"serde?/std",
]
-parallel = ["dep:rayon", "std"]
-simd-stable = ["simba/wide", "parry2d/simd-stable", "simd-is-enabled"]
-simd-nightly = ["simba/portable_simd", "parry2d/simd-nightly", "simd-is-enabled"]
-# Do not enable this feature directly. It is automatically
-# enabled with the "simd-stable" or "simd-nightly" feature.
-simd-is-enabled = []
+parallel = ["dep:rayon", "std", "parry2d/parallel"]
+# Drops the `Sync` requirement from callbacks (`PhysicsHooks`, `EventHandler`).
+# As a side effect, this also removes the dedicated threadpool API from the physics world
+# and pipeline.
+unsync-callbacks = []
+# Widens the solver's SIMD from 4 to 8 lanes (f32 only), replacing the default
+# 4-lane path. The compiler only emits real 256-bit AVX instructions on an
+# AVX-enabled target (`RUSTFLAGS="-C target-feature=+avx2,+fma"` or
+# `-C target-cpu=native`); otherwise it runs (correctly) as two 128-bit halves.
+simd8 = ["parry2d/simd8"]
serde-serialize = [
+ "arrayvec/serde",
"nalgebra/serde-serialize",
"parry2d/serde-serialize",
"dep:serde",
@@ -67,8 +77,16 @@ debug-disable-legitimate-fe-exceptions = []
# Do not enable this unless you are working on the engine internals.
dev-remove-slow-accessors = []
+# Opt-in bounds checks on the solver's otherwise-unchecked SIMD body gathers
+# (`SolverBodies::gather_*` use `get_unchecked`). Validates the per-constraint
+# solver-body ids once at constraint generation — off the per-iteration solve
+# loop — so a stale id from a solver-graph maintenance bug becomes a clean
+# panic instead of an out-of-bounds gather (UB / segfault in the SIMD build).
+# Cheap but non-zero; off by default.
+solver-bounds-checks = []
+
[package.metadata.docs.rs]
-features = ["parallel", "simd-stable", "serde-serialize", "debug-render"]
+features = ["parallel", "serde-serialize", "debug-render"]
[lib]
name = "rapier2d"
@@ -79,9 +97,10 @@ doctest = false # All doctests are written assuming the 3D version.
[dependencies]
glamx.workspace = true
parry2d.workspace = true
-simba.workspace = true
+simba = { workspace = true, features = ["wide"] }
num-traits.workspace = true
approx.workspace = true
+arrayvec.workspace = true
bitflags.workspace = true
log.workspace = true
thiserror.workspace = true
diff --git a/crates/rapier2d/tests/ccd_default_vs_fixed.rs b/crates/rapier2d/tests/ccd_default_vs_fixed.rs
new file mode 100644
index 000000000..831e2769c
--- /dev/null
+++ b/crates/rapier2d/tests/ccd_default_vs_fixed.rs
@@ -0,0 +1,213 @@
+//! Tests for the default CCD tier: fast dynamic bodies get continuous collision
+//! detection against **fixed** colliders automatically, while
+//! `ccd_enabled` upgrades a body to also sweep against kinematic/dynamic bodies.
+
+use rapier2d::prelude::*;
+
+/// Minimal world harness so each test reads as scenario + assertions.
+struct Harness {
+ 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 Harness {
+ 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(),
+ // No gravity: keep the fast bodies on a clean 1D path along +X.
+ gravity: Vector::ZERO,
+ }
+ }
+
+ 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 run(&mut self, steps: usize) {
+ for _ in 0..steps {
+ self.step();
+ }
+ }
+
+ fn x(&self, h: RigidBodyHandle) -> Real {
+ self.bodies[h].translation().x
+ }
+}
+
+/// A thin fixed wall centered at the origin, spanning the Y axis.
+fn insert_thin_fixed_wall(h: &mut Harness) {
+ let wall = h.bodies.insert(RigidBodyBuilder::fixed());
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.05, 5.0), wall, &mut h.bodies);
+}
+
+/// A small dynamic body far on the -X side, moving fast toward +X. In a single
+/// `1/60`s step it moves ~3.3m, far more than the wall thickness — so without CCD
+/// it tunnels straight through.
+fn insert_fast_dynamic(h: &mut Harness, ccd_enabled: bool) -> RigidBodyHandle {
+ let body = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0))
+ .linvel(Vector::new(200.0, 0.0))
+ .ccd_enabled(ccd_enabled),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.1, 0.1), body, &mut h.bodies);
+ body
+}
+
+/// A default-tier fast dynamic body must NOT tunnel through a fixed wall,
+/// even without `ccd_enabled`.
+#[test]
+fn default_ccd_vs_fixed_no_tunnel() {
+ let mut h = Harness::new();
+ insert_thin_fixed_wall(&mut h);
+ let body = insert_fast_dynamic(&mut h, /* ccd_enabled */ false);
+
+ h.run(120);
+
+ // The body should have been stopped on the near (-X) side of the wall.
+ assert!(
+ h.x(body) < 0.0,
+ "body tunneled through the fixed wall (x = {})",
+ h.x(body)
+ );
+}
+
+/// Two default-tier fast dynamic bodies on a head-on course DO pass through each
+/// other: the default tier only sweeps fixed colliders, so there is no
+/// moving-vs-moving CCD. This locks the fixed-only scope.
+#[test]
+fn default_tier_ignores_dynamic() {
+ let mut h = Harness::new();
+
+ let a = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0))
+ .linvel(Vector::new(200.0, 0.0)),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.1, 0.1), a, &mut h.bodies);
+
+ let b = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(3.0, 0.0))
+ .linvel(Vector::new(-200.0, 0.0)),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.1, 0.1), b, &mut h.bodies);
+
+ h.run(5);
+
+ // They swapped sides — passed through each other untouched.
+ assert!(
+ h.x(a) > 0.0 && h.x(b) < 0.0,
+ "default-tier dynamic bodies should tunnel through each other (a.x = {}, b.x = {})",
+ h.x(a),
+ h.x(b)
+ );
+}
+
+/// A `ccd_enabled` ("bullet") body still collides with a dynamic body — the
+/// upgrade tier is unaffected by the change.
+#[test]
+fn bullet_still_hits_dynamic() {
+ let mut h = Harness::new();
+
+ let bullet = insert_fast_dynamic(&mut h, /* ccd_enabled */ true);
+
+ // A stationary dynamic target at the origin (not ccd_enabled).
+ let target = h
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.0)));
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.2, 0.2), target, &mut h.bodies);
+
+ h.run(60);
+
+ // The bullet hit the target and pushed it along +X (it did not tunnel).
+ assert!(
+ h.x(target) > 0.05,
+ "bullet did not hit the dynamic target (target.x = {})",
+ h.x(target)
+ );
+ assert!(
+ h.x(bullet) < h.x(target),
+ "bullet passed through its target (bullet.x = {}, target.x = {})",
+ h.x(bullet),
+ h.x(target)
+ );
+}
+
+/// Setting `max_ccd_substeps = 0` disables CCD for the whole world: the fast
+/// default-tier body tunnels through the fixed wall again.
+#[test]
+fn global_ccd_off_tunnels() {
+ let mut h = Harness::new();
+ h.params.max_ccd_substeps = 0;
+ insert_thin_fixed_wall(&mut h);
+ let body = insert_fast_dynamic(&mut h, /* ccd_enabled */ false);
+
+ h.run(60);
+
+ assert!(
+ h.x(body) > 1.0,
+ "body should tunnel through the wall when CCD is globally disabled (x = {})",
+ h.x(body)
+ );
+}
+
+/// Kinematic bodies are NOT default-tier targets: a default-tier fast body
+/// tunnels through a (stationary) kinematic wall. Contrast with
+/// `default_ccd_vs_fixed_no_tunnel`, where the same body is stopped by a fixed wall.
+#[test]
+fn kinematic_not_a_default_target() {
+ let mut h = Harness::new();
+
+ let wall = h
+ .bodies
+ .insert(RigidBodyBuilder::kinematic_position_based());
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.05, 5.0), wall, &mut h.bodies);
+
+ let body = insert_fast_dynamic(&mut h, /* ccd_enabled */ false);
+
+ h.run(60);
+
+ assert!(
+ h.x(body) > 1.0,
+ "default-tier body should tunnel through a kinematic wall (x = {})",
+ h.x(body)
+ );
+}
diff --git a/crates/rapier2d/tests/ccd_oriented_wall.rs b/crates/rapier2d/tests/ccd_oriented_wall.rs
new file mode 100644
index 000000000..2755998ad
--- /dev/null
+++ b/crates/rapier2d/tests/ccd_oriented_wall.rs
@@ -0,0 +1,86 @@
+//! Isolate: does CCD catch a fast body fired at an ORIENTED polyline wall,
+//! the same way it does against a cuboid wall?
+
+use rapier2d::prelude::*;
+
+fn fires_through(wall: ColliderBuilder, speed: f32) -> f32 {
+ let mut bodies = RigidBodySet::new();
+ let mut colliders = ColliderSet::new();
+ let mut impulse_joints = ImpulseJointSet::new();
+ let mut multibody_joints = MultibodyJointSet::new();
+ let mut pipeline = PhysicsPipeline::new();
+ let mut bf = BroadPhaseBvh::new();
+ let mut nf = NarrowPhase::new();
+ let mut islands = IslandManager::new();
+ let mut ccd = CCDSolver::new();
+ let params = IntegrationParameters::default();
+
+ let ground = bodies.insert(RigidBodyBuilder::fixed());
+ colliders.insert_with_parent(wall, ground, &mut bodies);
+
+ // Ball fired at +X from x=-3 toward the wall at x=0.
+ let ball = bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0))
+ .linvel(Vector::new(speed, 0.0)),
+ );
+ colliders.insert_with_parent(ColliderBuilder::ball(0.2), ball, &mut bodies);
+
+ for _ in 0..120 {
+ pipeline.step(
+ Vector::ZERO,
+ ¶ms,
+ &mut islands,
+ &mut bf,
+ &mut nf,
+ &mut bodies,
+ &mut colliders,
+ &mut impulse_joints,
+ &mut multibody_joints,
+ &mut ccd,
+ &(),
+ &(),
+ );
+ }
+ bodies[ball].translation().x
+}
+
+/// A vertical wall as an oriented polyline (solid on the -X side, since the
+/// segment is wound so its outward normal faces -X). A ball fired at +X into it
+/// must not pass through.
+#[test]
+fn ccd_catches_fast_ball_oriented_polyline() {
+ // Segment from (0, +5) down to (0, -5): outward normal faces +X (right-hand
+ // rule on the downward edge), so the solid side is +X and a ball coming from
+ // -X hits the front face.
+ let pts = vec![Vector::new(0.0, 5.0), Vector::new(0.0, -5.0)];
+ let wall = ColliderBuilder::oriented_polyline(pts, Some(vec![[0, 1]])).friction(0.1);
+ let x = fires_through(wall, 400.0);
+ println!("oriented polyline: final x = {x}");
+ assert!(
+ x < 0.2,
+ "ball tunneled through the oriented polyline wall (x = {x})"
+ );
+}
+
+/// Same, but a cuboid wall (known-good CCD target) as a control.
+#[test]
+fn ccd_catches_fast_ball_cuboid_control() {
+ let wall = ColliderBuilder::cuboid(0.05, 5.0).friction(0.1);
+ let x = fires_through(wall, 400.0);
+ println!("cuboid: final x = {x}");
+ assert!(x < 0.2, "ball tunneled through the cuboid wall (x = {x})");
+}
+
+/// Same, but a non-oriented (two-sided) polyline.
+#[test]
+fn ccd_catches_fast_ball_plain_polyline() {
+ let pts = vec![Vector::new(0.0, 5.0), Vector::new(0.0, -5.0)];
+ let wall = ColliderBuilder::polyline(pts, Some(vec![[0, 1]])).friction(0.1);
+ let x = fires_through(wall, 400.0);
+ println!("plain polyline: final x = {x}");
+ assert!(
+ x < 0.2,
+ "ball tunneled through the plain polyline wall (x = {x})"
+ );
+}
diff --git a/crates/rapier2d/tests/ccd_semantics.rs b/crates/rapier2d/tests/ccd_semantics.rs
new file mode 100644
index 000000000..a14a0a8a1
--- /dev/null
+++ b/crates/rapier2d/tests/ccd_semantics.rs
@@ -0,0 +1,278 @@
+//! Regression tests locking the CCD semantics:
+//! - a time-of-impact clamp changes the pose only, never the velocity;
+//! - bullets never sweep against other bullets;
+//! - sensor crossings are never missed, both when a fast solid body tunnels through a
+//! sensor and when the fast body's own sensor collider tunnels through geometry;
+//! - `max_ccd_substeps > 1` still prevents tunneling (the substep splitter path).
+
+use rapier2d::prelude::*;
+use std::sync::Mutex;
+
+#[derive(Default)]
+struct EventCollector {
+ events: Mutex>,
+}
+
+impl EventHandler for EventCollector {
+ fn handle_collision_event(
+ &self,
+ _bodies: &RigidBodySet,
+ _colliders: &ColliderSet,
+ event: CollisionEvent,
+ _contact_pair: Option<&ContactPair>,
+ ) {
+ self.events.lock().unwrap().push(event);
+ }
+
+ fn handle_contact_force_event(
+ &self,
+ _dt: Real,
+ _bodies: &RigidBodySet,
+ _colliders: &ColliderSet,
+ _contact_pair: &ContactPair,
+ _total_force_magnitude: Real,
+ ) {
+ }
+}
+
+struct Harness {
+ bodies: RigidBodySet,
+ colliders: ColliderSet,
+ impulse_joints: ImpulseJointSet,
+ multibody_joints: MultibodyJointSet,
+ pipeline: PhysicsPipeline,
+ bf: BroadPhaseBvh,
+ nf: NarrowPhase,
+ islands: IslandManager,
+ ccd: CCDSolver,
+ params: IntegrationParameters,
+ gravity: Vector,
+ events: EventCollector,
+}
+
+impl Harness {
+ 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(),
+ // No gravity: keep the fast bodies on a clean 1D path along +X.
+ gravity: Vector::ZERO,
+ events: EventCollector::default(),
+ }
+ }
+
+ 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,
+ &(),
+ &self.events,
+ );
+ }
+
+ fn run(&mut self, steps: usize) {
+ for _ in 0..steps {
+ self.step();
+ }
+ }
+
+ /// A thin fixed wall at x = 0.
+ fn add_fixed_wall(&mut self) {
+ self.colliders
+ .insert(ColliderBuilder::cuboid(0.1, 10.0).build());
+ }
+
+ /// A fast ball flying toward +X from x = -3 at 400 m/s.
+ fn add_fast_ball(&mut self, ccd_enabled: bool) -> RigidBodyHandle {
+ let body = self.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0))
+ .linvel(Vector::new(400.0, 0.0))
+ .ccd_enabled(ccd_enabled)
+ .build(),
+ );
+ self.colliders.insert_with_parent(
+ ColliderBuilder::ball(0.2).build(),
+ body,
+ &mut self.bodies,
+ );
+ body
+ }
+}
+
+/// A TOI clamp must change the pose only: the velocity right after the clamping step is
+/// exactly the pre-impact velocity (the continuous stage never touches velocities;
+/// the contact is resolved by the regular solver on the *next* step).
+#[test]
+fn clamp_preserves_velocity() {
+ let mut h = Harness::new();
+ h.add_fixed_wall();
+ let ball = h.add_fast_ball(false);
+
+ h.step();
+
+ let rb = &h.bodies[ball];
+ // The ball was clamped in front of the wall instead of tunneling…
+ assert!(
+ rb.translation().x < 0.0,
+ "ball should be clamped before the wall, got x = {}",
+ rb.translation().x
+ );
+ assert!(rb.translation().x > -1.0, "ball should have moved forward");
+ // …but its velocity is untouched.
+ assert!(
+ (rb.linvel().x - 400.0).abs() < 1.0e-3,
+ "velocity must be preserved by the clamp, got {}",
+ rb.linvel().x
+ );
+}
+
+/// Bullets never sweep against other bullets: two bullets flying through each
+/// other with no other obstacle pass through.
+#[test]
+fn bullet_ignores_other_bullet() {
+ let mut h = Harness::new();
+
+ let left = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0))
+ .linvel(Vector::new(400.0, 0.0))
+ .ccd_enabled(true)
+ .build(),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2).build(), left, &mut h.bodies);
+
+ let right = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(3.0, 0.0))
+ .linvel(Vector::new(-400.0, 0.0))
+ .ccd_enabled(true)
+ .build(),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2).build(), right, &mut h.bodies);
+
+ h.run(3);
+
+ assert!(
+ h.bodies[left].translation().x > 3.0,
+ "left bullet should pass through the other bullet, got x = {}",
+ h.bodies[left].translation().x
+ );
+ assert!(
+ h.bodies[right].translation().x < -3.0,
+ "right bullet should pass through the other bullet, got x = {}",
+ h.bodies[right].translation().x
+ );
+}
+
+/// A fast solid body crossing a thin fixed sensor in a single step must still produce the
+/// intersection events the narrow phase would otherwise never observe.
+#[test]
+fn fast_body_through_sensor_emits_events() {
+ let mut h = Harness::new();
+ h.colliders.insert(
+ ColliderBuilder::cuboid(0.1, 10.0)
+ .sensor(true)
+ .active_events(ActiveEvents::COLLISION_EVENTS)
+ .build(),
+ );
+ let _ball = h.add_fast_ball(false);
+
+ h.run(3);
+
+ let events = h.events.events.lock().unwrap();
+ let started = events
+ .iter()
+ .any(|e| matches!(e, CollisionEvent::Started(_, _, f) if f.contains(CollisionEventFlags::SENSOR)));
+ let stopped = events
+ .iter()
+ .any(|e| matches!(e, CollisionEvent::Stopped(_, _, f) if f.contains(CollisionEventFlags::SENSOR)));
+ assert!(
+ started && stopped,
+ "expected paired sensor events for the tunneled sensor, got {:?}",
+ *events
+ );
+}
+
+/// A fast body whose *own* collider is a sensor must also report crossings
+/// (sensor shapes are swept for event detection even though they never clamp).
+#[test]
+fn fast_sensor_origin_emits_events() {
+ let mut h = Harness::new();
+ // Thin fixed *solid* wall the flying sensor crosses.
+ h.colliders.insert(
+ ColliderBuilder::cuboid(0.1, 10.0)
+ .active_events(ActiveEvents::COLLISION_EVENTS)
+ .build(),
+ );
+
+ let body = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0))
+ .linvel(Vector::new(400.0, 0.0))
+ .additional_mass(1.0)
+ .build(),
+ );
+ h.colliders.insert_with_parent(
+ ColliderBuilder::ball(0.2)
+ .sensor(true)
+ .active_events(ActiveEvents::COLLISION_EVENTS)
+ .build(),
+ body,
+ &mut h.bodies,
+ );
+
+ h.run(3);
+
+ assert!(
+ h.bodies[body].translation().x > 3.0,
+ "the sensor body must fly through the wall unclamped, got x = {}",
+ h.bodies[body].translation().x
+ );
+
+ let events = h.events.events.lock().unwrap();
+ let started = events
+ .iter()
+ .any(|e| matches!(e, CollisionEvent::Started(_, _, f) if f.contains(CollisionEventFlags::SENSOR)));
+ let stopped = events
+ .iter()
+ .any(|e| matches!(e, CollisionEvent::Stopped(_, _, f) if f.contains(CollisionEventFlags::SENSOR)));
+ assert!(
+ started && stopped,
+ "expected paired sensor events from the fast sensor origin, got {:?}",
+ *events
+ );
+}
+
+/// Multiple CCD substeps (`max_ccd_substeps > 1`, the substep splitter path) still
+/// stop a fast body at a fixed wall.
+#[test]
+fn multi_ccd_substeps_no_tunnel() {
+ let mut h = Harness::new();
+ h.params.max_ccd_substeps = 4;
+ h.add_fixed_wall();
+ let ball = h.add_fast_ball(false);
+
+ h.run(10);
+
+ let x = h.bodies[ball].translation().x;
+ assert!(x < 0.2, "ball must not tunnel with 4 CCD substeps, x = {x}");
+}
diff --git a/crates/rapier2d/tests/heightfield_solver_graph.rs b/crates/rapier2d/tests/heightfield_solver_graph.rs
new file mode 100644
index 000000000..c825c4330
--- /dev/null
+++ b/crates/rapier2d/tests/heightfield_solver_graph.rs
@@ -0,0 +1,69 @@
+//! Regression test for the 2D heightfield stress demo segfault.
+//!
+//! Heightfield/composite pairs rebuild their manifold list in BVH-traversal
+//! order every full update, so a manifold's ordinal is unstable and dropped
+//! subshapes lose their stored `graph_pos`. The persistent solver contact
+//! graph must not keep stale `ContactRef`s pointing at those ordinals — a
+//! stale one used to index out of the shrunken manifold list at solve time and
+//! segfault (only debug builds caught it, via the shadow validator). Stepping
+//! a busy heightfield scene exercises the reorder/drop churn.
+#![cfg(feature = "dim2")]
+
+use rapier2d::prelude::*;
+
+#[test]
+fn heightfield_stress_solver_graph_consistency() {
+ let mut world = PhysicsWorld::new();
+
+ let ground_size = Vec2::new(50.0, 1.0);
+ let nsubdivs = 2000;
+
+ let heights = (0..nsubdivs + 1)
+ .map(|i| {
+ if i == 0 || i == nsubdivs {
+ 80.0
+ } else {
+ (i as f32 * ground_size.x / (nsubdivs as f32)).cos() * 2.0
+ }
+ })
+ .collect();
+
+ let rigid_body = RigidBodyBuilder::fixed();
+ let collider = ColliderBuilder::heightfield(heights, ground_size);
+ let handle = world.bodies.insert(rigid_body);
+ world
+ .colliders
+ .insert_with_parent(collider, handle, &mut world.bodies);
+
+ let num = 26;
+ let rad = 0.5;
+ let shift = rad * 2.0;
+ let centerx = shift * (num / 2) as f32;
+ let centery = shift / 2.0;
+
+ for i in 0..num {
+ for j in 0usize..num * 5 {
+ let x = i as f32 * shift - centerx;
+ let y = j as f32 * shift + centery + 3.0;
+ let rigid_body = RigidBodyBuilder::dynamic().translation(Vec2::new(x, y));
+ let handle = world.bodies.insert(rigid_body);
+ if j % 2 == 0 {
+ world.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(rad, rad),
+ handle,
+ &mut world.bodies,
+ );
+ } else {
+ world.colliders.insert_with_parent(
+ ColliderBuilder::ball(rad),
+ handle,
+ &mut world.bodies,
+ );
+ }
+ }
+ }
+
+ for _ in 0..300 {
+ world.step();
+ }
+}
diff --git a/crates/rapier2d/tests/joint_assembly_persistence.rs b/crates/rapier2d/tests/joint_assembly_persistence.rs
new file mode 100644
index 000000000..9ed46fa41
--- /dev/null
+++ b/crates/rapier2d/tests/joint_assembly_persistence.rs
@@ -0,0 +1,107 @@
+//! Regression tests for the persistent joint constraint assembly: the solver
+//! recycles joint builders across steps, so runtime changes to joints or their
+//! attached bodies must correctly invalidate the cached assembly.
+
+#[cfg(feature = "dim2")]
+use rapier2d::prelude::*;
+#[cfg(feature = "dim3")]
+use rapier3d::prelude::*;
+
+fn pendulum(
+ world: &mut PhysicsWorld,
+ anchor_pos: Vector,
+) -> (RigidBodyHandle, RigidBodyHandle, ImpulseJointHandle) {
+ let anchor = world
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(anchor_pos));
+ let bob = world
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(anchor_pos - Vector::Y * 2.0));
+ world
+ .colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), bob, &mut world.bodies);
+ #[cfg(feature = "dim2")]
+ let joint = RevoluteJointBuilder::new().local_anchor2(Vector::Y * 2.0);
+ #[cfg(feature = "dim3")]
+ let joint = SphericalJointBuilder::new().local_anchor2(Vector::Y * 2.0);
+ let j = world.impulse_joints.insert(anchor, bob, joint, true);
+ (anchor, bob, j)
+}
+
+/// A joint removed after the assembly was cached must stop constraining.
+#[test]
+fn removed_joint_stops_constraining() {
+ let mut world = PhysicsWorld::new();
+ let (_, bob, joint) = pendulum(&mut world, Vector::ZERO);
+
+ for _ in 0..30 {
+ world.step();
+ }
+ let y_held = world.bodies[bob].translation().y;
+ assert!(y_held > -2.5, "joint should hold the bob (y = {y_held})");
+
+ world.impulse_joints.remove(joint, true);
+ for _ in 0..60 {
+ world.step();
+ }
+ let y_free = world.bodies[bob].translation().y;
+ assert!(
+ y_free < -4.0,
+ "bob should free-fall after joint removal (y = {y_free})"
+ );
+}
+
+/// Mutating a joint through `get_mut` after the assembly was cached must take
+/// effect on the next step.
+#[test]
+fn joint_mutation_invalidates_cached_assembly() {
+ let mut world = PhysicsWorld::new();
+ let (_, bob, joint) = pendulum(&mut world, Vector::ZERO);
+
+ for _ in 0..30 {
+ world.step();
+ }
+
+ // Re-anchor the bob 4.0 below the pivot instead of 2.0.
+ world
+ .impulse_joints
+ .get_mut(joint, true)
+ .unwrap()
+ .data
+ .set_local_anchor2(Vector::Y * 4.0);
+ for _ in 0..120 {
+ world.step();
+ }
+ let y = world.bodies[bob].translation().y;
+ assert!(
+ y < -3.4 && y > -4.6,
+ "bob should hang ~4.0 below the pivot after re-anchoring (y = {y})"
+ );
+}
+
+/// Moving a (fixed) body attached to a joint must invalidate the cached
+/// assembly: the joint frame of a fixed body is baked into the cached builder.
+#[test]
+fn moved_fixed_anchor_invalidates_cached_assembly() {
+ let mut world = PhysicsWorld::new();
+ let (anchor, bob, _) = pendulum(&mut world, Vector::ZERO);
+
+ for _ in 0..30 {
+ world.step();
+ }
+
+ let shift = Vector::X * 5.0;
+ let new_pos = world.bodies[anchor].translation() + shift;
+ world.bodies[anchor].set_translation(new_pos, true);
+ for _ in 0..300 {
+ world.step();
+ }
+ // The bob swings, so assert the constraint itself: it must be pinned 2.0
+ // away from the *new* anchor (a stale cached joint frame would keep it
+ // pinned around the old anchor, 3.0..7.0 away from the new one).
+ let dist = (world.bodies[bob].translation() - new_pos).length();
+ assert!(
+ (dist - 2.0).abs() < 0.3,
+ "bob should be pinned 2.0 from the moved anchor (dist = {dist})"
+ );
+}
diff --git a/crates/rapier2d/tests/joint_contact_solve_order.rs b/crates/rapier2d/tests/joint_contact_solve_order.rs
new file mode 100644
index 000000000..b36735e92
--- /dev/null
+++ b/crates/rapier2d/tests/joint_contact_solve_order.rs
@@ -0,0 +1,75 @@
+//! Joints must be solved BEFORE contacts in every solver pass: with few
+//! iterations per substep, the constraint solved last on a body wins its
+//! velocity residual, and a joint solved after the contacts of a much heavier
+//! contacting body re-imposes the joint velocity on its light body — letting
+//! the heavy body push straight through it.
+//!
+//! This reproduces the `Spring Joints` testbed demo: heavy cubes (200x the
+//! ball mass) dropped onto light balls hanging from spring joints. When the
+//! solve order flips to contacts-then-joints, every cube tunnels through its
+//! ball. The pair count matters: with few pairs all constraints share the
+//! staged solver's worker-0 overflow stage (whose internal order was correct),
+//! so the bug only appeared once the contacts got colored stages of their own.
+
+#[cfg(feature = "dim2")]
+use rapier2d::prelude::*;
+#[cfg(feature = "dim3")]
+use rapier3d::prelude::*;
+
+#[cfg(feature = "dim2")]
+fn vect(x: f32, y: f32) -> Vector {
+ Vec2::new(x, y)
+}
+#[cfg(feature = "dim3")]
+fn vect(x: f32, y: f32) -> Vector {
+ Vec3::new(x, y, 0.0)
+}
+
+#[test]
+fn heavy_cubes_rest_on_spring_jointed_balls() {
+ let mut world = PhysicsWorld::new();
+
+ let ground_handle = world.insert_body(RigidBodyBuilder::fixed());
+
+ let num = 30;
+ let radius = 0.5;
+ let mass = Ball::new(radius).mass_properties(1.0).mass();
+ let stiffness = 1.0e3;
+ let critical_damping = 2.0 * (stiffness * mass).sqrt();
+ let mut pairs = vec![];
+ for i in 0..=num {
+ let ball_pos = vect(-6.0 + 1.5 * i as f32, 4.5);
+ let rigid_body = RigidBodyBuilder::dynamic()
+ .translation(ball_pos)
+ .can_sleep(false);
+ let (ball, _) = world.insert(rigid_body, ColliderBuilder::ball(radius));
+
+ let damping_ratio = i as f32 / (num as f32 / 2.0);
+ let damping = damping_ratio * critical_damping;
+ let joint = SpringJointBuilder::new(0.0, stiffness, damping)
+ .local_anchor1(ball_pos - Vector::Y * 3.0);
+ world.insert_impulse_joint(ground_handle, ball, joint);
+
+ let rigid_body = RigidBodyBuilder::dynamic().translation(ball_pos + Vector::Y * 5.0);
+ #[cfg(feature = "dim2")]
+ let collider = ColliderBuilder::cuboid(radius, radius).density(100.0);
+ #[cfg(feature = "dim3")]
+ let collider = ColliderBuilder::cuboid(radius, radius, radius).density(100.0);
+ let (cube, _) = world.insert(rigid_body, collider);
+ pairs.push((ball, cube));
+ }
+
+ for _ in 0..300 {
+ world.step();
+ }
+
+ for (i, (ball, cube)) in pairs.iter().enumerate() {
+ let ball_y = world.bodies[*ball].translation().y;
+ let cube_y = world.bodies[*cube].translation().y;
+ assert!(
+ cube_y > ball_y,
+ "cube {i} tunneled through its spring-jointed ball \
+ (cube y = {cube_y:.3}, ball y = {ball_y:.3})"
+ );
+ }
+}
diff --git a/crates/rapier2d/tests/joint_stability.rs b/crates/rapier2d/tests/joint_stability.rs
new file mode 100644
index 000000000..63b3d0622
--- /dev/null
+++ b/crates/rapier2d/tests/joint_stability.rs
@@ -0,0 +1,319 @@
+//! Long-run joint stability regressions (testbed `ImpulseJoint prismatic`/`ball`
+//! scenes): bilateral joint structures are conservative, so any per-step solver
+//! energy injection compounds into a blow-up over thousands of steps.
+
+#[cfg(feature = "dim2")]
+use rapier2d::prelude::*;
+#[cfg(feature = "dim3")]
+use rapier3d::prelude::*;
+
+#[cfg(feature = "dim2")]
+fn vect(x: f32, y: f32, _z: f32) -> Vector {
+ Vec2::new(x, y)
+}
+#[cfg(feature = "dim3")]
+fn vect(x: f32, y: f32, z: f32) -> Vector {
+ Vec3::new(x, y, z)
+}
+
+fn assert_sane(world: &PhysicsWorld, scene: &str, bound: f32) {
+ let mut max_vel: f32 = 0.0;
+ for (_, body) in world.bodies.iter() {
+ let pos = body.translation();
+ assert!(
+ pos.length() < bound,
+ "{scene}: body at non-sane position {pos:?}"
+ );
+ max_vel = max_vel.max(body.linvel().length());
+ }
+ assert!(
+ max_vel < 100.0,
+ "{scene}: runaway velocity {max_vel} (energy is being injected)"
+ );
+}
+
+/// Hanging chains of boxes on limited prismatic joints along alternating diagonal
+/// rails (the `ImpulseJoint prismatic` stress scene): the chains hang from the
+/// engaged limit rows, so any limit-row energy injection accumulates.
+#[test]
+fn prismatic_limit_chains_remain_stable() {
+ let mut world = PhysicsWorld::new();
+ let rad = 0.4;
+ let shift = 1.0;
+ let num = 10;
+
+ for chain in 0..10 {
+ let x = chain as f32 * 4.0;
+ let ground = RigidBodyBuilder::fixed().translation(vect(x, 0.0, 0.0));
+ let mut curr_parent = world.bodies.insert(ground);
+ world.colliders.insert_with_parent(
+ #[cfg(feature = "dim2")]
+ ColliderBuilder::cuboid(rad, rad),
+ #[cfg(feature = "dim3")]
+ ColliderBuilder::cuboid(rad, rad, rad),
+ curr_parent,
+ &mut world.bodies,
+ );
+
+ for i in 0..num {
+ let y = -(i + 1) as f32 * shift;
+ let rigid_body = RigidBodyBuilder::dynamic()
+ .translation(vect(x, y, 0.0))
+ .can_sleep(false);
+ let curr_child = world.bodies.insert(rigid_body);
+ world.colliders.insert_with_parent(
+ #[cfg(feature = "dim2")]
+ ColliderBuilder::cuboid(rad, rad),
+ #[cfg(feature = "dim3")]
+ ColliderBuilder::cuboid(rad, rad, rad),
+ curr_child,
+ &mut world.bodies,
+ );
+
+ let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
+ #[cfg(feature = "dim2")]
+ let axis = Vec2::new(sign, 1.0).normalize();
+ #[cfg(feature = "dim3")]
+ let axis = Vec3::new(sign, 1.0, 0.0).normalize();
+
+ let prism = PrismaticJointBuilder::new(axis)
+ .local_anchor2(vect(0.0, shift, 0.0))
+ .limits([-1.5, 1.5]);
+ world
+ .impulse_joints
+ .insert(curr_parent, curr_child, prism, true);
+
+ curr_parent = curr_child;
+ }
+ }
+
+ for k in 0..10_000 {
+ world.step();
+ if k % 1000 == 999 {
+ assert_sane(&world, "prismatic_limit_chains", 200.0);
+ }
+ }
+}
+
+/// A pinned net of revolute (2D) / spherical (3D) joints (the `ImpulseJoint
+/// ball` stress scene, reduced): a conservative swinging structure that heats
+/// up and eventually breaks apart if the solver injects energy.
+#[test]
+fn joint_net_remains_stable() {
+ let mut world = PhysicsWorld::new();
+ let n = 50;
+ let rad = 0.4;
+ let shift = 1.0;
+ let mut handles = vec![RigidBodyHandle::invalid(); n * n];
+
+ for i in 0..n {
+ for j in 0..n {
+ let pos = vect(j as f32 * shift, -(i as f32) * shift, 0.0);
+ let body = if i == 0 && (j % 4 == 0 || j == n - 1) {
+ RigidBodyBuilder::fixed().translation(pos)
+ } else {
+ RigidBodyBuilder::dynamic()
+ .translation(pos)
+ .can_sleep(false)
+ };
+ let handle = world.bodies.insert(body);
+ world.colliders.insert_with_parent(
+ ColliderBuilder::ball(rad),
+ handle,
+ &mut world.bodies,
+ );
+ handles[i * n + j] = handle;
+ }
+ }
+
+ #[cfg(feature = "dim2")]
+ let joint = |anchor1: Vector, anchor2: Vector| {
+ RevoluteJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(anchor2)
+ .build()
+ .data
+ };
+ #[cfg(feature = "dim3")]
+ let joint = |anchor1: Vector, anchor2: Vector| {
+ SphericalJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(anchor2)
+ .build()
+ .data
+ };
+
+ for i in 0..n {
+ for j in 0..n {
+ if i > 0 {
+ let a = handles[(i - 1) * n + j];
+ let b = handles[i * n + j];
+ world.impulse_joints.insert(
+ a,
+ b,
+ joint(vect(0.0, -shift / 2.0, 0.0), vect(0.0, shift / 2.0, 0.0)),
+ true,
+ );
+ }
+ if j > 0 {
+ let a = handles[i * n + j - 1];
+ let b = handles[i * n + j];
+ world.impulse_joints.insert(
+ a,
+ b,
+ joint(vect(shift / 2.0, 0.0, 0.0), vect(-shift / 2.0, 0.0, 0.0)),
+ true,
+ );
+ }
+ }
+ }
+
+ for k in 0..8000 {
+ world.step();
+ if k % 1000 == 999 {
+ assert_sane(&world, "joint_net", 500.0);
+ }
+ }
+}
+
+/// Extended manual variants: longer runs and bigger structures than CI allows.
+/// Run: `cargo test --release --test joint_stability -- --ignored`
+#[test]
+#[ignore = "long manual stability validation"]
+fn prismatic_limit_chains_long_run() {
+ let mut world = PhysicsWorld::new();
+ let rad = 0.4;
+ let shift = 1.0;
+ let num = 10;
+
+ for chain in 0..10 {
+ let x = chain as f32 * 4.0;
+ let ground = RigidBodyBuilder::fixed().translation(vect(x, 0.0, 0.0));
+ let mut curr_parent = world.bodies.insert(ground);
+ world.colliders.insert_with_parent(
+ #[cfg(feature = "dim2")]
+ ColliderBuilder::cuboid(rad, rad),
+ #[cfg(feature = "dim3")]
+ ColliderBuilder::cuboid(rad, rad, rad),
+ curr_parent,
+ &mut world.bodies,
+ );
+
+ for i in 0..num {
+ let y = -(i + 1) as f32 * shift;
+ let rigid_body = RigidBodyBuilder::dynamic()
+ .translation(vect(x, y, 0.0))
+ .can_sleep(false);
+ let curr_child = world.bodies.insert(rigid_body);
+ world.colliders.insert_with_parent(
+ #[cfg(feature = "dim2")]
+ ColliderBuilder::cuboid(rad, rad),
+ #[cfg(feature = "dim3")]
+ ColliderBuilder::cuboid(rad, rad, rad),
+ curr_child,
+ &mut world.bodies,
+ );
+
+ let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
+ #[cfg(feature = "dim2")]
+ let axis = Vec2::new(sign, 1.0).normalize();
+ #[cfg(feature = "dim3")]
+ let axis = Vec3::new(sign, 1.0, 0.0).normalize();
+
+ let prism = PrismaticJointBuilder::new(axis)
+ .local_anchor2(vect(0.0, shift, 0.0))
+ .limits([-1.5, 1.5]);
+ world
+ .impulse_joints
+ .insert(curr_parent, curr_child, prism, true);
+
+ curr_parent = curr_child;
+ }
+ }
+
+ for k in 0..50_000 {
+ world.step();
+ if k % 2000 == 1999 {
+ assert_sane(&world, "prismatic_limit_chains_long_run", 200.0);
+ }
+ }
+}
+
+#[test]
+#[ignore = "long manual stability validation"]
+fn joint_net_long_run() {
+ let mut world = PhysicsWorld::new();
+ let n = 80;
+ let rad = 0.4;
+ let shift = 1.0;
+ let mut handles = vec![RigidBodyHandle::invalid(); n * n];
+
+ for i in 0..n {
+ for j in 0..n {
+ let pos = vect(j as f32 * shift, -(i as f32) * shift, 0.0);
+ let body = if i == 0 && (j % 4 == 0 || j == n - 1) {
+ RigidBodyBuilder::fixed().translation(pos)
+ } else {
+ RigidBodyBuilder::dynamic()
+ .translation(pos)
+ .can_sleep(false)
+ };
+ let handle = world.bodies.insert(body);
+ world.colliders.insert_with_parent(
+ ColliderBuilder::ball(rad),
+ handle,
+ &mut world.bodies,
+ );
+ handles[i * n + j] = handle;
+ }
+ }
+
+ #[cfg(feature = "dim2")]
+ let joint = |anchor1: Vector, anchor2: Vector| {
+ RevoluteJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(anchor2)
+ .build()
+ .data
+ };
+ #[cfg(feature = "dim3")]
+ let joint = |anchor1: Vector, anchor2: Vector| {
+ SphericalJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(anchor2)
+ .build()
+ .data
+ };
+
+ for i in 0..n {
+ for j in 0..n {
+ if i > 0 {
+ let a = handles[(i - 1) * n + j];
+ let b = handles[i * n + j];
+ world.impulse_joints.insert(
+ a,
+ b,
+ joint(vect(0.0, -shift / 2.0, 0.0), vect(0.0, shift / 2.0, 0.0)),
+ true,
+ );
+ }
+ if j > 0 {
+ let a = handles[i * n + j - 1];
+ let b = handles[i * n + j];
+ world.impulse_joints.insert(
+ a,
+ b,
+ joint(vect(shift / 2.0, 0.0, 0.0), vect(-shift / 2.0, 0.0, 0.0)),
+ true,
+ );
+ }
+ }
+ }
+
+ for k in 0..20_000 {
+ world.step();
+ if k % 2000 == 1999 {
+ assert_sane(&world, "joint_net_long_run", 800.0);
+ }
+ }
+}
diff --git a/crates/rapier2d/tests/miri_scenes.rs b/crates/rapier2d/tests/miri_scenes.rs
new file mode 100644
index 000000000..48a14b2ee
--- /dev/null
+++ b/crates/rapier2d/tests/miri_scenes.rs
@@ -0,0 +1,229 @@
+//! Tiny scenes exercising the pipeline's unsafe hot paths (manifold store, solver
+//! graph buckets, raw color-mask slices) under Miri's aliasing checks — 2D variant.
+//!
+//! Under Miri each step costs seconds, so scenes start in contact and run a
+//! handful of steps; natively they run long enough to also assert behavior.
+//! Run with: `cargo +nightly miri test -p rapier2d --test miri_scenes`.
+//! On Apple Silicon add `--target x86_64-unknown-linux-gnu`: glam's aarch64 NEON
+//! backend hits foreign intrinsics Miri does not implement, while its x86 SSE2
+//! path is fully supported.
+
+use rapier2d::prelude::*;
+
+/// Steps per scene: enough to reach the solver's steady state paths (prepare,
+/// warm-start, writeback, graph maintenance) under Miri; long enough natively
+/// for the scene's behavioral assertion to be meaningful.
+fn steps(native: usize) -> usize {
+ if cfg!(miri) { 3 } else { native }
+}
+
+struct World {
+ bodies: RigidBodySet,
+ colliders: ColliderSet,
+ impulse_joints: ImpulseJointSet,
+ multibody_joints: MultibodyJointSet,
+ islands: IslandManager,
+ broad_phase: DefaultBroadPhase,
+ narrow_phase: NarrowPhase,
+ ccd: CCDSolver,
+ pipeline: PhysicsPipeline,
+ params: IntegrationParameters,
+ gravity: Vector,
+}
+
+impl World {
+ fn new() -> Self {
+ Self {
+ bodies: RigidBodySet::new(),
+ colliders: ColliderSet::new(),
+ impulse_joints: ImpulseJointSet::new(),
+ multibody_joints: MultibodyJointSet::new(),
+ islands: IslandManager::new(),
+ broad_phase: DefaultBroadPhase::new(),
+ narrow_phase: NarrowPhase::new(),
+ ccd: CCDSolver::new(),
+ pipeline: PhysicsPipeline::new(),
+ params: IntegrationParameters::default(),
+ gravity: Vector::Y * -9.81,
+ }
+ }
+
+ fn step(&mut self) {
+ self.pipeline.step(
+ self.gravity,
+ &self.params,
+ &mut self.islands,
+ &mut self.broad_phase,
+ &mut self.narrow_phase,
+ &mut self.bodies,
+ &mut self.colliders,
+ &mut self.impulse_joints,
+ &mut self.multibody_joints,
+ &mut self.ccd,
+ &(),
+ &(),
+ );
+ }
+
+ fn run(&mut self, native_steps: usize) {
+ for _ in 0..steps(native_steps) {
+ self.step();
+ }
+ self.assert_all_finite();
+ }
+
+ fn assert_all_finite(&self) {
+ for (_, rb) in self.bodies.iter() {
+ let p = rb.translation();
+ assert!(
+ p.x.is_finite() && p.y.is_finite(),
+ "non-finite body position: {p:?}"
+ );
+ }
+ }
+
+ fn floor(&mut self) -> RigidBodyHandle {
+ let floor = self
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5)));
+ self.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(10.0, 0.5),
+ floor,
+ &mut self.bodies,
+ );
+ floor
+ }
+}
+
+/// Resting contact + the contact-force-event pass (exact solver-active pair list).
+#[test]
+fn resting_ball_with_force_events() {
+ let mut w = World::new();
+ let floor = w
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5)));
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(10.0, 0.5)
+ .active_events(ActiveEvents::CONTACT_FORCE_EVENTS)
+ .contact_force_event_threshold(0.0),
+ floor,
+ &mut w.bodies,
+ );
+
+ let ball = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.5)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.5), ball, &mut w.bodies);
+
+ w.run(120);
+ if !cfg!(miri) {
+ let y = w.bodies[ball].translation().y;
+ assert!((y - 0.5).abs() < 0.05, "ball not resting on floor: y = {y}");
+ }
+}
+
+/// Multi-manifold stack: warm-starting, solver colors, manifold writeback.
+#[test]
+fn small_box_stack() {
+ let mut w = World::new();
+ w.floor();
+
+ let mut tops = Vec::new();
+ for i in 0..3 {
+ let y = 0.5 + i as Real;
+ let b = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, y)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5), b, &mut w.bodies);
+ tops.push(b);
+ }
+
+ w.run(120);
+ if !cfg!(miri) {
+ let y = w.bodies[tops[2]].translation().y;
+ assert!((y - 2.5).abs() < 0.1, "stack collapsed: top y = {y}");
+ }
+}
+
+/// Impulse-joint solver: a horizontal pendulum swinging on a revolute joint.
+#[test]
+fn revolute_pendulum() {
+ let mut w = World::new();
+ let anchor = w.bodies.insert(RigidBodyBuilder::fixed());
+ let bob = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(1.0, 0.0)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.1), bob, &mut w.bodies);
+
+ let joint = RevoluteJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, 0.0))
+ .local_anchor2(Vector::new(-1.0, 0.0));
+ w.impulse_joints.insert(anchor, bob, joint, true);
+
+ w.run(120);
+ if !cfg!(miri) {
+ let d = w.bodies[bob].translation().length();
+ assert!((d - 1.0).abs() < 0.05, "pendulum arm stretched: |p| = {d}");
+ }
+}
+
+/// CCD sweeps: a bullet ball must not tunnel through a thin floor.
+#[test]
+fn ccd_bullet_vs_thin_floor() {
+ let mut w = World::new();
+ let floor = w.bodies.insert(RigidBodyBuilder::fixed());
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(10.0, 0.05), floor, &mut w.bodies);
+
+ let bullet = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 1.0))
+ .linvel(Vector::Y * -100.0)
+ .ccd_enabled(true),
+ );
+ w.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.1), bullet, &mut w.bodies);
+
+ w.run(30);
+ let y = w.bodies[bullet].translation().y;
+ assert!(y > 0.0, "bullet tunneled through the floor: y = {y}");
+}
+
+/// Mid-run body removal: pair removal, solver-graph and island maintenance.
+#[test]
+fn body_removal_midrun() {
+ let mut w = World::new();
+ w.floor();
+
+ let a = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.5)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5), a, &mut w.bodies);
+ let b = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(1.0, 0.5)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5), b, &mut w.bodies);
+
+ w.run(2);
+ w.bodies.remove(
+ a,
+ &mut w.islands,
+ &mut w.colliders,
+ &mut w.impulse_joints,
+ &mut w.multibody_joints,
+ true,
+ );
+ w.run(60);
+ if !cfg!(miri) {
+ let y = w.bodies[b].translation().y;
+ assert!(
+ (y - 0.5).abs() < 0.05,
+ "surviving box sank or jumped: y = {y}"
+ );
+ }
+}
diff --git a/crates/rapier2d/tests/snapshot_portability.rs b/crates/rapier2d/tests/snapshot_portability.rs
new file mode 100644
index 000000000..f2af740bc
--- /dev/null
+++ b/crates/rapier2d/tests/snapshot_portability.rs
@@ -0,0 +1,149 @@
+//! Cross-target snapshot portability (2D).
+//!
+//! A snapshot taken on one machine must be byte-identical to one taken on another, so a
+//! server and a browser client (or two peers in a lockstep game) can exchange and compare
+//! them. That only holds with `enhanced-determinism` — which pins the floating-point
+//! results — plus a stored layout that has no target-dependent encoding left in it. This
+//! test pins the second half: it steps a fixed scene and asserts the snapshot's size and
+//! digest against a committed constant, so the *same* test binary compiled for a different
+//! target must reproduce the *same* number.
+//!
+//! It is a portability test only when it is actually run on more than one target. The CI
+//! `wasm-determinism` job runs it under `wasm32-wasip1` (32-bit pointers, a different
+//! libm, a different codegen backend) against the same golden the native jobs check; run
+//! it locally the same way with:
+//!
+//! ```text
+//! cargo test -p rapier2d --release --features enhanced-determinism,serde-serialize \
+//! --target wasm32-wasip1 --test snapshot_portability
+//! ```
+//!
+//! (with `CARGO_TARGET_WASM32_WASIP1_RUNNER` pointing at wasmtime or an equivalent).
+//!
+//! What it has caught: `usize` sentinels. `RigidBodyIds`' active-set ids and the multibody
+//! `IndexSequence`'s `first_to_remove` all stored `usize::MAX` to mean "none", which
+//! bincode writes as `0xFFFF_FFFF_FFFF_FFFF` on a 64-bit target and `0xFFFF_FFFF` on a
+//! 32-bit one — the same state, different bytes. They are `u32` now.
+#![cfg(all(feature = "serde-serialize", feature = "enhanced-determinism"))]
+
+use rapier2d::prelude::*;
+
+/// Snapshot size in bytes, and its FNV-1a digest. Both, because the size alone localizes a
+/// failure: a differing size means a container's *encoding* changed, an equal size with a
+/// differing digest means the values did.
+const GOLDEN: (usize, u64) = (88_532, 0xa750_70b5_0fd4_e7f8);
+
+const STEPS: usize = 60;
+
+fn digest(bytes: &[u8]) -> u64 {
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+ for b in bytes {
+ h ^= *b as u64;
+ h = h.wrapping_mul(0x100_0000_01b3);
+ }
+ h
+}
+
+/// Deliberately mixed: box contacts (some settling into sleep), an impulse-joint chain, a
+/// multibody articulation, a sensor and a CCD body — so the snapshot covers the island,
+/// broad-phase, narrow-phase, solver-graph and multibody containers rather than just body
+/// poses.
+fn scene() -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -9.81);
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5)),
+ ColliderBuilder::cuboid(20.0, 0.5),
+ );
+
+ for i in 0..10 {
+ for j in 0..4 {
+ let jitter = (i as Real * 0.013 + j as Real * 0.017) % 0.05;
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(
+ i as Real * 1.05 - 5.0 + jitter,
+ j as Real * 1.05 + 0.55,
+ )),
+ ColliderBuilder::cuboid(0.5, 0.5),
+ );
+ }
+ }
+
+ // Impulse-joint chain: exercises the solver's persisted joint coloring.
+ let mut prev = world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(0.0, 7.0)));
+ for i in 0..4 {
+ let rb = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.6 * (i + 1) as Real, 7.0))
+ .can_sleep(false),
+ );
+ world.insert_collider(ColliderBuilder::ball(0.25), Some(rb));
+ world.insert_impulse_joint(
+ prev,
+ rb,
+ RevoluteJointBuilder::new()
+ .local_anchor1(Vector::X * 0.3)
+ .local_anchor2(Vector::X * -0.3),
+ );
+ prev = rb;
+ }
+
+ // Multibody chain: the augmented-mass dof permutation and the topology epoch.
+ let size = 0.4;
+ let mut last = world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(6.0, 4.0)));
+ for i in 0..6 {
+ let rb = world.insert_body(RigidBodyBuilder::dynamic().can_sleep(false));
+ world.insert_collider(
+ ColliderBuilder::cuboid(size / 8.0, size / 2.0).density(1.0),
+ Some(rb),
+ );
+ let joint = RevoluteJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, size / 2.0 * (i != 0) as usize as Real))
+ .local_anchor2(Vector::new(0.0, -size / 2.0))
+ .build()
+ .data;
+ world.insert_multibody_joint(last, rb, joint);
+ last = rb;
+ }
+
+ // A sensor to swing through, and a bullet to sweep across the pile.
+ world.insert_collider(
+ ColliderBuilder::cuboid(3.0, 0.5)
+ .translation(Vector::new(0.0, 4.0))
+ .sensor(true),
+ None,
+ );
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-8.0, 2.0))
+ .linvel(Vector::new(90.0, -20.0))
+ .ccd_enabled(true)
+ .can_sleep(false),
+ ColliderBuilder::ball(0.15),
+ );
+
+ world
+}
+
+#[test]
+fn snapshot_is_target_independent() {
+ let mut world = scene();
+ for _ in 0..STEPS {
+ world.step();
+ }
+ let bytes = bincode::serialize(&world).expect("snapshot serialization");
+ let got = (bytes.len(), digest(&bytes));
+
+ assert_eq!(
+ got,
+ GOLDEN,
+ "\nsnapshot differs from the golden on this target \
+ ({}-bit pointers).\n golden: {:?}\n got: {:?}\n\
+ Either a target-dependent encoding crept back into the stored state (a `usize` \
+ sentinel is the usual one), or the simulation itself changed — in which case \
+ re-mint GOLDEN and re-verify it on wasm32 before committing.\n",
+ core::mem::size_of::() * 8,
+ GOLDEN,
+ got,
+ );
+}
diff --git a/crates/rapier2d/tests/snapshot_roundtrip.rs b/crates/rapier2d/tests/snapshot_roundtrip.rs
new file mode 100644
index 000000000..fa9ba9bb4
--- /dev/null
+++ b/crates/rapier2d/tests/snapshot_roundtrip.rs
@@ -0,0 +1,436 @@
+//! Snapshot round-trip determinism (2D).
+//!
+//! The 2D counterpart of `rapier3d/tests/snapshot_roundtrip.rs`, same contract:
+//! serializing a [`PhysicsWorld`], restoring it, and stepping on must continue the
+//! simulation exactly as not snapshotting at all would have, and re-serializing at every
+//! step after the restore must yield **the same bytes** as the run it resumed.
+//!
+//! Worth its own copy rather than trusting the 3D suite: the serialized state is mostly
+//! dimension-generic, but the scenes that fill it are not — 2D contact manifolds carry two
+//! points instead of up to eight, `Rot`/angular velocity are scalars, and the solver's
+//! constraint layout differs. A field skipped only on the 2D path would go unseen here
+//! otherwise.
+#![cfg(feature = "serde-serialize")]
+
+use rapier2d::prelude::*;
+
+/// Serializes the world — what a user's save file holds: the simulation inputs plus the
+/// seven structures, with the pipeline and CCD workspace left out.
+fn save(world: &PhysicsWorld) -> Vec {
+ bincode::serialize(world).expect("snapshot serialization")
+}
+
+/// Restores into a fresh world, the way loading a save file does.
+fn restore(bytes: &[u8]) -> PhysicsWorld {
+ bincode::deserialize(bytes).expect("snapshot deserialization")
+}
+
+const PART_NAMES: [&str; 9] = [
+ "gravity",
+ "integration_parameters",
+ "islands",
+ "broad_phase",
+ "narrow_phase",
+ "bodies",
+ "colliders",
+ "impulse_joints",
+ "multibody_joints",
+];
+
+/// Per-structure digests, used only to name the culprit when the bytes differ.
+fn parts(world: &PhysicsWorld) -> [u64; 9] {
+ let fnv = |bytes: Vec| {
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+ for b in bytes {
+ h ^= b as u64;
+ h = h.wrapping_mul(0x100_0000_01b3);
+ }
+ h
+ };
+ [
+ fnv(bincode::serialize(&world.gravity).unwrap()),
+ fnv(bincode::serialize(&world.integration_parameters).unwrap()),
+ fnv(bincode::serialize(&world.islands).unwrap()),
+ fnv(bincode::serialize(&world.broad_phase).unwrap()),
+ fnv(bincode::serialize(&world.narrow_phase).unwrap()),
+ fnv(bincode::serialize(&world.bodies).unwrap()),
+ fnv(bincode::serialize(&world.colliders).unwrap()),
+ fnv(bincode::serialize(&world.impulse_joints).unwrap()),
+ fnv(bincode::serialize(&world.multibody_joints).unwrap()),
+ ]
+}
+
+/// One body per line, bit-faithfully (`Debug` on floats round-trips exactly). Only used to
+/// report whether the physics moved when the bytes did.
+fn state(world: &PhysicsWorld) -> Vec {
+ let mut out: Vec<_> = world
+ .bodies
+ .iter()
+ .map(|(h, rb)| {
+ format!(
+ "{} {:?} {:?} {:?} {:?} {}",
+ h.into_raw_parts().0,
+ rb.translation(),
+ rb.rotation(),
+ rb.linvel(),
+ rb.angvel(),
+ rb.is_sleeping()
+ )
+ })
+ .collect();
+ out.sort();
+ out
+}
+
+/// What one step of a run produces: the snapshot bytes, plus diagnostics for the message.
+struct Step {
+ bytes: Vec,
+ parts: [u64; 9],
+ state: Vec,
+}
+
+fn record(world: &PhysicsWorld) -> Step {
+ Step {
+ bytes: save(world),
+ parts: parts(world),
+ state: state(world),
+ }
+}
+
+fn assert_same(step: usize, want: &Step, got: &Step, what: &str) {
+ if want.bytes == got.bytes {
+ return;
+ }
+ let differing: Vec<_> = (0..9)
+ .filter(|i| want.parts[*i] != got.parts[*i])
+ .map(|i| PART_NAMES[i])
+ .collect();
+ let body = want
+ .state
+ .iter()
+ .zip(got.state.iter())
+ .find(|(a, b)| a != b)
+ .map(|(a, b)| format!("\n uninterrupted: {a}\n restored: {b}"))
+ .unwrap_or_else(|| {
+ " none — the stored layout moved without the simulation moving".to_string()
+ });
+ panic!(
+ "{what}: the snapshot is not byte-identical {} step(s) after the restore.\n \
+ sizes {} vs {} bytes\n differing structures: {differing:?}\n \
+ first differing body:{body}",
+ step + 1,
+ want.bytes.len(),
+ got.bytes.len(),
+ );
+}
+
+/// Steps `before`, snapshots, then runs the original world and a restored one in
+/// lockstep for `after` steps, requiring their snapshots to be byte-identical at every
+/// step.
+///
+/// `edit` runs before each step with the absolute step index and that world's own spawn
+/// list, so structural changes are applied identically to both continuations.
+/// `keep_pipeline` restores into the live pipeline (the testbed's pattern) instead of a
+/// fresh one.
+fn check_roundtrip(
+ what: &str,
+ mut world: PhysicsWorld,
+ before: usize,
+ after: usize,
+ keep_pipeline: bool,
+ mut edit: impl FnMut(&mut PhysicsWorld, usize, &mut Vec),
+) {
+ let mut spawned = Vec::new();
+ for step in 0..before {
+ edit(&mut world, step, &mut spawned);
+ world.step();
+ }
+ let snapshot = save(&world);
+ let mut spawned_restored = spawned.clone();
+
+ let mut restored = restore(&snapshot);
+ if keep_pipeline {
+ // The testbed's Save/Restore: the world is replaced, the live pipeline keeps
+ // stepping. Moving them over leaves `world` with fresh ones, which is fine — it is
+ // the *restored* side whose pipeline state is under test here.
+ restored.physics_pipeline = core::mem::take(&mut world.physics_pipeline);
+ restored.ccd_solver = core::mem::take(&mut world.ccd_solver);
+ }
+
+ for step in 0..after {
+ edit(&mut world, before + step, &mut spawned);
+ world.step();
+ edit(&mut restored, before + step, &mut spawned_restored);
+ restored.step();
+ assert_same(step, &record(&world), &record(&restored), what);
+ }
+}
+
+fn no_edits(_: &mut PhysicsWorld, _: usize, _: &mut Vec) {}
+
+/// A pile of boxes on a ground. `sleepy` lets it settle and fall asleep — which switches
+/// the broad phase to SAH re-insertion and the narrow phase to its sparse-awake path —
+/// and adds a few permanent movers so the scene is not simply frozen.
+fn pile(sleepy: bool) -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5)),
+ ColliderBuilder::cuboid(20.0, 0.5),
+ );
+
+ for i in 0..10 {
+ for j in 0..6 {
+ let jitter = (i as Real * 0.013 + j as Real * 0.017) % 0.05;
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(
+ i as Real * 1.05 - 5.0 + jitter,
+ j as Real * 1.05 + 0.55,
+ ))
+ .can_sleep(sleepy),
+ ColliderBuilder::cuboid(0.5, 0.5),
+ );
+ }
+ }
+
+ if sleepy {
+ for i in 0..4 {
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-9.0 + i as Real * 0.7, 3.4 + i as Real))
+ .linvel(Vector::new(4.0, 0.3 * i as Real))
+ .can_sleep(false),
+ ColliderBuilder::ball(0.4),
+ );
+ }
+ }
+
+ world
+}
+
+/// A hanging chain of revolute joints, plus a sensor it swings through.
+fn with_joints_and_sensor(world: &mut PhysicsWorld) {
+ let anchor = world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(0.0, 7.0)));
+ let mut prev = anchor;
+ for i in 0..4 {
+ let rb = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.6 * (i + 1) as Real, 7.0))
+ .can_sleep(false),
+ );
+ world.insert_collider(ColliderBuilder::ball(0.25), Some(rb));
+ world.insert_impulse_joint(
+ prev,
+ rb,
+ RevoluteJointBuilder::new()
+ .local_anchor1(Vector::X * 0.3)
+ .local_anchor2(Vector::X * -0.3),
+ );
+ prev = rb;
+ }
+ world.insert_collider(
+ ColliderBuilder::cuboid(3.0, 0.5)
+ .translation(Vector::new(0.0, 4.0))
+ .sensor(true),
+ None,
+ );
+}
+
+/// The `large_pyramids2` stress scene (`examples2d/stress_tests/large_pyramids2.rs`), same
+/// geometry: `count` piles with a base row of `base` boxes each, sleeping disabled.
+///
+/// Size is the point: at full size (8 piles of 1,540 boxes) it crosses the bulk-path
+/// thresholds — batched leaf updates, chunked pair filtering, blocked contact update, the
+/// solver-graph counting-sort rebuild — that the smaller scenes never reach.
+fn pyramids(count: usize, base: usize) -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+ let rad = 0.5;
+ let gap = 10.0;
+ let pyramid_width = base as Real * 2.0 * rad;
+ let total_width = count as Real * (pyramid_width + gap);
+
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0)),
+ ColliderBuilder::cuboid(total_width, 1.0),
+ );
+
+ let shift = rad * 2.0;
+ for p in 0..count {
+ let x0 = p as Real * (pyramid_width + gap) - 0.5 * total_width;
+ for i in 0..base {
+ for j in i..base {
+ let x = x0 + (i as Real * shift / 2.0) + (j - i) as Real * shift;
+ let y = i as Real * shift * 1.001 + rad;
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(x, y))
+ .can_sleep(false),
+ ColliderBuilder::cuboid(rad, rad),
+ );
+ }
+ }
+ }
+ world
+}
+
+/// Every leaf moves every step: the broad phase stays in its bulk in-place-update regime
+/// and the periodic tree optimizer keeps firing.
+#[test]
+fn awake_pile() {
+ check_roundtrip("awake pile", pile(false), 100, 100, false, no_edits);
+}
+
+/// Few leaves move: SAH re-insertion, sparse-awake narrow phase, sleeping islands.
+#[test]
+fn sleeping_pile() {
+ check_roundtrip("sleeping pile", pile(true), 100, 100, false, no_edits);
+}
+
+/// A pyramid big enough to reach the bulk paths, small enough for CI.
+#[test]
+fn pyramid_stress_scene() {
+ check_roundtrip("pyramids", pyramids(2, 20), 100, 40, false, no_edits);
+}
+
+/// The real thing: 8 piles of 1,540 boxes, 12,320 bodies. Slow and memory-hungry, so it is
+/// `#[ignore]`d and run explicitly:
+///
+/// ```text
+/// cargo test -p rapier2d --release --features serde-serialize \
+/// --test snapshot_roundtrip -- --ignored --nocapture
+/// ```
+#[test]
+#[ignore = "full-size stress scene: 12,320 bodies, minutes to run"]
+fn pyramid_stress_scene_full() {
+ let world = pyramids(8, 55);
+ println!("large_pyramids2: {} bodies", world.bodies.len());
+ check_roundtrip("large_pyramids2", world, 100, 20, false, no_edits);
+}
+
+/// Fast CCD-enabled bodies. The CCD solver holds a cache that snapshots do not carry, so
+/// this checks that a restore does not depend on it.
+#[test]
+fn ccd_bodies() {
+ let mut world = pile(true);
+ for i in 0..8 {
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-6.0 + i as Real * 1.5, 4.0 + i as Real * 0.3))
+ .linvel(Vector::new(90.0, -45.0 + 3.0 * i as Real))
+ .ccd_enabled(true)
+ .can_sleep(false),
+ ColliderBuilder::ball(0.15),
+ );
+ }
+ check_roundtrip("ccd", world, 100, 100, false, no_edits);
+}
+
+/// Joints and a sensor: the solver's joint assembly is cached across steps, and a restore
+/// necessarily rebuilds it.
+#[test]
+fn joints_and_sensor() {
+ let mut world = pile(true);
+ with_joints_and_sensor(&mut world);
+ check_roundtrip("joints", world, 100, 100, false, no_edits);
+}
+
+/// An articulation: a chain of multibody joints over a settled pile.
+///
+/// Multibodies exercise two things nothing else does. Their topology epoch is stored by
+/// the narrow phase alongside its solver contact graph, so the two must agree across a
+/// restore or the graph is rebuilt into a different (equally valid) layout — that is what
+/// broke every failing scene in the 3D example sweep, the 2D IK scene included. And their
+/// chain links populate `PersistentIslands::joint_link_locs`, a hash map whose iteration
+/// order is insertion-history dependent: serializing it as-is made two snapshots of the
+/// same state differ.
+#[test]
+fn multibody_articulation() {
+ let mut world = pile(true);
+
+ let segments = 10;
+ let size = 0.4;
+ let mut last = world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(6.0, 4.0)));
+ for i in 0..segments {
+ let rb = world.insert_body(RigidBodyBuilder::dynamic().can_sleep(false));
+ world.insert_collider(
+ ColliderBuilder::cuboid(size / 8.0, size / 2.0).density(1.0),
+ Some(rb),
+ );
+ let joint = RevoluteJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, size / 2.0 * (i != 0) as usize as Real))
+ .local_anchor2(Vector::new(0.0, -size / 2.0))
+ .build()
+ .data;
+ world.insert_multibody_joint(last, rb, joint);
+ last = rb;
+ }
+
+ check_roundtrip("multibody", world, 100, 60, false, no_edits);
+}
+
+/// The testbed's Save/Restore pattern: the world is replaced but the same
+/// [`PhysicsPipeline`] keeps stepping, carrying its workspace across the restore.
+#[test]
+fn restoring_into_a_live_pipeline() {
+ check_roundtrip("live pipeline", pile(true), 100, 100, true, no_edits);
+ check_roundtrip("live pipeline, awake", pile(false), 100, 60, true, no_edits);
+}
+
+/// Structural churn on both sides of the snapshot: pair creation and deletion, island
+/// merges and splits, arena and edge-id recycling — with a joint chain and sensor present.
+#[test]
+fn structural_changes() {
+ let mut world = pile(true);
+ with_joints_and_sensor(&mut world);
+
+ check_roundtrip(
+ "structural",
+ world,
+ 100,
+ 100,
+ false,
+ |world, step, spawned| {
+ if step % 17 == 0 {
+ let rb = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(
+ (step % 5) as Real - 2.0,
+ 6.0 + (step % 3) as Real,
+ ))
+ .can_sleep(false),
+ );
+ world.insert_collider(ColliderBuilder::ball(0.45), Some(rb));
+ spawned.push(rb);
+ }
+ if step % 23 == 0 && !spawned.is_empty() {
+ let rb = spawned.remove(0);
+ world.remove_body(rb);
+ }
+ },
+ );
+}
+
+/// Replacing the pipeline mid-run, with no snapshot involved: [`PhysicsPipeline`] is
+/// documented as holding workspace only, and a restored world always starts from a fresh
+/// one. The joint chain is the point — the solver's joint coloring is cached across steps
+/// and only survives a rebuild because joints persist their color.
+#[test]
+fn replacing_the_pipeline_changes_nothing() {
+ let mut a = pile(true);
+ with_joints_and_sensor(&mut a);
+ let mut b = pile(true);
+ with_joints_and_sensor(&mut b);
+
+ for _ in 0..100 {
+ a.step();
+ b.step();
+ }
+ b.physics_pipeline = PhysicsPipeline::new();
+ b.ccd_solver = CCDSolver::new();
+
+ for step in 0..100 {
+ a.step();
+ b.step();
+ assert_same(step, &record(&a), &record(&b), "fresh pipeline");
+ }
+}
diff --git a/crates/rapier2d/tests/speed_cap.rs b/crates/rapier2d/tests/speed_cap.rs
new file mode 100644
index 000000000..f5e40e132
--- /dev/null
+++ b/crates/rapier2d/tests/speed_cap.rs
@@ -0,0 +1,181 @@
+//! Tests for the per-step velocity speed cap and the CCD
+//! initial-contact tolerance (the spinner-arm anti-jitter fix).
+
+use rapier2d::prelude::*;
+
+struct Harness {
+ 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 Harness {
+ fn new(gravity: Vector) -> 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,
+ }
+ }
+
+ 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 run(&mut self, steps: usize) {
+ for _ in 0..steps {
+ self.step();
+ }
+ }
+}
+
+/// A body given an absurd linear velocity is clamped to `max_linear_velocity()`.
+#[test]
+fn linear_speed_cap() {
+ let mut h = Harness::new(Vector::ZERO);
+ let cap = h.params.max_linear_velocity();
+ assert!(cap.is_finite(), "linear cap should be finite by default");
+
+ let body = h
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().linvel(Vector::new(10_000.0, 0.0)));
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), body, &mut h.bodies);
+
+ h.step();
+
+ let speed = h.bodies[body].linvel().length();
+ assert!(
+ (speed - cap).abs() < 1.0,
+ "linear velocity should be capped to {cap} (got {speed})"
+ );
+}
+
+/// Disabling the cap (`normalized_max_linear_velocity = Real::MAX`) restores
+/// uncapped motion.
+#[test]
+fn linear_cap_disabled() {
+ let mut h = Harness::new(Vector::ZERO);
+ h.params.normalized_max_linear_velocity = Real::MAX;
+
+ let body = h
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().linvel(Vector::new(10_000.0, 0.0)));
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), body, &mut h.bodies);
+
+ h.step();
+
+ let speed = h.bodies[body].linvel().length();
+ assert!(
+ speed > 9_000.0,
+ "linear velocity should be uncapped when disabled (got {speed})"
+ );
+}
+
+/// A body given an absurd angular velocity rotates at most ~`MAX_ROTATION`/step
+/// unless `allow_fast_rotation` is set.
+#[test]
+fn angular_speed_cap() {
+ // max angular speed ≈ (π/4) * 60 ≈ 47.1 rad/s at the default 60 Hz step.
+ let max_ang = core::f64::consts::FRAC_PI_4 as Real * IntegrationParameters::default().inv_dt();
+
+ // Capped body.
+ let mut h = Harness::new(Vector::ZERO);
+ let capped = h.bodies.insert(RigidBodyBuilder::dynamic().angvel(500.0));
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), capped, &mut h.bodies);
+ h.step();
+ let w = h.bodies[capped].angvel().abs();
+ assert!(
+ (w - max_ang).abs() < 2.0,
+ "angular velocity should be capped to ~{max_ang} (got {w})"
+ );
+
+ // Bypassed body.
+ let mut h = Harness::new(Vector::ZERO);
+ let fast = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .angvel(500.0)
+ .allow_fast_rotation(true),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), fast, &mut h.bodies);
+ h.step();
+ let w = h.bodies[fast].angvel().abs();
+ assert!(
+ w > 400.0,
+ "allow_fast_rotation should bypass the angular cap (got {w})"
+ );
+}
+
+/// A fast body sliding tangentially while in contact with a fixed floor keeps
+/// translating: CCD must skip a pair the discrete solver already owns, instead
+/// of clamping the body's motion toward a standstill (the spinner-arm jitter).
+#[test]
+fn ccd_skips_in_contact_pair() {
+ let mut h = Harness::new(Vector::new(0.0, -9.81));
+
+ // Frictionless fixed floor.
+ let floor = h.bodies.insert(RigidBodyBuilder::fixed());
+ h.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(50.0, 0.05).friction(0.0),
+ floor,
+ &mut h.bodies,
+ );
+
+ // A ball resting on the floor (slight overlap → persistent contact),
+ // sliding fast along +X. Fast enough to be CCD-active, under the linear cap.
+ let ball = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-5.0, 0.24))
+ .linvel(Vector::new(50.0, 0.0)),
+ );
+ h.colliders.insert_with_parent(
+ ColliderBuilder::ball(0.2).friction(0.0),
+ ball,
+ &mut h.bodies,
+ );
+
+ h.run(30);
+
+ // ~50 m/s over ~0.5 s ⇒ it should have slid far along +X, not been frozen.
+ let x = h.bodies[ball].translation().x;
+ assert!(
+ x > 10.0,
+ "in-contact body was clamped by CCD instead of sliding freely (x = {x})"
+ );
+ assert!(
+ h.bodies[ball].is_ccd_active(),
+ "the sliding body should be CCD-active (otherwise the test proves nothing)"
+ );
+}
diff --git a/crates/rapier2d/tests/spinner_containment.rs b/crates/rapier2d/tests/spinner_containment.rs
new file mode 100644
index 000000000..5e0fbeeee
--- /dev/null
+++ b/crates/rapier2d/tests/spinner_containment.rs
@@ -0,0 +1,132 @@
+//! Regression: a spinner scene (a rotating arm stirring ~6000 small bodies)
+//! keeps its small bodies contained inside the oriented-polyline container.
+//! Guards the CCD core-circle + exact-TOI clamp and the oriented (one-sided)
+//! container wall. Prints a per-stage timing breakdown (run with `--nocapture`).
+
+use rapier2d::prelude::*;
+use std::f32::consts::PI;
+
+const POINT_COUNT: usize = 360;
+const CENTER_Y: f32 = 32.0;
+const RADIUS: f32 = 40.0;
+
+#[test]
+fn spinner_keeps_bodies_contained() {
+ let mut bodies = RigidBodySet::new();
+ let mut colliders = ColliderSet::new();
+ let mut impulse_joints = ImpulseJointSet::new();
+ let mut multibody_joints = MultibodyJointSet::new();
+ let mut pipeline = PhysicsPipeline::new();
+ let mut bf = BroadPhaseBvh::new();
+ let mut nf = NarrowPhase::new();
+ let mut islands = IslandManager::new();
+ let mut ccd = CCDSolver::new();
+ let params = IntegrationParameters::default();
+ let gravity = Vector::new(0.0, -10.0);
+
+ let ground = bodies.insert(RigidBodyBuilder::fixed());
+ let q = Rotation::new(-2.0 * PI / POINT_COUNT as f32);
+ let mut p = Vector::new(RADIUS, 0.0);
+ let mut points = Vec::with_capacity(POINT_COUNT);
+ for _ in 0..POINT_COUNT {
+ points.push(Vector::new(p.x, p.y + CENTER_Y));
+ p = q * p;
+ }
+ let indices: Vec<[u32; 2]> = (0..POINT_COUNT as u32)
+ .map(|i| [i, (i + 1) % POINT_COUNT as u32])
+ .collect();
+ colliders.insert_with_parent(
+ ColliderBuilder::oriented_polyline(points, Some(indices)).friction(0.1),
+ ground,
+ &mut bodies,
+ );
+
+ let spinner = bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 12.0))
+ .can_sleep(false),
+ );
+ colliders.insert_with_parent(
+ ColliderBuilder::round_cuboid(0.4, 20.0, 0.2).friction(0.0),
+ spinner,
+ &mut bodies,
+ );
+ let joint = RevoluteJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, 12.0))
+ .local_anchor2(Vector::new(0.0, 0.0))
+ .motor_velocity(5.0, 1.0e5)
+ .motor_max_force(1.0e9);
+ impulse_joints.insert(ground, spinner, joint, true);
+
+ let body_count = 2 * 3038;
+ let mut small = Vec::new();
+ let mut x = -23.0f32;
+ let mut y = 2.0f32;
+ for i in 0..body_count {
+ let handle = bodies.insert(RigidBodyBuilder::dynamic().translation(Vector::new(x, y)));
+ let collider = match i % 3 {
+ 0 => ColliderBuilder::capsule_from_endpoints(
+ Vector::new(-0.25, 0.0),
+ Vector::new(0.25, 0.0),
+ 0.25,
+ ),
+ 1 => ColliderBuilder::ball(0.35),
+ _ => ColliderBuilder::cuboid(0.35, 0.35),
+ }
+ .density(0.25)
+ .friction(0.1)
+ .restitution(0.1);
+ colliders.insert_with_parent(collider, handle, &mut bodies);
+ small.push(handle);
+ x += 0.5;
+ if x >= 23.0 {
+ x = -23.0;
+ y += 0.5;
+ }
+ }
+
+ let mut totals = [0.0f64; 6]; // step, collision detection, broad, narrow, solver, ccd
+ let start = std::time::Instant::now();
+ for _ in 0..3000 {
+ pipeline.step(
+ gravity,
+ ¶ms,
+ &mut islands,
+ &mut bf,
+ &mut nf,
+ &mut bodies,
+ &mut colliders,
+ &mut impulse_joints,
+ &mut multibody_joints,
+ &mut ccd,
+ &(),
+ &(),
+ );
+ totals[1] += pipeline.counters.stages.collision_detection_time.time_ms();
+ totals[2] += pipeline.counters.cd.broad_phase_time.time_ms();
+ totals[3] += pipeline.counters.cd.narrow_phase_time.time_ms();
+ totals[4] += pipeline.counters.stages.solver_time.time_ms();
+ totals[5] += pipeline.counters.ccd.toi_computation_time.time_ms();
+ }
+ totals[0] = start.elapsed().as_secs_f64() * 1000.0;
+ println!("spinner (full scale, 3000 steps) avg per step:");
+ println!(" total: {:.3} ms", totals[0] / 3000.0);
+ println!(
+ " collision detection: {:.3} ms (broad {:.3} / narrow {:.3})",
+ totals[1] / 3000.0,
+ totals[2] / 3000.0,
+ totals[3] / 3000.0
+ );
+ println!(" solver: {:.3} ms", totals[4] / 3000.0);
+ println!(" ccd: {:.3} ms", totals[5] / 3000.0);
+
+ let center = Vector::new(0.0, CENTER_Y);
+ let escaped = small
+ .iter()
+ .filter(|h| (bodies[**h].translation() - center).length() > RADIUS + 1.0)
+ .count();
+ assert!(
+ escaped <= 2,
+ "spinner leaked {escaped}/{body_count} bodies through the container"
+ );
+}
diff --git a/crates/rapier3d-f64/Cargo.toml b/crates/rapier3d-f64/Cargo.toml
index 3c0f1bf03..1b31f1330 100644
--- a/crates/rapier3d-f64/Cargo.toml
+++ b/crates/rapier3d-f64/Cargo.toml
@@ -19,14 +19,20 @@ maintenance = { status = "actively-developed" }
[lints]
clippy = { needless_lifetimes = "allow" }
rust.unexpected_cfgs = { level = "warn", check-cfg = [
- 'cfg(feature, values("dim2", "f32", "std", "alloc", "bytemuck", "simd-is-enabled", "simd-stable", "simd-nightly"))',
+ 'cfg(feature, values("dim2", "f32", "std", "alloc", "bytemuck", "simd8"))',
'cfg(target_arch, values("spirv"))',
] }
[features]
-default = ["dim3", "f64", "std"]
+default = ["dim3", "f64", "std", "block-solver"]
dim3 = []
f64 = []
+# Enables the 2x2 block solver for contact manifolds (couples the two normal
+# constraints of a contact pair into a single 2x2 MLCP solve). Disabling it
+# also removes the extra per-contact `r_mat_elts` field from the solver's
+# normal-constraint struct. NOT enabled by default in 3D: it introduces jitter
+# in the 3D domino demo. Exposed as an opt-in for experimentation.
+block-solver = []
alloc = [
"nalgebra/alloc",
"parry3d-f64/alloc",
@@ -42,18 +48,13 @@ std = [
"thiserror/std",
"serde?/std",
]
-parallel = ["dep:rayon", "std"]
-# SoA SIMD not supported yet on f64
-#simd-stable = ["parry3d-f64/simd-stable", "simba/wide", "simd-is-enabled"]
-#simd-nightly = [
-# "parry3d-f64/simd-nightly",
-# "simba/portable_simd",
-# "simd-is-enabled",
-#]
-## Do not enable this feature directly. It is automatically
-## enabled with the "simd-stable" or "simd-nightly" feature.
-#simd-is-enabled = []
+parallel = ["dep:rayon", "std", "parry3d-f64/parallel"]
+# Drops the `Sync` requirement from callbacks (`PhysicsHooks`, `EventHandler`).
+# As a side effect, this also removes the dedicated threadpool API from the physics world
+# and pipeline.
+unsync-callbacks = []
serde-serialize = [
+ "arrayvec/serde",
"nalgebra/serde-serialize",
"parry3d-f64/serde-serialize",
"dep:serde",
@@ -71,8 +72,16 @@ debug-disable-legitimate-fe-exceptions = []
# Do not enable this unless you are working on the engine internals.
dev-remove-slow-accessors = []
+# Opt-in bounds checks on the solver's otherwise-unchecked SIMD body gathers
+# (`SolverBodies::gather_*` use `get_unchecked`). Validates the per-constraint
+# solver-body ids once at constraint generation — off the per-iteration solve
+# loop — so a stale id from a solver-graph maintenance bug becomes a clean
+# panic instead of an out-of-bounds gather (UB / segfault in the SIMD build).
+# Cheap but non-zero; off by default.
+solver-bounds-checks = []
+
[package.metadata.docs.rs]
-features = ["parallel", "simd-stable", "serde-serialize", "debug-render"]
+features = ["parallel", "serde-serialize", "debug-render"]
[lib]
name = "rapier3d_f64"
@@ -86,6 +95,7 @@ parry3d-f64.workspace = true
simba.workspace = true
num-traits.workspace = true
approx.workspace = true
+arrayvec.workspace = true
bitflags.workspace = true
log.workspace = true
thiserror.workspace = true
@@ -107,3 +117,5 @@ bincode.workspace = true
serde_json.workspace = true
serde = { workspace = true, features = ["std"] }
oorandom.workspace = true
+
+[target.'cfg(target_vendor = "apple")'.dependencies]
diff --git a/crates/rapier3d-mjcf/src/hooks.rs b/crates/rapier3d-mjcf/src/hooks.rs
index 69868f4fa..bc36ed205 100644
--- a/crates/rapier3d-mjcf/src/hooks.rs
+++ b/crates/rapier3d-mjcf/src/hooks.rs
@@ -77,9 +77,8 @@ impl PhysicsHooks for MjcfContactHooks {
let key = (ctx.collider1, ctx.collider2);
if let Some(ov) = self.overrides.get(&key) {
if let Some(f) = ov.friction {
- for c in ctx.solver_contacts.iter_mut() {
- c.friction = f;
- }
+ // Contact materials are per-manifold since the solver-contact slimming.
+ *ctx.friction = f;
}
// Margin: rapier's solver uses `dist` as penetration depth;
// shifting it acts like adding to the contact margin.
diff --git a/crates/rapier3d/Cargo.toml b/crates/rapier3d/Cargo.toml
index 085c61d7e..cfbf8362a 100644
--- a/crates/rapier3d/Cargo.toml
+++ b/crates/rapier3d/Cargo.toml
@@ -24,9 +24,15 @@ rust.unexpected_cfgs = { level = "warn", check-cfg = [
] }
[features]
-default = ["dim3", "f32", "std"]
+default = ["dim3", "f32", "std"] # , "block-solver"]
dim3 = []
f32 = []
+# Enables the 2x2 block solver for contact manifolds (couples the two normal
+# constraints of a contact pair into a single 2x2 MLCP solve). Disabling it
+# also removes the extra per-contact `r_mat_elts` field from the solver's
+# normal-constraint struct. NOT enabled by default in 3D: it introduces jitter
+# in the 3D domino demo. Exposed as an opt-in for experimentation.
+block-solver = []
alloc = [
"nalgebra/alloc",
"parry3d/alloc",
@@ -43,17 +49,18 @@ std = [
"wide/std",
"serde?/std",
]
-parallel = ["dep:rayon", "std"]
-simd-stable = ["parry3d/simd-stable", "simba/wide", "simd-is-enabled"]
-simd-nightly = [
- "parry3d/simd-nightly",
- "simba/portable_simd",
- "simd-is-enabled",
-]
-# Do not enable this feature directly. It is automatically
-# enabled with the "simd-stable" or "simd-nightly" feature.
-simd-is-enabled = []
+parallel = ["dep:rayon", "std", "parry3d/parallel"]
+# Drops the `Sync` requirement from callbacks (`PhysicsHooks`, `EventHandler`).
+# As a side effect, this also removes the dedicated threadpool API from the physics world
+# and pipeline.
+unsync-callbacks = []
+# Widens the solver's SIMD from 4 to 8 lanes (f32 only), replacing the default
+# 4-lane path. The compiler only emits real 256-bit AVX instructions on an
+# AVX-enabled target (`RUSTFLAGS="-C target-feature=+avx2,+fma"` or
+# `-C target-cpu=native`); otherwise it runs (correctly) as two 128-bit halves.
+simd8 = ["parry3d/simd8"]
serde-serialize = [
+ "arrayvec/serde",
"nalgebra/serde-serialize",
"parry3d/serde-serialize",
"dep:serde",
@@ -71,8 +78,16 @@ debug-disable-legitimate-fe-exceptions = []
# Do not enable this unless you are working on the engine internals.
dev-remove-slow-accessors = []
+# Opt-in bounds checks on the solver's otherwise-unchecked SIMD body gathers
+# (`SolverBodies::gather_*` use `get_unchecked`). Validates the per-constraint
+# solver-body ids once at constraint generation — off the per-iteration solve
+# loop — so a stale id from a solver-graph maintenance bug becomes a clean
+# panic instead of an out-of-bounds gather (UB / segfault in the SIMD build).
+# Cheap but non-zero; off by default.
+solver-bounds-checks = []
+
[package.metadata.docs.rs]
-features = ["parallel", "simd-stable", "serde-serialize", "debug-render"]
+features = ["parallel", "serde-serialize", "debug-render"]
[lib]
name = "rapier3d"
@@ -82,9 +97,10 @@ required-features = ["dim3", "f32"]
[dependencies]
glamx.workspace = true
parry3d.workspace = true
-simba.workspace = true
+simba = { workspace = true, features = ["wide"] }
num-traits.workspace = true
approx.workspace = true
+arrayvec.workspace = true
bitflags.workspace = true
log.workspace = true
thiserror.workspace = true
diff --git a/crates/rapier3d/tests/additional_solver_iterations.rs b/crates/rapier3d/tests/additional_solver_iterations.rs
new file mode 100644
index 000000000..bf64ed4e6
--- /dev/null
+++ b/crates/rapier3d/tests/additional_solver_iterations.rs
@@ -0,0 +1,194 @@
+//! `RigidBody::additional_solver_iterations` semantics: the body's whole connected
+//! component runs that many extra substeps (smaller per-group dt); other components
+//! keep the base substep count.
+
+use rapier3d::prelude::*;
+
+struct World {
+ bodies: RigidBodySet,
+ colliders: ColliderSet,
+ impulse_joints: ImpulseJointSet,
+ multibody_joints: MultibodyJointSet,
+ pipeline: PhysicsPipeline,
+ islands: IslandManager,
+ broad_phase: DefaultBroadPhase,
+ narrow_phase: NarrowPhase,
+ ccd: CCDSolver,
+}
+
+impl World {
+ fn new() -> Self {
+ Self {
+ bodies: RigidBodySet::new(),
+ colliders: ColliderSet::new(),
+ impulse_joints: ImpulseJointSet::new(),
+ multibody_joints: MultibodyJointSet::new(),
+ pipeline: PhysicsPipeline::new(),
+ islands: IslandManager::new(),
+ broad_phase: DefaultBroadPhase::new(),
+ narrow_phase: NarrowPhase::new(),
+ ccd: CCDSolver::new(),
+ }
+ }
+
+ fn step(&mut self) {
+ self.pipeline.step(
+ Vector::new(0.0, -9.81, 0.0),
+ &IntegrationParameters::default(),
+ &mut self.islands,
+ &mut self.broad_phase,
+ &mut self.narrow_phase,
+ &mut self.bodies,
+ &mut self.colliders,
+ &mut self.impulse_joints,
+ &mut self.multibody_joints,
+ &mut self.ccd,
+ &(),
+ &(),
+ );
+ }
+}
+
+/// A heavy cube resting on a light cube on the ground (high mass ratio through
+/// contacts), with extra iterations requested on the heavy body.
+fn build_heavy_stack(world: &mut World, extra_iters: usize) -> (RigidBodyHandle, RigidBodyHandle) {
+ let ground = world.bodies.insert(RigidBodyBuilder::fixed());
+ world.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(10.0, 0.5, 10.0).translation(Vector::new(0.0, -0.5, 0.0)),
+ ground,
+ &mut world.bodies,
+ );
+
+ let light = world
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.5, 0.0)));
+ world.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5).density(1.0),
+ light,
+ &mut world.bodies,
+ );
+
+ let heavy = world.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 1.5, 0.0))
+ .additional_solver_iterations(extra_iters),
+ );
+ world.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5).density(200.0),
+ heavy,
+ &mut world.bodies,
+ );
+
+ (light, heavy)
+}
+
+/// A rope of impulse joints hanging from a fixed anchor with a heavy weight at
+/// the end (high mass ratio through joints), extra iterations on the weight.
+fn build_heavy_chain(world: &mut World, extra_iters: usize) -> RigidBodyHandle {
+ let mut prev = world.bodies.insert(RigidBodyBuilder::fixed());
+ let num_links = 6;
+
+ for i in 0..num_links {
+ let is_last = i == num_links - 1;
+ let mut builder =
+ RigidBodyBuilder::dynamic().translation(Vector::new(0.0, -(i as Real + 1.0), 0.0));
+ if is_last {
+ builder = builder.additional_solver_iterations(extra_iters);
+ }
+ let link = world.bodies.insert(builder);
+ world.colliders.insert_with_parent(
+ ColliderBuilder::ball(0.4).density(if is_last { 100.0 } else { 1.0 }),
+ link,
+ &mut world.bodies,
+ );
+
+ let joint = SphericalJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, -0.5, 0.0))
+ .local_anchor2(Vector::new(0.0, 0.5, 0.0));
+ world.impulse_joints.insert(prev, link, joint, true);
+ prev = link;
+ }
+
+ prev
+}
+
+#[test]
+fn heavy_stack_stays_stable_with_extra_iterations() {
+ let mut world = World::new();
+ let (light, heavy) = build_heavy_stack(&mut world, 16);
+
+ for _ in 0..300 {
+ world.step();
+ }
+
+ let light_pos = world.bodies[light].translation();
+ let heavy_pos = world.bodies[heavy].translation();
+ assert!(
+ light_pos.y > 0.3 && light_pos.y < 0.7,
+ "light box crushed or launched: y = {}",
+ light_pos.y
+ );
+ assert!(
+ heavy_pos.y > 1.2 && heavy_pos.y < 1.8,
+ "heavy box sank or launched: y = {}",
+ heavy_pos.y
+ );
+ assert!(
+ world.bodies[heavy].linvel().length() < 0.1,
+ "heavy box still moving: {:?}",
+ world.bodies[heavy].linvel()
+ );
+}
+
+#[test]
+fn heavy_chain_stays_stable_with_extra_iterations() {
+ let mut world = World::new();
+ let end = build_heavy_chain(&mut world, 16);
+
+ for _ in 0..300 {
+ world.step();
+ }
+
+ let end_pos = world.bodies[end].translation();
+ assert!(
+ end_pos.y.is_finite() && end_pos.length() < 20.0,
+ "chain exploded: end at {end_pos:?}"
+ );
+ // The rope is 6 links of length 1: the end must hang around y = -6, with
+ // limited joint stretch despite the 100x mass ratio.
+ assert!(
+ end_pos.y > -7.5 && end_pos.y < -4.5,
+ "chain over-stretched or bunched: end y = {}",
+ end_pos.y
+ );
+}
+
+/// The extra iterations must actually run: with them enabled the trajectory of
+/// a not-yet-settled high-mass-ratio stack differs from the plain solve, while
+/// two identical runs stay bitwise identical (determinism).
+#[test]
+fn extra_iterations_take_effect_and_are_deterministic() {
+ let run = |extra: usize| {
+ let mut world = World::new();
+ let (light, heavy) = build_heavy_stack(&mut world, extra);
+ // Drop the heavy cube from higher up so the early steps are dynamic.
+ world.bodies[heavy].set_translation(Vector::new(0.1, 3.0, 0.0), true);
+ for _ in 0..60 {
+ world.step();
+ }
+ (
+ world.bodies[light].translation(),
+ world.bodies[heavy].translation(),
+ )
+ };
+
+ let plain = run(0);
+ let extra1 = run(8);
+ let extra2 = run(8);
+
+ assert_eq!(extra1, extra2, "extra iterations broke determinism");
+ assert_ne!(
+ plain, extra1,
+ "additional_solver_iterations had no effect on the solve"
+ );
+}
diff --git a/crates/rapier3d/tests/broad_phase_pair_filter.rs b/crates/rapier3d/tests/broad_phase_pair_filter.rs
new file mode 100644
index 000000000..b834ebc91
--- /dev/null
+++ b/crates/rapier3d/tests/broad_phase_pair_filter.rs
@@ -0,0 +1,128 @@
+//! The broad phase no longer creates pairs that the narrow phase's
+//! `ActiveCollisionTypes` filter would drop every frame (e.g. fixed-vs-fixed):
+//! big static environments would otherwise flood the contact graph with
+//! millions of dead edges. These tests pin the filtering itself and the
+//! re-discovery paths for colliders whose filter inputs change afterwards.
+
+#![cfg(feature = "dim3")]
+
+use rapier3d::prelude::*;
+
+fn overlapping_world() -> (PhysicsWorld, ColliderHandle, ColliderHandle) {
+ // Two overlapping parentless (thus fixed) colliders.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -9.81, 0.0);
+ let c1 = world.insert_collider(ColliderBuilder::cuboid(1.0, 1.0, 1.0), None);
+ let c2 = world.insert_collider(
+ ColliderBuilder::cuboid(1.0, 1.0, 1.0).translation(Vector::new(0.5, 0.5, 0.0)),
+ None,
+ );
+ (world, c1, c2)
+}
+
+#[test]
+fn no_fixed_fixed_pairs() {
+ let (mut world, _, _) = overlapping_world();
+ for _ in 0..3 {
+ world.step();
+ }
+ assert_eq!(world.narrow_phase.contact_pairs().count(), 0);
+}
+
+#[test]
+fn fixed_fixed_pair_kept_when_opted_in() {
+ // A collider opting into FIXED_FIXED collision types keeps its pairs.
+ let mut world = PhysicsWorld::new();
+ let _ = world.insert_collider(
+ ColliderBuilder::cuboid(1.0, 1.0, 1.0).active_collision_types(ActiveCollisionTypes::all()),
+ None,
+ );
+ let _ = world.insert_collider(
+ ColliderBuilder::cuboid(1.0, 1.0, 1.0).translation(Vector::new(0.5, 0.5, 0.0)),
+ None,
+ );
+ for _ in 0..3 {
+ world.step();
+ }
+ assert_eq!(world.narrow_phase.contact_pairs().count(), 1);
+}
+
+/// A body whose type changes from fixed to dynamic must re-discover the pairs
+/// that were suppressed while it was fixed.
+#[test]
+fn body_type_change_rediscovers_pairs() {
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -9.81, 0.0);
+
+ // Static ground, and a FIXED body resting slightly above it (AABBs overlap).
+ let _ground = world.insert_collider(ColliderBuilder::cuboid(5.0, 0.5, 5.0), None);
+ let (body, _) = world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, 1.01, 0.0)),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ );
+
+ for _ in 0..3 {
+ world.step();
+ }
+ // Fixed vs fixed: no pair.
+ assert_eq!(world.narrow_phase.contact_pairs().count(), 0);
+
+ world
+ .bodies
+ .get_mut(body)
+ .unwrap()
+ .set_body_type(RigidBodyType::Dynamic, true);
+
+ for _ in 0..10 {
+ world.step();
+ }
+ assert_eq!(
+ world.narrow_phase.contact_pairs().count(),
+ 1,
+ "pair not re-discovered after fixed -> dynamic switch"
+ );
+
+ // And the contact must actually resolve: the box stays on the ground.
+ for _ in 0..100 {
+ world.step();
+ }
+ let y = world.bodies.get(body).unwrap().translation().y;
+ assert!(
+ (y - 1.0).abs() < 0.05,
+ "box did not rest on the ground: y = {y}"
+ );
+}
+
+/// A parentless collider attached to a dynamic body afterwards must re-discover
+/// its suppressed pairs.
+#[test]
+fn set_parent_rediscovers_pairs() {
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -9.81, 0.0);
+
+ let _ground = world.insert_collider(ColliderBuilder::cuboid(5.0, 0.5, 5.0), None);
+ let orphan = world.insert_collider(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5).translation(Vector::new(0.0, 1.01, 0.0)),
+ None,
+ );
+
+ for _ in 0..3 {
+ world.step();
+ }
+ assert_eq!(world.narrow_phase.contact_pairs().count(), 0);
+
+ let body = world
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 1.01, 0.0)));
+ let bodies = &mut world.bodies;
+ world.colliders.set_parent(orphan, Some(body), bodies);
+
+ for _ in 0..10 {
+ world.step();
+ }
+ assert_eq!(
+ world.narrow_phase.contact_pairs().count(),
+ 1,
+ "pair not re-discovered after attaching the collider to a dynamic body"
+ );
+}
diff --git a/crates/rapier3d/tests/ccd_default_vs_fixed.rs b/crates/rapier3d/tests/ccd_default_vs_fixed.rs
new file mode 100644
index 000000000..9d37ab50c
--- /dev/null
+++ b/crates/rapier3d/tests/ccd_default_vs_fixed.rs
@@ -0,0 +1,371 @@
+//! Tests for the default CCD tier: fast dynamic bodies get continuous collision
+//! detection against **fixed** colliders automatically, while
+//! `ccd_enabled` upgrades a body to also sweep against kinematic/dynamic bodies.
+
+use rapier3d::prelude::*;
+
+/// Minimal world harness so each test reads as scenario + assertions.
+struct Harness {
+ 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 Harness {
+ 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(),
+ // No gravity: keep the fast bodies on a clean 1D path along +X.
+ gravity: Vector::ZERO,
+ }
+ }
+
+ 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 run(&mut self, steps: usize) {
+ for _ in 0..steps {
+ self.step();
+ }
+ }
+
+ fn x(&self, h: RigidBodyHandle) -> Real {
+ self.bodies[h].translation().x
+ }
+}
+
+/// A thin fixed wall centered at the origin, spanning the Y/Z plane.
+fn insert_thin_fixed_wall(h: &mut Harness) {
+ let wall = h.bodies.insert(RigidBodyBuilder::fixed());
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.05, 5.0, 5.0), wall, &mut h.bodies);
+}
+
+/// A small dynamic body far on the -X side, moving fast toward +X. In a single
+/// `1/60`s step it moves ~3.3m, far more than the wall thickness — so without CCD
+/// it tunnels straight through.
+fn insert_fast_dynamic(h: &mut Harness, ccd_enabled: bool) -> RigidBodyHandle {
+ let body = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0, 0.0))
+ .linvel(Vector::new(200.0, 0.0, 0.0))
+ .ccd_enabled(ccd_enabled),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.1, 0.1, 0.1), body, &mut h.bodies);
+ body
+}
+
+/// A default-tier fast dynamic body must NOT tunnel through a fixed wall,
+/// even without `ccd_enabled`.
+#[test]
+fn default_ccd_vs_fixed_no_tunnel() {
+ let mut h = Harness::new();
+ insert_thin_fixed_wall(&mut h);
+ let body = insert_fast_dynamic(&mut h, /* ccd_enabled */ false);
+
+ h.run(120);
+
+ // The body should have been stopped on the near (-X) side of the wall.
+ assert!(
+ h.x(body) < 0.0,
+ "body tunneled through the fixed wall (x = {})",
+ h.x(body)
+ );
+}
+
+/// Two default-tier fast dynamic bodies on a head-on course DO pass through each
+/// other: the default tier only sweeps fixed colliders, so there is no
+/// moving-vs-moving CCD. This locks the fixed-only scope.
+#[test]
+fn default_tier_ignores_dynamic() {
+ let mut h = Harness::new();
+
+ let a = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0, 0.0))
+ .linvel(Vector::new(200.0, 0.0, 0.0)),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.1, 0.1, 0.1), a, &mut h.bodies);
+
+ let b = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(3.0, 0.0, 0.0))
+ .linvel(Vector::new(-200.0, 0.0, 0.0)),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.1, 0.1, 0.1), b, &mut h.bodies);
+
+ h.run(5);
+
+ // They swapped sides — passed through each other untouched.
+ assert!(
+ h.x(a) > 0.0 && h.x(b) < 0.0,
+ "default-tier dynamic bodies should tunnel through each other (a.x = {}, b.x = {})",
+ h.x(a),
+ h.x(b)
+ );
+}
+
+/// A `ccd_enabled` ("bullet") body still collides with a dynamic body — the
+/// upgrade tier is unaffected by the change.
+#[test]
+fn bullet_still_hits_dynamic() {
+ let mut h = Harness::new();
+
+ let bullet = insert_fast_dynamic(&mut h, /* ccd_enabled */ true);
+
+ // A stationary dynamic target at the origin (not ccd_enabled).
+ let target = h
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.0, 0.0)));
+ h.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.2, 0.2, 0.2),
+ target,
+ &mut h.bodies,
+ );
+
+ h.run(60);
+
+ // The bullet hit the target and pushed it along +X (it did not tunnel).
+ assert!(
+ h.x(target) > 0.05,
+ "bullet did not hit the dynamic target (target.x = {})",
+ h.x(target)
+ );
+ assert!(
+ h.x(bullet) < h.x(target),
+ "bullet passed through its target (bullet.x = {}, target.x = {})",
+ h.x(bullet),
+ h.x(target)
+ );
+}
+
+/// Setting `max_ccd_substeps = 0` disables CCD for the whole world: the fast
+/// default-tier body tunnels through the fixed wall again.
+#[test]
+fn global_ccd_off_tunnels() {
+ let mut h = Harness::new();
+ h.params.max_ccd_substeps = 0;
+ insert_thin_fixed_wall(&mut h);
+ let body = insert_fast_dynamic(&mut h, /* ccd_enabled */ false);
+
+ h.run(60);
+
+ assert!(
+ h.x(body) > 1.0,
+ "body should tunnel through the wall when CCD is globally disabled (x = {})",
+ h.x(body)
+ );
+}
+
+/// A fast dynamic **trimesh** body is never swept (there is no continuous collision for
+/// moving meshes): it does not become `ccd_active`, and it tunnels through a thin fixed wall,
+/// relying on speculative contacts only. This locks the never-swept fast-shape rule and
+/// the `ccd_thickness` gate fix (a trimesh's zero thickness must not flag the body fast).
+#[test]
+fn trimesh_fast_body_is_never_swept() {
+ use rapier3d::parry::shape::Ball;
+
+ let mut h = Harness::new();
+ insert_thin_fixed_wall(&mut h);
+
+ let (vtx, idx) = Ball::new(0.1).to_trimesh(8, 8);
+ let shape = SharedShape::new(TriMesh::new(vtx, idx).unwrap());
+ let body = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0, 0.0))
+ .linvel(Vector::new(200.0, 0.0, 0.0)),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::new(shape), body, &mut h.bodies);
+
+ h.step();
+
+ assert!(
+ !h.bodies[body].is_ccd_active(),
+ "a trimesh-only body must never be flagged ccd_active"
+ );
+
+ h.run(59);
+
+ assert!(
+ h.x(body) > 1.0,
+ "a fast trimesh body is not swept and should tunnel through the thin wall (x = {})",
+ h.x(body)
+ );
+}
+
+/// A fast dynamic **compound** body sweeps each convex child: it must not tunnel through
+/// a thin fixed wall.
+#[test]
+fn compound_fast_body_no_tunnel() {
+ let mut h = Harness::new();
+ insert_thin_fixed_wall(&mut h);
+
+ let shape = SharedShape::compound(vec![
+ (
+ Pose::from_translation(Vector::new(0.0, 0.15, 0.0)),
+ SharedShape::cuboid(0.1, 0.1, 0.1),
+ ),
+ (
+ Pose::from_translation(Vector::new(0.0, -0.15, 0.0)),
+ SharedShape::cuboid(0.1, 0.1, 0.1),
+ ),
+ ]);
+ let body = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0, 0.0))
+ .linvel(Vector::new(200.0, 0.0, 0.0)),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::new(shape), body, &mut h.bodies);
+
+ h.run(120);
+
+ assert!(
+ h.x(body) < 0.0,
+ "compound body tunneled through the fixed wall (x = {})",
+ h.x(body)
+ );
+}
+
+/// A fast dynamic compound body vs a fixed **trimesh** wall exercises the per-child
+/// convex-vs-composite sweep: it must not tunnel through the (zero-thickness) mesh.
+#[test]
+fn compound_fast_body_vs_trimesh_wall_no_tunnel() {
+ let mut h = Harness::new();
+
+ // A trimesh wall: a two-triangle quad at x = 0 spanning the Y/Z plane.
+ let vtx = vec![
+ Vector::new(0.0, -5.0, -5.0),
+ Vector::new(0.0, 5.0, -5.0),
+ Vector::new(0.0, 5.0, 5.0),
+ Vector::new(0.0, -5.0, 5.0),
+ ];
+ let idx = vec![[0, 1, 2], [0, 2, 3]];
+ let wall = SharedShape::new(TriMesh::new(vtx, idx).unwrap());
+ h.colliders.insert(ColliderBuilder::new(wall));
+
+ let shape = SharedShape::compound(vec![(Pose::IDENTITY, SharedShape::cuboid(0.1, 0.1, 0.1))]);
+ let body = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.0, 0.0))
+ .linvel(Vector::new(200.0, 0.0, 0.0)),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::new(shape), body, &mut h.bodies);
+
+ h.run(120);
+
+ assert!(
+ h.x(body) < 0.0,
+ "compound body tunneled through the trimesh wall (x = {})",
+ h.x(body)
+ );
+}
+
+/// Benchmark reproducing the `dynamic_trimesh3` demo shape mix: many dynamic trimesh
+/// bodies falling onto a large fixed trimesh ground. Run with:
+/// `cargo test --release -p rapier3d --test ccd_default_vs_fixed -- --ignored --nocapture --test-threads=1`
+#[test]
+#[ignore = "benchmark"]
+fn bench_dynamic_trimeshes_on_trimesh_ground() {
+ use rapier3d::parry::shape::Ball;
+
+ let mut h = Harness::new();
+ h.gravity = Vector::new(0.0, -9.81, 0.0);
+
+ // ~20k-triangle wavy ground, same construction as the dynamic_trimesh3 demo.
+ let nsubdivs = 100;
+ let heights = Array2::from_fn(nsubdivs + 1, nsubdivs + 1, |i, j| {
+ -(i as Real * 40.0 / (nsubdivs as Real) / 2.0).cos()
+ - (j as Real * 40.0 / (nsubdivs as Real) / 2.0).cos()
+ });
+ let heightfield = HeightField::new(heights, Vector::new(100.0, 2.0, 100.0));
+ let mut ground = TriMesh::from(heightfield);
+ let _ = ground.set_flags(TriMeshFlags::FIX_INTERNAL_EDGES);
+ h.colliders
+ .insert(ColliderBuilder::new(SharedShape::new(ground)));
+
+ // 60 dynamic trimesh bodies (ball meshes, ~200 triangles each) in a grid.
+ let (vtx, idx) = Ball::new(1.5).to_trimesh(10, 10);
+ let shape = SharedShape::new(TriMesh::new(vtx, idx).unwrap());
+ for k in 0..60 {
+ let (i, j) = (k % 8, k / 8);
+ let body = h
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(
+ i as Real * 5.0 - 17.5,
+ 6.0 + (k % 3) as Real * 4.0,
+ j as Real * 5.0 - 17.5,
+ )));
+ h.colliders
+ .insert_with_parent(ColliderBuilder::new(shape.clone()), body, &mut h.bodies);
+ }
+
+ let t0 = std::time::Instant::now();
+ let steps = 300;
+ h.run(steps);
+ let elapsed = t0.elapsed().as_secs_f64() * 1000.0;
+ println!(
+ "{steps} steps: {elapsed:.1} ms ({:.3} ms/step)",
+ elapsed / steps as f64
+ );
+}
+
+/// Kinematic bodies are NOT default-tier targets: a default-tier fast body
+/// tunnels through a (stationary) kinematic wall. Contrast with
+/// `default_ccd_vs_fixed_no_tunnel`, where the same body is stopped by a fixed wall.
+#[test]
+fn kinematic_not_a_default_target() {
+ let mut h = Harness::new();
+
+ let wall = h
+ .bodies
+ .insert(RigidBodyBuilder::kinematic_position_based());
+ h.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.05, 5.0, 5.0), wall, &mut h.bodies);
+
+ let body = insert_fast_dynamic(&mut h, /* ccd_enabled */ false);
+
+ h.run(60);
+
+ assert!(
+ h.x(body) > 1.0,
+ "default-tier body should tunnel through a kinematic wall (x = {})",
+ h.x(body)
+ );
+}
diff --git a/crates/rapier3d/tests/contact_clustering.rs b/crates/rapier3d/tests/contact_clustering.rs
new file mode 100644
index 000000000..af7174695
--- /dev/null
+++ b/crates/rapier3d/tests/contact_clustering.rs
@@ -0,0 +1,206 @@
+//! Contact clustering (`IntegrationParameters::contact_clustering`): manifolds of a
+//! same pair with (nearly) parallel normals are merged into a single cluster manifold
+//! before constraint generation. These tests pin the defining properties: a box on a
+//! flat trimesh gets a single cluster (instead of one manifold per triangle), rests
+//! as stably as without clustering, and the cluster impulses are warm-started across
+//! steps.
+
+use rapier3d::prelude::*;
+
+struct World {
+ pipeline: PhysicsPipeline,
+ islands: IslandManager,
+ broad_phase: DefaultBroadPhase,
+ narrow_phase: NarrowPhase,
+ bodies: RigidBodySet,
+ colliders: ColliderSet,
+ impulse_joints: ImpulseJointSet,
+ multibody_joints: MultibodyJointSet,
+ ccd: CCDSolver,
+ params: IntegrationParameters,
+}
+
+impl World {
+ fn step(&mut self) {
+ self.pipeline.step(
+ Vector::new(0.0, -9.81, 0.0),
+ &self.params,
+ &mut self.islands,
+ &mut self.broad_phase,
+ &mut self.narrow_phase,
+ &mut self.bodies,
+ &mut self.colliders,
+ &mut self.impulse_joints,
+ &mut self.multibody_joints,
+ &mut self.ccd,
+ &(),
+ &(),
+ );
+ }
+}
+
+/// A flat 4x4-quad trimesh floor in the xz plane plus one dynamic box dropped on it.
+fn box_on_trimesh_floor(contact_clustering: bool) -> (World, RigidBodyHandle, ColliderHandle) {
+ let mut bodies = RigidBodySet::new();
+ let mut colliders = ColliderSet::new();
+
+ const N: usize = 4;
+ let mut vertices = Vec::new();
+ let mut indices = Vec::new();
+ for i in 0..=N {
+ for j in 0..=N {
+ vertices.push(Vector::new(
+ i as Real - N as Real / 2.0,
+ 0.0,
+ j as Real - N as Real / 2.0,
+ ));
+ }
+ }
+ for i in 0..N as u32 {
+ for j in 0..N as u32 {
+ let a = i * (N as u32 + 1) + j;
+ let b = a + 1;
+ let c = a + (N as u32 + 1);
+ let d = c + 1;
+ indices.push([a, b, c]);
+ indices.push([b, d, c]);
+ }
+ }
+
+ let floor = colliders.insert(
+ ColliderBuilder::trimesh_with_flags(vertices, indices, TriMeshFlags::FIX_INTERNAL_EDGES)
+ .unwrap(),
+ );
+
+ // A wide flat box: rests across several triangles, so the pair has multiple manifolds.
+ // Sleeping is disabled so the solver equilibrium (contact impulses balancing
+ // gravity) can be asserted at any step.
+ let box_body = bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 0.3, 0.0))
+ .can_sleep(false),
+ );
+ let box_co = colliders.insert_with_parent(
+ ColliderBuilder::cuboid(1.2, 0.25, 1.2),
+ box_body,
+ &mut bodies,
+ );
+
+ let world = World {
+ pipeline: PhysicsPipeline::new(),
+ islands: IslandManager::new(),
+ broad_phase: DefaultBroadPhase::new(),
+ narrow_phase: NarrowPhase::new(),
+ bodies,
+ colliders,
+ impulse_joints: ImpulseJointSet::new(),
+ multibody_joints: MultibodyJointSet::new(),
+ ccd: CCDSolver::new(),
+ params: IntegrationParameters {
+ contact_clustering,
+ ..Default::default()
+ },
+ };
+
+ let _ = floor;
+ (world, box_body, box_co)
+}
+
+#[test]
+fn box_rests_stably_on_trimesh_with_clustering() {
+ let (mut world, box_body, box_co) = box_on_trimesh_floor(true);
+
+ for _ in 0..200 {
+ world.step();
+ }
+
+ let rb = &world.bodies[box_body];
+ let pos = rb.translation();
+ let allowed_error = 2.0 * IntegrationParameters::default().allowed_linear_error();
+
+ // The box must rest on the floor (half-height 0.25), not sink or bounce away.
+ assert!(
+ (pos.y - 0.25).abs() < allowed_error + 0.01,
+ "box rest height drifted: y = {}",
+ pos.y
+ );
+ assert!(
+ rb.linvel().length() < 1.0e-2,
+ "box still moving at rest: |v| = {}",
+ rb.linvel().length()
+ );
+
+ // Clustering must actually have kicked in: several per-triangle manifolds, but a
+ // single flat contact plane, hence exactly one solver cluster with at most 4
+ // solver contacts.
+ let pair = world
+ .narrow_phase
+ .contact_pair(box_co, world.colliders.iter().next().unwrap().0)
+ .expect("no contact pair between the box and the floor");
+ assert!(
+ pair.manifolds.len() > 1,
+ "test setup must yield multiple per-triangle manifolds, got {}",
+ pair.manifolds.len()
+ );
+ assert_eq!(pair.solver_clusters.len(), 1);
+ let cluster = &pair.solver_clusters[0];
+ assert!(!cluster.data.solver_contacts.is_empty());
+ assert!(cluster.data.solver_contacts.len() <= 4);
+
+ // The cluster is what the solver saw: its contacts hold the impulses that support
+ // the box against gravity, and warm-starting must have carried them across steps.
+ // Only the points selected as solver contacts hold the impulses of the last solve
+ // (unselected points may keep stale values, like with parry's contact matching).
+ let total_impulse: Real = cluster
+ .data
+ .solver_contacts
+ .iter()
+ .map(|sc| {
+ cluster.points[sc.contact_indices()[0] as usize]
+ .data
+ .impulse
+ })
+ .sum();
+ // The sum includes the soft-constraint bias share, so it is somewhat above the
+ // pure weight support (an unclustered run yields the exact same total).
+ let weight_dt = 9.81 * world.bodies[box_body].mass() * world.params.dt;
+ assert!(
+ total_impulse >= weight_dt * 0.9 && total_impulse <= weight_dt * 2.0,
+ "cluster impulses don't support the box: {total_impulse} vs {weight_dt}"
+ );
+ assert!(
+ cluster
+ .points
+ .iter()
+ .any(|pt| pt.data.warmstart_impulse != 0.0)
+ );
+
+ // The plain manifolds are still exposed for queries, but hold no solver contacts.
+ assert!(
+ pair.manifolds
+ .iter()
+ .all(|m| m.data.solver_contacts.is_empty())
+ );
+ assert!(pair.manifolds.iter().any(|m| !m.points.is_empty()));
+}
+
+#[test]
+fn clustering_matches_unclustered_rest_behavior() {
+ let (mut with, box_a, _) = box_on_trimesh_floor(true);
+ let (mut without, box_b, _) = box_on_trimesh_floor(false);
+
+ for _ in 0..200 {
+ with.step();
+ without.step();
+ }
+
+ let pa = with.bodies[box_a].translation();
+ let pb = without.bodies[box_b].translation();
+
+ // Not bit-identical (different constraint sets), but both must settle at the same
+ // place on the flat floor.
+ assert!(
+ (pa - pb).length() < 1.0e-2,
+ "clustered and unclustered rest positions diverged: {pa:?} vs {pb:?}"
+ );
+}
diff --git a/crates/rapier3d/tests/gyroscopic.rs b/crates/rapier3d/tests/gyroscopic.rs
new file mode 100644
index 000000000..46a014b72
--- /dev/null
+++ b/crates/rapier3d/tests/gyroscopic.rs
@@ -0,0 +1,157 @@
+//! Verifies the staged solver's per-substep gyroscopic pass.
+//!
+//! A torque-free rigid body spun about an axis that is *not* one of its
+//! principal axes has an angular momentum `I·w` that is not parallel to `w`, so
+//! the gyroscopic term `w × I·w` continuously reorients the angular-velocity
+//! vector (precession/nutation). With the term integrated in the substep loop
+//! the world-space angular velocity must therefore change over time; with
+//! gyroscopic forces disabled there is no torque at all, so it must stay fixed.
+//!
+//! Crucially, a torque-free body conserves its *world-space angular momentum*
+//! `L = R·I_local·Rᵀ·w`, both in magnitude and direction. That invariant only
+//! holds if the solver rotates the angular velocity into the true principal
+//! frame — i.e. accounts for `MassProperties::principal_inertia_local_frame` —
+//! before applying the diagonal inertia. It is grossly violated (tested at ~50%
+//! magnitude drift) if the solver naively assumes the principal axes coincide
+//! with the body's local frame.
+
+use rapier3d::prelude::*;
+
+/// A torque-free world with one box (half-extents 1×2×3 → three distinct
+/// principal inertias) spun about a non-principal axis.
+fn spinning_box(gyroscopic: bool) -> (PhysicsWorld, RigidBodyHandle) {
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::ZERO; // torque-free: isolate the gyroscopic term.
+
+ let handle = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .angvel(Vector::new(6.0, 6.0, 0.0)) // spans two different-inertia axes
+ .gyroscopic_forces_enabled(gyroscopic)
+ .can_sleep(false),
+ );
+ world.insert_collider(
+ ColliderBuilder::cuboid(1.0, 2.0, 3.0).density(1.0),
+ Some(handle),
+ );
+ (world, handle)
+}
+
+/// Cosine of the angle between the current and initial angular-velocity vectors.
+fn cos_with_initial(w: Vector, w0: Vector) -> f32 {
+ w.dot(w0) / (w.length() * w0.length())
+}
+
+/// World-space angular momentum `L = R · I_local · Rᵀ · w`.
+fn angular_momentum(world: &PhysicsWorld, handle: RigidBodyHandle) -> Vector {
+ let body = &world.bodies[handle];
+ let rot = *body.rotation();
+ let inertia_local = body
+ .mass_properties()
+ .local_mprops
+ .reconstruct_inertia_matrix();
+ let w = body.angvel();
+ rot * (inertia_local * (rot.inverse() * w))
+}
+
+#[test]
+fn angular_velocity_precesses_with_gyroscopic() {
+ let (mut world, handle) = spinning_box(true);
+ let w0 = world.bodies[handle].angvel();
+
+ let mut min_cos = f32::INFINITY;
+ for _ in 0..300 {
+ world.step();
+ min_cos = min_cos.min(cos_with_initial(world.bodies[handle].angvel(), w0));
+ }
+
+ // The gyroscopic torque must swing the angular-velocity direction well away
+ // from its start (a ~26°+ deflection, cos < 0.9).
+ assert!(
+ min_cos < 0.9,
+ "expected the angular-velocity direction to precess (min cos = {min_cos})"
+ );
+}
+
+#[test]
+fn angular_velocity_fixed_without_gyroscopic() {
+ let (mut world, handle) = spinning_box(false);
+ let w0 = world.bodies[handle].angvel();
+
+ let mut min_cos = f32::INFINITY;
+ let mut max_speed_err = 0.0f32;
+ for _ in 0..300 {
+ world.step();
+ let w = world.bodies[handle].angvel();
+ min_cos = min_cos.min(cos_with_initial(w, w0));
+ max_speed_err = max_speed_err.max((w.length() - w0.length()).abs());
+ }
+
+ // No torque: the world-space angular velocity is rigorously constant.
+ assert!(
+ min_cos > 0.9999,
+ "expected a fixed angular-velocity direction without gyroscopic forces (min cos = {min_cos})"
+ );
+ assert!(
+ max_speed_err < 1.0e-3,
+ "expected a fixed angular-velocity magnitude without gyroscopic forces (err = {max_speed_err})"
+ );
+}
+
+#[test]
+fn angular_momentum_conserved_with_tilted_principal_frame() {
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::ZERO;
+
+ // Attach the box with a tilted local rotation so its principal axes do NOT
+ // line up with the body's local frame (a non-identity
+ // `principal_inertia_local_frame`) — exactly the configuration the "trees"
+ // benchmark bodies have.
+ let handle = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .angvel(Vector::new(3.0, 7.0, 2.0))
+ .gyroscopic_forces_enabled(true)
+ .can_sleep(false),
+ );
+ let tilt = Rotation::from_axis_angle(Vector::new(1.0, 1.0, 1.0).normalize(), 0.7);
+ world.insert_collider(
+ ColliderBuilder::cuboid(1.0, 2.0, 3.0)
+ .density(1.0)
+ .rotation(tilt.to_scaled_axis()),
+ Some(handle),
+ );
+
+ // Sanity: the principal frame really is tilted (otherwise the test is moot).
+ let frame = world.bodies[handle]
+ .mass_properties()
+ .local_mprops
+ .principal_inertia_local_frame;
+ let frame_angle = frame.to_axis_angle().1;
+ assert!(
+ frame_angle > 0.1,
+ "test setup expects a tilted principal frame (angle = {frame_angle})"
+ );
+
+ let l0 = angular_momentum(&world, handle);
+ let n0 = l0.length();
+
+ let mut max_mag_err = 0.0f32;
+ let mut min_cos = f32::INFINITY;
+ for _ in 0..600 {
+ world.step();
+ let l = angular_momentum(&world, handle);
+ max_mag_err = max_mag_err.max((l.length() - n0).abs() / n0);
+ min_cos = min_cos.min(l.dot(l0) / (l.length() * n0));
+ }
+
+ // A torque-free body conserves world angular momentum. Only correct handling
+ // of the tilted principal frame achieves this; the naive body-frame
+ // assumption drifts by tens of percent.
+ assert!(
+ max_mag_err < 0.02,
+ "world angular-momentum magnitude drifted (max relative error = {max_mag_err})"
+ );
+ assert!(
+ min_cos > 0.999,
+ "world angular-momentum direction drifted (min cos = {min_cos})"
+ );
+}
diff --git a/crates/rapier3d/tests/heightfield_solver_graph.rs b/crates/rapier3d/tests/heightfield_solver_graph.rs
new file mode 100644
index 000000000..380ffba81
--- /dev/null
+++ b/crates/rapier3d/tests/heightfield_solver_graph.rs
@@ -0,0 +1,63 @@
+//! Regression test for the 3D heightfield stress scene (composite pairs +
+//! contact clustering) — the 3D analogue of the 2D heightfield segfault. See
+//! the 2D test for the failure mechanism; the clustered solver-manifold list
+//! is the extra wrinkle exercised here.
+#![cfg(feature = "dim3")]
+
+use rapier3d::prelude::*;
+
+#[test]
+fn heightfield_stress_solver_graph_consistency_3d() {
+ let mut world = PhysicsWorld::new();
+
+ let ground_size = Vec3::new(50.0, 1.0, 50.0);
+ let nsubdivs = 50;
+
+ let heights = Array2::from_fn(nsubdivs + 1, nsubdivs + 1, |i, j| {
+ if i == 0 || i == nsubdivs || j == 0 || j == nsubdivs {
+ 8.0
+ } else {
+ let x = i as f32 * ground_size.x / (nsubdivs as f32);
+ let z = j as f32 * ground_size.z / (nsubdivs as f32);
+ (x.cos() + z.sin()) * 1.5
+ }
+ });
+
+ let rigid_body = RigidBodyBuilder::fixed();
+ let collider = ColliderBuilder::heightfield(heights, ground_size);
+ let handle = world.bodies.insert(rigid_body);
+ world
+ .colliders
+ .insert_with_parent(collider, handle, &mut world.bodies);
+
+ let num = 8;
+ let rad = 0.5;
+ let shift = rad * 2.5;
+ let centerx = shift * (num / 2) as f32;
+ let centery = shift / 2.0;
+ let centerz = shift * (num / 2) as f32;
+
+ for i in 0..num {
+ for j in 0usize..num * 4 {
+ for k in 0..num {
+ let x = i as f32 * shift - centerx;
+ let y = j as f32 * shift + centery + 3.0;
+ let z = k as f32 * shift - centerz;
+ let rigid_body = RigidBodyBuilder::dynamic().translation(Vec3::new(x, y, z));
+ let handle = world.bodies.insert(rigid_body);
+ let collider = if (i + j + k) % 2 == 0 {
+ ColliderBuilder::cuboid(rad, rad, rad)
+ } else {
+ ColliderBuilder::ball(rad)
+ };
+ world
+ .colliders
+ .insert_with_parent(collider, handle, &mut world.bodies);
+ }
+ }
+ }
+
+ for _ in 0..200 {
+ world.step();
+ }
+}
diff --git a/crates/rapier3d/tests/joint_assembly_persistence.rs b/crates/rapier3d/tests/joint_assembly_persistence.rs
new file mode 100644
index 000000000..9ed46fa41
--- /dev/null
+++ b/crates/rapier3d/tests/joint_assembly_persistence.rs
@@ -0,0 +1,107 @@
+//! Regression tests for the persistent joint constraint assembly: the solver
+//! recycles joint builders across steps, so runtime changes to joints or their
+//! attached bodies must correctly invalidate the cached assembly.
+
+#[cfg(feature = "dim2")]
+use rapier2d::prelude::*;
+#[cfg(feature = "dim3")]
+use rapier3d::prelude::*;
+
+fn pendulum(
+ world: &mut PhysicsWorld,
+ anchor_pos: Vector,
+) -> (RigidBodyHandle, RigidBodyHandle, ImpulseJointHandle) {
+ let anchor = world
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(anchor_pos));
+ let bob = world
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(anchor_pos - Vector::Y * 2.0));
+ world
+ .colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), bob, &mut world.bodies);
+ #[cfg(feature = "dim2")]
+ let joint = RevoluteJointBuilder::new().local_anchor2(Vector::Y * 2.0);
+ #[cfg(feature = "dim3")]
+ let joint = SphericalJointBuilder::new().local_anchor2(Vector::Y * 2.0);
+ let j = world.impulse_joints.insert(anchor, bob, joint, true);
+ (anchor, bob, j)
+}
+
+/// A joint removed after the assembly was cached must stop constraining.
+#[test]
+fn removed_joint_stops_constraining() {
+ let mut world = PhysicsWorld::new();
+ let (_, bob, joint) = pendulum(&mut world, Vector::ZERO);
+
+ for _ in 0..30 {
+ world.step();
+ }
+ let y_held = world.bodies[bob].translation().y;
+ assert!(y_held > -2.5, "joint should hold the bob (y = {y_held})");
+
+ world.impulse_joints.remove(joint, true);
+ for _ in 0..60 {
+ world.step();
+ }
+ let y_free = world.bodies[bob].translation().y;
+ assert!(
+ y_free < -4.0,
+ "bob should free-fall after joint removal (y = {y_free})"
+ );
+}
+
+/// Mutating a joint through `get_mut` after the assembly was cached must take
+/// effect on the next step.
+#[test]
+fn joint_mutation_invalidates_cached_assembly() {
+ let mut world = PhysicsWorld::new();
+ let (_, bob, joint) = pendulum(&mut world, Vector::ZERO);
+
+ for _ in 0..30 {
+ world.step();
+ }
+
+ // Re-anchor the bob 4.0 below the pivot instead of 2.0.
+ world
+ .impulse_joints
+ .get_mut(joint, true)
+ .unwrap()
+ .data
+ .set_local_anchor2(Vector::Y * 4.0);
+ for _ in 0..120 {
+ world.step();
+ }
+ let y = world.bodies[bob].translation().y;
+ assert!(
+ y < -3.4 && y > -4.6,
+ "bob should hang ~4.0 below the pivot after re-anchoring (y = {y})"
+ );
+}
+
+/// Moving a (fixed) body attached to a joint must invalidate the cached
+/// assembly: the joint frame of a fixed body is baked into the cached builder.
+#[test]
+fn moved_fixed_anchor_invalidates_cached_assembly() {
+ let mut world = PhysicsWorld::new();
+ let (anchor, bob, _) = pendulum(&mut world, Vector::ZERO);
+
+ for _ in 0..30 {
+ world.step();
+ }
+
+ let shift = Vector::X * 5.0;
+ let new_pos = world.bodies[anchor].translation() + shift;
+ world.bodies[anchor].set_translation(new_pos, true);
+ for _ in 0..300 {
+ world.step();
+ }
+ // The bob swings, so assert the constraint itself: it must be pinned 2.0
+ // away from the *new* anchor (a stale cached joint frame would keep it
+ // pinned around the old anchor, 3.0..7.0 away from the new one).
+ let dist = (world.bodies[bob].translation() - new_pos).length();
+ assert!(
+ (dist - 2.0).abs() < 0.3,
+ "bob should be pinned 2.0 from the moved anchor (dist = {dist})"
+ );
+}
diff --git a/crates/rapier3d/tests/joint_contact_solve_order.rs b/crates/rapier3d/tests/joint_contact_solve_order.rs
new file mode 100644
index 000000000..b36735e92
--- /dev/null
+++ b/crates/rapier3d/tests/joint_contact_solve_order.rs
@@ -0,0 +1,75 @@
+//! Joints must be solved BEFORE contacts in every solver pass: with few
+//! iterations per substep, the constraint solved last on a body wins its
+//! velocity residual, and a joint solved after the contacts of a much heavier
+//! contacting body re-imposes the joint velocity on its light body — letting
+//! the heavy body push straight through it.
+//!
+//! This reproduces the `Spring Joints` testbed demo: heavy cubes (200x the
+//! ball mass) dropped onto light balls hanging from spring joints. When the
+//! solve order flips to contacts-then-joints, every cube tunnels through its
+//! ball. The pair count matters: with few pairs all constraints share the
+//! staged solver's worker-0 overflow stage (whose internal order was correct),
+//! so the bug only appeared once the contacts got colored stages of their own.
+
+#[cfg(feature = "dim2")]
+use rapier2d::prelude::*;
+#[cfg(feature = "dim3")]
+use rapier3d::prelude::*;
+
+#[cfg(feature = "dim2")]
+fn vect(x: f32, y: f32) -> Vector {
+ Vec2::new(x, y)
+}
+#[cfg(feature = "dim3")]
+fn vect(x: f32, y: f32) -> Vector {
+ Vec3::new(x, y, 0.0)
+}
+
+#[test]
+fn heavy_cubes_rest_on_spring_jointed_balls() {
+ let mut world = PhysicsWorld::new();
+
+ let ground_handle = world.insert_body(RigidBodyBuilder::fixed());
+
+ let num = 30;
+ let radius = 0.5;
+ let mass = Ball::new(radius).mass_properties(1.0).mass();
+ let stiffness = 1.0e3;
+ let critical_damping = 2.0 * (stiffness * mass).sqrt();
+ let mut pairs = vec![];
+ for i in 0..=num {
+ let ball_pos = vect(-6.0 + 1.5 * i as f32, 4.5);
+ let rigid_body = RigidBodyBuilder::dynamic()
+ .translation(ball_pos)
+ .can_sleep(false);
+ let (ball, _) = world.insert(rigid_body, ColliderBuilder::ball(radius));
+
+ let damping_ratio = i as f32 / (num as f32 / 2.0);
+ let damping = damping_ratio * critical_damping;
+ let joint = SpringJointBuilder::new(0.0, stiffness, damping)
+ .local_anchor1(ball_pos - Vector::Y * 3.0);
+ world.insert_impulse_joint(ground_handle, ball, joint);
+
+ let rigid_body = RigidBodyBuilder::dynamic().translation(ball_pos + Vector::Y * 5.0);
+ #[cfg(feature = "dim2")]
+ let collider = ColliderBuilder::cuboid(radius, radius).density(100.0);
+ #[cfg(feature = "dim3")]
+ let collider = ColliderBuilder::cuboid(radius, radius, radius).density(100.0);
+ let (cube, _) = world.insert(rigid_body, collider);
+ pairs.push((ball, cube));
+ }
+
+ for _ in 0..300 {
+ world.step();
+ }
+
+ for (i, (ball, cube)) in pairs.iter().enumerate() {
+ let ball_y = world.bodies[*ball].translation().y;
+ let cube_y = world.bodies[*cube].translation().y;
+ assert!(
+ cube_y > ball_y,
+ "cube {i} tunneled through its spring-jointed ball \
+ (cube y = {cube_y:.3}, ball y = {ball_y:.3})"
+ );
+ }
+}
diff --git a/crates/rapier3d/tests/joint_stability.rs b/crates/rapier3d/tests/joint_stability.rs
new file mode 100644
index 000000000..d7d43c01a
--- /dev/null
+++ b/crates/rapier3d/tests/joint_stability.rs
@@ -0,0 +1,322 @@
+//! Long-run joint stability regressions (testbed `ImpulseJoint prismatic`/`ball`
+//! scenes): bilateral joint structures are conservative, so any per-step solver
+//! energy injection compounds into a blow-up over thousands of steps.
+
+#[cfg(feature = "dim2")]
+use rapier2d::prelude::*;
+#[cfg(feature = "dim3")]
+use rapier3d::prelude::*;
+
+#[cfg(feature = "dim2")]
+fn vect(x: f32, y: f32, _z: f32) -> Vector {
+ Vec2::new(x, y)
+}
+#[cfg(feature = "dim3")]
+fn vect(x: f32, y: f32, z: f32) -> Vector {
+ Vec3::new(x, y, z)
+}
+
+fn assert_sane(world: &PhysicsWorld, scene: &str, bound: f32) {
+ let mut max_vel: f32 = 0.0;
+ for (_, body) in world.bodies.iter() {
+ let pos = body.translation();
+ assert!(
+ pos.length() < bound,
+ "{scene}: body at non-sane position {pos:?}"
+ );
+ max_vel = max_vel.max(body.linvel().length());
+ }
+ assert!(
+ max_vel < 100.0,
+ "{scene}: runaway velocity {max_vel} (energy is being injected)"
+ );
+}
+
+/// Hanging chains of boxes on limited prismatic joints along alternating diagonal
+/// rails (the `ImpulseJoint prismatic` stress scene): the chains hang from the
+/// engaged limit rows, so any limit-row energy injection accumulates.
+#[test]
+fn prismatic_limit_chains_remain_stable() {
+ let mut world = PhysicsWorld::new();
+ let rad = 0.4;
+ let shift = 1.0;
+ let num = 10;
+
+ for chain in 0..10 {
+ let x = chain as f32 * 4.0;
+ let ground = RigidBodyBuilder::fixed().translation(vect(x, 0.0, 0.0));
+ let mut curr_parent = world.bodies.insert(ground);
+ world.colliders.insert_with_parent(
+ #[cfg(feature = "dim2")]
+ ColliderBuilder::cuboid(rad, rad),
+ #[cfg(feature = "dim3")]
+ ColliderBuilder::cuboid(rad, rad, rad),
+ curr_parent,
+ &mut world.bodies,
+ );
+
+ for i in 0..num {
+ let y = -(i + 1) as f32 * shift;
+ let rigid_body = RigidBodyBuilder::dynamic()
+ .translation(vect(x, y, 0.0))
+ .can_sleep(false);
+ let curr_child = world.bodies.insert(rigid_body);
+ world.colliders.insert_with_parent(
+ #[cfg(feature = "dim2")]
+ ColliderBuilder::cuboid(rad, rad),
+ #[cfg(feature = "dim3")]
+ ColliderBuilder::cuboid(rad, rad, rad),
+ curr_child,
+ &mut world.bodies,
+ );
+
+ let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
+ #[cfg(feature = "dim2")]
+ let axis = Vec2::new(sign, 1.0).normalize();
+ #[cfg(feature = "dim3")]
+ let axis = Vec3::new(sign, 1.0, 0.0).normalize();
+
+ let prism = PrismaticJointBuilder::new(axis)
+ .local_anchor2(vect(0.0, shift, 0.0))
+ .limits([-1.5, 1.5]);
+ world
+ .impulse_joints
+ .insert(curr_parent, curr_child, prism, true);
+
+ curr_parent = curr_child;
+ }
+ }
+
+ for k in 0..10_000 {
+ world.step();
+ if k % 1000 == 999 {
+ assert_sane(&world, "prismatic_limit_chains", 200.0);
+ }
+ }
+}
+
+/// A pinned net of revolute (2D) / spherical (3D) joints (the `ImpulseJoint
+/// ball` stress scene, reduced): a conservative swinging structure that heats
+/// up and eventually breaks apart if the solver injects energy.
+///
+/// CI-scale: sized to run in a few seconds per feature config. The full-size,
+/// longer-horizon validation is [`joint_net_long_run`] (`--ignored`).
+#[test]
+fn joint_net_remains_stable() {
+ let mut world = PhysicsWorld::new();
+ let n = 32;
+ let rad = 0.4;
+ let shift = 1.0;
+ let mut handles = vec![RigidBodyHandle::invalid(); n * n];
+
+ for i in 0..n {
+ for j in 0..n {
+ let pos = vect(j as f32 * shift, -(i as f32) * shift, 0.0);
+ let body = if i == 0 && (j % 4 == 0 || j == n - 1) {
+ RigidBodyBuilder::fixed().translation(pos)
+ } else {
+ RigidBodyBuilder::dynamic()
+ .translation(pos)
+ .can_sleep(false)
+ };
+ let handle = world.bodies.insert(body);
+ world.colliders.insert_with_parent(
+ ColliderBuilder::ball(rad),
+ handle,
+ &mut world.bodies,
+ );
+ handles[i * n + j] = handle;
+ }
+ }
+
+ #[cfg(feature = "dim2")]
+ let joint = |anchor1: Vector, anchor2: Vector| {
+ RevoluteJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(anchor2)
+ .build()
+ .data
+ };
+ #[cfg(feature = "dim3")]
+ let joint = |anchor1: Vector, anchor2: Vector| {
+ SphericalJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(anchor2)
+ .build()
+ .data
+ };
+
+ for i in 0..n {
+ for j in 0..n {
+ if i > 0 {
+ let a = handles[(i - 1) * n + j];
+ let b = handles[i * n + j];
+ world.impulse_joints.insert(
+ a,
+ b,
+ joint(vect(0.0, -shift / 2.0, 0.0), vect(0.0, shift / 2.0, 0.0)),
+ true,
+ );
+ }
+ if j > 0 {
+ let a = handles[i * n + j - 1];
+ let b = handles[i * n + j];
+ world.impulse_joints.insert(
+ a,
+ b,
+ joint(vect(shift / 2.0, 0.0, 0.0), vect(-shift / 2.0, 0.0, 0.0)),
+ true,
+ );
+ }
+ }
+ }
+
+ for k in 0..4000 {
+ world.step();
+ if k % 1000 == 999 {
+ assert_sane(&world, "joint_net", 500.0);
+ }
+ }
+}
+
+/// Extended manual variants: longer runs and bigger structures than CI allows.
+/// Run: `cargo test --release --test joint_stability -- --ignored`
+#[test]
+#[ignore = "long manual stability validation"]
+fn prismatic_limit_chains_long_run() {
+ let mut world = PhysicsWorld::new();
+ let rad = 0.4;
+ let shift = 1.0;
+ let num = 10;
+
+ for chain in 0..10 {
+ let x = chain as f32 * 4.0;
+ let ground = RigidBodyBuilder::fixed().translation(vect(x, 0.0, 0.0));
+ let mut curr_parent = world.bodies.insert(ground);
+ world.colliders.insert_with_parent(
+ #[cfg(feature = "dim2")]
+ ColliderBuilder::cuboid(rad, rad),
+ #[cfg(feature = "dim3")]
+ ColliderBuilder::cuboid(rad, rad, rad),
+ curr_parent,
+ &mut world.bodies,
+ );
+
+ for i in 0..num {
+ let y = -(i + 1) as f32 * shift;
+ let rigid_body = RigidBodyBuilder::dynamic()
+ .translation(vect(x, y, 0.0))
+ .can_sleep(false);
+ let curr_child = world.bodies.insert(rigid_body);
+ world.colliders.insert_with_parent(
+ #[cfg(feature = "dim2")]
+ ColliderBuilder::cuboid(rad, rad),
+ #[cfg(feature = "dim3")]
+ ColliderBuilder::cuboid(rad, rad, rad),
+ curr_child,
+ &mut world.bodies,
+ );
+
+ let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
+ #[cfg(feature = "dim2")]
+ let axis = Vec2::new(sign, 1.0).normalize();
+ #[cfg(feature = "dim3")]
+ let axis = Vec3::new(sign, 1.0, 0.0).normalize();
+
+ let prism = PrismaticJointBuilder::new(axis)
+ .local_anchor2(vect(0.0, shift, 0.0))
+ .limits([-1.5, 1.5]);
+ world
+ .impulse_joints
+ .insert(curr_parent, curr_child, prism, true);
+
+ curr_parent = curr_child;
+ }
+ }
+
+ for k in 0..50_000 {
+ world.step();
+ if k % 2000 == 1999 {
+ assert_sane(&world, "prismatic_limit_chains_long_run", 200.0);
+ }
+ }
+}
+
+#[test]
+#[ignore = "long manual stability validation"]
+fn joint_net_long_run() {
+ let mut world = PhysicsWorld::new();
+ let n = 80;
+ let rad = 0.4;
+ let shift = 1.0;
+ let mut handles = vec![RigidBodyHandle::invalid(); n * n];
+
+ for i in 0..n {
+ for j in 0..n {
+ let pos = vect(j as f32 * shift, -(i as f32) * shift, 0.0);
+ let body = if i == 0 && (j % 4 == 0 || j == n - 1) {
+ RigidBodyBuilder::fixed().translation(pos)
+ } else {
+ RigidBodyBuilder::dynamic()
+ .translation(pos)
+ .can_sleep(false)
+ };
+ let handle = world.bodies.insert(body);
+ world.colliders.insert_with_parent(
+ ColliderBuilder::ball(rad),
+ handle,
+ &mut world.bodies,
+ );
+ handles[i * n + j] = handle;
+ }
+ }
+
+ #[cfg(feature = "dim2")]
+ let joint = |anchor1: Vector, anchor2: Vector| {
+ RevoluteJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(anchor2)
+ .build()
+ .data
+ };
+ #[cfg(feature = "dim3")]
+ let joint = |anchor1: Vector, anchor2: Vector| {
+ SphericalJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(anchor2)
+ .build()
+ .data
+ };
+
+ for i in 0..n {
+ for j in 0..n {
+ if i > 0 {
+ let a = handles[(i - 1) * n + j];
+ let b = handles[i * n + j];
+ world.impulse_joints.insert(
+ a,
+ b,
+ joint(vect(0.0, -shift / 2.0, 0.0), vect(0.0, shift / 2.0, 0.0)),
+ true,
+ );
+ }
+ if j > 0 {
+ let a = handles[i * n + j - 1];
+ let b = handles[i * n + j];
+ world.impulse_joints.insert(
+ a,
+ b,
+ joint(vect(shift / 2.0, 0.0, 0.0), vect(-shift / 2.0, 0.0, 0.0)),
+ true,
+ );
+ }
+ }
+ }
+
+ for k in 0..20_000 {
+ world.step();
+ if k % 2000 == 1999 {
+ assert_sane(&world, "joint_net_long_run", 800.0);
+ }
+ }
+}
diff --git a/crates/rapier3d/tests/miri_scenes.rs b/crates/rapier3d/tests/miri_scenes.rs
new file mode 100644
index 000000000..8fb58da2f
--- /dev/null
+++ b/crates/rapier3d/tests/miri_scenes.rs
@@ -0,0 +1,316 @@
+//! Tiny scenes exercising the pipeline's unsafe hot paths (manifold store, solver
+//! graph buckets, raw color-mask slices) under Miri's aliasing checks.
+//!
+//! Under Miri each step costs seconds, so scenes start in contact and run a
+//! handful of steps; natively they run long enough to also assert behavior.
+//! Run with: `cargo +nightly miri test -p rapier3d --test miri_scenes`.
+//! On Apple Silicon add `--target x86_64-unknown-linux-gnu`: glam's aarch64 NEON
+//! backend hits foreign intrinsics Miri does not implement, while its x86 SSE2
+//! path is fully supported.
+
+use rapier3d::prelude::*;
+
+/// Steps per scene: enough to reach the solver's steady state paths (prepare,
+/// warm-start, writeback, graph maintenance) under Miri; long enough natively
+/// for the scene's behavioral assertion to be meaningful.
+fn steps(native: usize) -> usize {
+ if cfg!(miri) { 3 } else { native }
+}
+
+struct World {
+ bodies: RigidBodySet,
+ colliders: ColliderSet,
+ impulse_joints: ImpulseJointSet,
+ multibody_joints: MultibodyJointSet,
+ islands: IslandManager,
+ broad_phase: DefaultBroadPhase,
+ narrow_phase: NarrowPhase,
+ ccd: CCDSolver,
+ pipeline: PhysicsPipeline,
+ params: IntegrationParameters,
+ gravity: Vector,
+}
+
+impl World {
+ fn new() -> Self {
+ Self {
+ bodies: RigidBodySet::new(),
+ colliders: ColliderSet::new(),
+ impulse_joints: ImpulseJointSet::new(),
+ multibody_joints: MultibodyJointSet::new(),
+ islands: IslandManager::new(),
+ broad_phase: DefaultBroadPhase::new(),
+ narrow_phase: NarrowPhase::new(),
+ ccd: CCDSolver::new(),
+ pipeline: PhysicsPipeline::new(),
+ params: IntegrationParameters::default(),
+ gravity: Vector::Y * -9.81,
+ }
+ }
+
+ fn step(&mut self) {
+ self.pipeline.step(
+ self.gravity,
+ &self.params,
+ &mut self.islands,
+ &mut self.broad_phase,
+ &mut self.narrow_phase,
+ &mut self.bodies,
+ &mut self.colliders,
+ &mut self.impulse_joints,
+ &mut self.multibody_joints,
+ &mut self.ccd,
+ &(),
+ &(),
+ );
+ }
+
+ fn run(&mut self, native_steps: usize) {
+ for _ in 0..steps(native_steps) {
+ self.step();
+ }
+ self.assert_all_finite();
+ }
+
+ fn assert_all_finite(&self) {
+ for (_, rb) in self.bodies.iter() {
+ let p = rb.translation();
+ assert!(
+ p.x.is_finite() && p.y.is_finite() && p.z.is_finite(),
+ "non-finite body position: {p:?}"
+ );
+ }
+ }
+
+ fn floor(&mut self) -> RigidBodyHandle {
+ let floor = self
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0)));
+ self.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(10.0, 0.5, 10.0),
+ floor,
+ &mut self.bodies,
+ );
+ floor
+ }
+}
+
+/// Resting contact + the contact-force-event pass (exact solver-active pair list).
+#[test]
+fn resting_ball_with_force_events() {
+ let mut w = World::new();
+ let floor = w
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0)));
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(10.0, 0.5, 10.0)
+ .active_events(ActiveEvents::CONTACT_FORCE_EVENTS)
+ .contact_force_event_threshold(0.0),
+ floor,
+ &mut w.bodies,
+ );
+
+ let ball = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.5, 0.0)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.5), ball, &mut w.bodies);
+
+ w.run(120);
+ if !cfg!(miri) {
+ let y = w.bodies[ball].translation().y;
+ assert!((y - 0.5).abs() < 0.05, "ball not resting on floor: y = {y}");
+ }
+}
+
+/// Multi-manifold stack: warm-starting, solver colors, manifold writeback.
+#[test]
+fn small_box_stack() {
+ let mut w = World::new();
+ w.floor();
+
+ let mut tops = Vec::new();
+ for i in 0..3 {
+ let y = 0.5 + i as Real;
+ let b = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, y, 0.0)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5, 0.5), b, &mut w.bodies);
+ tops.push(b);
+ }
+
+ w.run(120);
+ if !cfg!(miri) {
+ let y = w.bodies[tops[2]].translation().y;
+ assert!((y - 2.5).abs() < 0.1, "stack collapsed: top y = {y}");
+ }
+}
+
+/// Impulse-joint solver: a horizontal pendulum swinging on a revolute joint.
+#[test]
+fn revolute_pendulum() {
+ let mut w = World::new();
+ let anchor = w.bodies.insert(RigidBodyBuilder::fixed());
+ let bob = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(1.0, 0.0, 0.0)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.1), bob, &mut w.bodies);
+
+ let joint = RevoluteJointBuilder::new(Vector::Z)
+ .local_anchor1(Vector::new(0.0, 0.0, 0.0))
+ .local_anchor2(Vector::new(-1.0, 0.0, 0.0));
+ w.impulse_joints.insert(anchor, bob, joint, true);
+
+ w.run(120);
+ if !cfg!(miri) {
+ let d = w.bodies[bob].translation().length();
+ assert!((d - 1.0).abs() < 0.05, "pendulum arm stretched: |p| = {d}");
+ }
+}
+
+/// Multibody-joint solver: one dynamic link articulated to a fixed base.
+#[test]
+fn multibody_link() {
+ let mut w = World::new();
+ let base = w.bodies.insert(RigidBodyBuilder::fixed());
+ let link = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(1.0, 0.0, 0.0)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.1), link, &mut w.bodies);
+
+ let joint = RevoluteJointBuilder::new(Vector::Z)
+ .local_anchor1(Vector::new(0.0, 0.0, 0.0))
+ .local_anchor2(Vector::new(-1.0, 0.0, 0.0));
+ w.multibody_joints.insert(base, link, joint, true);
+
+ w.run(120);
+ if !cfg!(miri) {
+ let d = w.bodies[link].translation().length();
+ assert!((d - 1.0).abs() < 0.05, "multibody arm stretched: |p| = {d}");
+ }
+}
+
+/// Sensor overlap: the intersection graph, alongside the contact graph.
+#[test]
+fn sensor_overlap() {
+ let mut w = World::new();
+ let sensor_body = w.bodies.insert(RigidBodyBuilder::fixed());
+ let sensor = w.colliders.insert_with_parent(
+ ColliderBuilder::ball(0.5).sensor(true),
+ sensor_body,
+ &mut w.bodies,
+ );
+
+ let ball = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.4, 0.0)));
+ let ball_co = w
+ .colliders
+ .insert_with_parent(ColliderBuilder::ball(0.5), ball, &mut w.bodies);
+
+ w.step();
+ assert_eq!(
+ w.narrow_phase.intersection_pair(sensor, ball_co),
+ Some(true),
+ "overlapping sensor not detected"
+ );
+
+ w.run(120);
+ if !cfg!(miri) {
+ // No floor: the ball fell away from the sensor.
+ assert_ne!(
+ w.narrow_phase.intersection_pair(sensor, ball_co),
+ Some(true)
+ );
+ }
+}
+
+/// CCD sweeps: a bullet ball must not tunnel through a thin floor.
+#[test]
+fn ccd_bullet_vs_thin_floor() {
+ let mut w = World::new();
+ let floor = w.bodies.insert(RigidBodyBuilder::fixed());
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(10.0, 0.05, 10.0),
+ floor,
+ &mut w.bodies,
+ );
+
+ let bullet = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 1.0, 0.0))
+ .linvel(Vector::Y * -100.0)
+ .ccd_enabled(true),
+ );
+ w.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.1), bullet, &mut w.bodies);
+
+ w.run(30);
+ let y = w.bodies[bullet].translation().y;
+ assert!(y > 0.0, "bullet tunneled through the floor: y = {y}");
+}
+
+/// Mid-run body removal: pair removal, solver-graph and island maintenance.
+#[test]
+fn body_removal_midrun() {
+ let mut w = World::new();
+ w.floor();
+
+ let a = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.5, 0.0)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5, 0.5), a, &mut w.bodies);
+ let b = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(1.0, 0.5, 0.0)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5, 0.5), b, &mut w.bodies);
+
+ w.run(2);
+ w.bodies.remove(
+ a,
+ &mut w.islands,
+ &mut w.colliders,
+ &mut w.impulse_joints,
+ &mut w.multibody_joints,
+ true,
+ );
+ w.run(60);
+ if !cfg!(miri) {
+ let y = w.bodies[b].translation().y;
+ assert!(
+ (y - 0.5).abs() < 0.05,
+ "surviving box sank or jumped: y = {y}"
+ );
+ }
+}
+
+/// Kinematic solver bodies: a dynamic box riding a velocity-based kinematic platform.
+#[test]
+fn kinematic_platform_carries_box() {
+ let mut w = World::new();
+ let platform = w
+ .bodies
+ .insert(RigidBodyBuilder::kinematic_velocity_based().linvel(Vector::Y * 0.5));
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(1.0, 0.1, 1.0),
+ platform,
+ &mut w.bodies,
+ );
+
+ let rider = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.3, 0.0)));
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.2, 0.2, 0.2), rider, &mut w.bodies);
+
+ w.run(60);
+ if !cfg!(miri) {
+ let y = w.bodies[rider].translation().y;
+ assert!(y > 0.6, "box fell off the rising platform: y = {y}");
+ }
+}
diff --git a/crates/rapier3d/tests/parallel_external_caller_livelock.rs b/crates/rapier3d/tests/parallel_external_caller_livelock.rs
new file mode 100644
index 000000000..cc3ed079e
--- /dev/null
+++ b/crates/rapier3d/tests/parallel_external_caller_livelock.rs
@@ -0,0 +1,122 @@
+//! Forward-progress test for `step()` called from a thread that is NOT a member of
+//! the pool it drives — the documented setup for `configure_thread_pool`, where
+//! rayon injects the whole step into the pool as a foreign job.
+//!
+//! Historically this configuration livelocked. `step_with_hot_workers` spawned one
+//! spinning helper per pool thread via `Scope::spawn_broadcast`, each looping until
+//! the step body set `done`; a worker executing the injected step could pull its own
+//! pending broadcast copy into a nested work-stealing wait, and spinning on `done`
+//! from inside that frame was a circular wait — `done` is set only after the step
+//! finishes, the step was blocked on the join, and the join could not return past the
+//! spinner. Symptom: `step()` never returned, every worker burning CPU in
+//! `yield_now`/`find_work`; one observed hang lasted 49 minutes on 4.9 s of work.
+//!
+//! The hot-worker machinery has since been removed outright (it bought ~0.13 ms/step
+//! in a narrow body-count band and cost ~0.1 ms on large 3D scenes — see
+//! `docs/parallel-caps-and-cuboid-fastpath-benchmark.md`), so that circular wait is
+//! now structurally impossible. The test stays because the injection path it drives
+//! is still live and still the one most likely to wedge.
+// The pool API this drives is compiled out by `unsync-callbacks`.
+#![cfg(all(feature = "parallel", not(feature = "unsync-callbacks")))]
+
+use rapier3d::prelude::*;
+use std::sync::mpsc;
+use std::time::{Duration, Instant};
+
+/// Wall-clock budget for one batch of steps. The batch below takes tens of
+/// milliseconds when healthy; a livelocked step never returns at all, so this
+/// only has to separate "running" from "wedged", not measure anything.
+const BATCH_TIMEOUT: Duration = Duration::from_secs(60);
+
+const WORKERS: usize = 8;
+const BATCHES: usize = 40;
+const STEPS_PER_BATCH: usize = 25;
+
+/// A grid of boxes with sleeping disabled, large enough that the step's parallel
+/// regions actually engage every worker rather than running their small-domain
+/// serial fallbacks.
+fn scene() -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0, 0.0)),
+ ColliderBuilder::cuboid(100.0, 1.0, 100.0),
+ );
+
+ let n = 10i32; // 1000 dynamic bodies
+ for i in 0..n {
+ for j in 0..n {
+ for k in 0..n {
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(
+ i as f32 * 2.0 - 10.0,
+ j as f32 * 2.0 + 1.0,
+ k as f32 * 2.0 - 10.0,
+ ))
+ // Keeps every body in the active set for the whole run, so
+ // the parallel regions stay engaged on every step.
+ .can_sleep(false),
+ ColliderBuilder::cuboid(1.0, 1.0, 1.0),
+ );
+ }
+ }
+ }
+
+ world
+}
+
+/// Steps from a non-pool thread against a dedicated pool — the configuration that
+/// used to livelock — and requires steady forward progress.
+///
+/// The stepping runs on a spawned thread so a regression fails the test instead of
+/// hanging the harness forever: the wedged thread is abandoned and the process
+/// tears it down on exit.
+#[test]
+fn external_caller_with_dedicated_pool_makes_progress() {
+ let (tx, rx) = mpsc::channel();
+
+ std::thread::spawn(move || {
+ for batch in 0..BATCHES {
+ // A fresh pool per batch re-exercises the injection path, which is
+ // where the stale broadcast copy gets picked up.
+ let mut world = scene();
+ world
+ .configure_thread_pool(WORKERS)
+ .expect("failed to build the rayon pool");
+
+ for _ in 0..STEPS_PER_BATCH {
+ world.step();
+ }
+
+ // A closed channel just means the test already failed; stop quietly.
+ if tx.send(batch).is_err() {
+ return;
+ }
+ }
+ });
+
+ let start = Instant::now();
+ for expected in 0..BATCHES {
+ match rx.recv_timeout(BATCH_TIMEOUT) {
+ Ok(batch) => assert_eq!(batch, expected, "batches must arrive in order"),
+ Err(mpsc::RecvTimeoutError::Timeout) => panic!(
+ "step() made no progress for {:?} on batch {expected}/{BATCHES} \
+ ({WORKERS} workers, external caller). Something in the step is \
+ waiting on work that cannot complete while the injected step job \
+ holds the worker running it — look for a rayon scope, broadcast or \
+ join whose completion depends on a task the same worker must first \
+ finish. See this file's header for the original instance.",
+ BATCH_TIMEOUT
+ ),
+ Err(mpsc::RecvTimeoutError::Disconnected) => {
+ panic!("stepping thread died on batch {expected}/{BATCHES}")
+ }
+ }
+ }
+
+ eprintln!(
+ "{BATCHES} batches x {STEPS_PER_BATCH} steps at {WORKERS} workers in {:?}",
+ start.elapsed()
+ );
+}
diff --git a/crates/rapier3d/tests/parallel_path_parity.rs b/crates/rapier3d/tests/parallel_path_parity.rs
new file mode 100644
index 000000000..0e9899f8d
--- /dev/null
+++ b/crates/rapier3d/tests/parallel_path_parity.rs
@@ -0,0 +1,161 @@
+//! The `parallel`-off ≡ `parallel`-on contract, end to end.
+//!
+//! Building with the `parallel` feature must not change what the engine computes — only
+//! who computes it. The realistic deployment is a native server built with `parallel` in
+//! lockstep with a single-threaded wasm client built without it: those two binaries must
+//! agree bit for bit.
+//!
+//! The checksum covers the *serialized* broad-phase and narrow-phase, so it pins stored
+//! container order (map iteration order, per-collider adjacency lists, graph edge order),
+//! not just float values — which is where a work-distribution difference actually shows
+//! up. Body poses and velocities are folded in too, so a divergence that has not yet
+//! reached the manifolds still fails the test.
+//!
+//! That contract spans separate binaries, so it cannot be asserted inside one test
+//! process: this pins a golden hash instead, and CI runs the file under both feature
+//! sets. A divergence fails whichever build drifted.
+//!
+//! Only meaningful under `enhanced-determinism`, which is also what makes the hash
+//! reproducible across platforms: parry's hash map is an `IndexMap` there, so
+//! `BroadPhaseBvh::pairs` serializes in insertion order rather than in an order that
+//! depends on the target's hashbrown control-group width. `simd8` is excluded by the
+//! `enhanced-determinism` compile error, so this is the 4-lane domain.
+#![cfg(all(feature = "enhanced-determinism", feature = "serde-serialize"))]
+
+use rapier3d::prelude::*;
+
+/// Golden hash of [`run`]. Identical in every build; re-mint (with a note saying why)
+/// only when a change is *meant* to alter the simulation.
+const GOLDEN: u64 = 0x85f5_c0d1_6125_f348;
+
+/// FNV-1a.
+struct Fnv(u64);
+
+impl Fnv {
+ fn new() -> Self {
+ Self(0xcbf29ce484222325)
+ }
+
+ fn eat_bytes(&mut self, bytes: &[u8]) {
+ for b in bytes {
+ self.0 ^= *b as u64;
+ self.0 = self.0.wrapping_mul(0x100000001b3);
+ }
+ }
+
+ fn eat(&mut self, f: Real) {
+ self.eat_bytes(&f.to_bits().to_le_bytes());
+ }
+}
+
+/// Hashes the serialized broad/narrow-phase plus every body's pose and velocity.
+fn checksum(world: &PhysicsWorld) -> u64 {
+ let mut h = Fnv::new();
+
+ h.eat_bytes(&bincode::serialize(&world.broad_phase).expect("broad-phase serialization"));
+ h.eat_bytes(&bincode::serialize(&world.narrow_phase).expect("narrow-phase serialization"));
+
+ let mut handles: Vec<_> = world.bodies.iter().map(|(handle, _)| handle).collect();
+ handles.sort_by_key(|h| h.into_raw_parts().0);
+ for handle in handles {
+ let rb = &world.bodies[handle];
+ for c in rb.translation().to_array() {
+ h.eat(c);
+ }
+ for c in rb.rotation().to_array() {
+ h.eat(c);
+ }
+ for c in rb.linvel().to_array() {
+ h.eat(c);
+ }
+ for c in rb.angvel().to_array() {
+ h.eat(c);
+ }
+ }
+
+ h.0
+}
+
+fn spawn_cluster(world: &mut PhysicsWorld, seed: usize, height: Real) {
+ for i in 0..12 {
+ // Jitter so clusters never land in a symmetric configuration that would hide
+ // ordering effects.
+ let a = (seed * 7 + i * 13) as Real * 0.011;
+ let b = (seed * 11 + i * 5) as Real * 0.017;
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(
+ (i as Real % 4.0) * 1.1 - 2.2 + a,
+ height + (i / 4) as Real * 1.1,
+ b % 3.0 - 1.5,
+ ))
+ // A sideways kick: these pairs stop overlapping while their colliders
+ // keep moving, which is what exercises stale-pair detection.
+ .linvel(Vector::new(a % 1.5 - 0.75, 0.0, b % 1.5 - 0.75)),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ );
+ }
+}
+
+/// A pile that settles and falls asleep, then repeated drops onto it.
+///
+/// Each ingredient targets one of the divergences this contract covers:
+/// - drops arrive while the pile's leaves also move, so one broad-phase update mixes
+/// in-place leaf updates with structural insertions (leaf-update ordering);
+/// - the pile sleeps between drops, so the narrow phase takes its sparse-awake branch,
+/// where the update-candidate order is per-collider adjacency rather than ascending
+/// edge id (dirty-list ordering);
+/// - the kicked bodies separate again, ageing pairs out of overlap (stale-pair
+/// detection, pair timestamps);
+/// - the run is long enough for deferred BVH optimization passes to fire.
+fn run() -> u64 {
+ let mut world = PhysicsWorld::new();
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0)),
+ ColliderBuilder::cuboid(30.0, 0.5, 30.0),
+ );
+
+ for i in 0..14 {
+ for j in 0..2 {
+ for k in 0..14 {
+ let jitter = (i as Real * 0.013 + k as Real * 0.017) % 0.05;
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(
+ i as Real * 1.05 - 7.0 + jitter,
+ j as Real * 1.05 + 0.55,
+ k as Real * 1.05 - 7.0 - jitter,
+ )),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ );
+ }
+ }
+ }
+
+ // Settle and sleep.
+ for _ in 0..220 {
+ world.step();
+ }
+
+ // Drops onto the (mostly sleeping) pile.
+ for round in 0..10 {
+ spawn_cluster(&mut world, round, 6.0);
+ for _ in 0..40 {
+ world.step();
+ }
+ }
+
+ checksum(&world)
+}
+
+#[test]
+fn parallel_and_sequential_builds_agree() {
+ let hash = run();
+ assert_eq!(
+ hash, GOLDEN,
+ "\nbroad/narrow-phase checksum drifted: got {hash:#018x}, expected {GOLDEN:#018x}.\n\
+ This build's work distribution changed what it computes. The `parallel` feature \
+ must only decide *who* runs a chunk — never how work is split, in what order \
+ results are merged, or which algorithm runs. If the change was intentional, \
+ re-mint GOLDEN and say why in the commit.\n"
+ );
+}
diff --git a/crates/rapier3d/tests/persistent_islands.rs b/crates/rapier3d/tests/persistent_islands.rs
new file mode 100644
index 000000000..b548e6f26
--- /dev/null
+++ b/crates/rapier3d/tests/persistent_islands.rs
@@ -0,0 +1,312 @@
+//! Structural tests for the persistent islands: eager merge on
+//! touch/joint-link, deferred split (one island per step) after constraint
+//! removals, and link refreshes on body lifecycle edits.
+//!
+//! These tests observe island *equality* between bodies through the
+//! test-only `IslandManager::persistent_island_of` accessor; the heavy
+//! structural invariants are asserted by the debug-build validation that runs
+//! inside every `world.step()`.
+
+use rapier3d::pipeline::PhysicsWorld;
+use rapier3d::prelude::*;
+
+fn world_with_ground() -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+ let ground = RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0));
+ world.insert(ground, ColliderBuilder::cuboid(100.0, 0.5, 100.0));
+ world
+}
+
+fn island_of(world: &PhysicsWorld, h: RigidBodyHandle) -> Option {
+ world.islands.persistent_island_of(&world.bodies, h)
+}
+
+fn same_island(world: &PhysicsWorld, h1: RigidBodyHandle, h2: RigidBodyHandle) -> bool {
+ let i1 = island_of(world, h1);
+ i1.is_some() && i1 == island_of(world, h2)
+}
+
+fn insert_box(world: &mut PhysicsWorld, x: Real, y: Real) -> RigidBodyHandle {
+ world
+ .insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(x, y, 0.0)),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ )
+ .0
+}
+
+/// Long enough for resting bodies to become sleep-eligible (1s = 60 steps),
+/// bid the pending split, and have it run.
+const SETTLE_STEPS: usize = 240;
+
+/// Two boxes stacked touch and must share an island; two distant boxes must
+/// not. After the stack's top is teleported away, the settled leftovers must
+/// end up in distinct islands (deferred split).
+#[test]
+fn merge_on_touch_split_on_separation() {
+ let mut world = world_with_ground();
+ let bottom = insert_box(&mut world, 0.0, 0.5);
+ let top = insert_box(&mut world, 0.0, 1.5);
+ let lone = insert_box(&mut world, 20.0, 0.5);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(
+ same_island(&world, bottom, top),
+ "touching boxes must merge"
+ );
+ assert!(
+ !same_island(&world, bottom, lone),
+ "distant boxes must not share an island"
+ );
+
+ // Teleport the top box far away: the pair is removed, and once everything
+ // settles the island must have been split.
+ world.bodies[top].set_translation(Vector::new(40.0, 0.5, 0.0), true);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(
+ !same_island(&world, bottom, top),
+ "separated boxes must end up in distinct islands after the deferred split"
+ );
+}
+
+/// A joint between two distant resting boxes merges their islands; removing
+/// it (without waking) must eventually split them apart again.
+#[test]
+fn joint_links_and_split_after_removal() {
+ let mut world = world_with_ground();
+ let a = insert_box(&mut world, 0.0, 0.5);
+ let b = insert_box(&mut world, 20.0, 0.5);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(!same_island(&world, a, b));
+
+ let joint = world.impulse_joints.insert(
+ a,
+ b,
+ RopeJointBuilder::new(30.0)
+ .local_anchor1(Vector::ZERO)
+ .local_anchor2(Vector::ZERO),
+ true,
+ );
+ world.step();
+ assert!(same_island(&world, a, b), "a joint must merge islands");
+
+ world.impulse_joints.remove(joint, true);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(
+ !same_island(&world, a, b),
+ "the deferred split must separate joint-disconnected islands"
+ );
+}
+
+/// Removing the middle box of a touching row must split the sides apart.
+#[test]
+fn body_removal_splits_row() {
+ let mut world = world_with_ground();
+ let left = insert_box(&mut world, 0.0, 0.5);
+ let middle = insert_box(&mut world, 1.0, 0.5);
+ let right = insert_box(&mut world, 2.0, 0.5);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(same_island(&world, left, right));
+
+ world.remove_body(middle);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(
+ !same_island(&world, left, right),
+ "removing the bridging body must split the island"
+ );
+}
+
+/// A separation is resolved by the *local* search, in the very step the contact
+/// stops touching — no waiting for a body to become sleep-eligible, bid for the
+/// split, and have the deferred global union-find run it.
+#[test]
+fn separation_splits_immediately() {
+ let mut world = world_with_ground();
+ let bottom = insert_box(&mut world, 0.0, 0.5);
+ let top = insert_box(&mut world, 0.0, 1.5);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(same_island(&world, bottom, top));
+
+ // Teleport the top box far away. The pair stops touching during this step's
+ // narrow phase, and the local search must peel it off before the step ends.
+ world.bodies[top].set_translation(Vector::new(40.0, 0.5, 0.0), true);
+ world.step();
+ assert!(
+ !same_island(&world, bottom, top),
+ "the local split must separate them in the very step they stop touching"
+ );
+}
+
+/// A detached *multi-body* component (not just a lone straggler) is moved out
+/// whole, with the links between its members following it.
+#[test]
+fn detached_chunk_moves_out_with_its_links() {
+ let mut world = world_with_ground();
+ // A row of 6 touching boxes: [0][1][2] | [3][4][5].
+ let row: Vec<_> = (0..6)
+ .map(|i| insert_box(&mut world, i as Real * 1.0, 0.5))
+ .collect();
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(same_island(&world, row[0], row[5]), "the row is one island");
+
+ // Lift the right half away as a block: it stays internally touching, so the
+ // island splits into two *multi-body* components rather than shedding
+ // singletons.
+ for (i, handle) in row.iter().enumerate().skip(3) {
+ let x = 30.0 + (i - 3) as Real;
+ world.bodies[*handle].set_translation(Vector::new(x, 0.5, 0.0), true);
+ }
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+
+ assert!(
+ !same_island(&world, row[0], row[3]),
+ "the two halves must end up in different islands"
+ );
+ assert!(
+ same_island(&world, row[0], row[2]),
+ "the left half stays one island"
+ );
+ assert!(
+ same_island(&world, row[3], row[5]),
+ "the detached half moves out as one island, links included"
+ );
+}
+
+/// Turning the middle box fixed removes it from the islands (fixed bodies
+/// never belong to one) and must split the sides; turning it dynamic again
+/// must re-merge everything.
+#[test]
+fn type_change_bridges_and_unbridges() {
+ let mut world = world_with_ground();
+ let left = insert_box(&mut world, 0.0, 0.5);
+ let middle = insert_box(&mut world, 1.0, 0.5);
+ let right = insert_box(&mut world, 2.0, 0.5);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(same_island(&world, left, right));
+
+ world.bodies[middle].set_body_type(RigidBodyType::Fixed, true);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(
+ island_of(&world, middle),
+ None,
+ "fixed bodies have no island"
+ );
+ assert!(
+ !same_island(&world, left, right),
+ "a fixed body doesn't connect its neighbors"
+ );
+
+ world.bodies[middle].set_body_type(RigidBodyType::Dynamic, true);
+ for _ in 0..8 {
+ world.step();
+ }
+ assert!(
+ same_island(&world, left, right),
+ "back to dynamic, the middle body must re-merge the row"
+ );
+}
+
+/// Disabling the middle box must split the row; re-enabling must re-merge it.
+#[test]
+fn disable_enable_bridges_and_unbridges() {
+ let mut world = world_with_ground();
+ let left = insert_box(&mut world, 0.0, 0.5);
+ let middle = insert_box(&mut world, 1.0, 0.5);
+ let right = insert_box(&mut world, 2.0, 0.5);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(same_island(&world, left, right));
+
+ world.bodies[middle].set_enabled(false);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(island_of(&world, middle), None);
+ assert!(!same_island(&world, left, right));
+
+ world.bodies[middle].set_enabled(true);
+ for _ in 0..8 {
+ world.step();
+ }
+ assert!(same_island(&world, left, right));
+}
+
+/// Two multibody branches hanging under a *fixed* root must share one island
+/// (multibodies are atomic), even though a joint edge to a fixed body doesn't
+/// connect anything by itself. Removing one branch's joint must split it off.
+#[test]
+fn multibody_fixed_root_branches_share_island() {
+ let mut world = world_with_ground();
+ let root = world
+ .insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, 5.0, 0.0)),
+ ColliderBuilder::ball(0.1),
+ )
+ .0;
+ let child_a = insert_box(&mut world, -2.0, 5.0);
+ let child_b = insert_box(&mut world, 2.0, 5.0);
+
+ let joint_a = world
+ .multibody_joints
+ .insert(
+ root,
+ child_a,
+ FixedJointBuilder::new().local_anchor1(Vector::new(-2.0, 0.0, 0.0)),
+ true,
+ )
+ .unwrap();
+ let _joint_b = world
+ .multibody_joints
+ .insert(
+ root,
+ child_b,
+ FixedJointBuilder::new().local_anchor1(Vector::new(2.0, 0.0, 0.0)),
+ true,
+ )
+ .unwrap();
+
+ world.step();
+ assert!(
+ same_island(&world, child_a, child_b),
+ "both branches of a fixed-root multibody must share an island"
+ );
+ assert_eq!(island_of(&world, root), None);
+
+ world.multibody_joints.remove(joint_a, true);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(
+ !same_island(&world, child_a, child_b),
+ "a detached branch must split off"
+ );
+}
diff --git a/crates/rapier3d/tests/simd_backend_determinism.rs b/crates/rapier3d/tests/simd_backend_determinism.rs
new file mode 100644
index 000000000..54e160119
--- /dev/null
+++ b/crates/rapier3d/tests/simd_backend_determinism.rs
@@ -0,0 +1,150 @@
+//! The cross-platform determinism contract, end to end.
+//!
+//! The solver is 4-lane AoSoA over `wide`'s `WideF32x4`, whose per-platform
+//! intrinsics are bitwise identical lane for lane to the portable scalar
+//! reference (pinned per-operation by `simd_backend_parity`). A simulation must
+//! therefore come out the same, bit for bit, whichever intrinsics `wide` picks.
+//!
+//! That contract spans separate binaries, so it cannot be asserted inside one
+//! test process: this pins a golden hash instead, and CI runs the file on every
+//! target. A divergence fails whichever build drifted.
+//!
+//! Only meaningful under `enhanced-determinism` (which pins the transcendentals
+//! through libm on every backend and forces glam's scalar core). `simd8` is
+//! excluded on purpose: 8 lanes bundle constraints differently and are their own
+//! determinism domain.
+#![cfg(all(feature = "enhanced-determinism", not(feature = "simd8")))]
+
+use rapier3d::prelude::*;
+
+/// FNV-1a over the raw bit patterns of every body's pose and velocity.
+struct Fnv(u64);
+
+impl Fnv {
+ fn new() -> Self {
+ Self(0xcbf29ce484222325)
+ }
+
+ fn eat(&mut self, f: Real) {
+ for b in f.to_bits().to_le_bytes() {
+ self.0 ^= b as u64;
+ self.0 = self.0.wrapping_mul(0x100000001b3);
+ }
+ }
+}
+
+fn state_hash(bodies: &RigidBodySet) -> u64 {
+ let mut h = Fnv::new();
+ let mut handles: Vec<_> = bodies.iter().map(|(handle, _)| handle).collect();
+ handles.sort_by_key(|h| h.into_raw_parts().0);
+
+ for handle in handles {
+ let rb = &bodies[handle];
+ for c in rb.translation().to_array() {
+ h.eat(c);
+ }
+ for c in rb.rotation().to_array() {
+ h.eat(c);
+ }
+ for c in rb.linvel().to_array() {
+ h.eat(c);
+ }
+ for c in rb.angvel().to_array() {
+ h.eat(c);
+ }
+ }
+ h.0
+}
+
+/// A pile (many same-color contacts, so the parallel color stages and the SIMD
+/// chunking genuinely engage) plus a joint chain, stepped long enough to settle.
+fn run(steps: usize) -> u64 {
+ let mut bodies = RigidBodySet::new();
+ let mut colliders = ColliderSet::new();
+ let mut impulse_joints = ImpulseJointSet::new();
+ let mut multibody_joints = MultibodyJointSet::new();
+ let mut pipeline = PhysicsPipeline::new();
+ let mut broad_phase = BroadPhaseBvh::new();
+ let mut narrow_phase = NarrowPhase::new();
+ let mut islands = IslandManager::new();
+ let mut ccd = CCDSolver::new();
+ let params = IntegrationParameters::default();
+
+ let ground = bodies.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0)));
+ colliders.insert_with_parent(
+ ColliderBuilder::cuboid(20.0, 0.5, 20.0),
+ ground,
+ &mut bodies,
+ );
+
+ for i in 0..12 {
+ for j in 0..3 {
+ for k in 0..12 {
+ // Jitter so the pile settles asymmetrically instead of landing
+ // in a symmetric configuration that hides ordering effects.
+ let jitter = (i as Real * 0.013 + k as Real * 0.017) % 0.05;
+ let rb = bodies.insert(RigidBodyBuilder::dynamic().translation(Vector::new(
+ i as Real * 1.05 - 6.0 + jitter,
+ j as Real * 1.05 + 0.55,
+ k as Real * 1.05 - 6.0 - jitter,
+ )));
+ colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ rb,
+ &mut bodies,
+ );
+ }
+ }
+ }
+
+ let anchor = bodies.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, 8.0, 0.0)));
+ let mut prev = anchor;
+ for i in 0..4 {
+ let rb = bodies.insert(RigidBodyBuilder::dynamic().translation(Vector::new(
+ 0.6 * (i + 1) as Real,
+ 8.0,
+ 0.0,
+ )));
+ colliders.insert_with_parent(ColliderBuilder::ball(0.25), rb, &mut bodies);
+ impulse_joints.insert(
+ prev,
+ rb,
+ SphericalJointBuilder::new()
+ .local_anchor1(Vector::X * 0.3)
+ .local_anchor2(Vector::X * -0.3),
+ true,
+ );
+ prev = rb;
+ }
+
+ for _ in 0..steps {
+ pipeline.step(
+ Vector::Y * -9.81,
+ ¶ms,
+ &mut islands,
+ &mut broad_phase,
+ &mut narrow_phase,
+ &mut bodies,
+ &mut colliders,
+ &mut impulse_joints,
+ &mut multibody_joints,
+ &mut ccd,
+ &(),
+ &(),
+ );
+ }
+ state_hash(&bodies)
+}
+
+#[test]
+fn golden_state_hash_is_backend_independent() {
+ // Regenerate by running this test on every supported target: they must all
+ // print the same value. If they don't, the backends have diverged and
+ // `simd_backend_parity` should say on which operation.
+ const GOLDEN: u64 = 0xd9ef_17af_939e_942b;
+ let hash = run(120);
+ assert_eq!(
+ hash, GOLDEN,
+ "state hash drifted: got {hash:#018x}, expected {GOLDEN:#018x}"
+ );
+}
diff --git a/crates/rapier3d/tests/simd_backend_parity.rs b/crates/rapier3d/tests/simd_backend_parity.rs
new file mode 100644
index 000000000..45e4e8fb2
--- /dev/null
+++ b/crates/rapier3d/tests/simd_backend_parity.rs
@@ -0,0 +1,183 @@
+//! The SIMD backend's bitwise-parity contract.
+//!
+//! The solver is 4-lane AoSoA in every build, over `wide`'s `WideF32x4`. That
+//! backend is only allowed to change codegen relative to the portable scalar
+//! reference (`AutoF32x4`), never results — otherwise the platform-specific
+//! intrinsics `wide` picks would each be their own determinism domain.
+//!
+//! This pins the guarantee at the op level, for every operation the solver
+//! performs on `SimdReal` (see the `simd_*` call sites under `src/dynamics`).
+//! [`simd_backend_determinism`] pins the same guarantee end-to-end on a whole
+//! simulation; this one localizes a regression to the offending operation.
+//!
+//! `simd8` is deliberately out of scope: it changes the lane WIDTH, which
+//! changes constraint bundling and is its own determinism domain.
+#![cfg(not(feature = "simd8"))]
+
+use rapier3d::na::{Matrix3, Quaternion, UnitQuaternion, Vector3};
+use simba::simd::{AutoF32x4, SimdBool as _, SimdPartialOrd, SimdRealField, SimdValue, WideF32x4};
+
+struct XorShift(u64);
+
+impl XorShift {
+ /// A finite float in roughly [-8, 8], never exactly zero: `±0.0` ties in
+ /// `min`/`max` are a documented bit-pattern caveat of the determinism
+ /// contract (see `glam_backend_determinism`), not a backend divergence.
+ fn f(&mut self) -> f32 {
+ let mut x = self.0;
+ x ^= x << 13;
+ x ^= x >> 7;
+ x ^= x << 17;
+ self.0 = x;
+ let f = ((((x >> 32) as u32) as f32) / u32::MAX as f32 - 0.5) * 16.0;
+ if f == 0.0 { 1.0 } else { f }
+ }
+}
+
+/// Runs every op the solver uses, generically over the backend, and flattens
+/// each result to lanes so two backends can be compared bit for bit.
+fn ops(s: &[f32], out: &mut Vec<(String, [f32; 4])>)
+where
+ N: SimdRealField + SimdValue + SimdPartialOrd + Copy,
+{
+ let lane = |i: usize| {
+ let mut n = N::splat(s[i]);
+ for k in 1..4 {
+ n.replace(k, s[(i + k) % s.len()]);
+ }
+ n
+ };
+ let lanes = |n: N| -> [f32; 4] { core::array::from_fn(|k| n.extract(k)) };
+
+ macro_rules! push {
+ ($name:expr, $n:expr) => {
+ out.push(($name.to_string(), lanes($n)))
+ };
+ }
+ macro_rules! push_v {
+ ($name:expr, $v:expr) => {
+ for (k, c) in $v.as_slice().iter().enumerate() {
+ out.push((format!("{}[{}]", $name, k), lanes(*c)));
+ }
+ };
+ }
+
+ let (a, b) = (lane(0), lane(4));
+
+ // Arithmetic and the reciprocal the solver leans on hardest (`simd_inv`).
+ push!("add", a + b);
+ push!("sub", a - b);
+ push!("mul", a * b);
+ push!("div", a / b);
+ push!("neg", -a);
+ push!("inv", N::one() / a);
+ push!("sqrt", a.simd_abs().simd_sqrt());
+ push!("abs", a.simd_abs());
+ push!("signum", a.simd_signum());
+ push!("copysign", a.simd_copysign(b));
+ push!("max", a.simd_max(b));
+ push!("min", a.simd_min(b));
+ push!("clamp", a.simd_clamp(-b.simd_abs(), b.simd_abs()));
+ push!("two_pi_mul", a * N::simd_two_pi());
+
+ // Comparisons feed `select`, so compare the selected values (a lane mask
+ // has no bit pattern of its own to hash).
+ push!("gt", a.simd_gt(b).if_else(|| a, || b));
+ push!("ge", a.simd_ge(b).if_else(|| a, || b));
+ push!("lt", a.simd_lt(b).if_else(|| a, || b));
+ push!("le", a.simd_le(b).if_else(|| a, || b));
+ push!("ne", a.simd_ne(b).if_else(|| a, || b));
+ push!(
+ "and_select",
+ (a.simd_gt(b) & a.simd_lt(-b)).if_else(|| a, || b)
+ );
+
+ // Transcendentals are only backend-stable under `enhanced-determinism`,
+ // which routes both backends per-lane through libm; otherwise `wide` uses
+ // polynomial approximations and `AutoSimd` the host libm.
+ #[cfg(feature = "enhanced-determinism")]
+ {
+ push!("asin", (a / N::splat(8.0)).simd_asin());
+ push!("sin", a.simd_sin());
+ push!("cos", a.simd_cos());
+ push!("atan2", a.simd_atan2(b));
+ }
+
+ // The nalgebra geometry the constraint math is built from.
+ let v1 = Vector3::new(lane(0), lane(1), lane(2));
+ let v2 = Vector3::new(lane(3), lane(4), lane(5));
+ let q1 = UnitQuaternion::new_normalize(Quaternion::from_parts(
+ lane(6),
+ Vector3::new(lane(7), lane(8), lane(9)),
+ ));
+ let q2 = UnitQuaternion::new_normalize(Quaternion::from_parts(
+ lane(10),
+ Vector3::new(lane(11), lane(12), lane(13)),
+ ));
+ #[rustfmt::skip]
+ let m = Matrix3::new(
+ lane(0), lane(1), lane(2),
+ lane(3), lane(4), lane(5),
+ lane(6), lane(7), lane(8),
+ );
+
+ push!("dot", v1.dot(&v2));
+ push!("norm", v1.norm());
+ push_v!("cross", v1.cross(&v2));
+ push_v!("normalize", v1.normalize());
+ push_v!("quat*vec", q1 * v1);
+ push_v!("quat_inv*vec", q1.inverse() * v1);
+ push_v!("quat*quat", (q1 * q2).coords.xyz());
+ push_v!(
+ "quat->mat_col0",
+ q1.to_rotation_matrix().into_inner().column(0).into_owned()
+ );
+ push_v!("mat*vec", m * v1);
+ push_v!("matmat_col0", (m * m).column(0).into_owned());
+ push_v!("mat_tr*vec", m.transpose() * v1);
+}
+
+#[test]
+fn portable_backend_matches_wide_bitwise() {
+ const ITERS: usize = 2000;
+ let mut rng = XorShift(0x243f6a8885a308d3);
+ let mut mismatches: Vec = Vec::new();
+
+ for iter in 0..ITERS {
+ let s: Vec = (0..16).map(|_| rng.f()).collect();
+ let (mut auto, mut wide) = (Vec::new(), Vec::new());
+ ops::(&s, &mut auto);
+ ops::(&s, &mut wide);
+ assert_eq!(
+ auto.len(),
+ wide.len(),
+ "the two runs must cover the same ops"
+ );
+
+ for ((name, a), (_, w)) in auto.iter().zip(wide.iter()) {
+ for k in 0..4 {
+ if a[k].to_bits() != w[k].to_bits() {
+ mismatches.push(format!(
+ "iter{iter}/{name}/lane{k}: AutoF32x4 {:e} ({:08x}) vs WideF32x4 {:e} ({:08x})",
+ a[k],
+ a[k].to_bits(),
+ w[k],
+ w[k].to_bits()
+ ));
+ }
+ }
+ }
+ }
+
+ if !mismatches.is_empty() {
+ let mut per_op: std::collections::BTreeMap<&str, usize> = Default::default();
+ for m in &mismatches {
+ *per_op.entry(m.split('/').nth(1).unwrap()).or_default() += 1;
+ }
+ panic!(
+ "{} bitwise mismatches over {ITERS} iterations; per-op counts: {per_op:?}\nfirst 6:\n{}",
+ mismatches.len(),
+ mismatches[..6.min(mismatches.len())].join("\n")
+ );
+ }
+}
diff --git a/crates/rapier3d/tests/single_worker_deferred_bvh.rs b/crates/rapier3d/tests/single_worker_deferred_bvh.rs
new file mode 100644
index 000000000..b39ce5563
--- /dev/null
+++ b/crates/rapier3d/tests/single_worker_deferred_bvh.rs
@@ -0,0 +1,90 @@
+//! Regression test for the deferred BVH optimization on a single-worker pool.
+//!
+//! The broad-phase defers its (quality-only) optimization pass in every build, so the
+//! tree this step's pair traversal walked stays un-optimized until the join point —
+//! that is what keeps a `parallel` build and a non-`parallel` build on the same tree.
+//! Only the *execution* needs a spare worker.
+//!
+//! `step()` runs inside the dedicated pool (`PhysicsPipeline::step`), so with a
+//! single-worker pool the step body *is* the pool's only thread. Handing the pass to
+//! `rayon::spawn` there queues it behind the `recv` that waits for it, and the step
+//! never returns. The fix routes those to `join_deferred_bvh_optimize`, which runs
+//! them inline.
+//!
+//! Symptom when it regresses: `step()` hangs on the first step that defers a pass —
+//! not the first step of the run, since the optimizer only fires once enough
+//! quality-degrading changes accumulate.
+// The pool API this drives is compiled out by `unsync-callbacks`.
+#![cfg(all(feature = "parallel", not(feature = "unsync-callbacks")))]
+
+use rapier3d::prelude::*;
+use std::sync::mpsc;
+use std::time::Duration;
+
+/// The scene below runs in milliseconds when healthy; a deadlocked step never returns,
+/// so this only has to separate "running" from "wedged".
+const TIMEOUT: Duration = Duration::from_secs(60);
+const STEPS: usize = 120;
+
+/// Falling balls over a ground: every body moves every step, so leaf updates accrue
+/// the optimizer debt that makes `update` defer a pass.
+fn scene() -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0, 0.0)),
+ ColliderBuilder::cuboid(100.0, 1.0, 100.0),
+ );
+
+ let n = 12i32;
+ for i in 0..n {
+ for j in 0..n {
+ for k in 0..n {
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(
+ i as f32 * 1.5 - 9.0,
+ j as f32 * 1.5 + 1.0,
+ k as f32 * 1.5 - 9.0,
+ ))
+ .can_sleep(false),
+ ColliderBuilder::ball(0.5),
+ );
+ }
+ }
+ }
+ world
+}
+
+/// Steps against a one-worker dedicated pool and requires the run to finish.
+///
+/// The stepping runs on a spawned thread so a regression fails the test instead of
+/// hanging the harness forever.
+#[test]
+fn single_worker_pool_steps_to_completion() {
+ let (tx, rx) = mpsc::channel();
+
+ std::thread::spawn(move || {
+ let mut world = scene();
+ world
+ .configure_thread_pool(1)
+ .expect("failed to build the single-worker pool");
+
+ for _ in 0..STEPS {
+ world.step();
+ }
+ let _ = tx.send(());
+ });
+
+ match rx.recv_timeout(TIMEOUT) {
+ Ok(()) => {}
+ Err(mpsc::RecvTimeoutError::Timeout) => panic!(
+ "step() made no progress for {TIMEOUT:?} on a single-worker pool. The \
+ deferred BVH optimization was handed to `rayon::spawn` with no spare \
+ worker to run it: the step blocks in `join_deferred_bvh_optimize`'s \
+ `recv` while the task it waits for is queued behind that very step. \
+ Check that `solve.rs` only spawns when `current_num_threads() > 1` and \
+ otherwise leaves the task to `deferred_bvh_inline`."
+ ),
+ Err(mpsc::RecvTimeoutError::Disconnected) => panic!("stepping thread died"),
+ }
+}
diff --git a/crates/rapier3d/tests/sleep_wake.rs b/crates/rapier3d/tests/sleep_wake.rs
new file mode 100644
index 000000000..268dd1492
--- /dev/null
+++ b/crates/rapier3d/tests/sleep_wake.rs
@@ -0,0 +1,810 @@
+use rapier3d::prelude::*;
+
+/// A body woken by an impulse (colliders barely moved) takes the contact-recycling
+/// path, which must repair the per-pair solver hint count-cleared at sleep time —
+/// otherwise the woken body falls through the ground (contacts never reach the solver).
+#[test]
+fn woken_body_is_supported_by_recycled_contacts() {
+ let mut bodies = RigidBodySet::new();
+ let mut colliders = ColliderSet::new();
+ let mut impulse_joints = ImpulseJointSet::new();
+ let mut multibody_joints = MultibodyJointSet::new();
+ let mut pipeline = PhysicsPipeline::new();
+ let mut bf = BroadPhaseBvh::new();
+ let mut nf = NarrowPhase::new();
+ let mut islands = IslandManager::new();
+ let mut ccd = CCDSolver::new();
+ let params = IntegrationParameters::default();
+ let gravity = Vector::Y * -9.81;
+
+ let ground = bodies.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0)));
+ colliders.insert_with_parent(
+ ColliderBuilder::cuboid(10.0, 0.5, 10.0),
+ ground,
+ &mut bodies,
+ );
+
+ let cube = bodies.insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.6, 0.0)));
+ colliders.insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5, 0.5), cube, &mut bodies);
+
+ let step = |bodies: &mut RigidBodySet,
+ colliders: &mut ColliderSet,
+ islands: &mut IslandManager,
+ bf: &mut BroadPhaseBvh,
+ nf: &mut NarrowPhase,
+ impulse_joints: &mut ImpulseJointSet,
+ multibody_joints: &mut MultibodyJointSet,
+ ccd: &mut CCDSolver,
+ pipeline: &mut PhysicsPipeline| {
+ pipeline.step(
+ gravity,
+ ¶ms,
+ islands,
+ bf,
+ nf,
+ bodies,
+ colliders,
+ impulse_joints,
+ multibody_joints,
+ ccd,
+ &(),
+ &(),
+ );
+ };
+
+ // Let the cube settle and fall asleep.
+ let mut slept = false;
+ for _ in 0..400 {
+ step(
+ &mut bodies,
+ &mut colliders,
+ &mut islands,
+ &mut bf,
+ &mut nf,
+ &mut impulse_joints,
+ &mut multibody_joints,
+ &mut ccd,
+ &mut pipeline,
+ );
+ if bodies[cube].is_sleeping() {
+ slept = true;
+ break;
+ }
+ }
+ assert!(slept, "the cube never fell asleep");
+
+ // Wake it with an impulse only: its colliders don't move, so the contact pair
+ // must go through the recycling path (which repairs the count-cleared hint).
+ bodies[cube].apply_impulse(Vector::new(0.5, 0.0, 0.0), true);
+ assert!(!bodies[cube].is_sleeping());
+
+ for _ in 0..120 {
+ step(
+ &mut bodies,
+ &mut colliders,
+ &mut islands,
+ &mut bf,
+ &mut nf,
+ &mut impulse_joints,
+ &mut multibody_joints,
+ &mut ccd,
+ &mut pipeline,
+ );
+ let y = bodies[cube].translation().y;
+ assert!(
+ y > 0.4,
+ "woken cube sank into the ground (y = {y}): its contacts were not solved"
+ );
+ }
+}
+
+/// Minimal world harness for the partial-island sleep tests below.
+struct World {
+ bodies: RigidBodySet,
+ colliders: ColliderSet,
+ impulse_joints: ImpulseJointSet,
+ multibody_joints: MultibodyJointSet,
+ pipeline: PhysicsPipeline,
+ bf: BroadPhaseBvh,
+ nf: NarrowPhase,
+ islands: IslandManager,
+ ccd: CCDSolver,
+ params: IntegrationParameters,
+}
+
+impl World {
+ fn new() -> Self {
+ let mut world = 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(),
+ };
+ let ground = world
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0)));
+ world.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(50.0, 0.5, 50.0),
+ ground,
+ &mut world.bodies,
+ );
+ world
+ }
+
+ fn add_cube(&mut self, builder: RigidBodyBuilder) -> RigidBodyHandle {
+ let handle = self.bodies.insert(builder);
+ self.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ handle,
+ &mut self.bodies,
+ );
+ handle
+ }
+
+ fn step(&mut self) {
+ self.pipeline.step(
+ Vector::Y * -9.81,
+ &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,
+ &(),
+ &(),
+ );
+ }
+}
+
+/// Whole-island sleep (partial-island sleep was removed): a row
+/// of touching cubes ending at a never-sleeping body is one component, so nothing
+/// in it may sleep; a distant independent cube must still sleep on its own.
+#[test]
+fn non_sleeping_neighbor_keeps_touching_row_awake() {
+ let mut w = World::new();
+
+ // A row of touching cubes: c[0] can never sleep.
+ let n = 8;
+ let mut row = Vec::new();
+ for i in 0..n {
+ let builder = RigidBodyBuilder::dynamic()
+ .translation(Vector::new(i as f32, 0.5, 0.0))
+ .can_sleep(i != 0);
+ row.push(w.add_cube(builder));
+ }
+ // A distant cube, disconnected from the row: its own island must sleep.
+ let lone = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(30.0, 0.5, 0.0)));
+
+ for _ in 0..400 {
+ w.step();
+ }
+
+ for (i, handle) in row.iter().enumerate() {
+ assert!(
+ !w.bodies[*handle].is_sleeping(),
+ "row cube {i} must stay awake: its island contains a non-sleeping body"
+ );
+ }
+ assert!(
+ w.bodies[lone].is_sleeping(),
+ "the disconnected cube must sleep on its own"
+ );
+
+ // Everybody is still resting in place.
+ for (i, handle) in row.iter().enumerate() {
+ let pos = w.bodies[*handle].translation();
+ assert!(
+ (pos.y - 0.5).abs() < 0.1 && (pos.x - i as f32).abs() < 0.1,
+ "row cube {i} drifted to {pos:?}"
+ );
+ }
+}
+
+/// An impact on a sleeping region must wake it and be resolved physically.
+#[test]
+fn impact_wakes_sleeping_region() {
+ let mut w = World::new();
+
+ let n = 6;
+ let mut row = Vec::new();
+ for i in 0..n {
+ row.push(
+ w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(i as f32, 0.5, 0.0))),
+ );
+ }
+
+ for _ in 0..400 {
+ w.step();
+ }
+ for (i, handle) in row.iter().enumerate() {
+ assert!(
+ w.bodies[*handle].is_sleeping(),
+ "row cube {i} should be asleep before the impact"
+ );
+ }
+
+ // Throw a fast cube at the end of the row.
+ let bullet = w.add_cube(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.5, 0.0))
+ .linvel(Vector::new(20.0, 0.0, 0.0)),
+ );
+
+ for _ in 0..30 {
+ w.step();
+ }
+
+ assert!(
+ !w.bodies[row[0]].is_sleeping(),
+ "the impacted cube must be awake"
+ );
+ assert!(
+ w.bodies[row[0]].linvel().length() > 0.05
+ || (w.bodies[row[0]].translation().x - 0.0) > 0.05,
+ "the impacted cube must have physically responded to the hit"
+ );
+ let _ = bullet;
+}
+
+/// A joint must never connect an awake body to a sleeping one: if one side of a
+/// jointed pair is kept awake (halo of a non-sleeping body), the other side must
+/// stay awake with it even if all its own contacts would allow sleeping.
+#[test]
+fn joint_keeps_both_sides_awake() {
+ let mut w = World::new();
+
+ // `mover` can never sleep; `b` touches it (halo); `a` is jointed to `b` but
+ // physically separate from everything else; `control` is a free cube that
+ // must sleep normally.
+ let mover = w.add_cube(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 0.5, 0.0))
+ .can_sleep(false),
+ );
+ let b = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(1.0, 0.5, 0.0)));
+ let a = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(2.5, 0.5, 0.0)));
+ let control = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(10.0, 0.5, 0.0)));
+
+ // Fixed joint holding `a` and `b` exactly at their rest poses (no residual
+ // force at equilibrium).
+ let joint = FixedJointBuilder::new()
+ .local_anchor1(Vector::new(1.5, 0.0, 0.0))
+ .local_anchor2(Vector::new(0.0, 0.0, 0.0));
+ w.impulse_joints.insert(b, a, joint, true);
+
+ for _ in 0..400 {
+ w.step();
+ }
+
+ assert!(!w.bodies[mover].is_sleeping());
+ assert!(
+ !w.bodies[b].is_sleeping(),
+ "halo neighbor of the non-sleeping body must stay awake"
+ );
+ assert!(
+ !w.bodies[a].is_sleeping(),
+ "a body jointed to an awake body must not sleep"
+ );
+ assert!(
+ w.bodies[control].is_sleeping(),
+ "the control cube should sleep normally"
+ );
+
+ // The jointed pair must be at rest at its original poses (the joint holds
+ // them without fighting).
+ assert!((w.bodies[a].translation().x - 2.5).abs() < 0.1);
+ assert!((w.bodies[b].translation().x - 1.0).abs() < 0.1);
+}
+
+/// Corner-velocity sleep metric: a long beam pivoting below the raw angular
+/// threshold still moves its tips fast — it must NOT sleep mid-motion; a small
+/// body spinning slightly above it barely moves its surface and SHOULD sleep.
+#[test]
+fn corner_velocity_sleep_metric() {
+ let mut w = World::new();
+
+ // Gravity-free spinners so their velocity stays exactly constant.
+ let beam = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 30.0, 0.0))
+ .angvel(Vector::new(0.0, 0.0, 0.3)) // below the raw 0.5 rad/s threshold
+ .gravity_scale(0.0),
+ );
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(10.0, 0.1, 0.1), beam, &mut w.bodies);
+
+ let pebble = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 30.0, 20.0))
+ // Above the raw 0.5 rad/s angular threshold, but the surface only
+ // moves at ~0.55 × 0.087 ≈ 0.048 m/s — below the 0.05 linear one.
+ .angvel(Vector::new(0.0, 0.0, 0.55))
+ .gravity_scale(0.0),
+ );
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.05, 0.05, 0.05),
+ pebble,
+ &mut w.bodies,
+ );
+
+ for _ in 0..200 {
+ w.step();
+ }
+
+ assert!(
+ !w.bodies[beam].is_sleeping(),
+ "a slowly pivoting long beam (tips at ~3 m/s) must not sleep mid-motion"
+ );
+ assert!(
+ w.bodies[pebble].is_sleeping(),
+ "a small spinner whose surface moves at ~0.05 m/s should be allowed to sleep"
+ );
+}
+
+/// Frontier drift monitor: a support dragged out at 0.15 m/s (below every velocity
+/// wake gate) from under a rider that fell asleep mid-slide must wake it via the
+/// relative-pose drift anchor, so it keeps riding instead of being left floating.
+#[test]
+fn sliding_support_wakes_sleeping_rider() {
+ let mut w = World::new();
+
+ // Kinematic pusher, slow enough to stay below every velocity wake gate.
+ let pusher = w.bodies.insert(
+ RigidBodyBuilder::kinematic_velocity_based()
+ .translation(Vector::new(-1.55, 0.5, 0.0))
+ .linvel(Vector::new(0.15, 0.0, 0.0)),
+ );
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ pusher,
+ &mut w.bodies,
+ );
+
+ let support = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.5, 0.0)));
+ let rider = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 1.5, 0.0)));
+
+ let mut rider_slept = false;
+ let mut rider_woke_after_sleep = false;
+ for _ in 0..400 {
+ w.step();
+ if w.bodies[rider].is_sleeping() {
+ rider_slept = true;
+ } else if rider_slept {
+ rider_woke_after_sleep = true;
+ }
+ }
+
+ assert!(
+ rider_slept,
+ "the rider should fall asleep mid-slide (its velocity is below the sleep threshold)"
+ );
+ assert!(
+ rider_woke_after_sleep,
+ "the frontier drift monitor must wake the frozen rider as its support slides away"
+ );
+ let rider_pos = w.bodies[rider].translation();
+ let support_pos = w.bodies[support].translation();
+ assert!(
+ rider_pos.x > 0.3,
+ "the rider must keep being dragged along overall (x = {})",
+ rider_pos.x
+ );
+ assert!(
+ (rider_pos.y - 1.5).abs() < 0.2 && (rider_pos.x - support_pos.x).abs() < 0.75,
+ "the rider must still ride its support (rider {rider_pos:?}, support {support_pos:?})"
+ );
+}
+
+/// A slow kinematic body pressing into a sleeping body must wake it instead of
+/// tunneling through the frozen "static wall" (contact starts wake on any
+/// visible approach; the drift anchor catches even sub-gate intrusions).
+#[test]
+fn slow_kinematic_wakes_sleeping_body_on_contact() {
+ let mut w = World::new();
+
+ let cube = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.5, 0.0)));
+
+ // Approaches at 0.3 m/s: below the 2x-threshold wake gate (0.8 m/s), so
+ // the contact start doesn't wake the cube — the frontier drift anchor
+ // stamped on the new pair must, as the wall keeps pressing in.
+ let wall = w.bodies.insert(
+ RigidBodyBuilder::kinematic_velocity_based()
+ .translation(Vector::new(-2.5, 0.5, 0.0))
+ .linvel(Vector::new(0.3, 0.0, 0.0)),
+ );
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5, 0.5), wall, &mut w.bodies);
+
+ // The cube falls asleep long before the wall arrives (~5s away).
+ for _ in 0..120 {
+ w.step();
+ }
+ assert!(w.bodies[cube].is_sleeping());
+
+ // Step until well past first contact (the drift anchor needs the wall to
+ // actually intrude by a fraction of the pair extent before it fires).
+ for _ in 0..320 {
+ w.step();
+ }
+
+ assert!(
+ !w.bodies[cube].is_sleeping(),
+ "the slow kinematic wall must wake the sleeping cube on contact"
+ );
+ assert!(
+ w.bodies[cube].translation().x > 0.2,
+ "the cube must have been pushed, not tunneled into (x = {})",
+ w.bodies[cube].translation().x
+ );
+}
+
+/// Drum regression: the frontier drift threshold must scale with the *sleeping*
+/// body's size, not the pair's — a huge slow platform (rotating-drum wall) must
+/// wake a small sleeping cube after sub-cube-size motion, not platform-sized.
+#[test]
+fn huge_slow_platform_wakes_small_sleeping_body() {
+ let mut w = World::new();
+
+ let cube = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.5, 0.0)));
+
+ // Huge collider sliding in at 0.3 m/s: below the 2x wake gate, so only the
+ // drift anchor can wake the cube — a pair-extent-scaled threshold would need
+ // ~1.4 units of travel (~4.7s) after contact instead of ~0.09.
+ let platform = w.bodies.insert(
+ RigidBodyBuilder::kinematic_velocity_based()
+ .translation(Vector::new(-11.0, 0.5, 0.0))
+ .linvel(Vector::new(0.3, 0.0, 0.0)),
+ );
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(10.0, 0.4, 10.0),
+ platform,
+ &mut w.bodies,
+ );
+
+ // The cube sleeps long before the platform arrives (gap 0.5 => ~100 steps).
+ for _ in 0..90 {
+ w.step();
+ }
+ assert!(w.bodies[cube].is_sleeping());
+
+ // First contact at ~step 100; with the sleeping-body-scaled threshold the
+ // drift anchor fires after ~0.09 units of intrusion (~18 steps).
+ for _ in 0..60 {
+ w.step();
+ }
+
+ assert!(
+ !w.bodies[cube].is_sleeping(),
+ "the huge slow platform must wake the small sleeping cube shortly after contact"
+ );
+ assert!(
+ w.bodies[cube].translation().x > 0.02,
+ "the cube must have been pushed by the platform (x = {})",
+ w.bodies[cube].translation().x
+ );
+}
+
+/// Frontier load-band wake regression (domino-demo stall): the toppling chain is a
+/// quasi-static leaning wedge below every velocity gate that accrues no relative-pose
+/// drift (drift anchor blind); its sustained normal impulse must wake the sleeper.
+#[test]
+fn slow_dynamic_intruder_wakes_sleeping_body() {
+ let mut w = World::new();
+
+ // A chain of tall dominoes, the first one leaning enough to topple. The
+ // standing ones sleep after ~1s (they are isolated islands), long before
+ // the slow leaning wave reaches them.
+ let n = 10;
+ let mut dominoes = Vec::new();
+ for i in 0..n {
+ let rot = if i == 0 {
+ Rotation::from_rotation_z(-0.2)
+ } else {
+ Rotation::IDENTITY
+ };
+ let handle = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .pose(Pose::from_parts(Vector::new(i as f32 * 0.4, 2.0, 0.0), rot)),
+ );
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.1, 2.0, 1.0),
+ handle,
+ &mut w.bodies,
+ );
+ dominoes.push(handle);
+ }
+
+ let tilt = |bodies: &RigidBodySet, h: RigidBodyHandle| {
+ let up = bodies[h].rotation() * Vector::Y;
+ up.y.clamp(-1.0, 1.0).acos()
+ };
+
+ // ~15 simulated seconds: enough for the (slow) wave to cross the chain.
+ for _ in 0..900 {
+ w.step();
+ }
+
+ for (i, handle) in dominoes.iter().enumerate() {
+ let t = tilt(&w.bodies, *handle);
+ assert!(
+ t > 0.5,
+ "domino {i} did not fall (tilt = {t}): the chain stalled against a sleeping domino"
+ );
+ }
+
+ // And the fallen chain must still be able to fall asleep afterwards.
+ for _ in 0..600 {
+ w.step();
+ }
+ for (i, handle) in dominoes.iter().enumerate() {
+ assert!(
+ w.bodies[*handle].is_sleeping(),
+ "fallen domino {i} never went back to sleep"
+ );
+ }
+}
+
+/// Domino-spiral variant of the load-band wake: the wedge tip already touches
+/// (carrying ~no load) when the standing dominoes fall asleep, so the sleep-time
+/// monitor must stay load-armed (unloaded reference) or the ring never falls.
+#[test]
+fn grazing_wedge_wakes_sleeping_chain() {
+ let mut w = World::new();
+
+ // One ring segment of the domino-spiral demo (the region around its first
+ // pre-tilted "starter"), reproduced verbatim: dominoes 150..=186 of the
+ // spiral, the last one tilted so it leans backward onto the segment.
+ let mut curr_angle = 0.0f32;
+ let mut curr_rad = 10.0f32;
+ let mut dominoes = Vec::new();
+ for i in 0..187 {
+ let perimeter = 2.0 * std::f32::consts::PI * curr_rad;
+ let spacing = 0.4;
+ let prev_angle = curr_angle;
+ curr_angle += 2.0 * std::f32::consts::PI * spacing / perimeter;
+ let (x, z) = curr_angle.sin_cos();
+ let two_pi = 2.0 * std::f32::consts::PI;
+ let nudged = curr_angle % two_pi < prev_angle % two_pi;
+ let tilt = if nudged { 0.2 } else { 0.0 };
+ if i >= 150 {
+ let rot = Rotation::from_rotation_y(curr_angle);
+ let tilt_axis = rot * Vector::Z;
+ let tilt_rot = Rotation::from_axis_angle(tilt_axis, tilt);
+ let handle = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().pose(Pose::from_parts(
+ Vector::new(x * curr_rad, 2.1, z * curr_rad),
+ tilt_rot * rot,
+ )));
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.1, 2.0, 1.0),
+ handle,
+ &mut w.bodies,
+ );
+ dominoes.push(handle);
+ }
+ curr_rad += 1.5 / perimeter;
+ }
+
+ // ~30 simulated seconds: enough for the backward wave to cross the segment.
+ for _ in 0..1800 {
+ w.step();
+ }
+
+ for (i, handle) in dominoes.iter().enumerate() {
+ let up = w.bodies[*handle].rotation() * Vector::Y;
+ let tilt = up.y.clamp(-1.0, 1.0).acos();
+ assert!(
+ tilt > 0.5,
+ "segment domino {i} did not fall (tilt = {tilt}): the wedge froze against a sleeping domino"
+ );
+ }
+}
+
+/// Closing-velocity wake: an impact below the absolute wake gate must still wake
+/// the sleeper *in the contact step* and transfer momentum — otherwise the sleeping
+/// side is an infinite-mass wall and the absorbed momentum is destroyed for good.
+#[test]
+fn sub_gate_impact_wakes_and_transfers_momentum() {
+ let mut w = World::new();
+
+ // Frictionless slider setup so the approach speed is controlled exactly.
+ let ground_friction0 = w
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 70.0)));
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(20.0, 0.5, 10.0).friction(0.0),
+ ground_friction0,
+ &mut w.bodies,
+ );
+
+ let target = w
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 0.5, 70.0)));
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5).friction(0.0),
+ target,
+ &mut w.bodies,
+ );
+ for _ in 0..120 {
+ w.step();
+ }
+ assert!(w.bodies[target].is_sleeping());
+
+ // 0.6 is below the absolute contact-start gate (2x the 0.4 threshold) but
+ // well above the closing-velocity gate (0.25x).
+ let mover = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-3.0, 0.5, 70.0))
+ .linvel(Vector::new(0.6, 0.0, 0.0)),
+ );
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5).friction(0.0),
+ mover,
+ &mut w.bodies,
+ );
+
+ // 2.0 of gap at 0.6/s: contact at ~t = 3.33s; run to 4s.
+ for _ in 0..240 {
+ w.step();
+ }
+
+ let target_v = w.bodies[target].linvel().x;
+ assert!(
+ !w.bodies[target].is_sleeping(),
+ "the sub-gate impact never woke the sleeping target"
+ );
+ assert!(
+ target_v > 0.25,
+ "the impact was partially absorbed by the sleeping target \
+ (target vx = {target_v}, expected ~0.3 as in the awake-vs-awake case)"
+ );
+}
+
+/// Anchoring rule: an eligible region whose only support is an awake body must NOT
+/// sleep partially (frozen mid-air above a live support, it destabilizes on wake);
+/// it needs a fixed/sleeping anchor, or its whole component must sleep at once.
+#[test]
+fn floating_region_does_not_sleep_partially() {
+ let mut w = World::new();
+
+ // Column: never-sleeping cube with three stacked on top. `b` is the awake
+ // halo; `c`/`d` form an eligible region touching only the halo — it must
+ // stay awake instead of freezing mid-air above a live support.
+ let mover = w.add_cube(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 0.5, 0.0))
+ .can_sleep(false),
+ );
+ let b = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 1.5, 0.0)));
+ let c = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 2.5, 0.0)));
+ let d = w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 3.5, 0.0)));
+
+ // Control column: fully sleepable, anchored by contact with the fixed
+ // ground, so partial-island sleep still applies to it normally.
+ let control: Vec<_> = (0..3)
+ .map(|i| {
+ w.add_cube(RigidBodyBuilder::dynamic().translation(Vector::new(
+ 10.0,
+ 0.5 + i as f32,
+ 0.0,
+ )))
+ })
+ .collect();
+
+ for _ in 0..600 {
+ w.step();
+ }
+
+ assert!(!w.bodies[mover].is_sleeping());
+ for (name, handle) in [("b", b), ("c", c), ("d", d)] {
+ assert!(
+ !w.bodies[handle].is_sleeping(),
+ "cube {name} floats above a live support and must not sleep"
+ );
+ }
+ for (i, handle) in control.iter().enumerate() {
+ assert!(
+ w.bodies[*handle].is_sleeping(),
+ "grounded control cube {i} should be asleep"
+ );
+ }
+
+ // The awake stack must still be resting in place.
+ for (i, handle) in [mover, b, c, d].iter().enumerate() {
+ let pos = w.bodies[*handle].translation();
+ let expected_y = 0.5 + i as f32;
+ assert!(
+ (pos.y - expected_y).abs() < 0.1 && pos.x.abs() < 0.1,
+ "stacked cube {i} drifted to {pos:?}"
+ );
+ }
+}
+
+/// The whole-island exception to the anchoring rule: an island with no fixed
+/// anchor at all (free-floating debris) must still be able to sleep — as a
+/// whole. This is the common "settled debris in space" case.
+#[test]
+fn whole_floating_island_sleeps() {
+ let mut w = World::new();
+
+ // Two barely-overlapping gravity-free cubes high in the air: one island,
+ // touching nothing fixed and nothing sleeping.
+ let a = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 30.0, 0.0))
+ .gravity_scale(0.0),
+ );
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5, 0.5), a, &mut w.bodies);
+ let b = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.999, 30.0, 0.0))
+ .gravity_scale(0.0),
+ );
+ w.colliders
+ .insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5, 0.5), b, &mut w.bodies);
+
+ for _ in 0..400 {
+ w.step();
+ }
+
+ assert!(
+ w.bodies[a].is_sleeping() && w.bodies[b].is_sleeping(),
+ "a fully floating island must still sleep as a whole"
+ );
+}
+
+/// Sleep metric: a body drifting at 0.3 length-units/s is *moving* and must
+/// never sleep mid-motion (the old 0.4 default threshold slept it; the default
+/// is now 0.05); a body creeping at 0.03 — below the threshold — may sleep.
+#[test]
+fn slow_drift_does_not_sleep_mid_motion() {
+ let mut w = World::new();
+
+ let drifter = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 30.0, 0.0))
+ .linvel(Vector::new(0.3, 0.0, 0.0))
+ .gravity_scale(0.0),
+ );
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ drifter,
+ &mut w.bodies,
+ );
+
+ let creeper = w.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 30.0, 20.0))
+ .linvel(Vector::new(0.03, 0.0, 0.0))
+ .gravity_scale(0.0),
+ );
+ w.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ creeper,
+ &mut w.bodies,
+ );
+
+ for _ in 0..400 {
+ w.step();
+ }
+
+ assert!(
+ !w.bodies[drifter].is_sleeping(),
+ "a body moving at 0.3 length-units/s must not sleep mid-motion"
+ );
+ assert!(
+ w.bodies[creeper].is_sleeping(),
+ "a body creeping below the sleep threshold may sleep"
+ );
+}
diff --git a/crates/rapier3d/tests/snapshot_portability.rs b/crates/rapier3d/tests/snapshot_portability.rs
new file mode 100644
index 000000000..4c04cede2
--- /dev/null
+++ b/crates/rapier3d/tests/snapshot_portability.rs
@@ -0,0 +1,158 @@
+//! Cross-target snapshot portability.
+//!
+//! A snapshot taken on one machine must be byte-identical to one taken on another, so a
+//! server and a browser client (or two peers in a lockstep game) can exchange and compare
+//! them. That only holds with `enhanced-determinism` — which pins the floating-point
+//! results — plus a stored layout that has no target-dependent encoding left in it. This
+//! test pins the second half: it steps a fixed scene and asserts the snapshot's size and
+//! digest against a committed constant, so the *same* test binary compiled for a different
+//! target must reproduce the *same* number.
+//!
+//! It is a portability test only when it is actually run on more than one target. The CI
+//! `wasm-determinism` job runs it under `wasm32-wasip1` (32-bit pointers, a different
+//! libm, a different codegen backend) against the same golden the native jobs check; run
+//! it locally the same way with:
+//!
+//! ```text
+//! cargo test -p rapier3d --release --features enhanced-determinism,serde-serialize \
+//! --target wasm32-wasip1 --test snapshot_portability
+//! ```
+//!
+//! (with `CARGO_TARGET_WASM32_WASIP1_RUNNER` pointing at wasmtime or an equivalent).
+//!
+//! What it has caught: `usize` sentinels. `RigidBodyIds`' active-set ids and the multibody
+//! `IndexSequence`'s `first_to_remove` all stored `usize::MAX` to mean "none", which
+//! bincode writes as `0xFFFF_FFFF_FFFF_FFFF` on a 64-bit target and `0xFFFF_FFFF` on a
+//! 32-bit one — the same state, different bytes. They are `u32` now.
+#![cfg(all(feature = "serde-serialize", feature = "enhanced-determinism"))]
+
+use rapier3d::prelude::*;
+
+/// Snapshot size in bytes, and its FNV-1a digest. Both, because the size alone localizes a
+/// failure: a differing size means a container's *encoding* changed, an equal size with a
+/// differing digest means the values did.
+const GOLDEN: (usize, u64) = (469_140, 0xe588_545e_de4c_5ccf);
+
+const STEPS: usize = 60;
+
+fn digest(bytes: &[u8]) -> u64 {
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+ for b in bytes {
+ h ^= *b as u64;
+ h = h.wrapping_mul(0x100_0000_01b3);
+ }
+ h
+}
+
+/// Deliberately mixed: box contacts (some settling into sleep), an impulse-joint chain, a
+/// multibody articulation, a sensor and a CCD body — so the snapshot covers the island,
+/// broad-phase, narrow-phase, solver-graph and multibody containers rather than just body
+/// poses.
+fn scene() -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -9.81, 0.0);
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0)),
+ ColliderBuilder::cuboid(20.0, 0.5, 20.0),
+ );
+
+ for i in 0..6 {
+ for j in 0..3 {
+ for k in 0..6 {
+ let jitter = (i as Real * 0.013 + k as Real * 0.017) % 0.05;
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(
+ i as Real * 1.05 - 3.0 + jitter,
+ j as Real * 1.05 + 0.55,
+ k as Real * 1.05 - 3.0 - jitter,
+ )),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ );
+ }
+ }
+ }
+
+ // Impulse-joint chain: exercises the solver's persisted joint coloring.
+ let mut prev =
+ world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(0.0, 7.0, 0.0)));
+ for i in 0..4 {
+ let rb = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.6 * (i + 1) as Real, 7.0, 0.0))
+ .can_sleep(false),
+ );
+ world.insert_collider(ColliderBuilder::ball(0.25), Some(rb));
+ world.insert_impulse_joint(
+ prev,
+ rb,
+ SphericalJointBuilder::new()
+ .local_anchor1(Vector::X * 0.3)
+ .local_anchor2(Vector::X * -0.3),
+ );
+ prev = rb;
+ }
+
+ // Multibody chain: the augmented-mass dof permutation and the topology epoch.
+ let size = 0.4;
+ let mut last =
+ world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(6.0, 4.0, 0.0)));
+ for i in 0..6 {
+ let rb = world.insert_body(RigidBodyBuilder::dynamic().can_sleep(false));
+ world.insert_collider(
+ ColliderBuilder::cuboid(size / 8.0, size / 2.0, size / 8.0).density(1.0),
+ Some(rb),
+ );
+ let joint = SphericalJointBuilder::new()
+ .local_anchor1(Vector::new(
+ 0.0,
+ size / 2.0 * (i != 0) as usize as Real,
+ 0.0,
+ ))
+ .local_anchor2(Vector::new(0.0, -size / 2.0, 0.0))
+ .build()
+ .data;
+ world.insert_multibody_joint(last, rb, joint);
+ last = rb;
+ }
+
+ // A sensor to swing through, and a bullet to sweep across the pile.
+ world.insert_collider(
+ ColliderBuilder::cuboid(3.0, 0.5, 3.0)
+ .translation(Vector::new(0.0, 4.0, 0.0))
+ .sensor(true),
+ None,
+ );
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-8.0, 2.0, 0.5))
+ .linvel(Vector::new(90.0, -20.0, 1.0))
+ .ccd_enabled(true)
+ .can_sleep(false),
+ ColliderBuilder::ball(0.15),
+ );
+
+ world
+}
+
+#[test]
+fn snapshot_is_target_independent() {
+ let mut world = scene();
+ for _ in 0..STEPS {
+ world.step();
+ }
+ let bytes = bincode::serialize(&world).expect("snapshot serialization");
+ let got = (bytes.len(), digest(&bytes));
+
+ assert_eq!(
+ got,
+ GOLDEN,
+ "\nsnapshot differs from the golden on this target \
+ ({}-bit pointers).\n golden: {:?}\n got: {:?}\n\
+ Either a target-dependent encoding crept back into the stored state (a `usize` \
+ sentinel is the usual one), or the simulation itself changed — in which case \
+ re-mint GOLDEN and re-verify it on wasm32 before committing.\n",
+ core::mem::size_of::() * 8,
+ GOLDEN,
+ got,
+ );
+}
diff --git a/crates/rapier3d/tests/snapshot_roundtrip.rs b/crates/rapier3d/tests/snapshot_roundtrip.rs
new file mode 100644
index 000000000..92740db8b
--- /dev/null
+++ b/crates/rapier3d/tests/snapshot_roundtrip.rs
@@ -0,0 +1,449 @@
+//! Snapshot round-trip determinism.
+//!
+//! Serializing a [`PhysicsWorld`], restoring it, and stepping on must continue the
+//! simulation exactly as not snapshotting at all would have — and the proof is the
+//! snapshot itself: at every step after the restore, re-serializing the world must yield
+//! **the same bytes** as the run it resumed produces at that step.
+//!
+//! Byte equality is the contract because it is the only check that sees stored *layout* —
+//! container order, free lists, map iteration order, incrementally-maintained indices.
+//! Derived views (body poses, contact impulses) can agree for many steps while a layout
+//! difference waits to be read; the pyramid scene below was found exactly that way.
+#![cfg(feature = "serde-serialize")]
+
+use rapier3d::prelude::*;
+
+/// Serializes the world — what a user's save file holds: the simulation inputs plus the
+/// seven structures, with the pipeline and CCD workspace left out.
+fn save(world: &PhysicsWorld) -> Vec {
+ bincode::serialize(world).expect("snapshot serialization")
+}
+
+/// Restores into a fresh world, the way loading a save file does.
+fn restore(bytes: &[u8]) -> PhysicsWorld {
+ bincode::deserialize(bytes).expect("snapshot deserialization")
+}
+
+const PART_NAMES: [&str; 9] = [
+ "gravity",
+ "integration_parameters",
+ "islands",
+ "broad_phase",
+ "narrow_phase",
+ "bodies",
+ "colliders",
+ "impulse_joints",
+ "multibody_joints",
+];
+
+/// Per-structure digests, used only to name the culprit when the bytes differ.
+fn parts(world: &PhysicsWorld) -> [u64; 9] {
+ let fnv = |bytes: Vec| {
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+ for b in bytes {
+ h ^= b as u64;
+ h = h.wrapping_mul(0x100_0000_01b3);
+ }
+ h
+ };
+ [
+ fnv(bincode::serialize(&world.gravity).unwrap()),
+ fnv(bincode::serialize(&world.integration_parameters).unwrap()),
+ fnv(bincode::serialize(&world.islands).unwrap()),
+ fnv(bincode::serialize(&world.broad_phase).unwrap()),
+ fnv(bincode::serialize(&world.narrow_phase).unwrap()),
+ fnv(bincode::serialize(&world.bodies).unwrap()),
+ fnv(bincode::serialize(&world.colliders).unwrap()),
+ fnv(bincode::serialize(&world.impulse_joints).unwrap()),
+ fnv(bincode::serialize(&world.multibody_joints).unwrap()),
+ ]
+}
+
+/// One body per line, bit-faithfully (`Debug` on floats round-trips exactly). Only used to
+/// report whether the physics moved when the bytes did.
+fn state(world: &PhysicsWorld) -> Vec {
+ let mut out: Vec<_> = world
+ .bodies
+ .iter()
+ .map(|(h, rb)| {
+ format!(
+ "{} {:?} {:?} {:?} {:?} {}",
+ h.into_raw_parts().0,
+ rb.translation(),
+ rb.rotation(),
+ rb.linvel(),
+ rb.angvel(),
+ rb.is_sleeping()
+ )
+ })
+ .collect();
+ out.sort();
+ out
+}
+
+/// What one step of a run produces: the snapshot bytes, plus diagnostics for the message.
+struct Step {
+ bytes: Vec,
+ parts: [u64; 9],
+ state: Vec,
+}
+
+fn record(world: &PhysicsWorld) -> Step {
+ Step {
+ bytes: save(world),
+ parts: parts(world),
+ state: state(world),
+ }
+}
+
+fn assert_same(step: usize, want: &Step, got: &Step, what: &str) {
+ if want.bytes == got.bytes {
+ return;
+ }
+ let differing: Vec<_> = (0..9)
+ .filter(|i| want.parts[*i] != got.parts[*i])
+ .map(|i| PART_NAMES[i])
+ .collect();
+ let body = want
+ .state
+ .iter()
+ .zip(got.state.iter())
+ .find(|(a, b)| a != b)
+ .map(|(a, b)| format!("\n uninterrupted: {a}\n restored: {b}"))
+ .unwrap_or_else(|| {
+ " none — the stored layout moved without the simulation moving".to_string()
+ });
+ panic!(
+ "{what}: the snapshot is not byte-identical {} step(s) after the restore.\n \
+ sizes {} vs {} bytes\n differing structures: {differing:?}\n \
+ first differing body:{body}",
+ step + 1,
+ want.bytes.len(),
+ got.bytes.len(),
+ );
+}
+
+/// Steps `before`, snapshots, then runs the original world and a restored one in
+/// lockstep for `after` steps, requiring their snapshots to be byte-identical at every
+/// step.
+///
+/// Lockstep rather than record-then-replay so memory stays bounded: the pyramid scene's
+/// snapshot is ~140 MiB, which would be ruinous to keep one copy of per step.
+///
+/// `edit` runs before each step with the absolute step index and that world's own spawn
+/// list, so structural changes are applied identically to both continuations.
+/// `keep_pipeline` restores into the live pipeline (the testbed's pattern) instead of a
+/// fresh one.
+fn check_roundtrip(
+ what: &str,
+ mut world: PhysicsWorld,
+ before: usize,
+ after: usize,
+ keep_pipeline: bool,
+ mut edit: impl FnMut(&mut PhysicsWorld, usize, &mut Vec),
+) {
+ let mut spawned = Vec::new();
+ for step in 0..before {
+ edit(&mut world, step, &mut spawned);
+ world.step();
+ }
+ let snapshot = save(&world);
+ let mut spawned_restored = spawned.clone();
+
+ let mut restored = restore(&snapshot);
+ if keep_pipeline {
+ // The testbed's Save/Restore: the world is replaced, the live pipeline keeps
+ // stepping. Moving them over leaves `world` with fresh ones, which is fine — it is
+ // the *restored* side whose pipeline state is under test here.
+ restored.physics_pipeline = core::mem::take(&mut world.physics_pipeline);
+ restored.ccd_solver = core::mem::take(&mut world.ccd_solver);
+ }
+
+ for step in 0..after {
+ edit(&mut world, before + step, &mut spawned);
+ world.step();
+ edit(&mut restored, before + step, &mut spawned_restored);
+ restored.step();
+ assert_same(step, &record(&world), &record(&restored), what);
+ }
+}
+
+fn no_edits(_: &mut PhysicsWorld, _: usize, _: &mut Vec) {}
+
+/// A pile of boxes on a ground. `sleepy` lets it settle and fall asleep — which switches
+/// the broad phase to SAH re-insertion and the narrow phase to its sparse-awake path —
+/// and adds a few permanent movers so the scene is not simply frozen.
+fn pile(sleepy: bool) -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0)),
+ ColliderBuilder::cuboid(20.0, 0.5, 20.0),
+ );
+
+ for i in 0..8 {
+ for j in 0..3 {
+ for k in 0..8 {
+ let jitter = (i as Real * 0.013 + k as Real * 0.017) % 0.05;
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(
+ i as Real * 1.05 - 4.0 + jitter,
+ j as Real * 1.05 + 0.55,
+ k as Real * 1.05 - 4.0 - jitter,
+ ))
+ .can_sleep(sleepy),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ );
+ }
+ }
+ }
+
+ if sleepy {
+ for i in 0..4 {
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-9.0 + i as Real * 0.7, 3.4, -3.0 + i as Real))
+ .linvel(Vector::new(4.0, 0.0, 0.3 * i as Real))
+ .can_sleep(false),
+ ColliderBuilder::ball(0.4),
+ );
+ }
+ }
+
+ world
+}
+
+/// A hanging chain of spherical joints, plus a sensor it swings through.
+fn with_joints_and_sensor(world: &mut PhysicsWorld) {
+ let anchor =
+ world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(0.0, 7.0, 0.0)));
+ let mut prev = anchor;
+ for i in 0..4 {
+ let rb = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.6 * (i + 1) as Real, 7.0, 0.0))
+ .can_sleep(false),
+ );
+ world.insert_collider(ColliderBuilder::ball(0.25), Some(rb));
+ world.insert_impulse_joint(
+ prev,
+ rb,
+ SphericalJointBuilder::new()
+ .local_anchor1(Vector::X * 0.3)
+ .local_anchor2(Vector::X * -0.3),
+ );
+ prev = rb;
+ }
+ world.insert_collider(
+ ColliderBuilder::cuboid(3.0, 0.5, 3.0)
+ .translation(Vector::new(0.0, 4.0, 0.0))
+ .sensor(true),
+ None,
+ );
+}
+
+/// The `pyramid3` stress scene (`examples3d/stress_tests/pyramid3.rs`), scaled down but
+/// geometrically faithful: shrunken 1.95-cubes on a 2.25 pitch, odd layers brick-offset by
+/// 1.0 so every box rests on four unequal corner patches.
+///
+/// Size is the point: at ~4,900 bodies and ~17,900 pairs it crosses the bulk-path
+/// thresholds (batched leaf updates, chunked pair filtering, blocked contact update, the
+/// solver-graph counting-sort rebuild) that the smaller scenes never reach.
+fn pyramid(height: i32) -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -9.81, 0.0);
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0, 0.0)),
+ ColliderBuilder::cuboid(100.0, 1.0, 100.0),
+ );
+
+ let box_size = 2.0;
+ let h = 0.5 * box_size - 0.025;
+ for i in 0..height {
+ let brick = if i & 1 != 0 { 0.5 * box_size } else { 0.0 };
+ let y = 1.0 + (box_size + 0.5) * i as Real;
+ for j in i / 2..height - (i + 1) / 2 {
+ for k in i / 2..height - (i + 1) / 2 {
+ let x = -(height as Real) + (box_size + 0.25) * j as Real + brick;
+ let z = -(height as Real) + (box_size + 0.25) * k as Real + brick;
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(x, y, z)),
+ ColliderBuilder::cuboid(h, h, h).density(1000.0),
+ );
+ }
+ }
+ }
+ world
+}
+
+/// Every leaf moves every step: the broad phase stays in its bulk in-place-update regime
+/// and the periodic tree optimizer keeps firing.
+#[test]
+fn awake_pile() {
+ check_roundtrip("awake pile", pile(false), 100, 100, false, no_edits);
+}
+
+/// Few leaves move: SAH re-insertion, sparse-awake narrow phase, sleeping islands.
+#[test]
+fn sleeping_pile() {
+ check_roundtrip("sleeping pile", pile(true), 100, 100, false, no_edits);
+}
+
+/// The real thing: 50 layers, ~43k bodies. Slow (minutes) and memory-hungry — its
+/// snapshot alone is well over 100 MiB — so it is `#[ignore]`d and run explicitly:
+///
+/// ```text
+/// cargo test -p rapier3d --release --features serde-serialize \
+/// --test snapshot_roundtrip -- --ignored --nocapture
+/// ```
+#[test]
+#[ignore = "full-size stress scene: ~43k bodies, minutes to run"]
+fn pyramid_stress_scene_full() {
+ let world = pyramid(50);
+ println!("pyramid3: {} bodies", world.bodies.len());
+ check_roundtrip("pyramid50", world, 100, 20, false, no_edits);
+}
+
+/// Fast CCD-enabled bodies. The CCD solver holds a cache that snapshots do not carry, so
+/// this checks that a restore does not depend on it.
+#[test]
+fn ccd_bodies() {
+ let mut world = pile(true);
+ for i in 0..8 {
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(
+ -6.0 + i as Real * 1.5,
+ 4.0 + i as Real * 0.3,
+ 0.0,
+ ))
+ .linvel(Vector::new(90.0, -45.0, 3.0 * i as Real))
+ .ccd_enabled(true)
+ .can_sleep(false),
+ ColliderBuilder::ball(0.15),
+ );
+ }
+ check_roundtrip("ccd", world, 100, 100, false, no_edits);
+}
+
+/// Joints and a sensor: the solver's joint assembly is cached across steps, and a restore
+/// necessarily rebuilds it.
+#[test]
+fn joints_and_sensor() {
+ let mut world = pile(true);
+ with_joints_and_sensor(&mut world);
+ check_roundtrip("joints", world, 100, 100, false, no_edits);
+}
+
+/// An articulation: a chain of multibody joints over a settled pile.
+///
+/// Multibodies exercise two things nothing else does. Their topology epoch is stored by
+/// the narrow phase alongside its solver contact graph, so the two must agree across a
+/// restore or the graph is rebuilt into a different (equally valid) layout — that is what
+/// broke every failing scene in the example sweep (3D articulations, IK, angular motors,
+/// 2D IK). And their chain links populate `PersistentIslands::joint_link_locs`, a hash map
+/// whose iteration order is insertion-history dependent: serializing it as-is made two
+/// snapshots of the same state differ.
+#[test]
+fn multibody_articulation() {
+ let mut world = if std::env::var("MB_AWAKE").is_ok() {
+ pile(false)
+ } else if std::env::var("MB_ALONE").is_ok() {
+ PhysicsWorld::new()
+ } else {
+ pile(true)
+ };
+
+ let segments = 10;
+ let mut last =
+ world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(6.0, 4.0, 0.0)));
+ for i in 0..segments {
+ let size = 0.4;
+ let rb = world.insert_body(RigidBodyBuilder::dynamic().can_sleep(false));
+ world.insert_collider(
+ ColliderBuilder::cuboid(size / 8.0, size / 2.0, size / 8.0).density(1.0),
+ Some(rb),
+ );
+ let joint = SphericalJointBuilder::new()
+ .local_anchor1(Vector::new(
+ 0.0,
+ size / 2.0 * (i != 0) as usize as Real,
+ 0.0,
+ ))
+ .local_anchor2(Vector::new(0.0, -size / 2.0, 0.0))
+ .build()
+ .data;
+ world.insert_multibody_joint(last, rb, joint);
+ last = rb;
+ }
+
+ check_roundtrip("multibody", world, 100, 60, false, no_edits);
+}
+
+/// The testbed's Save/Restore pattern: the world is replaced but the same
+/// [`PhysicsPipeline`] keeps stepping, carrying its workspace across the restore.
+#[test]
+fn restoring_into_a_live_pipeline() {
+ check_roundtrip("live pipeline", pile(true), 100, 100, true, no_edits);
+ check_roundtrip("live pipeline, awake", pile(false), 100, 60, true, no_edits);
+}
+
+/// Structural churn on both sides of the snapshot: pair creation and deletion, island
+/// merges and splits, arena and edge-id recycling — with a joint chain and sensor present.
+#[test]
+fn structural_changes() {
+ let mut world = pile(true);
+ with_joints_and_sensor(&mut world);
+
+ check_roundtrip(
+ "structural",
+ world,
+ 100,
+ 100,
+ false,
+ |world, step, spawned| {
+ if step % 17 == 0 {
+ let rb = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(
+ (step % 5) as Real - 2.0,
+ 6.0 + (step % 3) as Real,
+ (step % 7) as Real - 3.0,
+ ))
+ .can_sleep(false),
+ );
+ world.insert_collider(ColliderBuilder::ball(0.45), Some(rb));
+ spawned.push(rb);
+ }
+ if step % 23 == 0 && !spawned.is_empty() {
+ let rb = spawned.remove(0);
+ world.remove_body(rb);
+ }
+ },
+ );
+}
+
+/// Replacing the pipeline mid-run, with no snapshot involved: [`PhysicsPipeline`] is
+/// documented as holding workspace only, and a restored world always starts from a fresh
+/// one. The joint chain is the point — the solver's joint coloring is cached across steps
+/// and only survives a rebuild because joints persist their color.
+#[test]
+fn replacing_the_pipeline_changes_nothing() {
+ let mut a = pile(true);
+ with_joints_and_sensor(&mut a);
+ let mut b = pile(true);
+ with_joints_and_sensor(&mut b);
+
+ for _ in 0..100 {
+ a.step();
+ b.step();
+ }
+ b.physics_pipeline = PhysicsPipeline::new();
+ b.ccd_solver = CCDSolver::new();
+
+ for step in 0..100 {
+ a.step();
+ b.step();
+ assert_same(step, &record(&a), &record(&b), "fresh pipeline");
+ }
+}
diff --git a/crates/rapier3d/tests/solver_graph_stale_refs.rs b/crates/rapier3d/tests/solver_graph_stale_refs.rs
new file mode 100644
index 000000000..f81294847
--- /dev/null
+++ b/crates/rapier3d/tests/solver_graph_stale_refs.rs
@@ -0,0 +1,110 @@
+//! Regression tests for stale `ContactRef`s left in the persistent solver
+//! contact graph (both found through crashing demos):
+//!
+//! 1. `remove_collider` swap-removes contact-graph nodes/edges and defers the
+//! solver-graph repair to a full rebuild — but `remove_pair` (broad-phase
+//! separations handled later in the same step) used to run its incremental
+//! surgical cleanup unconditionally, dereferencing the now-garbage
+//! `graph_pos` back-references (fountain3 crash: index OOB in
+//! `remove_and_fixup`).
+//!
+//! 2. The contact-update filter early-outs (`pair.clear()` on same-parent,
+//! joint/group filters, soft-CCD swept-AABB miss) destroy a pair's
+//! manifolds — and with them the `graph_pos` back-refs — without pulling
+//! the pair's entries out of the graph (debug_thin_cube_on_mesh3 crash:
+//! "stale ContactRef manifold ordinal" at solve time).
+//!
+//! With debug assertions on (tests always are), the narrow phase's shadow
+//! validator asserts graph == from-scratch selection every step, so these
+//! scenarios fail fast when either hole reopens.
+#![cfg(feature = "dim3")]
+
+use rapier3d::prelude::*;
+
+/// Fountain-style body churn: spawn a body every step, remove the outermost
+/// ones once over the cap. Exercises collider removal (node swap-removes) and
+/// broad-phase pair removal in the same step, plus sleep/wake transitions.
+#[test]
+fn body_churn_keeps_solver_graph_exact() {
+ const MAX_BODIES: usize = 120;
+
+ let mut world = PhysicsWorld::new();
+
+ let rad = 0.5;
+ let rigid_body = RigidBodyBuilder::fixed().translation(Vector::new(0.0, -2.1, 0.0));
+ let collider = ColliderBuilder::cuboid(40.0, 2.1, 40.0);
+ world.insert(rigid_body, collider);
+
+ for step_id in 1..1500usize {
+ world.step();
+
+ let rigid_body = RigidBodyBuilder::dynamic().translation(Vector::new(0.0, 10.0, 0.0));
+ let handle = world.bodies.insert(rigid_body);
+ let collider = match step_id % 3 {
+ 0 => ColliderBuilder::round_cylinder(rad, rad, rad / 10.0),
+ 1 => ColliderBuilder::cone(rad, rad),
+ _ => ColliderBuilder::cuboid(rad, rad, rad),
+ };
+ world
+ .colliders
+ .insert_with_parent(collider, handle, &mut world.bodies);
+
+ if world.bodies.len() > MAX_BODIES {
+ let mut to_remove: Vec<(RigidBodyHandle, Vector)> = world
+ .bodies
+ .iter()
+ .filter(|e| e.1.is_dynamic())
+ .map(|e| (e.0, e.1.translation()))
+ .collect();
+ to_remove.sort_by(|a, b| {
+ (a.1.x.abs() + a.1.z.abs())
+ .partial_cmp(&(b.1.x.abs() + b.1.z.abs()))
+ .unwrap()
+ .reverse()
+ });
+
+ let num_to_remove = to_remove.len().saturating_sub(MAX_BODIES);
+ for (handle, _) in &to_remove[..num_to_remove] {
+ world.bodies.remove(
+ *handle,
+ &mut world.islands,
+ &mut world.colliders,
+ &mut world.impulse_joints,
+ &mut world.multibody_joints,
+ true,
+ );
+ }
+ }
+ }
+}
+
+/// Thin cuboid slammed into a heightfield with soft-CCD: on the bounce, the
+/// swept-AABB early-out clears the (composite, solver-active) pair's
+/// manifolds. Exercises the filter-clear paths of the contact update.
+#[test]
+fn soft_ccd_filter_clear_keeps_solver_graph_exact() {
+ let mut world = PhysicsWorld::new();
+
+ let heights = Array2::repeat(2, 2, 0.0);
+ let collider = ColliderBuilder::heightfield_with_flags(
+ heights,
+ Vector::new(50.0, 1.0, 50.0),
+ HeightFieldFlags::FIX_INTERNAL_EDGES,
+ );
+ world.insert_collider(collider, None);
+
+ let rigid_body = RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 5.0, 0.0))
+ .rotation(Vector::new(0.5, 0.0, 0.5))
+ .linvel(Vector::new(0.0, -100.0, 0.0))
+ .soft_ccd_prediction(10.0);
+ let collider = ColliderBuilder::cuboid(5.0, 0.015, 5.0);
+ let (handle, _) = world.insert(rigid_body, collider);
+
+ for _ in 0..2000 {
+ world.step();
+ }
+
+ // The cube must have come to rest on the heightfield, not tunneled through.
+ assert!(world.bodies[handle].translation().y > -0.5);
+}
diff --git a/crates/rapier3d/tests/speed_cap.rs b/crates/rapier3d/tests/speed_cap.rs
new file mode 100644
index 000000000..1bc6d69bc
--- /dev/null
+++ b/crates/rapier3d/tests/speed_cap.rs
@@ -0,0 +1,184 @@
+//! Tests for the per-step velocity speed cap and the CCD
+//! initial-contact tolerance (the spinner-arm anti-jitter fix).
+
+use rapier3d::prelude::*;
+
+struct Harness {
+ 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 Harness {
+ fn new(gravity: Vector) -> 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,
+ }
+ }
+
+ 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 run(&mut self, steps: usize) {
+ for _ in 0..steps {
+ self.step();
+ }
+ }
+}
+
+/// A body given an absurd linear velocity is clamped to `max_linear_velocity()`.
+#[test]
+fn linear_speed_cap() {
+ let mut h = Harness::new(Vector::ZERO);
+ let cap = h.params.max_linear_velocity();
+ assert!(cap.is_finite(), "linear cap should be finite by default");
+
+ let body = h
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().linvel(Vector::new(10_000.0, 0.0, 0.0)));
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), body, &mut h.bodies);
+
+ h.step();
+
+ let speed = h.bodies[body].linvel().length();
+ assert!(
+ (speed - cap).abs() < 1.0,
+ "linear velocity should be capped to {cap} (got {speed})"
+ );
+}
+
+/// Disabling the cap (`normalized_max_linear_velocity = Real::MAX`) restores
+/// uncapped motion.
+#[test]
+fn linear_cap_disabled() {
+ let mut h = Harness::new(Vector::ZERO);
+ h.params.normalized_max_linear_velocity = Real::MAX;
+
+ let body = h
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().linvel(Vector::new(10_000.0, 0.0, 0.0)));
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), body, &mut h.bodies);
+
+ h.step();
+
+ let speed = h.bodies[body].linvel().length();
+ assert!(
+ speed > 9_000.0,
+ "linear velocity should be uncapped when disabled (got {speed})"
+ );
+}
+
+/// A body given an absurd angular velocity rotates at most ~`MAX_ROTATION`/step
+/// unless `allow_fast_rotation` is set. Uses a ball (isotropic inertia, no
+/// gyroscopic term) so the only thing acting on the spin is the cap.
+#[test]
+fn angular_speed_cap() {
+ // max angular speed ≈ (π/4) * 60 ≈ 47.1 rad/s at the default 60 Hz step.
+ let max_ang = core::f64::consts::FRAC_PI_4 as Real * IntegrationParameters::default().inv_dt();
+
+ // Capped body.
+ let mut h = Harness::new(Vector::ZERO);
+ let capped = h
+ .bodies
+ .insert(RigidBodyBuilder::dynamic().angvel(Vector::new(0.0, 500.0, 0.0)));
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), capped, &mut h.bodies);
+ h.step();
+ let w = h.bodies[capped].angvel().length();
+ assert!(
+ (w - max_ang).abs() < 2.0,
+ "angular velocity should be capped to ~{max_ang} (got {w})"
+ );
+
+ // Bypassed body.
+ let mut h = Harness::new(Vector::ZERO);
+ let fast = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .angvel(Vector::new(0.0, 500.0, 0.0))
+ .allow_fast_rotation(true),
+ );
+ h.colliders
+ .insert_with_parent(ColliderBuilder::ball(0.2), fast, &mut h.bodies);
+ h.step();
+ let w = h.bodies[fast].angvel().length();
+ assert!(
+ w > 400.0,
+ "allow_fast_rotation should bypass the angular cap (got {w})"
+ );
+}
+
+/// A fast body sliding tangentially while in contact with a fixed floor keeps
+/// translating: CCD must skip a pair the discrete solver already owns, instead
+/// of clamping the body's motion toward a standstill (the spinner-arm jitter).
+#[test]
+fn ccd_skips_in_contact_pair() {
+ let mut h = Harness::new(Vector::new(0.0, -9.81, 0.0));
+
+ // Frictionless fixed floor.
+ let floor = h.bodies.insert(RigidBodyBuilder::fixed());
+ h.colliders.insert_with_parent(
+ ColliderBuilder::cuboid(50.0, 0.05, 50.0).friction(0.0),
+ floor,
+ &mut h.bodies,
+ );
+
+ // A ball resting on the floor (slight overlap → persistent contact),
+ // sliding fast along +X. Fast enough to be CCD-active, under the linear cap.
+ let ball = h.bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-5.0, 0.24, 0.0))
+ .linvel(Vector::new(50.0, 0.0, 0.0)),
+ );
+ h.colliders.insert_with_parent(
+ ColliderBuilder::ball(0.2).friction(0.0),
+ ball,
+ &mut h.bodies,
+ );
+
+ h.run(30);
+
+ // ~50 m/s over ~0.5 s ⇒ it should have slid far along +X, not been frozen.
+ let x = h.bodies[ball].translation().x;
+ assert!(
+ x > 10.0,
+ "in-contact body was clamped by CCD instead of sliding freely (x = {x})"
+ );
+ assert!(
+ h.bodies[ball].is_ccd_active(),
+ "the sliding body should be CCD-active (otherwise the test proves nothing)"
+ );
+}
diff --git a/crates/rapier3d/tests/substep_chain_high_mass_ratio.rs b/crates/rapier3d/tests/substep_chain_high_mass_ratio.rs
new file mode 100644
index 000000000..224f519cd
--- /dev/null
+++ b/crates/rapier3d/tests/substep_chain_high_mass_ratio.rs
@@ -0,0 +1,161 @@
+//! Regression test for `RigidBody::additional_solver_iterations` as extra
+//! substeps: a hanging chain with a very heavy end ball (1000:1 mass ratio) —
+//! the `debug_chain_high_mass_ratio3` testbed scene — must hold together much
+//! more tightly with extra substeps than without.
+//!
+//! The old semantics (extra flat velocity-PGS sweeps over a frozen bias) had
+//! no effect here: the velocity solve converges after a few sweeps, while the
+//! visible stretch is a property of the once-per-substep soft position bias.
+//! Extra *substeps* re-derive the bias at a smaller dt and re-integrate
+//! positions, which is what actually closes the gap.
+
+use rapier3d::pipeline::PhysicsWorld;
+use rapier3d::prelude::*;
+
+/// Builds the chain scene; every body requests `extra` additional iterations.
+fn chain_world(extra: usize) -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+
+ let num = 17;
+ let rad = 0.2;
+ let mut prev_handle: Option = None;
+
+ for i in 0..num {
+ let fi = i as Real;
+ let status = if i == 0 {
+ RigidBodyType::Fixed
+ } else {
+ RigidBodyType::Dynamic
+ };
+ let ball_rad = if i == num - 1 { rad * 10.0 } else { rad };
+ let shift1 = rad * 1.1;
+ let shift2 = ball_rad + rad * 0.1;
+ let z = if i == 0 {
+ 0.0
+ } else {
+ (fi - 1.0) * 2.0 * shift1 + shift1 + shift2
+ };
+
+ let rigid_body = RigidBodyBuilder::new(status)
+ .translation(Vector::new(0.0, 0.0, z))
+ .can_sleep(false)
+ .additional_solver_iterations(extra);
+ let collider = ColliderBuilder::ball(ball_rad);
+ let (child_handle, _) = world.insert(rigid_body, collider);
+
+ if let Some(parent_handle) = prev_handle {
+ let joint = if i == 1 {
+ SphericalJointBuilder::new().local_anchor2(Vector::new(0.0, 0.0, -shift1 * 2.0))
+ } else {
+ SphericalJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, 0.0, shift1))
+ .local_anchor2(Vector::new(0.0, 0.0, -shift2))
+ };
+ world.insert_impulse_joint(parent_handle, child_handle, joint);
+ }
+ prev_handle = Some(child_handle);
+ }
+
+ world
+}
+
+/// Max world-space separation of the two anchor points over all joints — the
+/// visible "chain stretch" constraint violation.
+fn max_joint_stretch(world: &PhysicsWorld) -> Real {
+ let mut max = 0.0f32;
+ for (_, joint) in world.impulse_joints.iter() {
+ let rb1 = &world.bodies[joint.body1()];
+ let rb2 = &world.bodies[joint.body2()];
+ let a1 = rb1.position() * joint.data.local_frame1.translation;
+ let a2 = rb2.position() * joint.data.local_frame2.translation;
+ max = max.max((a1 - a2).length());
+ }
+ max
+}
+
+fn peak_stretch(extra: usize, steps: usize) -> Real {
+ let mut world = chain_world(extra);
+ let mut peak = 0.0f32;
+ for _ in 0..steps {
+ world.step();
+ peak = peak.max(max_joint_stretch(&world));
+ }
+ peak
+}
+
+/// Locality of the per-group substep ring: in a mixed scene (a big default
+/// pile + one elevated chain), only the chain's group runs the extra
+/// substeps. Stepping must be substantially cheaper than the same scene with
+/// everything elevated (the global-elevation cost model).
+///
+/// Timing-sensitive: run with `--ignored --test-threads=1`.
+#[test]
+#[ignore = "timing comparison; run with --ignored --test-threads=1"]
+fn substep_groups_locality_timing() {
+ fn pile_world(pile_extra: usize, chain_extra: usize) -> PhysicsWorld {
+ let mut world = chain_world(chain_extra);
+ // Ground + a 10x8 wall of boxes, far from the chain.
+ let ground = RigidBodyBuilder::fixed().translation(Vector::new(50.0, -1.0, 0.0));
+ world.insert(ground, ColliderBuilder::cuboid(30.0, 0.5, 30.0));
+ for i in 0..10 {
+ for j in 0..8 {
+ let body = RigidBodyBuilder::dynamic()
+ .translation(Vector::new(40.0 + i as Real * 1.01, j as Real * 1.01, 0.0))
+ .can_sleep(false)
+ .additional_solver_iterations(pile_extra);
+ world.insert(body, ColliderBuilder::cuboid(0.5, 0.5, 0.5));
+ }
+ }
+ world
+ }
+
+ fn time_steps(world: &mut PhysicsWorld, steps: usize) -> f64 {
+ let start = std::time::Instant::now();
+ for _ in 0..steps {
+ world.step();
+ }
+ start.elapsed().as_secs_f64()
+ }
+
+ let warmup = 30;
+ let steps = 120;
+
+ // A: only the chain elevated -> the pile's group stays at base substeps.
+ let mut grouped = pile_world(0, 16);
+ time_steps(&mut grouped, warmup);
+ let t_grouped = time_steps(&mut grouped, steps);
+
+ // B: everything elevated -> the whole scene pays the extra substeps.
+ let mut global = pile_world(16, 16);
+ time_steps(&mut global, warmup);
+ let t_global = time_steps(&mut global, steps);
+
+ println!(
+ "mixed-scene step time: per-group={:.3}ms, all-elevated={:.3}ms ({:.2}x)",
+ t_grouped * 1e3 / steps as f64,
+ t_global * 1e3 / steps as f64,
+ t_global / t_grouped,
+ );
+ assert!(
+ t_grouped < t_global * 0.7,
+ "per-group substeps did not pay off: grouped={t_grouped}s vs global={t_global}s"
+ );
+}
+
+#[test]
+fn substep_chain_high_mass_ratio_stretch() {
+ // 5 simulated seconds: covers the initial swing (the worst transient).
+ let steps = 300;
+ let baseline = peak_stretch(0, steps);
+ let elevated = peak_stretch(16, steps);
+
+ println!("chain peak stretch: baseline={baseline}, extra-substeps={elevated}");
+
+ // The elevated chain must hold together at least 4x more tightly than the
+ // baseline. (Measured: substeps 4 -> 20 shrinks the peak stretch far more
+ // than that; the loose factor keeps the test robust across platforms.)
+ assert!(
+ elevated < baseline / 4.0,
+ "extra substeps did not stiffen the chain: baseline={baseline}, elevated={elevated}"
+ );
+}
diff --git a/crates/rapier3d/tests/thread_count_determinism.rs b/crates/rapier3d/tests/thread_count_determinism.rs
new file mode 100644
index 000000000..a91064893
--- /dev/null
+++ b/crates/rapier3d/tests/thread_count_determinism.rs
@@ -0,0 +1,252 @@
+//! The `parallel` feature's determinism contract: results must be bitwise
+//! identical for any rayon pool size (and therefore identical across machines
+//! with different core counts). See `LAYOUT_REF_WORKERS` and
+//! `NarrowPhase::apply_pair_transitions` for the mechanisms under test.
+#![cfg(feature = "parallel")]
+
+use rapier3d::geometry::ContactPair;
+use rapier3d::pipeline::EventHandler;
+use rapier3d::prelude::*;
+use std::sync::Mutex;
+
+/// Records the collision-event sequence: the emission ORDER is part of the
+/// determinism contract (it is far more sensitive to scheduling leaks than the
+/// body states, which are often permutation-invariant).
+#[derive(Default)]
+struct EventLog(Mutex>);
+
+impl EventHandler for EventLog {
+ fn handle_collision_event(
+ &self,
+ _bodies: &RigidBodySet,
+ _colliders: &ColliderSet,
+ event: CollisionEvent,
+ _contact_pair: Option<&ContactPair>,
+ ) {
+ self.0.lock().unwrap().push(format!("{event:?}"));
+ }
+
+ fn handle_contact_force_event(
+ &self,
+ _dt: Real,
+ _bodies: &RigidBodySet,
+ _colliders: &ColliderSet,
+ _contact_pair: &ContactPair,
+ _total_force_magnitude: Real,
+ ) {
+ }
+}
+
+/// One bit-faithful snapshot per body, sorted by handle. Float `Debug` output is
+/// round-trip exact, so identical strings ⟺ identical bit patterns (modulo NaN
+/// payloads, which must not occur here anyway).
+fn snapshot(bodies: &RigidBodySet) -> Vec<(u32, String)> {
+ let mut out: Vec<_> = bodies
+ .iter()
+ .map(|(h, rb)| {
+ (
+ h.into_raw_parts().0,
+ format!(
+ "{:?} {:?} {:?} {:?} {}",
+ rb.translation(),
+ rb.rotation(),
+ rb.linvel(),
+ rb.angvel(),
+ rb.is_sleeping()
+ ),
+ )
+ })
+ .collect();
+ out.sort_by_key(|e| e.0);
+ out
+}
+
+/// FNV-1a over the serialized broad-phase + narrow-phase.
+///
+/// The body snapshots above are float state; this is stored *order and layout* —
+/// map iteration order, per-collider adjacency lists, graph edge order — which is
+/// where a work-distribution leak shows up first, often a few steps before it moves
+/// a float. Sampled rather than computed every step: serializing both structures is
+/// far more expensive than the step itself in a debug build.
+#[cfg(feature = "serde-serialize")]
+fn phase_checksum(bf: &BroadPhaseBvh, nf: &NarrowPhase) -> u64 {
+ let mut h: u64 = 0xcbf29ce484222325;
+ let mut eat = |bytes: &[u8]| {
+ for b in bytes {
+ h ^= *b as u64;
+ h = h.wrapping_mul(0x100000001b3);
+ }
+ };
+ eat(&bincode::serialize(bf).expect("broad-phase serialization"));
+ eat(&bincode::serialize(nf).expect("narrow-phase serialization"));
+ h
+}
+
+#[cfg(not(feature = "serde-serialize"))]
+fn phase_checksum(_bf: &BroadPhaseBvh, _nf: &NarrowPhase) -> u64 {
+ 0
+}
+
+/// Steps a moderately chaotic scene (pile + joint chain + sensor + mid-run
+/// collider removal and wake impulse, to exercise pair creation/deletion,
+/// touching transitions, sleeping and island edits) and records a snapshot
+/// after every step.
+type StepRecord = (Vec<(u32, String)>, Vec, u64);
+
+fn run_sim(num_threads: usize, num_steps: usize) -> Vec {
+ let pool = rapier3d::rayon::ThreadPoolBuilder::new()
+ .num_threads(num_threads)
+ .build()
+ .unwrap();
+
+ let mut bodies = RigidBodySet::new();
+ let mut colliders = ColliderSet::new();
+ let mut impulse_joints = ImpulseJointSet::new();
+ let mut multibody_joints = MultibodyJointSet::new();
+ let mut pipeline = PhysicsPipeline::new();
+ let mut bf = BroadPhaseBvh::new();
+ let mut nf = NarrowPhase::new();
+ let mut islands = IslandManager::new();
+ let mut ccd = CCDSolver::new();
+ let params = IntegrationParameters::default();
+ let gravity = Vector::Y * -9.81;
+
+ let ground = bodies.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0)));
+ colliders.insert_with_parent(
+ ColliderBuilder::cuboid(20.0, 0.5, 20.0),
+ ground,
+ &mut bodies,
+ );
+
+ // A 12x3x12 jittered pile: enough same-color contacts that some colors clear
+ // `min_color_chunks` and the parallel color stages genuinely engage.
+ let mut cubes = Vec::new();
+ for i in 0..12 {
+ for j in 0..3 {
+ for k in 0..12 {
+ let jitter = (i as f32 * 0.013 + k as f32 * 0.017) % 0.05;
+ let pos = Vector::new(
+ i as f32 * 1.05 - 6.0 + jitter,
+ j as f32 * 1.05 + 0.55,
+ k as f32 * 1.05 - 6.0 - jitter,
+ );
+ let rb = bodies.insert(RigidBodyBuilder::dynamic().translation(pos));
+ colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5)
+ .active_events(ActiveEvents::COLLISION_EVENTS),
+ rb,
+ &mut bodies,
+ );
+ cubes.push(rb);
+ }
+ }
+ }
+
+ // A swinging chain of spherical joints anchored above the pile.
+ let anchor = bodies.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.0, 8.0, 0.0)));
+ let mut prev = anchor;
+ for i in 0..4 {
+ let rb = bodies.insert(RigidBodyBuilder::dynamic().translation(Vector::new(
+ 0.6 * (i + 1) as f32,
+ 8.0,
+ 0.0,
+ )));
+ colliders.insert_with_parent(ColliderBuilder::ball(0.25), rb, &mut bodies);
+ let joint = SphericalJointBuilder::new()
+ .local_anchor1(Vector::X * 0.3)
+ .local_anchor2(Vector::X * -0.3);
+ impulse_joints.insert(prev, rb, joint, true);
+ prev = rb;
+ }
+
+ // A sensor volume the pile settles through (intersection events path).
+ colliders.insert(
+ ColliderBuilder::cuboid(3.0, 0.5, 3.0)
+ .translation(Vector::new(0.0, 1.0, 0.0))
+ .sensor(true)
+ .active_events(ActiveEvents::COLLISION_EVENTS),
+ );
+
+ let events = EventLog::default();
+ let mut snapshots = Vec::new();
+ for step in 0..num_steps {
+ if step == 60 {
+ // Remove a cube mid-pile: broad-phase pair deletions + island edits.
+ bodies.remove(
+ cubes[7],
+ &mut islands,
+ &mut colliders,
+ &mut impulse_joints,
+ &mut multibody_joints,
+ true,
+ );
+ }
+ if step == 100 {
+ // Wake part of the (possibly sleeping) pile.
+ bodies[cubes[20]].apply_impulse(Vector::new(2.0, 3.0, 1.0), true);
+ }
+
+ pool.install(|| {
+ pipeline.step(
+ gravity,
+ ¶ms,
+ &mut islands,
+ &mut bf,
+ &mut nf,
+ &mut bodies,
+ &mut colliders,
+ &mut impulse_joints,
+ &mut multibody_joints,
+ &mut ccd,
+ &(),
+ &events,
+ );
+ });
+ let step_events = core::mem::take(&mut *events.0.lock().unwrap());
+ let checksum = if step % 10 == 0 || step + 1 == num_steps {
+ phase_checksum(&bf, &nf)
+ } else {
+ 0
+ };
+ snapshots.push((snapshot(&bodies), step_events, checksum));
+ }
+ snapshots
+}
+
+#[test]
+fn identical_results_for_any_worker_count() {
+ const STEPS: usize = 160;
+ let base = run_sim(1, STEPS);
+ for num_threads in [2, 8] {
+ let run = run_sim(num_threads, STEPS);
+ for step in 0..STEPS {
+ let ((base_state, base_events, base_sum), (run_state, run_events, run_sum)) =
+ (&base[step], &run[step]);
+ assert_eq!(
+ base_events, run_events,
+ "event-sequence divergence at step {step}, {num_threads} threads vs 1"
+ );
+ assert_eq!(
+ base_sum, run_sum,
+ "broad/narrow-phase checksum divergence at step {step}, {num_threads} threads \
+ vs 1: the two pool sizes stored the same simulation in a different layout"
+ );
+ if base_state != run_state {
+ let (h, s) = base_state
+ .iter()
+ .zip(run_state.iter())
+ .find(|(a, b)| a != b)
+ .map(|(a, b)| {
+ (
+ a.0,
+ format!("1 thread: {}\n{num_threads} threads: {}", a.1, b.1),
+ )
+ })
+ .unwrap();
+ panic!(
+ "state divergence at step {step}, body {h}, {num_threads} threads vs 1:\n{s}"
+ );
+ }
+ }
+ }
+}
diff --git a/crates/rapier3d/tests/unsync_callbacks.rs b/crates/rapier3d/tests/unsync_callbacks.rs
new file mode 100644
index 000000000..a4f290016
--- /dev/null
+++ b/crates/rapier3d/tests/unsync_callbacks.rs
@@ -0,0 +1,191 @@
+//! The `unsync-callbacks` contract: a `PhysicsHooks` / `EventHandler` that is **not** `Sync`
+//! compiles and runs, whatever `parallel` is doing.
+//!
+//! Most of the value here is that this file compiles at all: the hook and the handler
+//! below hold a `Cell`, so `Sync` is not satisfied and any `Sync` bound creeping back onto
+//! either trait turns into a build error. The assertions then confirm the callbacks were
+//! actually reached rather than silently skipped.
+//!
+//! Run with `--features parallel,unsync-callbacks` for the combination that matters — the
+//! engine has to keep the callbacks on the thread driving the step while the solver stays
+//! threaded.
+#![cfg(feature = "unsync-callbacks")]
+
+use rapier3d::prelude::*;
+use std::cell::Cell;
+
+/// Counts its own invocations through a `Cell`, which is `Send` but not `Sync`.
+#[derive(Default)]
+struct UnsyncHooks {
+ filtered: Cell,
+}
+
+impl PhysicsHooks for UnsyncHooks {
+ fn filter_contact_pair(&self, _: &PairFilterContext) -> Option {
+ self.filtered.set(self.filtered.get() + 1);
+ Some(SolverFlags::COMPUTE_IMPULSES)
+ }
+}
+
+#[derive(Default)]
+struct UnsyncEvents {
+ collisions: Cell,
+}
+
+impl EventHandler for UnsyncEvents {
+ fn handle_collision_event(
+ &self,
+ _bodies: &RigidBodySet,
+ _colliders: &ColliderSet,
+ _event: CollisionEvent,
+ _contact_pair: Option<&ContactPair>,
+ ) {
+ self.collisions.set(self.collisions.get() + 1);
+ }
+
+ fn handle_contact_force_event(
+ &self,
+ _dt: Real,
+ _bodies: &RigidBodySet,
+ _colliders: &ColliderSet,
+ _contact_pair: &ContactPair,
+ _total_force_magnitude: Real,
+ ) {
+ }
+}
+
+/// Enough falling boxes to push the step onto its parallel paths where `parallel` is on.
+fn scene() -> (RigidBodySet, ColliderSet, IslandManager) {
+ let mut bodies = RigidBodySet::new();
+ let mut colliders = ColliderSet::new();
+
+ colliders.insert(
+ ColliderBuilder::cuboid(100.0, 1.0, 100.0)
+ .translation(Vector::new(0.0, -1.0, 0.0))
+ .active_hooks(ActiveHooks::FILTER_CONTACT_PAIRS)
+ .active_events(ActiveEvents::COLLISION_EVENTS)
+ .build(),
+ );
+
+ for i in 0..8 {
+ for j in 0..8 {
+ for k in 0..8 {
+ let rb = bodies.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(
+ i as Real * 1.1 - 4.0,
+ j as Real * 1.1 + 1.0,
+ k as Real * 1.1 - 4.0,
+ ))
+ .build(),
+ );
+ colliders.insert_with_parent(
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5)
+ .active_hooks(ActiveHooks::FILTER_CONTACT_PAIRS)
+ .active_events(ActiveEvents::COLLISION_EVENTS)
+ .build(),
+ rb,
+ &mut bodies,
+ );
+ }
+ }
+ }
+
+ (bodies, colliders, IslandManager::new())
+}
+
+#[test]
+fn non_sync_hooks_and_events_are_invoked() {
+ let (mut bodies, mut colliders, mut islands) = scene();
+ let mut pipeline = PhysicsPipeline::new();
+ let mut broad_phase = BroadPhaseBvh::new();
+ let mut narrow_phase = NarrowPhase::new();
+ let mut impulse_joints = ImpulseJointSet::new();
+ let mut multibody_joints = MultibodyJointSet::new();
+ let mut ccd_solver = CCDSolver::new();
+ let params = IntegrationParameters::default();
+
+ let hooks = UnsyncHooks::default();
+ let events = UnsyncEvents::default();
+
+ for _ in 0..60 {
+ pipeline.step(
+ Vector::new(0.0, -9.81, 0.0),
+ ¶ms,
+ &mut islands,
+ &mut broad_phase,
+ &mut narrow_phase,
+ &mut bodies,
+ &mut colliders,
+ &mut impulse_joints,
+ &mut multibody_joints,
+ &mut ccd_solver,
+ &hooks,
+ &events,
+ );
+ }
+
+ assert!(
+ hooks.filtered.get() > 0,
+ "filter_contact_pair was never called"
+ );
+ assert!(
+ events.collisions.get() > 0,
+ "handle_collision_event was never called"
+ );
+}
+
+/// A dedicated thread pool is still usable under `unsync-callbacks` — the caller enters it
+/// themselves, since the pipeline's own pool API is compiled out along with the bound.
+///
+/// `ThreadPool::install` runs its body on the calling thread whenever that thread is already
+/// a member of the same pool (rayon's `Registry::in_worker` only migrates for outsiders), and
+/// every nested rayon construct then targets that pool. So stepping from inside the pool
+/// gives the pool's threads *and* keeps the callbacks on one known thread. The callback is
+/// built inside the closure because `install` needs its body to be `Send`, which a
+/// thread-affine callback is not.
+#[cfg(feature = "parallel")]
+#[test]
+fn dedicated_pool_is_usable_when_the_caller_enters_it() {
+ let pool = rapier3d::rayon::ThreadPoolBuilder::new()
+ .num_threads(3)
+ .build()
+ .unwrap();
+
+ let (hook_calls, workers) = pool.install(|| {
+ // Built here: nothing non-`Send` crosses into the pool.
+ let hooks = UnsyncHooks::default();
+ let events = UnsyncEvents::default();
+ let (mut bodies, mut colliders, mut islands) = scene();
+ let mut pipeline = PhysicsPipeline::new();
+ let mut broad_phase = BroadPhaseBvh::new();
+ let mut narrow_phase = NarrowPhase::new();
+ let mut impulse_joints = ImpulseJointSet::new();
+ let mut multibody_joints = MultibodyJointSet::new();
+ let mut ccd_solver = CCDSolver::new();
+ let params = IntegrationParameters::default();
+
+ for _ in 0..30 {
+ pipeline.step(
+ Vector::new(0.0, -9.81, 0.0),
+ ¶ms,
+ &mut islands,
+ &mut broad_phase,
+ &mut narrow_phase,
+ &mut bodies,
+ &mut colliders,
+ &mut impulse_joints,
+ &mut multibody_joints,
+ &mut ccd_solver,
+ &hooks,
+ &events,
+ );
+ }
+ // Reports the pool this thread currently belongs to — which is what we want to
+ // observe: the step's parallel regions went to our pool, not the global one.
+ (hooks.filtered.get(), rapier3d::rayon::current_num_threads())
+ });
+
+ assert_eq!(workers, 3, "the step ran against the global pool, not ours");
+ assert!(hook_calls > 0, "filter_contact_pair was never called");
+}
diff --git a/crates/rapier3d/tests/whole_island_sleep.rs b/crates/rapier3d/tests/whole_island_sleep.rs
new file mode 100644
index 000000000..b400b8b71
--- /dev/null
+++ b/crates/rapier3d/tests/whole_island_sleep.rs
@@ -0,0 +1,316 @@
+//! Tests for whole-island sleep (the only sleep behavior): an
+//! island only sleeps once *all* of its bodies are sleep-eligible, and it
+//! sleeps — and wakes — as a single unit.
+
+use rapier3d::pipeline::PhysicsWorld;
+use rapier3d::prelude::*;
+
+/// Inserts a vertical stack of `num` unit cubes resting on the ground, at
+/// horizontal offset `x`. Returns the body handles, bottom first.
+fn insert_stack(world: &mut PhysicsWorld, x: Real, num: usize) -> Vec {
+ (0..num)
+ .map(|i| {
+ let body =
+ RigidBodyBuilder::dynamic().translation(Vector::new(x, 0.5 + i as Real * 1.0, 0.0));
+ world.insert(body, ColliderBuilder::cuboid(0.5, 0.5, 0.5)).0
+ })
+ .collect()
+}
+
+fn world_with_ground() -> PhysicsWorld {
+ let mut world = PhysicsWorld::new();
+ let ground = RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5, 0.0));
+ world.insert(ground, ColliderBuilder::cuboid(100.0, 0.5, 100.0));
+ world
+}
+
+fn num_sleeping(world: &PhysicsWorld) -> usize {
+ world
+ .rigid_bodies()
+ .filter(|(_, rb)| rb.is_dynamic() && rb.is_sleeping())
+ .count()
+}
+
+/// More than enough steps for a pre-settled stack to become sleep-eligible
+/// (default `time_until_sleep` is 1s = 60 steps).
+const SETTLE_STEPS: usize = 240;
+
+/// A `can_sleep(false)` body atop a stack must keep the *whole* stack awake.
+/// Once removed, the stack must sleep even though no body newly becomes eligible
+/// at that point (the re-queue path); waking one body wakes the whole island.
+#[test]
+fn whole_island_blocks_partial_sleep() {
+ let mut world = world_with_ground();
+
+ let stack = insert_stack(&mut world, 0.0, 6);
+ let restless = world
+ .insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 6.5, 0.0))
+ .can_sleep(false),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ )
+ .0;
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(
+ num_sleeping(&world),
+ 0,
+ "no body of an island containing a non-sleeping body may sleep"
+ );
+
+ // The non-sleeper leaves the island: the stack must now fall asleep even
+ // though none of its bodies newly becomes eligible (they already were).
+ world.remove_body(restless);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(
+ num_sleeping(&world),
+ stack.len(),
+ "the whole stack must sleep"
+ );
+
+ // Waking any body wakes the island as a unit.
+ world.wake_up(stack[0], true);
+ assert_eq!(num_sleeping(&world), 0, "the island must wake as a unit");
+}
+
+/// The same scene under the *default* configuration: whole-island sleep is
+/// the only behavior now (partial-island/Region sleep was removed), so no
+/// body of the stack may sleep while the non-sleeping body touches it.
+#[test]
+fn default_never_sleeps_partial_islands() {
+ let mut world = world_with_ground();
+
+ insert_stack(&mut world, 0.0, 6);
+ let restless = world
+ .insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 6.5, 0.0))
+ .can_sleep(false),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ )
+ .0;
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(
+ num_sleeping(&world),
+ 0,
+ "no body of an island containing a non-sleeping body may sleep"
+ );
+ assert!(!world.bodies[restless].is_sleeping());
+}
+
+/// Sleep re-attempts are event-driven: a kinematic body grazing the stack (no
+/// motion transfer) taints the island but never becomes eligible itself, so when
+/// its contact stops, the contact-stop re-queue is the only path back to sleep.
+#[test]
+fn whole_island_sleeps_after_mover_departs() {
+ let mut world = world_with_ground();
+
+ let stack = insert_stack(&mut world, 0.0, 4);
+ // A kinematic cube grazing the side of the stack's bottom cube (1mm
+ // overlap: keeps the contact active without meaningfully pushing the
+ // stack), sliding along that face fast enough to never be sleep-eligible.
+ let mover = world
+ .insert(
+ RigidBodyBuilder::kinematic_velocity_based()
+ .translation(Vector::new(0.999, 0.5, -0.9))
+ .linvel(Vector::new(0.0, 0.0, 0.8)),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ )
+ .0;
+
+ // The stack is eligible after ~60 steps; the mover stays in contact until
+ // it slid past the face (|z| > 1), around step 142.
+ for _ in 0..90 {
+ world.step();
+ }
+ assert_eq!(
+ num_sleeping(&world),
+ 0,
+ "the sliding kinematic body in contact with the island must keep it awake"
+ );
+
+ // Keep sliding: the contact stops while the kinematic body keeps moving
+ // forever (no eligibility transition ever fires, for anything).
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(
+ num_sleeping(&world),
+ stack.len(),
+ "the stack must sleep once the kinematic mover slid out of contact"
+ );
+ assert!(!world.bodies[mover].is_sleeping());
+}
+
+/// Inserts a grounded cube jointed to a "taint anchor": a never-sleeping,
+/// collider-less, gravity-free floating body — eternally non-eligible yet
+/// motionless, so only the explicit joint-edit re-queues can ever heal the cube.
+fn insert_cube_jointed_to_floating_taint(
+ world: &mut PhysicsWorld,
+) -> (RigidBodyHandle, RigidBodyHandle, ImpulseJointHandle) {
+ let cube = insert_stack(world, 0.0, 1)[0];
+ let restless = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(5.0, 3.0, 0.0))
+ .additional_mass(1.0)
+ .gravity_scale(0.0)
+ .can_sleep(false),
+ );
+ // Anchors coincide at the floating body's position: the joint starts (and
+ // stays) at zero error, so it transmits no force and nothing ever moves.
+ let joint = world.impulse_joints.insert(
+ cube,
+ restless,
+ SphericalJointBuilder::new().local_anchor1(Vector::new(5.0, 2.5, 0.0)),
+ true,
+ );
+ (cube, restless, joint)
+}
+
+/// A joint removed with `wake_up = false` silently deletes the edge that kept
+/// a never-sleeping body attached to the island: the leftover body must still
+/// fall asleep (the joint-removal re-queue), without anything being woken.
+#[test]
+fn whole_island_sleeps_after_joint_removed_without_wake() {
+ let mut world = world_with_ground();
+
+ let (cube, restless, joint) = insert_cube_jointed_to_floating_taint(&mut world);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(
+ num_sleeping(&world),
+ 0,
+ "the joint to a never-sleeping body must keep the cube awake"
+ );
+
+ // Remove the joint WITHOUT waking: no eligibility transition ever fires
+ // for the cube, only the removal re-queue can put it to sleep.
+ world.impulse_joints.remove(joint, false);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(
+ world.bodies[cube].is_sleeping(),
+ "the cube must sleep once the joint to the never-sleeping body is removed"
+ );
+ assert!(!world.bodies[restless].is_sleeping());
+}
+
+/// Turning a collider-less joint partner into a fixed body removes its taint
+/// without touching any contact or removing the joint: only the type-change
+/// re-queue of joint partners can let the cube sleep.
+#[test]
+fn whole_island_sleeps_after_joint_partner_turned_fixed() {
+ let mut world = world_with_ground();
+
+ let (cube, restless, _joint) = insert_cube_jointed_to_floating_taint(&mut world);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(num_sleeping(&world), 0);
+
+ // Freeze the floating body: fixed bodies neither sleep nor taint, and a
+ // collider-less body has no contacts through which anything gets woken.
+ world.bodies[restless].set_body_type(RigidBodyType::Fixed, false);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(
+ world.bodies[cube].is_sleeping(),
+ "the cube must sleep once its never-sleeping joint partner is frozen to fixed"
+ );
+}
+
+/// Turning the island's mover into a fixed body removes its taint without any
+/// eligibility transition or edge removal: the type-change re-queue must let
+/// the rest of the island sleep.
+#[test]
+fn whole_island_sleeps_after_mover_turned_fixed() {
+ let mut world = world_with_ground();
+
+ let stack = insert_stack(&mut world, 0.0, 4);
+ // A never-sleeping cube on top of the stack.
+ let restless = world
+ .insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 4.5, 0.0))
+ .can_sleep(false),
+ ColliderBuilder::cuboid(0.5, 0.5, 0.5),
+ )
+ .0;
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(num_sleeping(&world), 0);
+
+ // Freeze the restless body: fixed bodies neither sleep nor taint.
+ world.bodies[restless].set_body_type(RigidBodyType::Fixed, false);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(
+ num_sleeping(&world),
+ stack.len(),
+ "the stack must sleep once the restless body is frozen to fixed"
+ );
+}
+
+/// Disabling a body detaches its joints without waking the partners: the
+/// disable re-queue must let the leftover body sleep.
+#[test]
+fn whole_island_sleeps_after_jointed_body_disabled() {
+ let mut world = world_with_ground();
+
+ let (cube, restless, _joint) = insert_cube_jointed_to_floating_taint(&mut world);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(num_sleeping(&world), 0);
+
+ world.bodies[restless].set_enabled(false);
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert!(
+ world.bodies[cube].is_sleeping(),
+ "the cube must sleep once its never-sleeping joint partner is disabled"
+ );
+}
+
+/// Two disjoint stacks are independent islands: each sleeps on its own, and
+/// waking one island leaves the other asleep.
+#[test]
+fn whole_island_components_are_independent() {
+ let mut world = world_with_ground();
+
+ let stack_a = insert_stack(&mut world, 0.0, 4);
+ let stack_b = insert_stack(&mut world, 20.0, 4);
+
+ for _ in 0..SETTLE_STEPS {
+ world.step();
+ }
+ assert_eq!(num_sleeping(&world), 8, "both stacks must sleep");
+
+ world.wake_up(stack_a[0], true);
+ assert!(
+ stack_a.iter().all(|h| !world.bodies[*h].is_sleeping()),
+ "waking one body must wake its whole island"
+ );
+ assert!(
+ stack_b.iter().all(|h| world.bodies[*h].is_sleeping()),
+ "the other island must stay asleep"
+ );
+}
diff --git a/crates/rapier_testbed2d-f64/Cargo.toml b/crates/rapier_testbed2d-f64/Cargo.toml
index 85c0b6401..c21952b5d 100644
--- a/crates/rapier_testbed2d-f64/Cargo.toml
+++ b/crates/rapier_testbed2d-f64/Cargo.toml
@@ -60,5 +60,5 @@ env_logger.workspace = true
[dependencies.rapier]
package = "rapier2d-f64"
path = "../rapier2d-f64"
-version = "0.34.0"
+version = "0.35.0-beta.0"
features = ["serde-serialize", "debug-render", "profiler"]
diff --git a/crates/rapier_testbed2d/Cargo.toml b/crates/rapier_testbed2d/Cargo.toml
index e4786a1d0..ec9a694e1 100644
--- a/crates/rapier_testbed2d/Cargo.toml
+++ b/crates/rapier_testbed2d/Cargo.toml
@@ -60,5 +60,5 @@ env_logger.workspace = true
[dependencies.rapier]
package = "rapier2d"
path = "../rapier2d"
-version = "0.34.0"
+version = "0.35.0-beta.0"
features = ["serde-serialize", "debug-render", "profiler"]
diff --git a/crates/rapier_testbed3d-f64/Cargo.toml b/crates/rapier_testbed3d-f64/Cargo.toml
index ccdab7354..210c0dc6c 100644
--- a/crates/rapier_testbed3d-f64/Cargo.toml
+++ b/crates/rapier_testbed3d-f64/Cargo.toml
@@ -60,5 +60,5 @@ env_logger.workspace = true
[dependencies.rapier]
package = "rapier3d-f64"
path = "../rapier3d-f64"
-version = "0.34.0"
+version = "0.35.0-beta.0"
features = ["serde-serialize", "debug-render", "profiler"]
diff --git a/crates/rapier_testbed3d/Cargo.toml b/crates/rapier_testbed3d/Cargo.toml
index 5ff0e0f30..ca6b722f2 100644
--- a/crates/rapier_testbed3d/Cargo.toml
+++ b/crates/rapier_testbed3d/Cargo.toml
@@ -60,5 +60,5 @@ env_logger.workspace = true
[dependencies.rapier]
package = "rapier3d"
path = "../rapier3d"
-version = "0.34.0"
+version = "0.35.0-beta.0"
features = ["serde-serialize", "debug-render", "profiler"]
diff --git a/examples2d/Cargo.toml b/examples2d/Cargo.toml
index 0d1bf9425..a420fd182 100644
--- a/examples2d/Cargo.toml
+++ b/examples2d/Cargo.toml
@@ -8,8 +8,6 @@ publish = false
[features]
parallel = ["rapier2d/parallel", "rapier_testbed2d/parallel"]
-simd-stable = ["rapier2d/simd-stable"]
-simd-nightly = ["rapier2d/simd-nightly"]
enhanced-determinism = ["rapier2d/enhanced-determinism"]
[dependencies]
diff --git a/examples2d/all_examples2.rs b/examples2d/all_examples2.rs
index b01fcbb6c..4aa3c4c44 100644
--- a/examples2d/all_examples2.rs
+++ b/examples2d/all_examples2.rs
@@ -6,6 +6,16 @@ use std::future::Future;
use std::pin::Pin;
mod add_remove2;
+mod b2d_compounds;
+mod b2d_joint_grid;
+mod b2d_junkyard;
+mod b2d_large_pyramid;
+mod b2d_many_pyramids;
+mod b2d_rain;
+mod b2d_smash;
+mod b2d_spinner;
+mod b2d_tumbler;
+mod b2d_washer;
mod ccd2;
mod character_controller2;
mod collision_groups2;
@@ -69,6 +79,7 @@ pub async fn main() {
const DEBUG: &str = "Debug";
const S2D: &str = "Inspired by Solver 2D";
const STRESS: &str = "Stress tests";
+ const B2D: &str = "Box2D benchmarks";
let examples: Vec<(ExampleEntry, ExampleFn)> = examples![
// ── Collisions ──────────────────────────────────────────────────────
@@ -116,13 +127,28 @@ pub async fn main() {
S2D, "Ball and chain", s2d_ball_and_chain::run;
S2D, "Joint grid", s2d_joint_grid::run;
S2D, "Far pyramid", s2d_far_pyramid::run;
+ // ── Box2D benchmarks (ports of box2d/benchmark) ─────────────────────
+ B2D, "Compounds", b2d_compounds::run;
+ B2D, "Joint grid", b2d_joint_grid::run;
+ B2D, "Junkyard", b2d_junkyard::run;
+ B2D, "Large pyramid", b2d_large_pyramid::run;
+ B2D, "Many pyramids", b2d_many_pyramids::run;
+ B2D, "Rain", b2d_rain::run;
+ B2D, "Smash", b2d_smash::run;
+ B2D, "Spinner", b2d_spinner::run;
+ B2D, "Tumbler", b2d_tumbler::run;
+ B2D, "Washer", b2d_washer::run;
// ── Stress tests ────────────────────────────────────────────────────
STRESS, "Balls", stress_tests::balls2::run;
STRESS, "Boxes", stress_tests::boxes2::run;
STRESS, "Capsules", stress_tests::capsules2::run;
STRESS, "Convex polygons", stress_tests::convex_polygons2::run;
STRESS, "Heightfield", stress_tests::heightfield2::run;
+ STRESS, "Large pyramids", stress_tests::large_pyramids2::run;
+ STRESS, "Many pyramids", stress_tests::many_pyramids2::run;
STRESS, "Pyramid", stress_tests::pyramid2::run;
+ STRESS, "Ragdoll piles", stress_tests::ragdolls2::run;
+ STRESS, "Ropes", stress_tests::ropes2::run;
STRESS, "Verticals stacks", stress_tests::vertical_stacks2::run;
STRESS, "(Stress test) joint ball", stress_tests::joint_ball2::run;
STRESS, "(Stress test) joint fixed", stress_tests::joint_fixed2::run;
diff --git a/examples2d/b2d_compounds.rs b/examples2d/b2d_compounds.rs
new file mode 100644
index 000000000..f00a2c4a5
--- /dev/null
+++ b/examples2d/b2d_compounds.rs
@@ -0,0 +1,99 @@
+//! Port of box2d's `compounds` benchmark (`CreateCompounds`,
+//! `box2d/shared/benchmarks.c`, from the Barrel sample's compound branch).
+//! Release: a 20x150 grid of two-triangle compound bodies dropped into a
+//! walled bin.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+
+fn create_walls(world: &mut PhysicsWorld) {
+ let grid = 1.0f32;
+ let ground = world.insert_body(RigidBodyBuilder::fixed());
+
+ let mut x = -40.0 * grid;
+ for _ in 0..81 {
+ world.insert_collider(
+ ColliderBuilder::cuboid(0.55 * grid, 0.5 * grid).translation(Vector::new(x, 0.0)),
+ Some(ground),
+ );
+ x += grid;
+ }
+ for wall_x in [-40.0 * grid, 40.0 * grid] {
+ let mut y = grid;
+ for _ in 0..100 {
+ world.insert_collider(
+ ColliderBuilder::cuboid(0.5 * grid, 0.55 * grid)
+ .translation(Vector::new(wall_x, y)),
+ Some(ground),
+ );
+ y += grid;
+ }
+ }
+ world.insert_collider(
+ ColliderBuilder::segment(Vector::new(-800.0, -80.0), Vector::new(800.0, -80.0)),
+ Some(ground),
+ );
+}
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+ create_walls(&mut world);
+
+ let column_count = 20i32;
+ let row_count = 150i32;
+
+ let left = [
+ Vector::new(-1.0, 0.0),
+ Vector::new(0.5, 1.0),
+ Vector::new(0.0, 2.0),
+ ];
+ let right = [
+ Vector::new(1.0, 0.0),
+ Vector::new(-0.5, 1.0),
+ Vector::new(0.0, 2.0),
+ ];
+ let left = SharedShape::convex_hull(&left).unwrap();
+ let right = SharedShape::convex_hull(&right).unwrap();
+
+ let shift = 2.0f32;
+ let extray = 0.25f32;
+ let mut side = 0.25f32;
+ let centerx = shift * column_count as f32 / 2.0 - 1.0;
+ let centery = 1.15 / 2.0;
+ let y_start = 5.0f32;
+
+ for i in 0..column_count {
+ let x = i as f32 * shift - centerx;
+ for j in 0..row_count {
+ let y = j as f32 * (shift + extray) + centery + y_start;
+ let handle = world
+ .insert_body(RigidBodyBuilder::dynamic().translation(Vector::new(x + side, y)));
+ side = -side;
+ world.insert_collider(
+ ColliderBuilder::new(left.clone())
+ .density(1.0)
+ .friction(0.5),
+ Some(handle),
+ );
+ world.insert_collider(
+ ColliderBuilder::new(right.clone())
+ .density(1.0)
+ .friction(0.5),
+ Some(handle),
+ );
+ }
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 120.0), 2.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/b2d_joint_grid.rs b/examples2d/b2d_joint_grid.rs
new file mode 100644
index 000000000..8fa693f11
--- /dev/null
+++ b/examples2d/b2d_joint_grid.rs
@@ -0,0 +1,73 @@
+//! Port of box2d's `joint_grid` benchmark (`CreateJointGrid`,
+//! `box2d/shared/benchmarks.c`). Release: a 100x100 grid of circles wired with
+//! revolute joints; a band of the top row is static. Circles don't collide with
+//! each other (box2d category/mask filter). Sleeping disabled.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+
+ let n = 100usize;
+ // box2d: circles are category 2, mask ~2 -> they collide with everything
+ // except other circles.
+ let groups = InteractionGroups::new(
+ Group::GROUP_2,
+ Group::ALL ^ Group::GROUP_2,
+ InteractionTestMode::And,
+ );
+
+ let mut bodies = vec![RigidBodyHandle::invalid(); n * n];
+ let mut index = 0usize;
+
+ for k in 0..n {
+ for i in 0..n {
+ let is_static = k >= n / 2 - 3 && k <= n / 2 + 3 && i == 0;
+ let body = if is_static {
+ RigidBodyBuilder::fixed()
+ } else {
+ RigidBodyBuilder::dynamic().can_sleep(false)
+ }
+ .translation(Vector::new(k as f32, -(i as f32)));
+ let handle = world.insert_body(body);
+ world.insert_collider(
+ ColliderBuilder::ball(0.4)
+ .density(1.0)
+ .collision_groups(groups),
+ Some(handle),
+ );
+
+ if i > 0 {
+ let joint = RevoluteJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, -0.5))
+ .local_anchor2(Vector::new(0.0, 0.5))
+ .contacts_enabled(false);
+ world.insert_impulse_joint(bodies[index - 1], handle, joint);
+ }
+ if k > 0 {
+ let joint = RevoluteJointBuilder::new()
+ .local_anchor1(Vector::new(0.5, 0.0))
+ .local_anchor2(Vector::new(-0.5, 0.0))
+ .contacts_enabled(false);
+ world.insert_impulse_joint(bodies[index - n], handle, joint);
+ }
+
+ bodies[index] = handle;
+ index += 1;
+ }
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(50.0, -50.0), 4.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/b2d_junkyard.rs b/examples2d/b2d_junkyard.rs
new file mode 100644
index 000000000..47b5ffac6
--- /dev/null
+++ b/examples2d/b2d_junkyard.rs
@@ -0,0 +1,91 @@
+//! Port of box2d's `junkyard` benchmark (`CreateJunkyard` + `StepJunkyard`,
+//! `box2d/shared/benchmarks.c`). Release: a walled bin filled with 200x40
+//! pentagon "rocks", stirred by a sweeping kinematic paddle.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+use std::f32::consts::PI;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+
+ // Walls: a floor of boxes plus two side columns.
+ let grid = 1.0f32;
+ let ground = world.insert_body(RigidBodyBuilder::fixed());
+ let mut x = -80.0 * grid;
+ for _ in 0..161 {
+ world.insert_collider(
+ ColliderBuilder::cuboid(0.55 * grid, 0.5 * grid).translation(Vector::new(x, 0.0)),
+ Some(ground),
+ );
+ x += grid;
+ }
+ for wall_x in [-80.0 * grid, 80.0 * grid] {
+ let mut y = grid;
+ for _ in 0..50 {
+ world.insert_collider(
+ ColliderBuilder::cuboid(0.5 * grid, 0.55 * grid)
+ .translation(Vector::new(wall_x, y)),
+ Some(ground),
+ );
+ y += grid;
+ }
+ }
+
+ // Pentagon "rocks" (one shared hull, matching box2d and enabling instancing).
+ let radius = 0.25f32;
+ let pentagon = SharedShape::convex_hull(&junkyard_pentagon(radius)).unwrap();
+ let column_count = 200i32;
+ let row_count = 40i32;
+ let mut side = -0.1f32;
+ let y_start = 15.0f32;
+ for i in 0..column_count {
+ let x = 1.5 * (2.0 * i as f32 - column_count as f32) * radius;
+ for j in 0..row_count {
+ let y = 4.0 * j as f32 * radius + y_start;
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(x + side, y)),
+ ColliderBuilder::new(pentagon.clone()),
+ );
+ side = -side;
+ }
+ }
+
+ // Sweeping kinematic paddle.
+ let pusher = world.insert_body(RigidBodyBuilder::kinematic_position_based());
+ world.insert_collider(
+ ColliderBuilder::cuboid(2.0, 4.0).translation(Vector::new(0.0, 4.0)),
+ Some(pusher),
+ );
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 20.0), 4.0);
+
+ let time_step = 1.0 / 60.0;
+ let mut step_count = 0i32;
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ let time = time_step * step_count as f32;
+ let target = Vector::new(60.0 * (0.2 * time).sin(), 0.0);
+ world.bodies[pusher].set_next_kinematic_translation(target);
+ step_count += 1;
+ world.step();
+ }
+ }
+ Ok(())
+}
+
+/// box2d's junkyard "rock": a 5-point Fibonacci-lattice convex polygon of the
+/// given `radius`.
+fn junkyard_pentagon(radius: f32) -> Vec {
+ let phi = PI * (5.0f32.sqrt() - 1.0);
+ (0..5)
+ .map(|i| {
+ let theta = phi * i as f32;
+ Vector::new(radius * theta.cos(), radius * theta.sin())
+ })
+ .collect()
+}
diff --git a/examples2d/b2d_large_pyramid.rs b/examples2d/b2d_large_pyramid.rs
new file mode 100644
index 000000000..eb8972729
--- /dev/null
+++ b/examples2d/b2d_large_pyramid.rs
@@ -0,0 +1,44 @@
+//! Port of box2d's `large_pyramid` benchmark (`CreateLargePyramid`,
+//! `box2d/shared/benchmarks.c`). Release: a 200-box base pyramid of unit
+//! squares on a ground box, sleeping disabled.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+
+ let base_count = 200i32;
+
+ // Ground: b2MakeBox(120, 1) at y = -1.
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0)),
+ ColliderBuilder::cuboid(120.0, 1.0),
+ );
+
+ let a = 0.5f32;
+ let shift = a;
+ for i in 0..base_count {
+ let y = (2.0 * i as f32 + 1.0) * shift;
+ for j in i..base_count {
+ let x = (i as f32 + 1.0) * shift + 2.0 * (j - i) as f32 * shift - a * base_count as f32;
+ let body = RigidBodyBuilder::dynamic()
+ .translation(Vector::new(x, y))
+ .can_sleep(false);
+ world.insert(body, ColliderBuilder::cuboid(a, a).density(1.0));
+ }
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 50.0), 2.5);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/b2d_many_pyramids.rs b/examples2d/b2d_many_pyramids.rs
new file mode 100644
index 000000000..1039dba4f
--- /dev/null
+++ b/examples2d/b2d_many_pyramids.rs
@@ -0,0 +1,75 @@
+//! Port of box2d's `many_pyramids` benchmark (`CreateManyPyramids`,
+//! `box2d/shared/benchmarks.c`). Release: a 20x20 grid of 10-base pyramids on a
+//! stack of segment "floors", sleeping disabled.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+
+fn create_small_pyramid(
+ world: &mut PhysicsWorld,
+ base_count: i32,
+ extent: f32,
+ center_x: f32,
+ base_y: f32,
+) {
+ for i in 0..base_count {
+ let y = (2.0 * i as f32 + 1.0) * extent + base_y;
+ for j in i..base_count {
+ let x = (i as f32 + 1.0) * extent + 2.0 * (j - i) as f32 * extent + center_x - 0.5;
+ let body = RigidBodyBuilder::dynamic()
+ .translation(Vector::new(x, y))
+ .can_sleep(false);
+ world.insert(body, ColliderBuilder::cuboid(extent, extent));
+ }
+ }
+}
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+
+ let base_count = 10i32;
+ let extent = 0.5f32;
+ let row_count = 20i32;
+ let column_count = 20i32;
+
+ let ground_delta_y = 2.0 * extent * (base_count as f32 + 1.0);
+ let ground_width = 2.0 * extent * column_count as f32 * (base_count as f32 + 1.0);
+
+ // Ground: one static body carrying `row_count` horizontal segments.
+ let ground = world.insert_body(RigidBodyBuilder::fixed());
+ let mut ground_y = 0.0f32;
+ for _ in 0..row_count {
+ world.insert_collider(
+ ColliderBuilder::segment(
+ Vector::new(-0.5 * ground_width, ground_y),
+ Vector::new(0.5 * ground_width, ground_y),
+ ),
+ Some(ground),
+ );
+ ground_y += ground_delta_y;
+ }
+
+ let base_width = 2.0 * extent * base_count as f32;
+ let mut base_y = 0.0f32;
+ for _ in 0..row_count {
+ for j in 0..column_count {
+ let center_x =
+ -0.5 * ground_width + j as f32 * (base_width + 2.0 * extent) + 2.0 * extent;
+ create_small_pyramid(&mut world, base_count, extent, center_x, base_y);
+ }
+ base_y += ground_delta_y;
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 60.0), 2.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/b2d_rain.rs b/examples2d/b2d_rain.rs
new file mode 100644
index 000000000..17677fe55
--- /dev/null
+++ b/examples2d/b2d_rain.rs
@@ -0,0 +1,307 @@
+//! Port of box2d's `rain` benchmark (`CreateRain` + `StepRain`,
+//! `box2d/shared/benchmarks.c`, using `box2d/shared/human.c`). Release: a bank
+//! of static box "floors" onto which columns of 5-human ragdoll groups are
+//! rained down over time and recycled once the grid is full.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+use std::f32::consts::PI;
+
+const ROW_COUNT: usize = 5;
+const COLUMN_COUNT: usize = 40;
+const GROUP_SIZE: usize = 5;
+const GRID_SIZE: f32 = 0.5;
+const GRID_COUNT: usize = 500;
+
+struct RainState {
+ groups: Vec>,
+ column_count: usize,
+ column_index: usize,
+}
+
+fn create_group(
+ viewer: &mut TestbedViewer,
+ world: &mut PhysicsWorld,
+ state: &mut RainState,
+ row: usize,
+ col: usize,
+) {
+ let group_index = row * COLUMN_COUNT + col;
+ let span = GRID_COUNT as f32 * GRID_SIZE;
+ let group_distance = span / COLUMN_COUNT as f32;
+
+ let mut x = -0.5 * span + group_distance * (col as f32 + 0.5);
+ let y = 40.0 + 45.0 * row as f32;
+
+ let mut humans = Vec::with_capacity(GROUP_SIZE);
+ for i in 0..GROUP_SIZE {
+ // box2d passes groupIndex = i + 1, so ragdolls in the same slot never
+ // collide with each other; map that to the per-slot filter bit.
+ let human = create_human(world, Vector::new(x, y), 1.0, 5.0, 0.5, (i + 1) as u32);
+ for bone in human.bones {
+ viewer.add_body(bone, world);
+ }
+ humans.push(human);
+ x += 0.5;
+ }
+ state.groups[group_index] = humans;
+}
+
+fn destroy_group(
+ viewer: &mut TestbedViewer,
+ world: &mut PhysicsWorld,
+ state: &mut RainState,
+ row: usize,
+ col: usize,
+) {
+ let group_index = row * COLUMN_COUNT + col;
+ for human in state.groups[group_index].drain(..) {
+ for bone in human.bones {
+ world.remove_body(bone);
+ viewer.remove_body(bone);
+ }
+ }
+}
+
+/// box2d `StepRain` (release: spawn/recycle one column every 8 steps).
+fn step_rain(
+ viewer: &mut TestbedViewer,
+ world: &mut PhysicsWorld,
+ state: &mut RainState,
+ step_count: i32,
+) {
+ if step_count & 0x7 != 0 {
+ return;
+ }
+
+ if state.column_count < COLUMN_COUNT {
+ let col = state.column_count;
+ for row in 0..ROW_COUNT {
+ create_group(viewer, world, state, row, col);
+ }
+ state.column_count += 1;
+ } else {
+ let col = state.column_index;
+ for row in 0..ROW_COUNT {
+ destroy_group(viewer, world, state, row, col);
+ create_group(viewer, world, state, row, col);
+ }
+ state.column_index = (state.column_index + 1) % COLUMN_COUNT;
+ }
+}
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+
+ // Static box "floors": ROW_COUNT rows of a long strip of boxes.
+ let ground = world.insert_body(RigidBodyBuilder::fixed());
+ let mut y = 0.0f32;
+ for _ in 0..ROW_COUNT {
+ let mut x = -0.5 * GRID_COUNT as f32 * GRID_SIZE;
+ for _ in 0..=GRID_COUNT {
+ world.insert_collider(
+ ColliderBuilder::cuboid(0.5 * GRID_SIZE, 0.5 * GRID_SIZE)
+ .translation(Vector::new(x, y)),
+ Some(ground),
+ );
+ x += GRID_SIZE;
+ }
+ y += 45.0;
+ }
+
+ let mut state = RainState {
+ groups: (0..ROW_COUNT * COLUMN_COUNT).map(|_| Vec::new()).collect(),
+ column_count: 0,
+ column_index: 0,
+ };
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 110.0), 2.0);
+
+ let mut step_count = 0i32;
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ step_rain(viewer, &mut world, &mut state, step_count);
+ step_count += 1;
+ world.step();
+ }
+ }
+ Ok(())
+}
+
+// ── Human ragdoll (box2d/shared/human.c) ─────────────────────────────────────
+
+const BONE_COUNT: usize = 11;
+
+const HIP: usize = 0;
+const TORSO: usize = 1;
+const HEAD: usize = 2;
+const UPPER_LEFT_LEG: usize = 3;
+const LOWER_LEFT_LEG: usize = 4;
+const UPPER_RIGHT_LEG: usize = 5;
+const LOWER_RIGHT_LEG: usize = 6;
+const UPPER_LEFT_ARM: usize = 7;
+const LOWER_LEFT_ARM: usize = 8;
+const UPPER_RIGHT_ARM: usize = 9;
+const LOWER_RIGHT_ARM: usize = 10;
+
+/// One bone, transcribed from `human.c` (all lengths in units of `scale`).
+struct BoneDef {
+ parent: i32,
+ /// Body position offset from the ragdoll origin (`bodyDef.position`).
+ pos_y: f32,
+ /// Capsule endpoints (x is always 0) + radius.
+ cap_a: [f32; 2],
+ cap_b: [f32; 2],
+ cap_r: f32,
+ /// Whether this bone carries the shared foot polygon (lower legs).
+ has_foot: bool,
+ /// Joint pivot Y (world offset from origin); joint to the parent.
+ pivot_y: f32,
+ /// Revolute limit `[lower, upper]`, radians.
+ limits: [f32; 2],
+ /// Extra rotation on the parent-side joint frame (lower arms use 0.25π).
+ frame_a_angle: f32,
+}
+
+/// Handles to a spawned human's 11 bone bodies (index order matches `BoneId`).
+struct HumanHandles {
+ pub bones: [RigidBodyHandle; BONE_COUNT],
+}
+
+/// Spawn one box2d human ragdoll with its origin at `position`. `scale`,
+/// `hertz`, `damping` mirror `CreateHuman`; `group_bit` selects the same-human
+/// collision filter (box2d's negative `filter.groupIndex`, which disables all
+/// self-collision within one ragdoll). Returns the bone handles so callers can
+/// recycle the ragdoll (used by the `rain` benchmark).
+fn create_human(
+ world: &mut PhysicsWorld,
+ position: Vector,
+ scale: f32,
+ hertz: f32,
+ damping: f32,
+ group_bit: u32,
+) -> HumanHandles {
+ let s = scale;
+ let defs = human_bone_defs();
+ let mut bones = [RigidBodyHandle::invalid(); BONE_COUNT];
+
+ // Same-human collision filter: all bones share membership bit `group_bit`
+ // and exclude it from their filter, so a ragdoll never self-collides while
+ // still colliding with the ground and other ragdolls.
+ let bit = Group::from_bits_truncate(1u32 << (group_bit % 24));
+ let groups = InteractionGroups::new(bit, Group::ALL ^ bit, InteractionTestMode::And);
+
+ // Shared foot polygon (box2d: rounded hull of 4 points).
+ let foot_points: Vec = [
+ [-0.03, -0.185],
+ [0.11, -0.185],
+ [0.11, -0.16],
+ [-0.03, -0.14],
+ ]
+ .iter()
+ .map(|p| Vector::new(p[0] * s, p[1] * s))
+ .collect();
+
+ for (i, def) in defs.iter().enumerate() {
+ let body =
+ RigidBodyBuilder::dynamic().translation(position + Vector::new(0.0, def.pos_y * s));
+ let handle = world.insert_body(body);
+
+ let capsule = ColliderBuilder::capsule_from_endpoints(
+ Vector::new(def.cap_a[0] * s, def.cap_a[1] * s),
+ Vector::new(def.cap_b[0] * s, def.cap_b[1] * s),
+ def.cap_r * s,
+ )
+ .friction(0.2)
+ .collision_groups(groups);
+ world.insert_collider(capsule, Some(handle));
+
+ if def.has_foot {
+ let foot = ColliderBuilder::round_convex_hull(&foot_points, 0.015 * s)
+ .unwrap()
+ .friction(0.05)
+ .collision_groups(groups);
+ world.insert_collider(foot, Some(handle));
+ }
+
+ bones[i] = handle;
+ }
+
+ // Soft angular spring (box2d hertz/damping) -> acceleration-based motor:
+ // stiffness = w^2, damping = 2*zeta*w, w = 2*pi*hertz.
+ let omega = 2.0 * PI * hertz;
+ let stiffness = omega * omega;
+ let motor_damping = 2.0 * damping * omega;
+
+ for (i, def) in defs.iter().enumerate() {
+ if def.parent < 0 {
+ continue;
+ }
+ let parent = bones[def.parent as usize];
+ let child = bones[i];
+ let parent_y = defs[def.parent as usize].pos_y;
+
+ // Anchors: pivot expressed in each body's local frame (bodies start
+ // axis-aligned, so this is just the pivot minus the body position).
+ let anchor_a = Vector::new(0.0, (def.pivot_y - parent_y) * s);
+ let anchor_b = Vector::new(0.0, (def.pivot_y - def.pos_y) * s);
+ let frame_a = Pose::from_parts(anchor_a, Rotation::new(def.frame_a_angle));
+ let frame_b = Pose::from_translation(anchor_b);
+
+ let joint = GenericJointBuilder::new(JointAxesMask::LIN_X | JointAxesMask::LIN_Y)
+ .local_frame1(frame_a)
+ .local_frame2(frame_b)
+ .contacts_enabled(false)
+ .limits(JointAxis::AngX, def.limits)
+ .motor_model(JointAxis::AngX, MotorModel::AccelerationBased)
+ .motor_position(JointAxis::AngX, 0.0, stiffness, motor_damping);
+
+ world.insert_impulse_joint(parent, child, joint);
+ }
+
+ HumanHandles { bones }
+}
+
+#[rustfmt::skip]
+fn human_bone_defs() -> [BoneDef; BONE_COUNT] {
+ let pi = PI;
+ [
+ // hip (root, no joint)
+ BoneDef { parent: -1, pos_y: 0.95, cap_a: [0.0, -0.02], cap_b: [0.0, 0.02], cap_r: 0.095,
+ has_foot: false, pivot_y: 0.0, limits: [0.0, 0.0], frame_a_angle: 0.0 },
+ // torso
+ BoneDef { parent: HIP as i32, pos_y: 1.2, cap_a: [0.0, -0.135], cap_b: [0.0, 0.135], cap_r: 0.09,
+ has_foot: false, pivot_y: 1.0, limits: [-0.25 * pi, 0.0], frame_a_angle: 0.0 },
+ // head
+ BoneDef { parent: TORSO as i32, pos_y: 1.475, cap_a: [0.0, -0.038], cap_b: [0.0, 0.039], cap_r: 0.075,
+ has_foot: false, pivot_y: 1.4, limits: [-0.3 * pi, 0.1 * pi], frame_a_angle: 0.0 },
+ // upper left leg
+ BoneDef { parent: HIP as i32, pos_y: 0.775, cap_a: [0.0, -0.125], cap_b: [0.0, 0.125], cap_r: 0.06,
+ has_foot: false, pivot_y: 0.9, limits: [-0.05 * pi, 0.4 * pi], frame_a_angle: 0.0 },
+ // lower left leg
+ BoneDef { parent: UPPER_LEFT_LEG as i32, pos_y: 0.475, cap_a: [0.0, -0.155], cap_b: [0.0, 0.125], cap_r: 0.045,
+ has_foot: true, pivot_y: 0.625, limits: [-0.5 * pi, -0.02 * pi], frame_a_angle: 0.0 },
+ // upper right leg
+ BoneDef { parent: HIP as i32, pos_y: 0.775, cap_a: [0.0, -0.125], cap_b: [0.0, 0.125], cap_r: 0.06,
+ has_foot: false, pivot_y: 0.9, limits: [-0.05 * pi, 0.4 * pi], frame_a_angle: 0.0 },
+ // lower right leg
+ BoneDef { parent: UPPER_RIGHT_LEG as i32, pos_y: 0.475, cap_a: [0.0, -0.155], cap_b: [0.0, 0.125], cap_r: 0.045,
+ has_foot: true, pivot_y: 0.625, limits: [-0.5 * pi, -0.02 * pi], frame_a_angle: 0.0 },
+ // upper left arm
+ BoneDef { parent: TORSO as i32, pos_y: 1.225, cap_a: [0.0, -0.125], cap_b: [0.0, 0.125], cap_r: 0.035,
+ has_foot: false, pivot_y: 1.35, limits: [-0.1 * pi, 0.8 * pi], frame_a_angle: 0.0 },
+ // lower left arm
+ BoneDef { parent: UPPER_LEFT_ARM as i32, pos_y: 0.975, cap_a: [0.0, -0.125], cap_b: [0.0, 0.125], cap_r: 0.03,
+ has_foot: false, pivot_y: 1.1, limits: [-0.2 * pi, 0.3 * pi], frame_a_angle: 0.25 * pi },
+ // upper right arm
+ BoneDef { parent: TORSO as i32, pos_y: 1.225, cap_a: [0.0, -0.125], cap_b: [0.0, 0.125], cap_r: 0.035,
+ has_foot: false, pivot_y: 1.35, limits: [-0.1 * pi, 0.8 * pi], frame_a_angle: 0.0 },
+ // lower right arm
+ BoneDef { parent: UPPER_RIGHT_ARM as i32, pos_y: 0.975, cap_a: [0.0, -0.125], cap_b: [0.0, 0.125], cap_r: 0.03,
+ has_foot: false, pivot_y: 1.1, limits: [-0.2 * pi, 0.3 * pi], frame_a_angle: 0.25 * pi },
+ ]
+}
diff --git a/examples2d/b2d_smash.rs b/examples2d/b2d_smash.rs
new file mode 100644
index 000000000..d9bb9708d
--- /dev/null
+++ b/examples2d/b2d_smash.rs
@@ -0,0 +1,49 @@
+//! Port of box2d's `smash` benchmark (`CreateSmash`,
+//! `box2d/shared/benchmarks.c`). Release: zero gravity; a heavy 8x8 box flung
+//! at 40 m/s into a 120x80 grid of small squares that start asleep.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+ world.gravity = Vector::ZERO;
+
+ // The smasher.
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(-20.0, 0.0))
+ .linvel(Vector::new(40.0, 0.0)),
+ ColliderBuilder::cuboid(4.0, 4.0).density(8.0),
+ );
+
+ // The wall of small squares (start asleep, box2d `isAwake = false`).
+ let d = 0.4f32;
+ let columns = 120i32;
+ let rows = 80i32;
+ for i in 0..columns {
+ for j in 0..rows {
+ let x = i as f32 * d + 30.0;
+ let y = (j as f32 - rows as f32 / 2.0) * d;
+ world.insert(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(x, y))
+ .sleeping(true),
+ ColliderBuilder::cuboid(0.5 * d, 0.5 * d),
+ );
+ }
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(20.0, 0.0), 8.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/b2d_spinner.rs b/examples2d/b2d_spinner.rs
new file mode 100644
index 000000000..d0ffc5f9b
--- /dev/null
+++ b/examples2d/b2d_spinner.rs
@@ -0,0 +1,93 @@
+//! Port of box2d's `spinner` benchmark (`CreateSpinner`,
+//! `box2d/shared/benchmarks.c`). Release: a motor-driven rounded bar stirring
+//! ~6000 mixed small bodies inside a circular chain container.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+use std::f32::consts::PI;
+
+const POINT_COUNT: usize = 360;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+
+ // Circular chain container (radius 40, centered at (0, 32)).
+ let ground = world.insert_body(RigidBodyBuilder::fixed());
+ let q = Rotation::new(-2.0 * PI / POINT_COUNT as f32);
+ let mut p = Vector::new(40.0, 0.0);
+ let mut points = Vec::with_capacity(POINT_COUNT);
+ for _ in 0..POINT_COUNT {
+ points.push(Vector::new(p.x, p.y + 32.0));
+ p = q * p;
+ }
+ let indices: Vec<[u32; 2]> = (0..POINT_COUNT as u32)
+ .map(|i| [i, (i + 1) % POINT_COUNT as u32])
+ .collect();
+ // Oriented (one-sided) container wall, like box2d's chain shape: only collides on
+ // its interior side, so crushed/piled bodies can't squeeze through the thin wall.
+ world.insert_collider(
+ ColliderBuilder::oriented_polyline(points, Some(indices)).friction(0.1),
+ Some(ground),
+ );
+
+ // The spinner bar + its motor.
+ let spinner = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 12.0))
+ .can_sleep(false),
+ );
+ world.insert_collider(
+ ColliderBuilder::round_cuboid(0.4, 20.0, 0.2).friction(0.0),
+ Some(spinner),
+ );
+ let joint = RevoluteJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, 12.0))
+ .local_anchor2(Vector::new(0.0, 0.0))
+ .motor_velocity(5.0, 1.0e5)
+ .motor_max_force(1.0e9);
+ world.insert_impulse_joint(ground, spinner, joint);
+
+ // ~6000 mixed small bodies.
+ let body_count = 2 * 3038;
+ let capsule = || {
+ ColliderBuilder::capsule_from_endpoints(
+ Vector::new(-0.25, 0.0),
+ Vector::new(0.25, 0.0),
+ 0.25,
+ )
+ };
+ let mut x = -23.0f32;
+ let mut y = 2.0f32;
+ for i in 0..body_count {
+ let body = RigidBodyBuilder::dynamic().translation(Vector::new(x, y));
+ let handle = world.insert_body(body);
+ let collider = match i % 3 {
+ 0 => capsule(),
+ 1 => ColliderBuilder::ball(0.35),
+ _ => ColliderBuilder::cuboid(0.35, 0.35),
+ }
+ .density(0.25)
+ .friction(0.1)
+ .restitution(0.1);
+ world.insert_collider(collider, Some(handle));
+
+ x += 0.5;
+ if x >= 23.0 {
+ x = -23.0;
+ y += 0.5;
+ }
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 32.0), 6.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/b2d_tumbler.rs b/examples2d/b2d_tumbler.rs
new file mode 100644
index 000000000..c41186b26
--- /dev/null
+++ b/examples2d/b2d_tumbler.rs
@@ -0,0 +1,69 @@
+//! Port of box2d's `tumbler` benchmark (`CreateTumbler`,
+//! `box2d/shared/benchmarks.c`). Release: a motor-driven hollow square drum
+//! tumbling a 45x45 grid of small boxes.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+use std::f32::consts::PI;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+
+ let ground = world.insert_body(RigidBodyBuilder::fixed());
+
+ // Drum: four walls forming a hollow box, driven by a revolute motor.
+ let drum = world.insert_body(
+ RigidBodyBuilder::dynamic()
+ .translation(Vector::new(0.0, 10.0))
+ .can_sleep(false),
+ );
+ for (hx, hy, off) in [
+ (0.5, 10.0, Vector::new(10.0, 0.0)),
+ (0.5, 10.0, Vector::new(-10.0, 0.0)),
+ (10.0, 0.5, Vector::new(0.0, 10.0)),
+ (10.0, 0.5, Vector::new(0.0, -10.0)),
+ ] {
+ world.insert_collider(
+ ColliderBuilder::cuboid(hx, hy)
+ .translation(off)
+ .density(50.0),
+ Some(drum),
+ );
+ }
+
+ let motor_speed = (PI / 180.0) * 25.0;
+ let joint = RevoluteJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, 10.0))
+ .local_anchor2(Vector::new(0.0, 0.0))
+ .motor_velocity(motor_speed, 1.0e5)
+ .motor_max_force(1.0e8);
+ world.insert_impulse_joint(ground, drum, joint);
+
+ // Grid of small boxes inside the drum.
+ let grid_count = 45i32;
+ let mut y = -0.2 * grid_count as f32 + 10.0;
+ for _ in 0..grid_count {
+ let mut x = -0.2 * grid_count as f32;
+ for _ in 0..grid_count {
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(x, y)),
+ ColliderBuilder::cuboid(0.125, 0.125),
+ );
+ x += 0.4;
+ }
+ y += 0.4;
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 10.0), 12.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/b2d_washer.rs b/examples2d/b2d_washer.rs
new file mode 100644
index 000000000..fe9dce470
--- /dev/null
+++ b/examples2d/b2d_washer.rs
@@ -0,0 +1,76 @@
+//! Port of box2d's `washer` benchmark (`CreateWasher`,
+//! `box2d/shared/benchmarks.c`). Release: a spinning kinematic ring (built from
+//! ~40 convex quads) tumbling a 90x90 grid of small squares.
+
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+use std::f32::consts::PI;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box2d's `b2DefaultWorldDef`: gravity (0, -10). box2d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0);
+
+ let _ground = world.insert_body(RigidBodyBuilder::fixed());
+
+ // Spinning kinematic washer (constant angular + tiny linear velocity).
+ let motor_speed = (PI / 180.0) * 25.0;
+ let washer = world.insert_body(
+ RigidBodyBuilder::kinematic_velocity_based()
+ .translation(Vector::new(0.0, 10.0))
+ .angvel(motor_speed)
+ .linvel(Vector::new(0.001, -0.002)),
+ );
+
+ let (r0, r1, r2) = (14.0f32, 16.0f32, 18.0f32);
+ let angle = PI / 18.0;
+ let q = Rotation::new(angle);
+ let qo = Rotation::new(0.1 * angle);
+ let mut u1 = Vector::new(1.0, 0.0);
+ for i in 0..36 {
+ let u2 = if i == 35 {
+ Vector::new(1.0, 0.0)
+ } else {
+ q * u1
+ };
+
+ let a1 = qo.inverse() * u1;
+ let a2 = qo * u2;
+ let seg = [r1 * a1, r2 * a1, r1 * a2, r2 * a2];
+ world.insert_collider(ColliderBuilder::convex_hull(&seg).unwrap(), Some(washer));
+
+ if i % 9 == 0 {
+ let inner = [r0 * u1, r1 * u1, r0 * u2, r1 * u2];
+ world.insert_collider(ColliderBuilder::convex_hull(&inner).unwrap(), Some(washer));
+ }
+
+ u1 = u2;
+ }
+
+ // Grid of small squares.
+ let grid_count = 90i32;
+ let a = 0.1f32;
+ let mut y = -1.1 * a * grid_count as f32 + 10.0;
+ for _ in 0..grid_count {
+ let mut x = -1.1 * a * grid_count as f32;
+ for _ in 0..grid_count {
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(x, y)),
+ ColliderBuilder::cuboid(a, a),
+ );
+ x += 2.1 * a;
+ }
+ y += 2.1 * a;
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 10.0), 12.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/joints2.rs b/examples2d/joints2.rs
index f6d321993..5bd0d7081 100644
--- a/examples2d/joints2.rs
+++ b/examples2d/joints2.rs
@@ -17,9 +17,6 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
* Create the balls
*/
// Build the rigid body.
- // NOTE: a smaller radius (e.g. 0.1) breaks Box2D so
- // in order to be able to compare rapier with Box2D,
- // we set it to 0.4.
let rad = 0.4;
let numi = 10; // Num vertical nodes.
let numk = 10; // Num horizontal nodes.
diff --git a/examples2d/stress_tests/convex_polygons2.rs b/examples2d/stress_tests/convex_polygons2.rs
index 4ac8b3627..67661796a 100644
--- a/examples2d/stress_tests/convex_polygons2.rs
+++ b/examples2d/stress_tests/convex_polygons2.rs
@@ -44,21 +44,24 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
let mut rng = StdRng::seed_from_u64(0);
let distribution = StandardUniform;
- for i in 0..num {
- for j in 0usize..num * 5 {
- let x = i as f32 * shift - centerx;
- let y = j as f32 * shift * 2.0 + centery + 2.0;
-
- let rigid_body = RigidBodyBuilder::dynamic().translation(Vec2::new(x, y));
-
+ let poly_shapes: Vec<_> = (0..5)
+ .map(|_| {
let mut points = Vec::new();
-
for _ in 0..10 {
let pt: [f32; 2] = distribution.sample(&mut rng);
points.push(Vec2::from(pt) * scale);
}
+ SharedShape::convex_hull(&points).unwrap()
+ })
+ .collect();
- let collider = ColliderBuilder::convex_hull(&points).unwrap();
+ for i in 0..num {
+ for j in 0usize..num * 5 {
+ let x = i as f32 * shift - centerx;
+ let y = j as f32 * shift * 2.0 + centery + 2.0;
+
+ let rigid_body = RigidBodyBuilder::dynamic().translation(Vec2::new(x, y));
+ let collider = ColliderBuilder::new(poly_shapes[i % 5].clone());
let _ = world.insert(rigid_body, collider);
}
}
diff --git a/examples2d/stress_tests/large_pyramids2.rs b/examples2d/stress_tests/large_pyramids2.rs
new file mode 100644
index 000000000..3f2a7c708
--- /dev/null
+++ b/examples2d/stress_tests/large_pyramids2.rs
@@ -0,0 +1,65 @@
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+
+/// A few *large* independent pyramids: 8 piles of 1540 boxes (base row of 55),
+/// 12 320 dynamic bodies, sleeping disabled.
+///
+/// Unlike the many-small-pyramids scene, each pile here is large enough to
+/// exercise intra-island parallelism: a few big piles stress how well the
+/// staged solver distributes constraints within an island.
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ /*
+ * World
+ */
+ let mut world = PhysicsWorld::new();
+
+ let num_pyramids = 8;
+ let base_count = 55;
+ let rad = 0.5;
+ let gap = 10.0;
+
+ /*
+ * Ground
+ */
+ let pyramid_width = base_count as f32 * 2.0 * rad;
+ let total_width = num_pyramids as f32 * (pyramid_width + gap);
+
+ let rigid_body = RigidBodyBuilder::fixed().translation(Vec2::new(0.0, -1.0));
+ let collider = ColliderBuilder::cuboid(total_width, 1.0);
+ let _ = world.insert(rigid_body, collider);
+
+ /*
+ * The pyramids.
+ */
+ let shift = rad * 2.0;
+
+ for p in 0..num_pyramids {
+ let x0 = p as f32 * (pyramid_width + gap) - 0.5 * total_width;
+
+ for i in 0..base_count {
+ for j in i..base_count {
+ let x = x0 + (i as f32 * shift / 2.0) + (j - i) as f32 * shift;
+ let y = i as f32 * shift * 1.001 + rad;
+
+ let rigid_body = RigidBodyBuilder::dynamic()
+ .translation(Vec2::new(x, y))
+ .can_sleep(false);
+ let collider = ColliderBuilder::cuboid(rad, rad);
+ let _ = world.insert(rigid_body, collider);
+ }
+ }
+ }
+
+ /*
+ * Set up the testbed.
+ */
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 0.5 * base_count as f32 * shift), 3.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/stress_tests/many_pyramids2.rs b/examples2d/stress_tests/many_pyramids2.rs
new file mode 100644
index 000000000..0373f2e01
--- /dev/null
+++ b/examples2d/stress_tests/many_pyramids2.rs
@@ -0,0 +1,78 @@
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+
+/// A 20×20 grid of 10-base box
+/// pyramids (22 000 dynamic bodies) resting on one segment per row, with
+/// sleeping disabled. Many equally-sized independent piles is the worst case
+/// for the solver's parallel load balancing.
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ /*
+ * World
+ */
+ let mut world = PhysicsWorld::new();
+
+ let base_count = 10;
+ let extent = 0.5;
+ let row_count = 20;
+ let column_count = 20;
+
+ /*
+ * Ground: one static body carrying one segment per pyramid row.
+ */
+ let ground_delta_y = 2.0 * extent * (base_count as f32 + 1.0);
+ let ground_width = 2.0 * extent * column_count as f32 * (base_count as f32 + 1.0);
+
+ let ground = world.insert_body(RigidBodyBuilder::fixed());
+ for i in 0..row_count {
+ let ground_y = i as f32 * ground_delta_y;
+ let _ = world.insert_collider(
+ ColliderBuilder::segment(
+ Vec2::new(-0.5 * ground_width, ground_y),
+ Vec2::new(0.5 * ground_width, ground_y),
+ ),
+ Some(ground),
+ );
+ }
+
+ /*
+ * The pyramids.
+ */
+ let base_width = 2.0 * extent * base_count as f32;
+
+ for row in 0..row_count {
+ let base_y = row as f32 * ground_delta_y;
+
+ for column in 0..column_count {
+ let center_x =
+ -0.5 * ground_width + column as f32 * (base_width + 2.0 * extent) + 2.0 * extent;
+
+ for i in 0..base_count {
+ let y = (2.0 * i as f32 + 1.0) * extent + base_y;
+
+ for j in i..base_count {
+ let x =
+ (i as f32 + 1.0) * extent + 2.0 * (j - i) as f32 * extent + center_x - 0.5;
+
+ let rigid_body = RigidBodyBuilder::dynamic()
+ .translation(Vec2::new(x, y))
+ .can_sleep(false);
+ let collider = ColliderBuilder::cuboid(extent, extent);
+ let _ = world.insert(rigid_body, collider);
+ }
+ }
+ }
+ }
+
+ /*
+ * Set up the testbed.
+ */
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(0.0, 0.5 * row_count as f32 * ground_delta_y), 3.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/stress_tests/mod.rs b/examples2d/stress_tests/mod.rs
index 3736fdb83..bfc501925 100644
--- a/examples2d/stress_tests/mod.rs
+++ b/examples2d/stress_tests/mod.rs
@@ -8,5 +8,9 @@ pub mod heightfield2;
pub mod joint_ball2;
pub mod joint_fixed2;
pub mod joint_prismatic2;
+pub mod large_pyramids2;
+pub mod many_pyramids2;
pub mod pyramid2;
+pub mod ragdolls2;
+pub mod ropes2;
pub mod vertical_stacks2;
diff --git a/examples2d/stress_tests/ragdolls2.rs b/examples2d/stress_tests/ragdolls2.rs
new file mode 100644
index 000000000..00e1d5e3c
--- /dev/null
+++ b/examples2d/stress_tests/ragdolls2.rs
@@ -0,0 +1,101 @@
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+
+/// A 10-body articulated ragdoll (torso, head, 2×2 arm links, 2×2 leg links)
+/// whose torso center is at `origin`. 9 revolute joints, all with limits.
+fn ragdoll(world: &mut PhysicsWorld, origin: Vec2) {
+ let part = |world: &mut PhysicsWorld, offset: Vec2, collider: ColliderBuilder| {
+ let body = RigidBodyBuilder::dynamic().translation(origin + offset);
+ let (handle, _) = world.insert(body, collider);
+ (handle, offset)
+ };
+
+ let revolute = |parent: (RigidBodyHandle, Vec2),
+ child: (RigidBodyHandle, Vec2),
+ anchor: Vec2,
+ limits: [f32; 2]| {
+ RevoluteJointBuilder::new()
+ .local_anchor1(anchor - parent.1)
+ .local_anchor2(anchor - child.1)
+ .limits(limits)
+ .contacts_enabled(false)
+ };
+
+ let torso = part(world, Vec2::ZERO, ColliderBuilder::capsule_y(0.3, 0.15));
+ let head = part(world, Vec2::new(0.0, 0.55), ColliderBuilder::ball(0.15));
+ let neck = revolute(torso, head, Vec2::new(0.0, 0.42), [-0.5, 0.5]);
+ world.insert_impulse_joint(torso.0, head.0, neck);
+
+ for side in [-1.0f32, 1.0] {
+ let upper_arm = part(
+ world,
+ Vec2::new(side * 0.36, 0.25),
+ ColliderBuilder::capsule_x(0.14, 0.06),
+ );
+ let forearm = part(
+ world,
+ Vec2::new(side * 0.70, 0.25),
+ ColliderBuilder::capsule_x(0.14, 0.06),
+ );
+ let thigh = part(
+ world,
+ Vec2::new(side * 0.09, -0.52),
+ ColliderBuilder::capsule_y(0.16, 0.07),
+ );
+ let shin = part(
+ world,
+ Vec2::new(side * 0.09, -0.92),
+ ColliderBuilder::capsule_y(0.16, 0.07),
+ );
+
+ let shoulder = revolute(torso, upper_arm, Vec2::new(side * 0.19, 0.25), [-1.2, 1.2]);
+ world.insert_impulse_joint(torso.0, upper_arm.0, shoulder);
+ let elbow = revolute(upper_arm, forearm, Vec2::new(side * 0.53, 0.25), [0.0, 2.5]);
+ world.insert_impulse_joint(upper_arm.0, forearm.0, elbow);
+ let hip = revolute(torso, thigh, Vec2::new(side * 0.09, -0.33), [-1.0, 1.0]);
+ world.insert_impulse_joint(torso.0, thigh.0, hip);
+ let knee = revolute(thigh, shin, Vec2::new(side * 0.09, -0.72), [0.0, 2.3]);
+ world.insert_impulse_joint(thigh.0, shin.0, knee);
+ }
+}
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ /*
+ * World
+ */
+ let mut world = PhysicsWorld::new();
+
+ /*
+ * Ground
+ */
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vec2::new(0.0, -1.0)),
+ ColliderBuilder::cuboid(1000.0, 1.0),
+ );
+
+ /*
+ * Ragdolls dropped into a pile: 20 columns, 10 layers (200 ragdolls,
+ * 2000 dynamic bodies, 1800 limit joints).
+ */
+ for layer in 0..10 {
+ for col in 0..20 {
+ ragdoll(
+ &mut world,
+ Vec2::new(col as f32 * 2.2, 1.5 + layer as f32 * 2.6),
+ );
+ }
+ }
+
+ /*
+ * Set up the testbed.
+ */
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(22.0, 6.0), 15.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples2d/stress_tests/ropes2.rs b/examples2d/stress_tests/ropes2.rs
new file mode 100644
index 000000000..97be5c4fc
--- /dev/null
+++ b/examples2d/stress_tests/ropes2.rs
@@ -0,0 +1,62 @@
+use rapier_testbed2d::TestbedViewer;
+use rapier2d::prelude::*;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ /*
+ * World
+ */
+ let mut world = PhysicsWorld::new();
+
+ /*
+ * 64 hanging ropes of 60 capsule segments each (3840 dynamic bodies, one
+ * revolute joint per segment), swinging from an initial sideways kick.
+ * Nearly contact-free: a joint-solver stress test.
+ */
+ let segments = 60;
+ let seg_len = 1.0;
+
+ for i in 0..64 {
+ let top = Vec2::new(i as f32 * 4.0, 0.0);
+ let mut parent = world
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(top));
+
+ for s in 0..segments {
+ let center = top + Vec2::new(0.0, -(s as f32 + 0.5) * seg_len);
+ let body = RigidBodyBuilder::dynamic()
+ .translation(center)
+ .linvel(Vec2::new(2.0, 0.0));
+ let handle = world.bodies.insert(body);
+ world.colliders.insert_with_parent(
+ ColliderBuilder::capsule_y(0.35, 0.1),
+ handle,
+ &mut world.bodies,
+ );
+
+ let anchor1 = if s == 0 {
+ Vec2::ZERO
+ } else {
+ Vec2::new(0.0, -seg_len / 2.0)
+ };
+ let joint = RevoluteJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(Vec2::new(0.0, seg_len / 2.0))
+ .contacts_enabled(false);
+ world.insert_impulse_joint(parent, handle, joint);
+ parent = handle;
+ }
+ }
+
+ /*
+ * Set up the testbed.
+ */
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec2::new(128.0, -30.0), 4.0);
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples3d-f64/Cargo.toml b/examples3d-f64/Cargo.toml
index 1cdd95f94..7202960a9 100644
--- a/examples3d-f64/Cargo.toml
+++ b/examples3d-f64/Cargo.toml
@@ -8,8 +8,6 @@ publish = false
[features]
parallel = ["rapier3d-f64/parallel", "rapier_testbed3d-f64/parallel"]
-#simd-stable = ["rapier3d-f64/simd-stable"]
-#simd-nightly = ["rapier3d-f64/simd-nightly"]
enhanced-determinism = ["rapier3d-f64/enhanced-determinism"]
[dependencies]
diff --git a/examples3d/Cargo.toml b/examples3d/Cargo.toml
index 962dbc471..96ae53e97 100644
--- a/examples3d/Cargo.toml
+++ b/examples3d/Cargo.toml
@@ -8,8 +8,6 @@ publish = false
[features]
parallel = ["rapier3d/parallel", "rapier_testbed3d/parallel"]
-simd-stable = ["rapier3d/simd-stable"]
-simd-nightly = ["rapier3d/simd-nightly"]
enhanced-determinism = ["rapier3d/enhanced-determinism"]
[dependencies]
diff --git a/examples3d/all_examples3.rs b/examples3d/all_examples3.rs
index 7ae9e00e8..3de30237e 100644
--- a/examples3d/all_examples3.rs
+++ b/examples3d/all_examples3.rs
@@ -7,6 +7,14 @@ use std::pin::Pin;
mod utils;
+mod b3d_joint_grid;
+mod b3d_junkyard;
+mod b3d_large_pyramid;
+mod b3d_large_world;
+mod b3d_many_pyramids;
+mod b3d_rain;
+mod b3d_trees;
+mod b3d_washer;
mod ccd3;
mod character_controller3;
mod collision_groups3;
@@ -87,6 +95,7 @@ pub async fn main() {
const DEBUG: &str = "Debug";
const ROBOTICS: &str = "Robotics";
const STRESS: &str = "Stress tests";
+ const B3D: &str = "Box3D benchmarks";
let examples: Vec<(ExampleEntry, ExampleFn)> = examples![
// ── Collisions ──────────────────────────────────────────────────────
@@ -171,9 +180,22 @@ pub async fn main() {
STRESS, "ImpulseJoint fixed", stress_tests::joint_fixed3::run;
STRESS, "ImpulseJoint revolute", stress_tests::joint_revolute3::run;
STRESS, "ImpulseJoint prismatic", stress_tests::joint_prismatic3::run;
+ STRESS, "Ragdoll piles", stress_tests::ragdolls3::run;
+ STRESS, "Ropes", stress_tests::ropes3::run;
STRESS, "Many pyramids", stress_tests::many_pyramids3::run;
STRESS, "Keva tower", stress_tests::keva3::run;
STRESS, "Ray cast", stress_tests::ray_cast3::run;
+ // ── Box3D benchmarks (ports of box3d/benchmark) ─────────────────────
+ B3D, "Large pyramid", b3d_large_pyramid::run;
+ B3D, "Many pyramids", b3d_many_pyramids::run;
+ B3D, "Joint grid", b3d_joint_grid::run;
+ B3D, "Junkyard", b3d_junkyard::run;
+ B3D, "Washer", b3d_washer::run;
+ B3D, "Trees 100", b3d_trees::run100;
+ B3D, "Trees 50", b3d_trees::run50;
+ B3D, "Trees 25", b3d_trees::run25;
+ B3D, "Rain", b3d_rain::run;
+ B3D, "Large world", b3d_large_world::run;
];
let (entries, run_fns): (Vec<_>, Vec) = examples.into_iter().unzip();
diff --git a/examples3d/b3d_joint_grid.rs b/examples3d/b3d_joint_grid.rs
new file mode 100644
index 000000000..c342c7849
--- /dev/null
+++ b/examples3d/b3d_joint_grid.rs
@@ -0,0 +1,62 @@
+//! Port of box3d's `joint_grid` benchmark (`CreateJointGrid`,
+//! `box3d/shared/benchmarks.c`). Release settings: a 100x100 grid of spheres
+//! wired together with spherical joints; the `i == 0` column is static.
+//! Sleeping disabled.
+
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box3d's `b3DefaultWorldDef`: gravity (0, -10, 0). box3d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0, 0.0);
+
+ let n = 100usize;
+ let mut bodies = vec![RigidBodyHandle::invalid(); n * n];
+ let mut index = 0usize;
+
+ for k in 0..n {
+ for i in 0..n {
+ let fk = k as f32;
+ let fi = i as f32;
+
+ let body = if i == 0 {
+ RigidBodyBuilder::fixed()
+ } else {
+ RigidBodyBuilder::dynamic().can_sleep(false)
+ }
+ .translation(Vector::new(fk, -fi, 0.0));
+ let handle = world.insert_body(body);
+ world.insert_collider(ColliderBuilder::ball(0.4), Some(handle));
+
+ if i > 0 {
+ // Spherical joint to the body above (previous i).
+ let joint = SphericalJointBuilder::new()
+ .local_anchor1(Vector::new(0.0, -0.5, 0.0))
+ .local_anchor2(Vector::new(0.0, 0.5, 0.0));
+ world.insert_impulse_joint(bodies[index - 1], handle, joint);
+ }
+ if k > 0 {
+ // Spherical joint to the body in the previous column.
+ let joint = SphericalJointBuilder::new()
+ .local_anchor1(Vector::new(0.5, 0.0, 0.0))
+ .local_anchor2(Vector::new(-0.5, 0.0, 0.0));
+ world.insert_impulse_joint(bodies[index - n], handle, joint);
+ }
+
+ bodies[index] = handle;
+ index += 1;
+ }
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(50.0, -25.0, 90.0), Vec3::new(50.0, -50.0, 0.0));
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples3d/b3d_junkyard.rs b/examples3d/b3d_junkyard.rs
new file mode 100644
index 000000000..0dd3fcaf6
--- /dev/null
+++ b/examples3d/b3d_junkyard.rs
@@ -0,0 +1,124 @@
+//! Port of box3d's `junkyard` benchmark (`CreateJunkyard` + `StepJunkyard`,
+//! `box3d/shared/benchmarks.c`). Release settings: a walled arena filled with a
+//! 24x21x21 stack of convex "rocks", stirred by an orbiting kinematic cylinder.
+
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+use std::f32::consts::PI;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box3d's `b3DefaultWorldDef`: gravity (0, -10, 0). box3d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0, 0.0);
+
+ // Ground + walls: one fixed body at y = -1 with 5 box colliders.
+ let ground =
+ world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0, 0.0)));
+ world.insert_collider(ColliderBuilder::cuboid(120.0, 1.0, 120.0), Some(ground));
+ for (hx, hy, hz, off) in [
+ (1.0, 8.0, 50.0, Vector::new(-50.0, 8.0, 0.0)),
+ (1.0, 8.0, 50.0, Vector::new(50.0, 8.0, 0.0)),
+ (50.0, 8.0, 1.0, Vector::new(0.0, 8.0, -50.0)),
+ (50.0, 8.0, 1.0, Vector::new(0.0, 8.0, 50.0)),
+ ] {
+ world.insert_collider(
+ ColliderBuilder::cuboid(hx, hy, hz).translation(off),
+ Some(ground),
+ );
+ }
+
+ // Rocks: 24 layers of a 21x21 grid. box3d shares a single hull across all
+ // rocks; do the same so the hull is computed once and the renderer can
+ // instance the ~10.5k identical bodies into one draw call.
+ let rock = SharedShape::convex_hull(&create_rock(1.5)).unwrap();
+ let count = 24i32;
+ let height = 24.0f32;
+ for y in 0..count {
+ for x in 0..=20 {
+ for z in 0..=20 {
+ let pos = Vector::new(
+ -40.0 + 4.0 * x as f32,
+ 4.0 * y as f32 + height + 1.0,
+ -40.0 + 4.0 * z as f32,
+ );
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(pos),
+ ColliderBuilder::new(rock.clone()),
+ );
+ }
+ }
+ }
+
+ // Orbiting kinematic pusher.
+ let radius = 35.0f32;
+ let pusher_hull = create_cylinder(24.0, 4.0, 0.0, 16);
+ let pusher = world.insert_body(
+ RigidBodyBuilder::kinematic_position_based().translation(Vector::new(radius, 0.0, 0.0)),
+ );
+ world.insert_collider(
+ ColliderBuilder::convex_hull(&pusher_hull).unwrap(),
+ Some(pusher),
+ );
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(0.0, 90.0, 125.0), Vec3::new(0.0, 0.0, 0.0));
+
+ let mut degrees = 0.0f32;
+ let time_step = 1.0 / 60.0;
+ let omega = -6.0f32;
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ degrees += omega * time_step;
+ let rad = degrees * PI / 180.0;
+ let target = Vector::new(radius * rad.cos(), 0.0, radius * rad.sin());
+ world.bodies[pusher].set_next_kinematic_translation(target);
+ world.step();
+ }
+ }
+ Ok(())
+}
+
+/// box3d `b3CreateCylinder` (`src/hull.c`): `2 * sides` points forming a
+/// cylinder of the given `height`/`radius`, its base at `y_offset`, aligned
+/// with the Y axis. Returned as a convex point cloud (rapier builds the hull).
+fn create_cylinder(height: f32, radius: f32, y_offset: f32, sides: usize) -> Vec {
+ let mut points = Vec::with_capacity(2 * sides);
+ let delta_alpha = 2.0 * PI / sides as f32;
+ let mut alpha = 0.0f32;
+ for _ in 0..sides {
+ let (sin_a, cos_a) = alpha.sin_cos();
+ points.push(Vector::new(radius * cos_a, y_offset, radius * sin_a));
+ points.push(Vector::new(
+ radius * cos_a,
+ y_offset + height,
+ radius * sin_a,
+ ));
+ alpha += delta_alpha;
+ }
+ points
+}
+
+/// box3d `b3CreateRock` (`src/hull.c`): 10 points on a Fibonacci lattice on a
+/// sphere of the given `radius`.
+fn create_rock(radius: f32) -> Vec {
+ let point_count = 10usize;
+ let phi = (1.0 + 5.0f32.sqrt()) / 2.0;
+ let theta = 2.0 * PI / phi;
+ let (delta_sin, delta_cos) = theta.sin_cos();
+ let (mut cos, mut sin) = (1.0f32, 0.0f32);
+ let mut points = Vec::with_capacity(point_count);
+ for i in 0..point_count {
+ let z = 1.0 - (2.0 * i as f32 + 1.0) / point_count as f32;
+ let radius_xy = (1.0 - z * z).sqrt();
+ points.push(Vector::new(
+ radius * radius_xy * cos,
+ radius * radius_xy * sin,
+ radius * z,
+ ));
+ let (c0, s0) = (cos, sin);
+ cos = delta_cos * c0 - delta_sin * s0;
+ sin = delta_sin * c0 + delta_cos * s0;
+ }
+ points
+}
diff --git a/examples3d/b3d_large_pyramid.rs b/examples3d/b3d_large_pyramid.rs
new file mode 100644
index 000000000..7e6c48bfd
--- /dev/null
+++ b/examples3d/b3d_large_pyramid.rs
@@ -0,0 +1,45 @@
+//! Port of box3d's `large_pyramid` benchmark (`CreateLargePyramid`,
+//! `box3d/shared/benchmarks.c`). Release settings: a 90-box base pyramid of
+//! unit cubes (density 100) on a large ground box, sleeping disabled.
+
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box3d's `b3DefaultWorldDef`: gravity (0, -10, 0). box3d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0, 0.0);
+
+ let base_count = 90i32;
+
+ // Ground: b3MakeBoxHull(400, 1, 400) at y = -1.
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0, 0.0)),
+ ColliderBuilder::cuboid(400.0, 1.0, 400.0),
+ );
+
+ let h = 0.5f32;
+ let shift = 1.0 * h;
+
+ for i in 0..base_count {
+ let y = (2.0 * i as f32 + 1.0) * shift;
+ for j in i..base_count {
+ let x = (i as f32 + 1.0) * shift + 2.0 * (j - i) as f32 * shift - h * base_count as f32;
+ let body = RigidBodyBuilder::dynamic()
+ .translation(Vector::new(x, y, 0.0))
+ .can_sleep(false);
+ world.insert(body, ColliderBuilder::cuboid(h, h, h).density(100.0));
+ }
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(0.0, 40.0, 110.0), Vec3::new(0.0, 20.0, 0.0));
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples3d/b3d_large_world.rs b/examples3d/b3d_large_world.rs
new file mode 100644
index 000000000..c4934ddb1
--- /dev/null
+++ b/examples3d/b3d_large_world.rs
@@ -0,0 +1,76 @@
+//! Port of box3d's `large_world` benchmark (`CreateLargeWorld` +
+//! `StepLargeWorld`, `box3d/shared/benchmarks.c`). Release settings: a
+//! 1000x1000 static box floor (one million static shapes) onto which 100
+//! dynamic spheres are dropped, one every 5 steps.
+//!
+//! box3d creates one static *body* per floor box; rapier's idiomatic (and
+//! perf-equivalent) static geometry is a parentless collider, so the floor is
+//! built from one million standalone fixed colliders. This is a heavy scene —
+//! expect a long build time and high memory use, matching the benchmark's
+//! intent of stressing the broad-phase with a huge static set.
+
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+
+const CELL_SIZE: f32 = 10.0;
+const GRID: i32 = 1000;
+const SPHERES: i32 = 100;
+const DROP_INTERVAL: i32 = 5;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box3d's `b3DefaultWorldDef`: gravity (0, -10, 0). box3d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0, 0.0);
+
+ let cell = CELL_SIZE;
+ let half_span = 0.5 * cell * GRID as f32;
+
+ for i in 0..GRID {
+ let x = -half_span + (i as f32 + 0.5) * cell;
+ for j in 0..GRID {
+ let z = -half_span + (j as f32 + 0.5) * cell;
+ world.insert_collider(
+ ColliderBuilder::cuboid(0.5 * cell, 0.25, 0.5 * cell)
+ .translation(Vector::new(x, 0.0, z)),
+ None,
+ );
+ }
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(0.0, 60.0, 250.0), Vec3::new(0.0, 0.0, 0.0));
+
+ // `StepLargeWorld`: drop one sphere every `DROP_INTERVAL` steps, spread on a
+ // coarse grid over the inner 80% of the floor, up to `SPHERES` total.
+ let mut side = 1i32;
+ while side * side < SPHERES {
+ side += 1;
+ }
+ let mut step_count = 0i32;
+ let mut dropped = 0i32;
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ if dropped < SPHERES && step_count > 0 && step_count % DROP_INTERVAL == 0 {
+ let idx = dropped;
+ let gi = idx % side;
+ let gj = idx / side;
+ let inset = 0.1 * 2.0 * half_span;
+ let usable = 2.0 * half_span - 2.0 * inset;
+ let x = -half_span + inset + (gi as f32 + 0.5) * (usable / side as f32);
+ let z = -half_span + inset + (gj as f32 + 0.5) * (usable / side as f32);
+ let (handle, _) = world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(x, 1.5, z)),
+ ColliderBuilder::ball(0.5),
+ );
+ // Register with the renderer (spawned after `set_world`).
+ viewer.add_body(handle, &world);
+ dropped += 1;
+ }
+ step_count += 1;
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples3d/b3d_many_pyramids.rs b/examples3d/b3d_many_pyramids.rs
new file mode 100644
index 000000000..b98a8eb3d
--- /dev/null
+++ b/examples3d/b3d_many_pyramids.rs
@@ -0,0 +1,69 @@
+//! Port of box3d's `many_pyramids` benchmark (`CreateManyPyramids`,
+//! `box3d/shared/benchmarks.c`). Release settings: a 14x14 grid of 10-base
+//! pyramids of small cubes (density 100) on a ground box, sleeping disabled.
+
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+
+fn create_small_pyramid(
+ world: &mut PhysicsWorld,
+ base_count: i32,
+ extent: f32,
+ center_x: f32,
+ base_z: f32,
+) {
+ for i in 0..base_count {
+ let y = (2.0 * i as f32 + 1.0) * extent;
+ for j in i..base_count {
+ let x = (i as f32 + 1.0) * extent + 2.0 * (j - i) as f32 * extent + center_x - 0.5;
+ let body = RigidBodyBuilder::dynamic()
+ .translation(Vector::new(x, y, base_z))
+ .can_sleep(false);
+ world.insert(
+ body,
+ ColliderBuilder::cuboid(extent, extent, extent).density(100.0),
+ );
+ }
+ }
+}
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box3d's `b3DefaultWorldDef`: gravity (0, -10, 0). box3d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0, 0.0);
+
+ let base_count = 10i32;
+ let extent = 0.5f32;
+ let row_count = 14i32;
+ let column_count = 14i32;
+ let ground_extent = extent * column_count as f32 * (base_count as f32 + 1.0);
+
+ // Ground.
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0, 0.0)),
+ ColliderBuilder::cuboid(ground_extent, 1.0, ground_extent),
+ );
+
+ let base_width = 2.0 * extent * base_count as f32;
+ let mut base_z = -ground_extent + 2.0 * extent;
+ let delta_z = 2.0 * (ground_extent - 2.0 * extent) / (row_count as f32 - 1.0);
+
+ for _ in 0..row_count {
+ for j in 0..column_count {
+ let center_x = -ground_extent + j as f32 * (base_width + 2.0 * extent) + 2.0 * extent;
+ create_small_pyramid(&mut world, base_count, extent, center_x, base_z);
+ }
+ base_z += delta_z;
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(0.0, 30.0, 120.0), Vec3::new(0.0, 5.0, 0.0));
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples3d/b3d_rain.rs b/examples3d/b3d_rain.rs
new file mode 100644
index 000000000..40d872c59
--- /dev/null
+++ b/examples3d/b3d_rain.rs
@@ -0,0 +1,520 @@
+//! Port of box3d's `rain` benchmark (`CreateRain` + `StepRain`,
+//! `box3d/shared/benchmarks.c`, using `box3d/shared/human.c`). Release settings:
+//! a 10x10 grid of static cells (each a grid-mesh patch + torus obstacle) onto
+//! which columns of 3-human ragdoll "groups" are rained down over time and
+//! recycled once the grid is full.
+
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+use std::f32::consts::PI;
+
+const GRID_COUNT: usize = 10;
+const GROUP_SIZE: usize = 3;
+const GRID_SIZE: f32 = 15.0;
+
+struct RainState {
+ /// Humans currently alive in each of the `GRID_COUNT * GRID_COUNT` cells.
+ groups: Vec>,
+ column_count: usize,
+ column_index: usize,
+}
+
+fn create_group(
+ viewer: &mut TestbedViewer,
+ world: &mut PhysicsWorld,
+ state: &mut RainState,
+ row: usize,
+ col: usize,
+) {
+ let group_index = row * GRID_COUNT + col;
+ let span = GRID_COUNT as f32 * GRID_SIZE;
+ let group_distance = span / GRID_COUNT as f32;
+
+ let mut x = -0.5 * span + group_distance * (col as f32 + 0.5);
+ let y = 20.0;
+ let z = -0.5 * span + group_distance * (row as f32 + 0.5);
+
+ let mut humans = Vec::with_capacity(GROUP_SIZE);
+ for _ in 0..GROUP_SIZE {
+ let human = create_human(
+ world,
+ Vector::new(x, y, z),
+ 5.0,
+ 1.0,
+ 0.7,
+ group_index as u32,
+ );
+ // Register the newly-spawned bodies with the renderer (they're created
+ // after `set_world`, so the viewer doesn't know about them yet).
+ for bone in human.bones {
+ viewer.add_body(bone, world);
+ }
+ humans.push(human);
+ x += 0.75;
+ }
+ state.groups[group_index] = humans;
+}
+
+fn destroy_group(
+ viewer: &mut TestbedViewer,
+ world: &mut PhysicsWorld,
+ state: &mut RainState,
+ row: usize,
+ col: usize,
+) {
+ let group_index = row * GRID_COUNT + col;
+ for human in state.groups[group_index].drain(..) {
+ for bone in human.bones {
+ world.remove_body(bone);
+ viewer.remove_body(bone);
+ }
+ }
+}
+
+/// box3d `StepRain` (release: spawn/recycle one column every 48 steps).
+fn step_rain(
+ viewer: &mut TestbedViewer,
+ world: &mut PhysicsWorld,
+ state: &mut RainState,
+ step_count: i32,
+) {
+ if step_count & 0x2F != 0 {
+ return;
+ }
+
+ if state.column_count < GRID_COUNT {
+ let col = state.column_count;
+ for row in 0..GRID_COUNT {
+ create_group(viewer, world, state, row, col);
+ }
+ state.column_count += 1;
+ } else {
+ let col = state.column_index;
+ for row in 0..GRID_COUNT {
+ destroy_group(viewer, world, state, row, col);
+ create_group(viewer, world, state, row, col);
+ }
+ state.column_index = (state.column_index + 1) % GRID_COUNT;
+ }
+}
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box3d's `b3DefaultWorldDef`: gravity (0, -10, 0). box3d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0, 0.0);
+
+ // Static cells: a 10x10 grid, each cell one static body carrying a small
+ // grid-mesh floor patch and a torus obstacle.
+ let half_mesh_grid_rows = 4;
+ let mesh_cell_width = GRID_SIZE / (2.0 * half_mesh_grid_rows as f32);
+ let (grid_verts, grid_indices) = create_grid_mesh(
+ 2 * half_mesh_grid_rows,
+ 2 * half_mesh_grid_rows,
+ mesh_cell_width,
+ );
+ let (torus_verts, torus_indices) = create_torus_mesh(16, 16, 0.25 * GRID_SIZE, 1.0);
+
+ let span = GRID_SIZE * GRID_COUNT as f32;
+ let mut x = -0.5 * span + 0.5 * GRID_SIZE;
+ for _ in 0..GRID_COUNT {
+ let mut z = -0.5 * span + 0.5 * GRID_SIZE;
+ for _ in 0..GRID_COUNT {
+ let cell =
+ world.insert_body(RigidBodyBuilder::fixed().translation(Vector::new(x, 0.0, z)));
+ world.insert_collider(
+ ColliderBuilder::trimesh(grid_verts.clone(), grid_indices.clone()).unwrap(),
+ Some(cell),
+ );
+ world.insert_collider(
+ ColliderBuilder::trimesh(torus_verts.clone(), torus_indices.clone()).unwrap(),
+ Some(cell),
+ );
+ z += GRID_SIZE;
+ }
+ x += GRID_SIZE;
+ }
+
+ let mut state = RainState {
+ groups: (0..GRID_COUNT * GRID_COUNT).map(|_| Vec::new()).collect(),
+ column_count: 0,
+ column_index: 0,
+ };
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(70.0, 30.0, 70.0), Vec3::new(0.0, 5.0, 0.0));
+
+ // box3d calls the step function once with step 0 before the first world
+ // step, then once per subsequent step.
+ let mut step_count = 0i32;
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ step_rain(viewer, &mut world, &mut state, step_count);
+ step_count += 1;
+ world.step();
+ }
+ }
+ Ok(())
+}
+
+/// Shared vertex/index grid builder for the box3d grid/wave meshes. `height`
+/// yields the Y coordinate at grid cell `(ix, iz)`.
+fn grid_mesh(
+ x_count: usize,
+ z_count: usize,
+ cell_width: f32,
+ height: impl Fn(usize, usize) -> f32,
+) -> (Vec, Vec<[u32; 3]>) {
+ let x_width = cell_width * x_count as f32;
+ let z_width = cell_width * z_count as f32;
+
+ let mut vertices = Vec::with_capacity((x_count + 1) * (z_count + 1));
+ let mut x = -0.5 * x_width;
+ for ix in 0..=x_count {
+ let mut z = -0.5 * z_width;
+ for iz in 0..=z_count {
+ vertices.push(Vector::new(x, height(ix, iz), z));
+ z += cell_width;
+ }
+ x += cell_width;
+ }
+
+ let mut indices = Vec::with_capacity(2 * x_count * z_count);
+ for ix in 0..x_count {
+ for iz in 0..z_count {
+ let i1 = (iz + (z_count + 1) * ix) as u32;
+ let i2 = i1 + 1;
+ let i3 = i2 + (z_count as u32 + 1);
+ let i4 = i3 - 1;
+ indices.push([i1, i2, i3]);
+ indices.push([i3, i4, i1]);
+ }
+ }
+ (vertices, indices)
+}
+
+/// box3d `b3CreateGridMesh` (`src/mesh.c`): a flat `x_count * z_count` grid.
+fn create_grid_mesh(
+ x_count: usize,
+ z_count: usize,
+ cell_width: f32,
+) -> (Vec, Vec<[u32; 3]>) {
+ grid_mesh(x_count, z_count, cell_width, |_, _| 0.0)
+}
+
+/// box3d `b3CreateTorusMesh` (`src/mesh.c`).
+fn create_torus_mesh(
+ radial_resolution: usize,
+ tubular_resolution: usize,
+ radius: f32,
+ thickness: f32,
+) -> (Vec, Vec<[u32; 3]>) {
+ let two_pi = 2.0 * PI;
+ let mut vertices = Vec::with_capacity(radial_resolution * tubular_resolution);
+ for radial in 0..radial_resolution {
+ for tubular in 0..tubular_resolution {
+ let u = tubular as f32 / tubular_resolution as f32 * two_pi;
+ let v = radial as f32 / radial_resolution as f32 * two_pi;
+ let x = (radius + thickness * v.cos()) * u.cos();
+ let y = (radius + thickness * v.cos()) * u.sin();
+ let z = thickness * v.sin();
+ vertices.push(Vector::new(x, y, z));
+ }
+ }
+
+ let mut indices = Vec::with_capacity(2 * radial_resolution * tubular_resolution);
+ for radial1 in 0..radial_resolution {
+ let radial2 = (radial1 + 1) % radial_resolution;
+ for tubular1 in 0..tubular_resolution {
+ let tubular2 = (tubular1 + 1) % tubular_resolution;
+ let i1 = (radial1 * tubular_resolution + tubular1) as u32;
+ let i2 = (radial1 * tubular_resolution + tubular2) as u32;
+ let i3 = (radial2 * tubular_resolution + tubular2) as u32;
+ let i4 = (radial2 * tubular_resolution + tubular1) as u32;
+ indices.push([i1, i2, i3]);
+ indices.push([i3, i4, i1]);
+ }
+ }
+ (vertices, indices)
+}
+
+// ── Human ragdoll (box3d/shared/human.c) ────────────────────────────────────
+
+const BONE_COUNT: usize = 14;
+
+/// Bone indices, matching box3d's `BoneId` enum order.
+const PELVIS: usize = 0;
+const SPINE_01: usize = 1;
+const SPINE_02: usize = 2;
+const SPINE_03: usize = 3;
+const NECK: usize = 4;
+const HEAD: usize = 5;
+const THIGH_L: usize = 6;
+const CALF_L: usize = 7;
+const THIGH_R: usize = 8;
+const CALF_R: usize = 9;
+const UPPER_ARM_L: usize = 10;
+const LOWER_ARM_L: usize = 11;
+const UPPER_ARM_R: usize = 12;
+const LOWER_ARM_R: usize = 13;
+
+const DEG_TO_RAD: f32 = PI / 180.0;
+
+#[derive(Clone, Copy)]
+enum JointKind {
+ Spherical,
+ Revolute,
+}
+
+/// One bone's full description, transcribed from `human.c`.
+struct BoneDef {
+ parent: i32,
+ /// Reference frame: body position offset + rotation (quaternion xyzw).
+ ref_p: [f32; 3],
+ ref_q: [f32; 4],
+ /// Capsule endpoints + radius, in the bone's local frame.
+ cap_a: [f32; 3],
+ cap_b: [f32; 3],
+ cap_r: f32,
+ /// Joint to the parent (unused for the pelvis).
+ kind: JointKind,
+ frame_a_p: [f32; 3],
+ frame_a_q: [f32; 4],
+ frame_b_p: [f32; 3],
+ frame_b_q: [f32; 4],
+ /// Cone half-angle (spherical only), degrees.
+ swing_deg: f32,
+ /// Twist limit `[lo, hi]`, degrees.
+ twist_deg: [f32; 2],
+ /// Whether this bone's spine/thigh shape gets the same-human collision
+ /// filter (box3d's negative `filter.groupIndex`).
+ filtered: bool,
+}
+
+/// Handles to a spawned human's 14 bone bodies (index order matches `BoneId`).
+struct HumanHandles {
+ pub bones: [RigidBodyHandle; BONE_COUNT],
+}
+
+fn quat(q: [f32; 4]) -> Rotation {
+ // box3d stores quaternions as {x, y, z, w}.
+ Rotation::from_xyzw(q[0], q[1], q[2], q[3]).normalize()
+}
+
+/// Spawn one box3d human ragdoll with its pelvis reference position at
+/// `position`. `friction_torque`/`hertz`/`damping` mirror the `CreateHuman`
+/// parameters; `group_bit` selects the same-human collision filter bit (box3d's
+/// per-human negative group index). Returns the bone body handles so callers can
+/// recycle the ragdoll (used by the `rain` benchmark).
+fn create_human(
+ world: &mut PhysicsWorld,
+ position: Vector,
+ friction_torque: f32,
+ hertz: f32,
+ damping: f32,
+ group_bit: u32,
+) -> HumanHandles {
+ let defs = human_bone_defs();
+ let mut bones = [RigidBodyHandle::invalid(); BONE_COUNT];
+
+ // Same-human collision filter: the three "filtered" shapes share membership
+ // bit `group_bit` and exclude it from their filter, so they never collide
+ // with each other (box3d's negative filter.groupIndex), while still
+ // colliding with every other shape.
+ let bit = Group::from_bits_truncate(1u32 << (group_bit % 24));
+ let filtered_groups = InteractionGroups::new(bit, Group::ALL ^ bit, InteractionTestMode::And);
+
+ for (i, def) in defs.iter().enumerate() {
+ let pose = Pose::from_parts(position + Vector::from(def.ref_p), quat(def.ref_q));
+ let body = RigidBodyBuilder::dynamic().pose(pose);
+ let handle = world.insert_body(body);
+
+ let mut collider = ColliderBuilder::capsule_from_endpoints(
+ Vector::from(def.cap_a),
+ Vector::from(def.cap_b),
+ def.cap_r,
+ )
+ .density(1000.0);
+ if def.filtered {
+ collider = collider.collision_groups(filtered_groups);
+ }
+ world.insert_collider(collider, Some(handle));
+ bones[i] = handle;
+ }
+
+ // Soft angular spring (box3d hertz/damping) mapped to an acceleration-based
+ // motor: stiffness = w^2, damping = 2*zeta*w, w = 2*pi*hertz.
+ let omega = 2.0 * PI * hertz;
+ let stiffness = omega * omega;
+ let motor_damping = 2.0 * damping * omega;
+ let _ = friction_torque; // box3d clamps the motor by friction torque; omitted (approximate).
+
+ for (i, def) in defs.iter().enumerate() {
+ if def.parent < 0 {
+ continue;
+ }
+ let parent = bones[def.parent as usize];
+ let child = bones[i];
+
+ let frame_a = Pose::from_parts(Vector::from(def.frame_a_p), quat(def.frame_a_q));
+ let frame_b = Pose::from_parts(Vector::from(def.frame_b_p), quat(def.frame_b_q));
+ let twist = [def.twist_deg[0] * DEG_TO_RAD, def.twist_deg[1] * DEG_TO_RAD];
+
+ // Both joint types: lock the 3 linear axes (ball), twist about ANG_X,
+ // swing about ANG_Y/ANG_Z (rapier convention), soft spring toward the
+ // reference pose. Revolute additionally locks the two swing axes.
+ let mut builder = GenericJointBuilder::new(
+ JointAxesMask::LIN_X | JointAxesMask::LIN_Y | JointAxesMask::LIN_Z,
+ )
+ .local_frame1(frame_a)
+ .local_frame2(frame_b)
+ .contacts_enabled(false)
+ .limits(JointAxis::AngX, twist)
+ .motor_model(JointAxis::AngX, MotorModel::AccelerationBased)
+ .motor_position(JointAxis::AngX, 0.0, stiffness, motor_damping);
+
+ match def.kind {
+ JointKind::Spherical => {
+ let swing = def.swing_deg * DEG_TO_RAD;
+ builder = builder
+ .limits(JointAxis::AngY, [-swing, swing])
+ .limits(JointAxis::AngZ, [-swing, swing])
+ .motor_model(JointAxis::AngY, MotorModel::AccelerationBased)
+ .motor_position(JointAxis::AngY, 0.0, stiffness, motor_damping)
+ .motor_model(JointAxis::AngZ, MotorModel::AccelerationBased)
+ .motor_position(JointAxis::AngZ, 0.0, stiffness, motor_damping);
+ }
+ JointKind::Revolute => {
+ builder = builder.locked_axes(
+ JointAxesMask::LIN_X
+ | JointAxesMask::LIN_Y
+ | JointAxesMask::LIN_Z
+ | JointAxesMask::ANG_Y
+ | JointAxesMask::ANG_Z,
+ );
+ }
+ }
+
+ world.insert_impulse_joint(parent, child, builder);
+ }
+
+ HumanHandles { bones }
+}
+
+#[rustfmt::skip]
+fn human_bone_defs() -> [BoneDef; BONE_COUNT] {
+ use JointKind::*;
+ [
+ // pelvis
+ BoneDef { parent: -1,
+ ref_p: [0.0, 0.932087, -0.051708], ref_q: [0.739169, 0.0, 0.0, 0.673520],
+ cap_a: [0.07, 0.0, -0.08], cap_b: [-0.07, 0.0, -0.08], cap_r: 0.13,
+ kind: Spherical, frame_a_p: [0.0; 3], frame_a_q: [0.0, 0.0, 0.0, 1.0],
+ frame_b_p: [0.0; 3], frame_b_q: [0.0, 0.0, 0.0, 1.0], swing_deg: 0.0, twist_deg: [0.0, 0.0],
+ filtered: false },
+ // spine_01
+ BoneDef { parent: PELVIS as i32,
+ ref_p: [0.0, 1.113505, -0.03481], ref_q: [0.739973, 0.0, 0.0, 0.672637],
+ cap_a: [0.06, 0.0, -0.052264], cap_b: [-0.06, 0.0, -0.052264], cap_r: 0.12,
+ kind: Spherical,
+ frame_a_p: [0.0, 0.0, -0.182204], frame_a_q: [-0.999999, 0.0, 0.0, 0.001194],
+ frame_b_p: [0.0, 0.0, -0.007736], frame_b_q: [-1.0, 0.0, 0.0, 0.0],
+ swing_deg: 25.0, twist_deg: [-15.0, 15.0], filtered: true },
+ // spine_02
+ BoneDef { parent: SPINE_01 as i32,
+ ref_p: [0.0, 1.194336, -0.027087], ref_q: [0.703611, 0.0, 0.0, 0.710586],
+ cap_a: [0.08, -0.015133, -0.091801], cap_b: [-0.08, -0.015133, -0.091801], cap_r: 0.10,
+ kind: Spherical,
+ frame_a_p: [0.0, 0.0, -0.088935], frame_a_q: [-0.998619, 0.0, 0.0, -0.052540],
+ frame_b_p: [0.0, 0.0, -0.008199], frame_b_q: [-1.0, 0.0, 0.0, 0.0],
+ swing_deg: 25.0, twist_deg: [-15.0, 15.0], filtered: false },
+ // spine_03
+ BoneDef { parent: SPINE_02 as i32,
+ ref_p: [0.0, 1.31043, -0.028232], ref_q: [0.669856, 0.000001, -0.000001, 0.742491],
+ cap_a: [0.11, -0.039753, -0.13], cap_b: [-0.11, -0.039753, -0.13], cap_r: 0.145,
+ kind: Spherical,
+ frame_a_p: [0.0, 0.0, -0.124298], frame_a_q: [-0.998921, 0.000001, -0.000001, -0.046434],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [-1.0, 0.0, -0.000001, 0.0],
+ swing_deg: 15.0, twist_deg: [-10.0, 10.0], filtered: false },
+ // neck
+ BoneDef { parent: SPINE_03 as i32,
+ ref_p: [0.0, 1.575582, -0.055837], ref_q: [0.879922, 0.0, 0.0, 0.475118],
+ cap_a: [-0.000001, 0.0, -0.02], cap_b: [0.0, -0.005, -0.08], cap_r: 0.07,
+ kind: Spherical,
+ frame_a_p: [0.000001, -0.000259, -0.266585], frame_a_q: [-0.942192, -0.000001, 0.0, 0.335074],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [-1.0, 0.0, -0.000001, 0.0],
+ swing_deg: 45.0, twist_deg: [-15.0, 15.0], filtered: false },
+ // head
+ BoneDef { parent: NECK as i32,
+ ref_p: [0.0, 1.653348, -0.003241], ref_q: [0.750288, 0.0, 0.0, 0.661111],
+ cap_a: [-0.000001, 0.016892, -0.05869], cap_b: [0.0, -0.003629, -0.115072], cap_r: 0.0975,
+ kind: Spherical,
+ frame_a_p: [0.0, 0.001321, -0.093873], frame_a_q: [-0.974301, 0.0, 0.0, -0.225251],
+ frame_b_p: [0.0, 0.001268, -0.005104], frame_b_q: [-1.0, 0.0, 0.0, 0.0],
+ swing_deg: 15.0, twist_deg: [-15.0, 15.0], filtered: false },
+ // thigh_l
+ BoneDef { parent: PELVIS as i32,
+ ref_p: [0.090416, 0.986104, -0.035090], ref_q: [-0.703287, -0.070715, 0.053866, 0.705327],
+ cap_a: [0.023719, 0.006008, -0.039068], cap_b: [-0.064492, -0.004664, -0.424718], cap_r: 0.09,
+ kind: Spherical,
+ frame_a_p: [0.05, 0.011537, -0.055325], frame_a_q: [-0.714896, -0.022305, -0.698361, -0.026790],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [-0.002064, 0.758987, 0.017046, 0.650880],
+ swing_deg: 10.0, twist_deg: [-60.0, 40.0], filtered: true },
+ // calf_l
+ BoneDef { parent: THIGH_L as i32,
+ ref_p: [0.101198, 0.527027, -0.037374], ref_q: [-0.653328, -0.066860, 0.058582, 0.751838],
+ cap_a: [0.001778, 0.0, 0.009841], cap_b: [-0.078577, 0.014707, -0.41816], cap_r: 0.075,
+ kind: Revolute,
+ frame_a_p: [-0.069989, 0.000253, -0.453844], frame_a_q: [-0.000677, 0.760087, 0.105674, 0.641171],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [-0.044589, 0.765540, 0.053368, 0.639619],
+ swing_deg: 0.0, twist_deg: [-5.0, 45.0], filtered: false },
+ // thigh_r
+ BoneDef { parent: PELVIS as i32,
+ ref_p: [-0.090416, 0.986104, -0.03509], ref_q: [-0.703287, 0.070715, -0.053865, 0.705326],
+ cap_a: [-0.023719, 0.006008, -0.039068], cap_b: [0.064492, -0.004664, -0.424718], cap_r: 0.09,
+ kind: Spherical,
+ frame_a_p: [-0.05, 0.011537, -0.055326], frame_a_q: [-0.039089, -0.714094, 0.043177, 0.697623],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [0.758805, -0.019886, -0.651012, -0.001759],
+ swing_deg: 10.0, twist_deg: [-30.0, 60.0], filtered: true },
+ // calf_r
+ BoneDef { parent: THIGH_R as i32,
+ ref_p: [-0.101198, 0.527027, -0.037373], ref_q: [-0.653327, 0.06686, -0.058582, 0.751839],
+ cap_a: [-0.001820, 0.0, 0.010071], cap_b: [0.077883, 0.014825, -0.418047], cap_r: 0.075,
+ kind: Revolute,
+ frame_a_p: [0.069988, 0.000253, -0.453844], frame_a_q: [0.760086, -0.000675, -0.641171, -0.105676],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [0.765540, -0.044589, -0.639619, -0.053368],
+ swing_deg: 0.0, twist_deg: [-45.0, 5.0], filtered: false },
+ // upper_arm_l
+ BoneDef { parent: SPINE_03 as i32,
+ ref_p: [0.20378, 1.484275, -0.115897], ref_q: [0.143082, 0.695980, -0.690130, 0.13733],
+ cap_a: [0.0, 0.0, 0.0], cap_b: [-0.091118, 0.037775, 0.229719], cap_r: 0.075,
+ kind: Spherical,
+ frame_a_p: [0.203780, -0.069369, -0.181921], frame_a_q: [-0.278486, 0.445600, -0.097014, 0.845266],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [-0.201396, -0.001586, 0.901850, 0.382234],
+ swing_deg: 60.0, twist_deg: [-5.0, 5.0], filtered: false },
+ // lower_arm_l
+ BoneDef { parent: UPPER_ARM_L as i32,
+ ref_p: [0.305614, 1.242908, -0.117599], ref_q: [0.165048, 0.563437, -0.802002, 0.109959],
+ cap_a: [0.0, 0.0, 0.0], cap_b: [-0.142406, 0.039392, 0.261092], cap_r: 0.05,
+ kind: Revolute,
+ frame_a_p: [-0.095482, 0.039584, 0.240723], frame_a_q: [0.512487, -0.180629, 0.839474, 0.003742],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [0.503803, -0.029831, 0.858168, 0.094017],
+ swing_deg: 0.0, twist_deg: [-5.0, 60.0], filtered: false },
+ // upper_arm_r
+ BoneDef { parent: SPINE_03 as i32,
+ ref_p: [-0.20378, 1.484276, -0.115899], ref_q: [0.143083, -0.695978, 0.690132, 0.137329],
+ cap_a: [0.0, 0.0, 0.0], cap_b: [0.091118, 0.037775, 0.229718], cap_r: 0.075,
+ kind: Spherical,
+ frame_a_p: [-0.203779, -0.069371, -0.181922], frame_a_q: [-0.253621, -0.414842, 0.106962, 0.867261],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [-0.201397, 0.001587, -0.901850, 0.382233],
+ swing_deg: 60.0, twist_deg: [-5.0, 5.0], filtered: false },
+ // lower_arm_r
+ BoneDef { parent: UPPER_ARM_R as i32,
+ ref_p: [-0.305614, 1.242907, -0.117599], ref_q: [0.165048, -0.563437, 0.802002, 0.109959],
+ cap_a: [0.0, 0.0, 0.0], cap_b: [0.142406, 0.039392, 0.261092], cap_r: 0.05,
+ kind: Revolute,
+ frame_a_p: [0.095484, 0.039585, 0.240723], frame_a_q: [-0.180627, 0.512487, -0.003744, -0.839474],
+ frame_b_p: [0.0, 0.0, 0.0], frame_b_q: [-0.029831, 0.503803, -0.094017, -0.858169],
+ swing_deg: 0.0, twist_deg: [-60.0, 5.0], filtered: false },
+ ]
+}
diff --git a/examples3d/b3d_trees.rs b/examples3d/b3d_trees.rs
new file mode 100644
index 000000000..6d764fc18
--- /dev/null
+++ b/examples3d/b3d_trees.rs
@@ -0,0 +1,187 @@
+//! Ports of box3d's `trees100`/`trees50`/`trees25` benchmarks
+//! (`CreateTrees(1|2|4)`, `box3d/shared/benchmarks.c`): 50 "tree" bodies, each
+//! a stack of 22 convex cylinders, spun onto a sinusoidal wave-mesh ground.
+//! The scale selects the ground tessellation density.
+
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+use std::f32::consts::PI;
+
+pub async fn run100(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ run(viewer, 1).await
+}
+
+pub async fn run50(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ run(viewer, 2).await
+}
+
+pub async fn run25(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ run(viewer, 4).await
+}
+
+async fn run(viewer: &mut TestbedViewer, scale: usize) -> anyhow::Result<()> {
+ // box3d's `b3DefaultWorldDef`: gravity (0, -10, 0). box3d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0, 0.0);
+ create_trees(&mut world, scale);
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(0.0, 30.0, 140.0), Vec3::new(0.0, 15.0, 0.0));
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
+
+/// Port of box3d's `CreateTrees` (`box3d/shared/benchmarks.c`). `scale` is
+/// 1/2/4 for the trees100/50/25 variants. Builds a sinusoidal wave-mesh ground
+/// and 50 "tree" bodies, each a stack of 22 convex cylinders, spun about Z with
+/// alternating direction (release settings, `tilt = 0`).
+fn create_trees(world: &mut PhysicsWorld, scale: usize) {
+ let x_count = scale * 150;
+ let z_count = scale * 200;
+ let cell_width = 1.0 / scale as f32;
+ let amplitude = 0.4;
+ let row_hz = 0.05;
+ let column_hz = 0.1;
+
+ let (vertices, indices) =
+ create_wave_mesh(x_count, z_count, cell_width, amplitude, row_hz, column_hz);
+ let ground = world.insert_body(RigidBodyBuilder::fixed());
+ world.insert_collider(
+ ColliderBuilder::trimesh(vertices, indices).unwrap(),
+ Some(ground),
+ );
+
+ // The 22 stacked cylinders shared by every tree body. Building each hull
+ // once (as a shared shape) lets all 50 trees reuse the geometry and lets
+ // the renderer instance the repeated hulls.
+ let hull_count = 22usize;
+ let mut hulls = Vec::with_capacity(hull_count);
+ let mut y = 1.0f32;
+ let mut r = 0.75f32;
+ let l = 1.5f32;
+ for _ in 0..hull_count {
+ let points = create_cylinder(l + 2.0 * r, r, y - r, 6);
+ hulls.push(SharedShape::convex_hull(&points).unwrap());
+ y += l + 2.0 * r;
+ r *= 0.95;
+ }
+
+ let body_count = 50i32;
+ let mut angular_velocity = -0.5f32;
+ let mut z = -70.0f32;
+ for body_index in 0..body_count {
+ let position = Vector::new(0.0, 1.0, z);
+ // box3d applies gyroscopic torque to every body every substep (see
+ // `b3IntegrateVelocitiesTask`, "improves the simulation of long skinny
+ // bodies"). rapier gates that term behind this flag, off by default, so
+ // enable it here or the ~1300:1-inertia trees tumble along wrong paths.
+ let handle = world.insert_body(RigidBodyBuilder::dynamic().translation(position));
+ for hull in &hulls {
+ world.insert_collider(
+ ColliderBuilder::new(hull.clone())
+ .density(1.0)
+ .friction(0.9),
+ Some(handle),
+ );
+ }
+
+ let velocity_scale = 0.5 + (0.5 * body_index as f32) / body_count as f32;
+ let center = world.bodies[handle].center_of_mass();
+ let omega = Vector::new(0.0, 0.0, velocity_scale * angular_velocity);
+ let v = omega.cross(center - position);
+ let body = &mut world.bodies[handle];
+ body.set_angvel(omega, true);
+ body.set_linvel(v, true);
+
+ z += 3.0;
+ angular_velocity = -angular_velocity;
+ }
+}
+
+/// box3d `b3CreateWaveMesh` (`src/mesh.c`): a grid whose height is a product of
+/// two sinusoids, used as the "trees" benchmark ground.
+fn create_wave_mesh(
+ x_count: usize,
+ z_count: usize,
+ cell_width: f32,
+ amplitude: f32,
+ row_frequency: f32,
+ column_frequency: f32,
+) -> (Vec, Vec<[u32; 3]>) {
+ let omega_z = 2.0 * PI * row_frequency * cell_width;
+ let omega_x = 2.0 * PI * column_frequency * cell_width;
+ grid_mesh(x_count, z_count, cell_width, |ix, iz| {
+ amplitude * (omega_x * ix as f32).sin() * (omega_z * iz as f32).sin()
+ })
+}
+
+/// Shared vertex/index grid builder for the box3d grid/wave meshes. `height`
+/// yields the Y coordinate at grid cell `(ix, iz)`.
+fn grid_mesh(
+ x_count: usize,
+ z_count: usize,
+ cell_width: f32,
+ height: impl Fn(usize, usize) -> f32,
+) -> (Vec, Vec<[u32; 3]>) {
+ let x_width = cell_width * x_count as f32;
+ let z_width = cell_width * z_count as f32;
+
+ let mut vertices = Vec::with_capacity((x_count + 1) * (z_count + 1));
+ let mut x = -0.5 * x_width;
+ for ix in 0..=x_count {
+ let mut z = -0.5 * z_width;
+ for iz in 0..=z_count {
+ vertices.push(Vector::new(x, height(ix, iz), z));
+ z += cell_width;
+ }
+ x += cell_width;
+ }
+
+ let mut indices = Vec::with_capacity(2 * x_count * z_count);
+ for ix in 0..x_count {
+ for iz in 0..z_count {
+ let i1 = (iz + (z_count + 1) * ix) as u32;
+ let i2 = i1 + 1;
+ let i3 = i2 + (z_count as u32 + 1);
+ let i4 = i3 - 1;
+ indices.push([i1, i2, i3]);
+ indices.push([i3, i4, i1]);
+ }
+ }
+ (vertices, indices)
+}
+
+/// box3d `b3CreateGridMesh` (`src/mesh.c`): a flat `x_count * z_count` grid.
+fn create_grid_mesh(
+ x_count: usize,
+ z_count: usize,
+ cell_width: f32,
+) -> (Vec, Vec<[u32; 3]>) {
+ grid_mesh(x_count, z_count, cell_width, |_, _| 0.0)
+}
+
+/// box3d `b3CreateCylinder` (`src/hull.c`): `2 * sides` points forming a
+/// cylinder of the given `height`/`radius`, its base at `y_offset`, aligned
+/// with the Y axis. Returned as a convex point cloud (rapier builds the hull).
+fn create_cylinder(height: f32, radius: f32, y_offset: f32, sides: usize) -> Vec {
+ let mut points = Vec::with_capacity(2 * sides);
+ let delta_alpha = 2.0 * PI / sides as f32;
+ let mut alpha = 0.0f32;
+ for _ in 0..sides {
+ let (sin_a, cos_a) = alpha.sin_cos();
+ points.push(Vector::new(radius * cos_a, y_offset, radius * sin_a));
+ points.push(Vector::new(
+ radius * cos_a,
+ y_offset + height,
+ radius * sin_a,
+ ));
+ alpha += delta_alpha;
+ }
+ points
+}
diff --git a/examples3d/b3d_washer.rs b/examples3d/b3d_washer.rs
new file mode 100644
index 000000000..c05d71c51
--- /dev/null
+++ b/examples3d/b3d_washer.rs
@@ -0,0 +1,111 @@
+//! Port of box3d's `washer` benchmark (`CreateWasher`,
+//! `box3d/shared/benchmarks.c`). Release settings: a spinning kinematic
+//! "washer" (a ring built from ~40 convex hulls) tumbling a 20x20x20 grid of
+//! small cubes.
+
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+use std::f32::consts::PI;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ // box3d's `b3DefaultWorldDef`: gravity (0, -10, 0). box3d steps at dt = 1/60
+ // with 4 solver substeps, matching rapier's defaults.
+ let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -10.0, 0.0);
+
+ // Ground.
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0, 0.0)),
+ ColliderBuilder::cuboid(60.0, 1.0, 60.0),
+ );
+
+ // Spinning kinematic washer body (velocity-based: constant angular +
+ // tiny linear velocity, matching box3d's kinematic branch).
+ let motor_speed = 25.0f32;
+ let washer = world.insert_body(
+ RigidBodyBuilder::kinematic_velocity_based()
+ .translation(Vector::new(0.0, 21.0, 0.0))
+ .angvel(Vector::new(0.0, 0.0, (PI / 180.0) * motor_speed))
+ .linvel(Vector::new(0.001, -0.002, 0.0)),
+ );
+
+ let r0 = 14.0f32;
+ let r1 = 16.0f32;
+ let r2 = 18.0f32;
+ let neg_d = Vector::new(0.0, 0.0, -10.0);
+ let pos_d = Vector::new(0.0, 0.0, 10.0);
+
+ let angle = PI / 18.0;
+ let q = Rotation::from_axis_angle(Vector::Z, angle);
+ let qo = Rotation::from_axis_angle(Vector::Z, 0.1 * angle);
+ let mut u1 = Vector::new(1.0, 0.0, 0.0);
+ for i in 0..36 {
+ let u2 = if i == 35 {
+ Vector::new(1.0, 0.0, 0.0)
+ } else {
+ q * u1
+ };
+
+ {
+ let a1 = qo.inverse() * u1;
+ let a2 = qo * u2;
+ let points = [
+ neg_d + r1 * a1,
+ neg_d + r2 * a1,
+ neg_d + r1 * a2,
+ neg_d + r2 * a2,
+ pos_d + r1 * a1,
+ pos_d + r2 * a1,
+ pos_d + r1 * a2,
+ pos_d + r2 * a2,
+ ];
+ world.insert_collider(ColliderBuilder::convex_hull(&points).unwrap(), Some(washer));
+ }
+
+ if i % 9 == 0 {
+ let points = [
+ neg_d + r0 * u1,
+ neg_d + r1 * u1,
+ neg_d + r0 * u2,
+ neg_d + r1 * u2,
+ pos_d + r0 * u1,
+ pos_d + r1 * u1,
+ pos_d + r0 * u2,
+ pos_d + r1 * u2,
+ ];
+ world.insert_collider(ColliderBuilder::convex_hull(&points).unwrap(), Some(washer));
+ }
+
+ u1 = u2;
+ }
+
+ // Grid of small cubes.
+ let grid_count = 20i32;
+ let a = 0.2f32;
+ let mut x = -2.0 * a * grid_count as f32;
+ for _ in 0..grid_count {
+ let mut y = -2.0 * a * grid_count as f32 + 21.0;
+ for _ in 0..grid_count {
+ let mut z = -2.0 * a * grid_count as f32;
+ for _ in 0..grid_count {
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(x, y, z)),
+ ColliderBuilder::cuboid(a, a, a).density(1000.0),
+ );
+ z += 4.0 * a;
+ }
+ y += 4.0 * a;
+ }
+ x += 4.0 * a;
+ }
+
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(60.0, 35.0, 60.0), Vec3::new(0.0, 15.0, 0.0));
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples3d/fountain3.rs b/examples3d/fountain3.rs
index bce8ffddb..44b26bb9b 100644
--- a/examples3d/fountain3.rs
+++ b/examples3d/fountain3.rs
@@ -1,7 +1,7 @@
use rapier_testbed3d::TestbedViewer;
use rapier3d::prelude::*;
-const MAX_NUMBER_OF_BODIES: usize = 400;
+const MAX_NUMBER_OF_BODIES: usize = 2000;
pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
let mut world = PhysicsWorld::new();
@@ -14,12 +14,9 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
let ground_size = 40.0;
let ground_height = 2.1; // 16.0;
- for k in 0..3 {
- let rigid_body =
- RigidBodyBuilder::fixed().translation(Vector::new(0.0, -ground_height - k as f32, 0.0));
- let collider = ColliderBuilder::cuboid(ground_size, ground_height, ground_size);
- let (_handle, _) = world.insert(rigid_body, collider);
- }
+ let rigid_body = RigidBodyBuilder::fixed().translation(Vector::new(0.0, -ground_height, 0.0));
+ let collider = ColliderBuilder::cuboid(ground_size, ground_height, ground_size);
+ let (_handle, _) = world.insert(rigid_body, collider);
/*
* Set up the viewer.
diff --git a/examples3d/newton_cradle3.rs b/examples3d/newton_cradle3.rs
index f39300aaf..2c3848829 100644
--- a/examples3d/newton_cradle3.rs
+++ b/examples3d/newton_cradle3.rs
@@ -16,7 +16,7 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
for i in 0..n {
let (ball_pos, attach) = (
- Vector::new(i as Real * 2.2 * radius, 0.0, 0.0),
+ Vector::new(i as Real * 2.02 * radius, 0.0, 0.0),
Vector::Y * length,
);
let vel = if i >= n - 1 {
diff --git a/examples3d/stress_tests/convex_polyhedron3.rs b/examples3d/stress_tests/convex_polyhedron3.rs
index 3f153c4fa..d461d3206 100644
--- a/examples3d/stress_tests/convex_polyhedron3.rs
+++ b/examples3d/stress_tests/convex_polyhedron3.rs
@@ -37,6 +37,17 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
let mut rng = StdRng::seed_from_u64(0);
let distribution = StandardUniform;
+ let poly_shape: Vec<_> = (0..5)
+ .map(|_| {
+ let mut points = Vec::new();
+ for _ in 0..10 {
+ let pt: [f32; 3] = distribution.sample(&mut rng);
+ points.push(Vec3::from(pt) * scale);
+ }
+ SharedShape::round_convex_hull(&points, border_rad).unwrap()
+ })
+ .collect();
+
for j in 0usize..47 {
for i in 0..num {
for k in 0usize..num {
@@ -44,15 +55,9 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
let y = j as f32 * shift + centery + 3.0;
let z = k as f32 * shift - centerz + offset;
- let mut points = Vec::new();
- for _ in 0..10 {
- let pt: [f32; 3] = distribution.sample(&mut rng);
- points.push(Vec3::from(pt) * scale);
- }
-
// Build the rigid body.
let rigid_body = RigidBodyBuilder::dynamic().translation(Vec3::new(x, y, z));
- let collider = ColliderBuilder::round_convex_hull(&points, border_rad).unwrap();
+ let collider = ColliderBuilder::new(poly_shape[(i + k) % 5].clone());
let _ = world.insert(rigid_body, collider);
}
}
diff --git a/examples3d/stress_tests/mod.rs b/examples3d/stress_tests/mod.rs
index daa7609fa..0c0baacc1 100644
--- a/examples3d/stress_tests/mod.rs
+++ b/examples3d/stress_tests/mod.rs
@@ -17,6 +17,8 @@ pub mod many_pyramids3;
pub mod many_sleep3;
pub mod many_static3;
pub mod pyramid3;
+pub mod ragdolls3;
pub mod ray_cast3;
+pub mod ropes3;
pub mod stacks3;
pub mod trimesh3;
diff --git a/examples3d/stress_tests/pyramid3.rs b/examples3d/stress_tests/pyramid3.rs
index a0246d2c6..f29a27cd4 100644
--- a/examples3d/stress_tests/pyramid3.rs
+++ b/examples3d/stress_tests/pyramid3.rs
@@ -1,73 +1,65 @@
+//! A wide brick-laid box pyramid: slightly shrunken 1.95-cubes on
+//! a 2.25 pitch with a 1.0 brick offset — which is *not* half of 2.25 — so the
+//! boxes rest on four **unequal** corner patches (0.95 and 0.70 wide). The
+//! asymmetry is deliberate: a perfectly symmetric brick is a degenerate,
+//! marginally-stable configuration.
+//!
+//! 50 layers with (50−i)² boxes per layer (~43k bodies), each box on
+//! four corner supports, dropped onto a large ground box.
+
use rapier_testbed3d::TestbedViewer;
use rapier3d::prelude::*;
-fn create_pyramid(
- bodies: &mut RigidBodySet,
- colliders: &mut ColliderSet,
- offset: Vec3,
- stack_height: usize,
- half_extents: Vec3,
-) {
- let shift = half_extents * 2.5;
- for i in 0usize..stack_height {
- for j in i..stack_height {
- for k in i..stack_height {
- let fi = i as f32;
- let fj = j as f32;
- let fk = k as f32;
- let x = (fi * shift.x / 2.0) + (fk - fi) * shift.x + offset.x
- - stack_height as f32 * half_extents.x;
- let y = fi * shift.y + offset.y;
- let z = (fi * shift.z / 2.0) + (fj - fi) * shift.z + offset.z
- - stack_height as f32 * half_extents.z;
-
- // Build the rigid body.
- let rigid_body = RigidBodyBuilder::dynamic().translation(Vec3::new(x, y, z));
- let rigid_body_handle = bodies.insert(rigid_body);
-
- let collider =
- ColliderBuilder::cuboid(half_extents.x, half_extents.y, half_extents.z);
- colliders.insert_with_parent(collider, rigid_body_handle, bodies);
- }
- }
- }
-}
-
pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
- /*
- * World
- */
let mut world = PhysicsWorld::new();
+ world.gravity = Vector::new(0.0, -9.81, 0.0);
/*
- * Ground
+ * Ground: a 100×1×100 half-extents box at y = -1, so its top face is y = 0.
*/
- let ground_size = 50.0;
- let ground_height = 0.1;
-
- let rigid_body = RigidBodyBuilder::fixed().translation(Vec3::new(0.0, -ground_height, 0.0));
- let collider = ColliderBuilder::cuboid(ground_size, ground_height, ground_size);
- let _ = world.insert(rigid_body, collider);
+ world.insert(
+ RigidBodyBuilder::fixed().translation(Vector::new(0.0, -1.0, 0.0)),
+ ColliderBuilder::cuboid(100.0, 1.0, 100.0),
+ );
/*
- * Create the cubes
+ * The pyramid.
*/
- let cube_size = 1.0;
- let hext = Vec3::splat(cube_size);
- let bottomy = cube_size;
- create_pyramid(
- &mut world.bodies,
- &mut world.colliders,
- Vec3::new(0.0, bottomy, 0.0),
- 24,
- hext,
- );
+ let pyramid_height = 50i32;
+ let box_size = 2.0;
+ let box_separation = 0.5;
+ let half_box_size = 0.5 * box_size;
+ // Shrunken cube: the boxes never *quite* fill their lattice cell.
+ let h = half_box_size - 0.025;
+
+ for i in 0..pyramid_height {
+ // Odd layers are brick-offset by a half box (1.0) — note this is NOT
+ // half of the 2.25 lateral pitch, which is what makes the four corner
+ // supports unequal.
+ let brick = if i & 1 != 0 { half_box_size } else { 0.0 };
+ let y = 1.0 + (box_size + box_separation) * i as f32;
+
+ for j in i / 2..pyramid_height - (i + 1) / 2 {
+ for k in i / 2..pyramid_height - (i + 1) / 2 {
+ let x = -(pyramid_height as f32) + (box_size + 0.25) * j as f32 + brick;
+ let z = -(pyramid_height as f32) + (box_size + 0.25) * k as f32 + brick;
+
+ world.insert(
+ RigidBodyBuilder::dynamic().translation(Vector::new(x, y, z)),
+ // Water density (1000). For a uniform-density pile this
+ // changes nothing dynamically (the soft-contact coefficients
+ // are mass-normalized).
+ ColliderBuilder::cuboid(h, h, h).density(1000.0),
+ );
+ }
+ }
+ }
/*
* Set up the testbed.
*/
viewer.set_world(&mut world);
- viewer.look_at(Vec3::new(100.0, 100.0, 100.0), Vec3::ZERO);
+ viewer.look_at(Vec3::new(200.0, 130.0, 200.0), Vec3::new(5.0, 50.0, 5.0));
while viewer.render_frame(&mut world).await {
if viewer.simulating() {
diff --git a/examples3d/stress_tests/ragdolls3.rs b/examples3d/stress_tests/ragdolls3.rs
new file mode 100644
index 000000000..edc576a2e
--- /dev/null
+++ b/examples3d/stress_tests/ragdolls3.rs
@@ -0,0 +1,125 @@
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+
+/// A 10-body articulated ragdoll (torso, head, 2×2 arm links, 2×2 leg links)
+/// whose torso center is at `origin`. 9 joints, all with limits: spherical
+/// neck, shoulders and hips; revolute elbows and knees.
+fn ragdoll(world: &mut PhysicsWorld, origin: Vec3) {
+ let part = |world: &mut PhysicsWorld, offset: Vec3, collider: ColliderBuilder| {
+ let body = RigidBodyBuilder::dynamic().translation(origin + offset);
+ let (handle, _) = world.insert(body, collider);
+ (handle, offset)
+ };
+
+ let spherical = |parent: (RigidBodyHandle, Vec3),
+ child: (RigidBodyHandle, Vec3),
+ anchor: Vec3,
+ limit: f32| {
+ SphericalJointBuilder::new()
+ .local_anchor1(anchor - parent.1)
+ .local_anchor2(anchor - child.1)
+ .limits(JointAxis::AngX, [-limit, limit])
+ .limits(JointAxis::AngY, [-limit, limit])
+ .limits(JointAxis::AngZ, [-limit, limit])
+ .contacts_enabled(false)
+ };
+ let revolute = |parent: (RigidBodyHandle, Vec3),
+ child: (RigidBodyHandle, Vec3),
+ anchor: Vec3,
+ limits: [f32; 2]| {
+ RevoluteJointBuilder::new(Vec3::Z)
+ .local_anchor1(anchor - parent.1)
+ .local_anchor2(anchor - child.1)
+ .limits(limits)
+ .contacts_enabled(false)
+ };
+
+ let torso = part(world, Vec3::ZERO, ColliderBuilder::capsule_y(0.3, 0.15));
+ let head = part(
+ world,
+ Vec3::new(0.0, 0.55, 0.0),
+ ColliderBuilder::ball(0.15),
+ );
+ let neck = spherical(torso, head, Vec3::new(0.0, 0.42, 0.0), 0.5);
+ world.insert_impulse_joint(torso.0, head.0, neck);
+
+ for side in [-1.0f32, 1.0] {
+ let upper_arm = part(
+ world,
+ Vec3::new(side * 0.36, 0.25, 0.0),
+ ColliderBuilder::capsule_x(0.14, 0.06),
+ );
+ let forearm = part(
+ world,
+ Vec3::new(side * 0.70, 0.25, 0.0),
+ ColliderBuilder::capsule_x(0.14, 0.06),
+ );
+ let thigh = part(
+ world,
+ Vec3::new(side * 0.09, -0.52, 0.0),
+ ColliderBuilder::capsule_y(0.16, 0.07),
+ );
+ let shin = part(
+ world,
+ Vec3::new(side * 0.09, -0.92, 0.0),
+ ColliderBuilder::capsule_y(0.16, 0.07),
+ );
+
+ let shoulder = spherical(torso, upper_arm, Vec3::new(side * 0.19, 0.25, 0.0), 1.2);
+ world.insert_impulse_joint(torso.0, upper_arm.0, shoulder);
+ let elbow = revolute(
+ upper_arm,
+ forearm,
+ Vec3::new(side * 0.53, 0.25, 0.0),
+ [0.0, 2.5],
+ );
+ world.insert_impulse_joint(upper_arm.0, forearm.0, elbow);
+ let hip = spherical(torso, thigh, Vec3::new(side * 0.09, -0.33, 0.0), 1.0);
+ world.insert_impulse_joint(torso.0, thigh.0, hip);
+ let knee = revolute(thigh, shin, Vec3::new(side * 0.09, -0.72, 0.0), [0.0, 2.3]);
+ world.insert_impulse_joint(thigh.0, shin.0, knee);
+ }
+}
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ /*
+ * World
+ */
+ let mut world = PhysicsWorld::new();
+
+ /*
+ * Ground
+ */
+ let (ground, _) = world.insert(
+ RigidBodyBuilder::fixed().translation(Vec3::new(0.0, -1.0, 0.0)),
+ ColliderBuilder::cuboid(100.0, 1.0, 100.0),
+ );
+ let _ = ground;
+
+ /*
+ * Ragdolls dropped into a pile: a 5x5 grid, 5 layers (125 ragdolls,
+ * 1250 dynamic bodies, 1125 limit joints).
+ */
+ for layer in 0..5 {
+ for row in 0..5 {
+ for col in 0..5 {
+ let origin =
+ Vec3::new(col as f32 * 2.2, 1.5 + layer as f32 * 2.6, row as f32 * 2.2);
+ ragdoll(&mut world, origin);
+ }
+ }
+ }
+
+ /*
+ * Set up the testbed.
+ */
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(-12.0, 10.0, -12.0), Vec3::new(4.5, 1.0, 4.5));
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/examples3d/stress_tests/ropes3.rs b/examples3d/stress_tests/ropes3.rs
new file mode 100644
index 000000000..ed2e517bb
--- /dev/null
+++ b/examples3d/stress_tests/ropes3.rs
@@ -0,0 +1,64 @@
+use rapier_testbed3d::TestbedViewer;
+use rapier3d::prelude::*;
+
+pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
+ /*
+ * World
+ */
+ let mut world = PhysicsWorld::new();
+
+ /*
+ * An 8x8 grid of hanging ropes, 60 capsule segments each (3840 dynamic
+ * bodies, one spherical joint per segment), swinging from an initial
+ * sideways kick. Nearly contact-free: a joint-solver stress test.
+ */
+ let segments = 60;
+ let seg_len = 1.0;
+
+ for i in 0..8 {
+ for k in 0..8 {
+ let top = Vec3::new(i as f32 * 4.0, 0.0, k as f32 * 4.0);
+ let mut parent = world
+ .bodies
+ .insert(RigidBodyBuilder::fixed().translation(top));
+
+ for s in 0..segments {
+ let center = top + Vec3::new(0.0, -(s as f32 + 0.5) * seg_len, 0.0);
+ let body = RigidBodyBuilder::dynamic()
+ .translation(center)
+ .linvel(Vec3::new(2.0, 0.0, 0.0));
+ let handle = world.bodies.insert(body);
+ world.colliders.insert_with_parent(
+ ColliderBuilder::capsule_y(0.35, 0.1),
+ handle,
+ &mut world.bodies,
+ );
+
+ let anchor1 = if s == 0 {
+ Vec3::ZERO
+ } else {
+ Vec3::new(0.0, -seg_len / 2.0, 0.0)
+ };
+ let joint = SphericalJointBuilder::new()
+ .local_anchor1(anchor1)
+ .local_anchor2(Vec3::new(0.0, seg_len / 2.0, 0.0))
+ .contacts_enabled(false);
+ world.insert_impulse_joint(parent, handle, joint);
+ parent = handle;
+ }
+ }
+ }
+
+ /*
+ * Set up the testbed.
+ */
+ viewer.set_world(&mut world);
+ viewer.look_at(Vec3::new(-45.0, -10.0, -45.0), Vec3::new(14.0, -30.0, 14.0));
+
+ while viewer.render_frame(&mut world).await {
+ if viewer.simulating() {
+ world.step();
+ }
+ }
+ Ok(())
+}
diff --git a/python/README.md b/python/README.md
index cc76c1a3c..7bb9bd91e 100644
--- a/python/README.md
+++ b/python/README.md
@@ -62,6 +62,22 @@ maturin develop -m python/rapier-py-3d/Cargo.toml # 3D f32 -> import rapier
python -c "import rapier3d; print(rapier3d.__version__)" # smoke check
```
+### Threads
+
+The engine is always built multi-threaded, and `step()` releases the GIL while
+it runs. A world defaults to one worker per logical CPU; each world owns its
+pool, so worlds stepped from different Python threads don't compete:
+
+```python
+world.set_num_threads(4) # four workers for this world
+world.num_threads # -> 4
+world.set_num_threads(1) # everything inline on the calling thread
+world.set_num_threads(None) # back to one worker per logical CPU
+```
+
+The worker count never changes the result: the same scene stepped with 1 and
+with 8 workers gives bit-identical states.
+
### Run the test suite
```bash
diff --git a/python/docs/changelog.rst b/python/docs/changelog.rst
index ae528f818..ef93c9789 100644
--- a/python/docs/changelog.rst
+++ b/python/docs/changelog.rst
@@ -26,3 +26,12 @@ The package is a standard ``abi3`` maturin wheel, so installs and platform
support (manylinux/musllinux, macOS, Windows) now go through the normal
wheel pipeline. The Panda3D testbed moved to a separate ``rapier-testbed``
package.
+
+**Multi-threaded by default.** The wheels are now built with the engine's
+``parallel`` feature always enabled, and the worker count is a runtime
+setting: :meth:`~rapier3d.PhysicsWorld.set_num_threads` (also on
+:class:`~rapier3d.PhysicsPipeline`) picks how many workers a world's
+parallel stages run on, and :attr:`~rapier3d.PhysicsWorld.num_threads`
+reports it. Each world owns its pool; ``None`` restores the default of one
+worker per logical CPU and ``1`` runs everything inline on the calling
+thread. Results are bit-identical whatever the worker count.
diff --git a/python/docs/getting_started.rst b/python/docs/getting_started.rst
index 5c65242ff..7fde879a0 100644
--- a/python/docs/getting_started.rst
+++ b/python/docs/getting_started.rst
@@ -62,6 +62,29 @@ and attaching colliders to the resulting rigid body. The lower-level
``world.rigid_bodies.insert(...)`` + ``world.colliders.insert_with_parent(...)``
flow is also available and matches the Rust API.
+Choosing the number of threads
+------------------------------
+
+The bindings always ship the multi-threaded engine, and ``step()``
+releases the GIL while it runs. By default a world spreads its parallel
+stages over as many workers as there are logical CPUs; give it a
+different worker count with ``set_num_threads``::
+
+ world.set_num_threads(4) # four workers, private to this world
+ print(world.num_threads) # -> 4
+
+ world.set_num_threads(1) # everything inline on the calling thread
+ world.set_num_threads(None) # back to one worker per logical CPU
+
+Each world gets its own pool, so several worlds stepped from different
+Python threads don't compete for one another's workers. On CPUs that mix
+performance and efficiency cores, passing the performance-core count is
+usually faster than the default: the solver's stages advance at the speed
+of their slowest worker.
+
+The worker count never changes the result of a simulation — stepping the
+same scene with 1 and with 8 workers gives bit-identical states.
+
What to read next
-----------------
diff --git a/python/rapier-py-3d/Cargo.toml b/python/rapier-py-3d/Cargo.toml
index 4a9e55414..8f654105c 100644
--- a/python/rapier-py-3d/Cargo.toml
+++ b/python/rapier-py-3d/Cargo.toml
@@ -36,7 +36,11 @@ pyo3 = { version = "0.22", features = ["extension-module", "abi3-py39", "multipl
numpy = "0.22"
nalgebra.workspace = true
thiserror.workspace = true
-rapier3d = { workspace = true, features = ["serde-serialize", "debug-render", "simd-stable"] }
+rapier3d = { workspace = true, features = [
+ "serde-serialize",
+ "debug-render",
+ "parallel",
+] }
# Phase 09 — Loaders. URDF + MJCF + mesh. Mesh-loader features are
# explicitly enabled so .stl / .obj / .dae meshes referenced from URDF /
# MJCF files work out of the box.
diff --git a/python/rapier-py-3d/python/rapier3d/_rapier3d.pyi b/python/rapier-py-3d/python/rapier3d/_rapier3d.pyi
index a1b86771f..114079fc7 100644
--- a/python/rapier-py-3d/python/rapier3d/_rapier3d.pyi
+++ b/python/rapier-py-3d/python/rapier3d/_rapier3d.pyi
@@ -815,9 +815,13 @@ class IntegrationParameters:
num_solver_iterations: int
num_internal_pgs_iterations: int
num_internal_stabilization_iterations: int
- min_island_size: int
+ contact_clustering: bool
+ contact_recycling: bool
+ normalized_contact_recycle_distance: float
+ normalized_max_linear_velocity: float
max_ccd_substeps: int
contact_softness: SpringCoefficients
+ static_contact_softness: SpringCoefficients
friction_model: FrictionModel
def __init__(self) -> None: ...
@staticmethod
@@ -1604,9 +1608,8 @@ class BroadPhaseBvh:
class SolverContact:
point: Point3
+ point2: Point3
dist: float
- friction: float
- restitution: float
contact_id: int
is_new: bool
@@ -1659,6 +1662,8 @@ class ContactModificationContext:
@property
def local_n2(self) -> Vec3: ...
normal: Vec3
+ friction: float
+ restitution: float
user_data: int
@property
def solver_contacts(self) -> list[SolverContact]: ...
diff --git a/python/rapier-py-3d/src/dynamics.rs b/python/rapier-py-3d/src/dynamics.rs
index 892c0c0d1..56f4336a2 100644
--- a/python/rapier-py-3d/src/dynamics.rs
+++ b/python/rapier-py-3d/src/dynamics.rs
@@ -1302,16 +1302,47 @@ impl IntegrationParameters {
fn set_num_internal_stabilization_iterations(&mut self, v: usize) {
self.0.num_internal_stabilization_iterations = v;
}
- /// Minimum number of bodies in an island before it is solved in
- /// parallel.
+ /// Cluster the sub-shape manifolds of composite-shape pairs before
+ /// the solver (on by default).
#[getter]
- fn min_island_size(&self) -> usize {
- self.0.min_island_size
+ fn contact_clustering(&self) -> bool {
+ self.0.contact_clustering
}
- /// Set the minimum parallel-island size.
+ /// Enable/disable contact clustering.
#[setter]
- fn set_min_island_size(&mut self, v: usize) {
- self.0.min_island_size = v;
+ fn set_contact_clustering(&mut self, v: bool) {
+ self.0.contact_clustering = v;
+ }
+ /// Reuse contact manifolds while the pair's relative pose drift stays
+ /// below the recycle distance (on by default).
+ #[getter]
+ fn contact_recycling(&self) -> bool {
+ self.0.contact_recycling
+ }
+ /// Enable/disable contact recycling.
+ #[setter]
+ fn set_contact_recycling(&mut self, v: bool) {
+ self.0.contact_recycling = v;
+ }
+ /// Contact-recycling drift threshold, in fractions of ``length_unit``.
+ #[getter]
+ fn normalized_contact_recycle_distance(&self) -> Real {
+ self.0.normalized_contact_recycle_distance
+ }
+ /// Set the normalized contact-recycle distance.
+ #[setter]
+ fn set_normalized_contact_recycle_distance(&mut self, v: Real) {
+ self.0.normalized_contact_recycle_distance = v;
+ }
+ /// Per-substep linear speed cap, in ``length_unit``s per second.
+ #[getter]
+ fn normalized_max_linear_velocity(&self) -> Real {
+ self.0.normalized_max_linear_velocity
+ }
+ /// Set the normalized linear speed cap.
+ #[setter]
+ fn set_normalized_max_linear_velocity(&mut self, v: Real) {
+ self.0.normalized_max_linear_velocity = v;
}
/// Maximum number of CCD substeps per simulation step.
#[getter]
@@ -1335,6 +1366,19 @@ impl IntegrationParameters {
self.0.contact_softness = v.0;
}
+ /// Stiffer spring coefficients applied to contacts touching a fixed
+ /// body (defaults to twice the natural frequency of
+ /// ``contact_softness``).
+ #[getter]
+ fn static_contact_softness(&self) -> SpringCoefficients {
+ SpringCoefficients(self.0.static_contact_softness)
+ }
+ /// Set the static-contact-spring coefficients.
+ #[setter]
+ fn set_static_contact_softness(&mut self, v: SpringCoefficients) {
+ self.0.static_contact_softness = v.0;
+ }
+
/// 3D friction model used by the contact solver.
#[getter]
fn friction_model(&self) -> FrictionModel {
diff --git a/python/rapier-py-3d/src/events_hooks.rs b/python/rapier-py-3d/src/events_hooks.rs
index 4b071b2a7..4d7b625da 100644
--- a/python/rapier-py-3d/src/events_hooks.rs
+++ b/python/rapier-py-3d/src/events_hooks.rs
@@ -20,7 +20,9 @@
//! from a worker thread. Our adapter types (`PyEventHandler`, `PyPhysicsHooks`,
//! `ChannelEventCollector`) hold `Py` callables; each callback wraps the
//! call in `Python::with_gil(...)` to re-acquire the GIL before touching any
-//! Python object.
+//! Python object. That is also what makes the adapters `Sync`, as the engine's
+//! worker threads require: `Py` and `Arc>` are `Sync` on their
+//! own, so no `unsafe impl` is involved.
//!
//! Exceptions raised inside Python callbacks are **deferred**: they're stashed
//! into a shared `Mutex