Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
## Unreleased

### Added

- `DebugRenderStyle::sleep_eligible_color_multiplier`: the debug-renderer now draws bodies that
are eligible for sleep but still awake in a distinct color, making it easy to spot what is
keeping a pile awake.

### Fixed

- Python: `DynamicRayCastVehicleController.update_vehicle` now excludes the chassis body from
the suspension raycasts by default (their origins sit on its own collider).

### Modified

- Sleep eligibility is now judged on the actual per-step pose displacement (measured at the
body’s farthest point) instead of the velocities: a body held in place by contacts can sleep
even with residual solver velocities, while a body creeping through solver position
corrections cannot.
- With the `block-solver` feature, manifolds whose 2x2 constraint matrix is almost singular
(e.g. redundant contact points) are now solved with the sequential solver instead of a
degraded one-point solve, removing residual jiggle in piles. The block solver also uses the
same lexicographic manifold point ordering as the sequential solver.
- Improved 3D solver performance by caching the constraints angular jacobians instead of
recomputing them at each solver iteration.

## v0.35.0-beta.0 (02 August 2026)

### Breaking changes
Expand Down
10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ 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.1", default-features = false }
simba = { version = "0.10.2", 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.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"] }
parry2d = { version = "0.30.1", default-features = false, features = ["required-features"] }
parry3d = { version = "0.30.1", default-features = false, features = ["required-features"] }
parry2d-f64 = { version = "0.30.1", default-features = false, features = ["required-features"] }
parry3d-f64 = { version = "0.30.1", default-features = false, features = ["required-features"] }

# Utilities
arrayvec = { version = "0.7", default-features = false }
Expand Down
2 changes: 1 addition & 1 deletion crates/rapier2d/tests/snapshot_portability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ 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 GOLDEN: (usize, u64) = (88_532, 0x38c1_e725_5669_8eba);

const STEPS: usize = 60;

Expand Down
2 changes: 1 addition & 1 deletion crates/rapier3d/tests/parallel_path_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ 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;
const GOLDEN: u64 = 0xa8b2_0bad_6b09_7e3b;

/// FNV-1a.
struct Fnv(u64);
Expand Down
2 changes: 1 addition & 1 deletion crates/rapier3d/tests/simd_backend_determinism.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ 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;
const GOLDEN: u64 = 0xe488_2a11_2d57_d212;
let hash = run(120);
assert_eq!(
hash, GOLDEN,
Expand Down
99 changes: 99 additions & 0 deletions crates/rapier3d/tests/sleep_wide_bodies.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! A body that has stopped moving must fall asleep however far its shape reaches.
//!
//! Deriving the rotation chord from `sqrt(1 − dot²)` turns dot-product rounding into a drift
//! floor that scales with `max_extent`, so wide still bodies could never sleep.

use rapier3d::prelude::*;

/// A `U`-shaped compound (a wide bar plus two uprights), as in `stress_tests/compound3`:
/// `max_extent` is ~4, well past the point where the old floor swamped the allowance.
fn wide_compound_world() -> (PhysicsWorld, Vec<RigidBodyHandle>) {
let mut world = PhysicsWorld::new();
world.insert(
RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.1, 0.0)),
ColliderBuilder::cuboid(50.0, 0.1, 50.0),
);

let rad = 0.2;
let mut handles = Vec::new();
// Varied yaws: a single orientation can miss the rounding floor by luck; a spread cannot.
for i in 0..8 {
for k in 0..8 {
let (handle, _) = world.insert(
RigidBodyBuilder::dynamic()
.translation(Vector::new(i as f32 * 6.0, rad + 0.01, k as f32 * 6.0))
.rotation(Vector::Y * (i as f32 * 0.11 + k as f32 * 0.037)),
ColliderBuilder::cuboid(rad * 10.0, rad, rad),
);
world.insert_collider(
ColliderBuilder::cuboid(rad, rad * 10.0, rad).translation(Vector::new(
rad * 10.0,
rad * 10.0,
0.0,
)),
Some(handle),
);
world.insert_collider(
ColliderBuilder::cuboid(rad, rad * 10.0, rad).translation(Vector::new(
-rad * 10.0,
rad * 10.0,
0.0,
)),
Some(handle),
);
handles.push(handle);
}
}
(world, handles)
}

#[test]
fn wide_bodies_at_rest_fall_asleep() {
let (mut world, handles) = wide_compound_world();
// At rest within a few steps + 30 steps of sleep timer; 300 is a wide margin that still
// fails outright when the drift floor scales with `max_extent`.
for _ in 0..300 {
world.step();
}

let asleep = handles
.iter()
.filter(|h| world.bodies[**h].is_sleeping())
.count();
assert_eq!(
asleep,
handles.len(),
"{} of {} wide bodies stayed awake at rest",
handles.len() - asleep,
handles.len(),
);
}

