From 34726f0da32763d120b7acbabac1d8f84bceec14 Mon Sep 17 00:00:00 2001 From: tomo0613 Date: Sun, 7 Jun 2026 14:39:24 +0200 Subject: [PATCH 1/3] fix wheel rotation calculation --- src/control/ray_cast_vehicle_controller.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/control/ray_cast_vehicle_controller.rs b/src/control/ray_cast_vehicle_controller.rs index 1822e3167..db86c0b57 100644 --- a/src/control/ray_cast_vehicle_controller.rs +++ b/src/control/ray_cast_vehicle_controller.rs @@ -465,10 +465,11 @@ impl DynamicRayCastVehicleController { let vel = chassis.velocity_at_point(wheel.raycast_info.hard_point_ws); if wheel.raycast_info.is_in_contact { - let mut fwd = - chassis.position().rotation * Vector::ith(self.index_forward_axis, 1.0); - let proj = fwd.dot(wheel.raycast_info.contact_normal_ws); - fwd -= wheel.raycast_info.contact_normal_ws * proj; + let fwd = wheel + .raycast_info + .contact_normal_ws + .cross(wheel.wheel_axle_ws) + .normalize_or_zero(); let proj2 = fwd.dot(vel); From 7b3914b4c68a72538bcab6fb584354d36f892078 Mon Sep 17 00:00:00 2001 From: tomo0613 Date: Sun, 7 Jun 2026 19:39:02 +0200 Subject: [PATCH 2/3] add tests --- python/tests/test_controllers.py | 104 +++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/python/tests/test_controllers.py b/python/tests/test_controllers.py index a89d0b46a..7a22f4639 100644 --- a/python/tests/test_controllers.py +++ b/python/tests/test_controllers.py @@ -417,3 +417,107 @@ def test_wheel_tuning_default_kwargs(ns): t2 = ns.WheelTuning(suspension_stiffness=10.0, friction_slip=5.0) assert abs(t2.suspension_stiffness - 10.0) < 1e-5 assert abs(t2.friction_slip - 5.0) < 1e-5 + +def test_wheel_rotation_updates_without_steering(ns): + """Regression test for https://github.com/dimforge/rapier/issues/925 + + wheel.rotation must accumulate while the vehicle drives straight forward + (engine force applied, steering = 0 on all wheels). Before the fix, + delta_rotation was computed using a forward vector inconsistent in sign + with the one used by update_friction(), so the wheel appeared frozen + (or span backwards) unless steering was applied. + """ + w = ns.PhysicsWorld(gravity=(0, -9.81, 0), auto_update_query=True) + w.add_body( + ns.RigidBody.fixed(translation=(0, -0.1, 0)), + colliders=[ns.Collider.cuboid(50, 0.1, 50)], + ) + _, veh = _build_vehicle(ns, w) + + # Let the chassis settle on the ground (no engine force yet). + for _ in range(120): + w.step() + w.update_query_pipeline() + veh.update_vehicle(1.0 / 60.0, w.rigid_bodies, w.colliders, w.query_pipeline) + + rotation_before = [veh.wheel(i).rotation for i in range(4)] + + # Drive straight forward — NO steering on any wheel. + for i in range(4): + veh.apply_engine_force(i, 100.0) + + for _ in range(60): + w.step() + w.update_query_pipeline() + veh.update_vehicle(1.0 / 60.0, w.rigid_bodies, w.colliders, w.query_pipeline) + + rotation_after = [veh.wheel(i).rotation for i in range(4)] + + # Every grounded wheel must have rotated by a non-trivial amount. + for i in range(4): + delta = abs(rotation_after[i] - rotation_before[i]) + assert delta > 0.01, ( + f"wheel {i} did not rotate without steering " + f"(before={rotation_before[i]:.4f}, after={rotation_after[i]:.4f})" + ) + + # All four wheels should rotate in the same direction (all positive or all + # negative) — they are symmetric left/right for straight driving. + signs = [ + (rotation_after[i] - rotation_before[i]) > 0 + for i in range(4) + ] + assert all(s == signs[0] for s in signs), ( + f"wheels rotated in inconsistent directions: deltas=" + f"{[rotation_after[i] - rotation_before[i] for i in range(4)]}" + ) + + +def test_wheel_rotation_direction_matches_travel(ns): + """When driving forward the wheel rotation sign must be consistent with + forward motion (not reversed), regardless of steering angle. + + This catches the sign-flip variant of the bug where rotation was non-zero + but in the wrong direction. + """ + w = ns.PhysicsWorld(gravity=(0, -9.81, 0), auto_update_query=True) + w.add_body( + ns.RigidBody.fixed(translation=(0, -0.1, 0)), + colliders=[ns.Collider.cuboid(50, 0.1, 50)], + ) + h, veh = _build_vehicle(ns, w) + + # Settle. + for _ in range(120): + w.step() + w.update_query_pipeline() + veh.update_vehicle(1.0 / 60.0, w.rigid_bodies, w.colliders, w.query_pipeline) + + # Drive forward, record wheel rotation accumulation and chassis displacement. + x_start = w.rigid_bodies[h].translation.x + for i in range(4): + veh.apply_engine_force(i, 100.0) + + for _ in range(60): + w.step() + w.update_query_pipeline() + veh.update_vehicle(1.0 / 60.0, w.rigid_bodies, w.colliders, w.query_pipeline) + + x_end = w.rigid_bodies[h].translation.x + chassis_moved_positive_x = (x_end - x_start) > 0 + + for i in range(4): + wheel_delta = veh.wheel(i).rotation + # The wheel rotation direction must agree with the direction of travel. + # (positive chassis displacement <-> positive wheel rotation and vice-versa) + if chassis_moved_positive_x: + assert wheel_delta > 0, ( + f"wheel {i} rotated in wrong direction for forward travel " + f"(delta={wheel_delta:.4f}, chassis moved +x)" + ) + else: + assert wheel_delta < 0, ( + f"wheel {i} rotated in wrong direction for forward travel " + f"(delta={wheel_delta:.4f}, chassis moved -x)" + ) + From 4a08d9bc68f32d911ab5cfa9064561319d18ad79 Mon Sep 17 00:00:00 2001 From: tomo0613 Date: Sun, 21 Jun 2026 11:39:38 +0200 Subject: [PATCH 3/3] handle wheel rotation in air and wheel locking --- src/control/ray_cast_vehicle_controller.rs | 42 +++++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/control/ray_cast_vehicle_controller.rs b/src/control/ray_cast_vehicle_controller.rs index db86c0b57..669a4af8d 100644 --- a/src/control/ray_cast_vehicle_controller.rs +++ b/src/control/ray_cast_vehicle_controller.rs @@ -47,6 +47,12 @@ pub struct WheelTuning { pub max_suspension_travel: Real, /// The multiplier of friction between a tire and the collider it's on top of. pub side_friction_stiffness: Real, + /// The rotational speed (radians per second) applied to the wheel's visual + /// rotation while it is airborne or fully sliding and engine force is applied. + /// + /// A value of 0 disables this behavior. The sign determines the spin + /// direction and depends on the wheel's axle orientation. + pub free_spin_speed: Real, /// Parameter controlling how much traction the tire has. /// /// The larger the value, the more instantaneous braking will happen (with the risk of @@ -64,6 +70,7 @@ impl Default for WheelTuning { suspension_damping: 0.88, max_suspension_travel: 5.0, side_friction_stiffness: 1.0, + free_spin_speed: 0.0, friction_slip: 10.5, max_suspension_force: 6000.0, } @@ -106,6 +113,7 @@ struct WheelDesc { pub max_suspension_force: Real, /// The multiplier of friction between a tire and the collider it's on top of. pub side_friction_stiffness: Real, + pub free_spin_speed: Real, } #[cfg_attr(feature = "serde-serialize", derive(Serialize, Deserialize))] @@ -149,6 +157,12 @@ pub struct Wheel { pub friction_slip: Real, /// The multiplier of friction between a tire and the collider it's on top of. pub side_friction_stiffness: Real, + /// The rotational speed (radians per second) applied to the wheel's visual + /// rotation while it is airborne or fully sliding and engine force is applied. + /// + /// A value of 0 disables this behavior. The sign determines the spin + /// direction and depends on the wheel's axle orientation. + pub free_spin_speed: Real, /// The wheel’s current rotation on its axle. pub rotation: Real, delta_rotation: Real, @@ -206,6 +220,7 @@ impl Wheel { side_impulse: 0.0, forward_impulse: 0.0, side_friction_stiffness: info.side_friction_stiffness, + free_spin_speed: info.free_spin_speed, } } @@ -292,6 +307,7 @@ impl DynamicRayCastVehicleController { max_suspension_travel: tuning.max_suspension_travel, max_suspension_force: tuning.max_suspension_force, side_friction_stiffness: tuning.side_friction_stiffness, + free_spin_speed: tuning.free_spin_speed, }; let wheel_id = self.wheels.len(); @@ -472,14 +488,30 @@ impl DynamicRayCastVehicleController { .normalize_or_zero(); let proj2 = fwd.dot(vel); + wheel.delta_rotation = (proj2 * dt) / wheel.radius; + } - wheel.delta_rotation = (proj2 * dt) / (wheel.radius); - wheel.rotation += wheel.delta_rotation; - } else { - wheel.rotation += wheel.delta_rotation; + // Wheelspin while airborne or sliding under power: without this, a wheel + // that's off the ground (or fully sliding) and has engine force applied + // never visually spins, since delta_rotation is only ever derived from + // ground-contact velocity above. + if (wheel.skid_info < 1.0 || !wheel.raycast_info.is_in_contact) + && wheel.engine_force != 0.0 + && wheel.free_spin_speed != 0.0 + { + let dir = if wheel.engine_force > 0.0 { 1.0 } else { -1.0 }; + wheel.delta_rotation = dir * wheel.free_spin_speed * dt; + } + + // Lock wheels: if the brake force exceeds the engine force, the wheel + // is locked by the brake and should stop visually rotating, even + // though the chassis may still be sliding over the ground. + if wheel.brake.abs() > wheel.engine_force.abs() { + wheel.delta_rotation = 0.0; } - wheel.delta_rotation *= 0.99; //damping of rotation when not in contact + wheel.rotation += wheel.delta_rotation; + wheel.delta_rotation *= 0.99; // damping of rotation when not in contact } }