/// The floor is a property of the drift math, not of any particular scene: a body pinned to one
/// pose must report a drift of zero no matter how far its shape reaches.
#[test]
fn still_wide_body_reports_no_drift() {
let mut world = PhysicsWorld::new();
world.gravity = Vector::ZERO;
let handle = world.insert_body(
// An orientation whose pose dot product rounds rather than landing exactly on 1.0.
RigidBodyBuilder::dynamic().rotation(Vector::new(0.3, -0.7, 0.15)),
);
world.insert_collider(ColliderBuilder::cuboid(0.2, 8.0, 0.2), Some(handle));

let pose = *world.bodies[handle].position();
for _ in 0..200 {
world.step();
}

let body = &world.bodies[handle];
assert_eq!(
*body.position(),
pose,
"a force-free body should not have moved at all"
);
assert!(
body.is_sleeping(),
"a motionless body with a far-reaching shape never slept",
);
}
2 changes: 1 addition & 1 deletion crates/rapier3d/tests/snapshot_portability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ 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 GOLDEN: (usize, u64) = (481_520, 0x7577_8919_4a52_5a49);

const STEPS: usize = 60;

Expand Down
2 changes: 1 addition & 1 deletion examples2d/stress_tests/vertical_stacks2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
*/

let shiftx_centerx = [
(rad * 2.0 + 0.0002, -(num as f32) * rad * 2.0 * 1.5),
(rad * 2.0, -(num as f32) * rad * 2.0 * 1.5),
(rad * 2.0 + rad, num as f32 * rad * 2.0 * 1.5),
];

Expand Down
2 changes: 1 addition & 1 deletion examples3d/b3d_large_pyramid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
let mut world = PhysicsWorld::new();
world.gravity = Vector::new(0.0, -10.0, 0.0);

let base_count = 90i32;
let base_count = 200i32;

// Ground: b3MakeBoxHull(400, 1, 400) at y = -1.
world.insert(
Expand Down
14 changes: 2 additions & 12 deletions examples3d/keva3.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
use kiss3d::color::Color;
use rapier_testbed3d::TestbedViewer;
use rapier3d::prelude::*;

pub fn build_block(
viewer: &mut TestbedViewer,
world: &mut PhysicsWorld,
half_extents: Vector,
shift: Vector,
Expand All @@ -16,8 +14,6 @@ pub fn build_block(
let block_width = 2.0 * half_extents.z * numx as f32;
let block_height = 2.0 * half_extents.y * numy as f32;
let spacing = (half_extents.z * numx as f32 - half_extents.x) / (numz as f32 - 1.0);
let mut color0 = Color::new(0.7, 0.5, 0.9, 1.0);
let mut color1 = Color::new(0.6, 1.0, 0.6, 1.0);

for i in 0..numy {
std::mem::swap(&mut numx, &mut numz);
Expand Down Expand Up @@ -45,10 +41,7 @@ pub fn build_block(
z + dim.z + shift.z,
));
let collider = ColliderBuilder::cuboid(dim.x, dim.y, dim.z);
let (handle, _) = world.insert(rigid_body, collider);

viewer.set_initial_body_color(handle, color0);
std::mem::swap(&mut color0, &mut color1);
let _ = world.insert(rigid_body, collider);
}
}
}
Expand All @@ -65,9 +58,7 @@ pub fn build_block(
j as f32 * dim.z * 2.0 + dim.z + shift.z,
));
let collider = ColliderBuilder::cuboid(dim.x, dim.y, dim.z);
let (handle, _) = world.insert(rigid_body, collider);
viewer.set_initial_body_color(handle, color0);
std::mem::swap(&mut color0, &mut color1);
let _ = world.insert(rigid_body, collider);
}
}
}
Expand Down Expand Up @@ -104,7 +95,6 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
let numz = numx * 3 + 1;
let block_width = numx as f32 * half_extents.z * 2.0;
build_block(
viewer,
&mut world,
half_extents,
Vector::new(-block_width / 2.0, block_height, -block_width / 2.0),
Expand Down
14 changes: 2 additions & 12 deletions examples3d/stress_tests/keva3.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
use kiss3d::color::Color;
use rapier_testbed3d::TestbedViewer;
use rapier3d::glamx::Vec3Swizzles;
use rapier3d::prelude::*;

pub fn build_block(
viewer: &mut TestbedViewer,
bodies: &mut RigidBodySet,
colliders: &mut ColliderSet,
half_extents: Vec3,
Expand All @@ -15,8 +13,6 @@ pub fn build_block(
let block_width = 2.0 * half_extents.z * numx as f32;
let block_height = 2.0 * half_extents.y * numy as f32;
let spacing = (half_extents.z * numx as f32 - half_extents.x) / (numz as f32 - 1.0);
let mut color0 = Color::new(0.7, 0.5, 0.9, 1.0);
let mut color1 = Color::new(0.6, 1.0, 0.6, 1.0);

for i in 0..numy {
std::mem::swap(&mut numx, &mut numz);
Expand Down Expand Up @@ -46,9 +42,6 @@ pub fn build_block(
let handle = bodies.insert(rigid_body);
let collider = ColliderBuilder::cuboid(dim.x, dim.y, dim.z);
colliders.insert_with_parent(collider, handle, bodies);

viewer.set_initial_body_color(handle, color0);
std::mem::swap(&mut color0, &mut color1);
}
}
}
Expand All @@ -67,8 +60,6 @@ pub fn build_block(
let handle = bodies.insert(rigid_body);
let collider = ColliderBuilder::cuboid(dim.x, dim.y, dim.z);
colliders.insert_with_parent(collider, handle, bodies);
viewer.set_initial_body_color(handle, color0);
std::mem::swap(&mut color0, &mut color1);
}
}
}
Expand Down Expand Up @@ -96,15 +87,14 @@ pub async fn run(viewer: &mut TestbedViewer) -> anyhow::Result<()> {
let mut block_height = 0.0;
// These should only be set to odd values otherwise
// the blocks won't align in the nicest way.
let numy = [0, 9, 13, 17, 21, 41];
let numy = [0, 13, 17, 21, 41, 83];

for i in (1..=5).rev() {
let numx = i;
let numx = i * 2;
let numy = numy[i];
let numz = numx * 3 + 1;
let block_width = numx as f32 * half_extents.z * 2.0;
build_block(
viewer,
&mut world.bodies,
&mut world.colliders,
half_extents,
Expand Down
14 changes: 9 additions & 5 deletions python/examples/vehicle/drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,21 @@ def main() -> None:
world.update_query_pipeline()
veh.update_vehicle(1.0 / 60.0, world.rigid_bodies, world.colliders, world.query_pipeline)

# Accelerate via the rear wheels.
veh.apply_engine_force(2, 100.0)
veh.apply_engine_force(3, 100.0)
# Accelerate via the rear wheels. 30N matches the Rust demo's arrow-key
# force; more would drive off the edge of the ground before step 240.
veh.apply_engine_force(2, 30.0)
veh.apply_engine_force(3, 30.0)
for _ in range(240):
world.step()
world.update_query_pipeline()
veh.update_vehicle(1.0 / 60.0, world.rigid_bodies, world.colliders, world.query_pipeline)

speed = veh.current_speed_km_hour()
vx = world.rigid_bodies[chassis].linvel.x
print(f"vehicle: speed={speed:+.1f} km/h vx={vx:+.2f}")
body = world.rigid_bodies[chassis]
vx = body.linvel.x
# Ride height: half-height + rest length + wheel radius, minus suspension sag.
y = body.translation.y
print(f"vehicle: speed={speed:+.1f} km/h vx={vx:+.2f} y={y:+.2f}")


if __name__ == "__main__":
Expand Down
1 change: 1 addition & 0 deletions python/rapier-py-3d/python/rapier3d/_rapier3d.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2002,6 +2002,7 @@ class DebugRenderStyle:
multibody_joint_anchor_color: DebugColor
multibody_joint_separation_color: DebugColor
sleep_color_multiplier: DebugColor
sleep_eligible_color_multiplier: DebugColor
disabled_color_multiplier: DebugColor
rigid_body_axes_length: float
contact_depth_color: DebugColor
Expand Down
6 changes: 5 additions & 1 deletion python/rapier-py-3d/src/controllers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1745,6 +1745,9 @@ impl DynamicRayCastVehicleController {
/// Requires a fresh :class:`QueryPipeline` — typically call
/// ``world.update_query_pipeline()`` first.
///
/// The chassis body is excluded from the suspension raycasts (their origins
/// sit on its own collider), unless ``filter`` already excludes a body.
///
/// :param dt: Time step in seconds.
/// :param bodies: Rigid-body set (mutated).
/// :param colliders: Collider set (mutated).
Expand All @@ -1764,7 +1767,8 @@ impl DynamicRayCastVehicleController {
let np = queries.narrow_phase.borrow(py);
let mut bodies_ref = bodies.borrow_mut(py);
let mut colliders_ref = colliders.borrow_mut(py);
let qf = filter.map(|f| f.as_rapier(None)).unwrap_or_default();
let mut qf = filter.map(|f| f.as_rapier(None)).unwrap_or_default();
qf.exclude_rigid_body = qf.exclude_rigid_body.or(Some(self.0.chassis));
let qpmut = bp.0.as_query_pipeline_mut(
np.0.query_dispatcher(),
&mut bodies_ref.0,
Expand Down
13 changes: 13 additions & 0 deletions python/rapier-py-3d/src/debug_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,19 @@ impl DebugRenderStyle {
self.0.sleep_color_multiplier = DebugColor::extract_from(v)?;
Ok(())
}
/// Multiplier applied to base colors of awake bodies that are eligible for sleep:
/// the plain awake color then marks whatever is holding the pile up.
#[getter]
fn sleep_eligible_color_multiplier(&self) -> DebugColor {
DebugColor(self.0.sleep_eligible_color_multiplier)
}
#[setter]
/// Set the HSLA multiplier applied to sleep-eligible (but awake) body colors,
/// from a :class:`DebugColor` or a 4-tuple HSLA.
fn set_sleep_eligible_color_multiplier(&mut self, v: &Bound<'_, PyAny>) -> PyResult<()> {
self.0.sleep_eligible_color_multiplier = DebugColor::extract_from(v)?;
Ok(())
}
/// Multiplier applied to base colors of disabled bodies.
#[getter]
fn disabled_color_multiplier(&self) -> DebugColor {
Expand Down
Loading
Loading