Skip to content

Commit 1a1c5d5

Browse files
pierre-lJondolf
andauthored
Add joint motors for revolute and prismatic joints (#913)
# Objective Supports velocity and position control with optional timestep-independent spring-damper parameters (frequency, damping_ratio). Includes warm starting and motor force feedback via JointForces. **Partially** fixes #465 This implementation only works for joints with a single axis (`PrismaticJoint`, `RevoluteJoint`). Additional work is needed to support multiple axes (`SphericalJoint`). ## Solution Add three `MotorModel`: `ForceBased` and `AccelerationBased` are directly inspired from the Rapier eponymous models, the `SpringDamper` model doesn't exists in Rapier but seems much easier to use to me. The latter is the default. The motors are solved through XPBD like any other joint constraint. Relevant Rapier sources: - [src/dynamics/joint/motor_model.rs](https://github.com/dimforge/rapier/blob/134132900adfb73e8ae7d062b0fed83a06ab8c30/src/dynamics/joint/motor_model.rs) - [src/dynamics/joint/generic_joint.rs](https://github.com/dimforge/rapier/blob/134132900adfb73e8ae7d062b0fed83a06ab8c30/src/dynamics/joint/generic_joint.rs) - [dynamics/solver/joint_constraint/joint_constraint_builder.rs](https://github.com/dimforge/rapier/blob/134132900adfb73e8ae7d062b0fed83a06ab8c30/src/dynamics/solver/joint_constraint/joint_constraint_builder.rs) - [src/dynamics/joint/revolute_joint.rs](https://github.com/dimforge/rapier/blob/134132900adfb73e8ae7d062b0fed83a06ab8c30/src/dynamics/joint/revolute_joint.rs) - [src/dynamics/joint/prismatic_joint.rs](https://github.com/dimforge/rapier/blob/134132900adfb73e8ae7d062b0fed83a06ab8c30/src/dynamics/joint/prismatic_joint.rs) ## Breaking change <details> <summary>(first commit only, outdated by the second one)</summary> The current implementation minimizes breaking changes, however, users who directly add the XPBD solver systems instead of using the plugin will have to make changes for motors to work: <img width="1081" height="386" alt="Screenshot from 2026-01-04 14-22-18" src="https://github.com/user-attachments/assets/59fa9c92-5f58-4036-b8b5-f17a7f06bdfb" /> Similarly, users who manually implemented `XpbdConstraint` for custom joints may need to implement `XpbdMotorConstraint` instead if they want motors to work. </details> Users who manually implemented `XpbdConstraint` for custom joints may need to write a custom implementation for the new `XpbdConstraint::warm_start_motors` method as its default implementation does nothing. Users who manually add the `solve_xpbd_joint` systems instead of using the plugins may need to also add `warm_start_xpbd_motors`. (why would anyone do that though?) ## Testing - A reasonable series of unit tests is included in the PR, - The `joint_motors_2d` and `joint_motors_3d` examples provide an interactive way to test these, - I used these examples to manually perform a brief test of motors+limits, no surprises there, - I couldn't find proper test vectors so there currently is no test scenario where it is verified that a specific motor configuration reaches a specific state in a precise time interval. --- ## Showcase <details> <summary>Concise showcase</summary> ```rust // Position-controlled motor (e.g., servo arm) RevoluteJoint::new(anchor, arm) .with_motor(AngularMotor { enabled: true, target_position: PI / 4.0, // 45 degrees max_torque: 100.0, motor_model: MotorModel::SpringDamper { frequency: 2.0, damping_ratio: 1.0, // critically damped }, ..default() }) // Velocity or position can then be set on demand fn set_motor_position(mut joints: Query<&mut RevoluteJoint>) { for mut joint in &mut joints { if let Some(motor) = joint.motor.as_mut() { motor.target_position = PI / 2.0; // 90 degrees } } } ``` </details> See the examples included in this PR for more detailed examples. [Screencast from 2026-01-11 14-55-37.webm](https://github.com/user-attachments/assets/2246dad7-2638-4741-9bf0-4d74ae5c0cee) --------- Co-authored-by: Joona Aalto <jondolf.dev@gmail.com>
1 parent 1d7e7c2 commit 1a1c5d5

File tree

15 files changed

+2234
-17
lines changed

15 files changed

+2234
-17
lines changed

crates/avian2d/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ required-features = ["2d", "default-collider"]
194194
name = "revolute_joint_2d"
195195
required-features = ["2d", "default-collider"]
196196

197+
[[example]]
198+
name = "joint_motors_2d"
199+
required-features = ["2d", "default-collider"]
200+
197201
[[example]]
198202
name = "sensor"
199203
required-features = ["2d", "default-collider"]
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
//! Demonstrates motor joints in 2D.
2+
//!
3+
//! - Left side: Revolute joint with velocity-controlled angular motor (spinning wheel)
4+
//! - Center: Revolute joint with position-controlled angular motor (servo)
5+
//! - Right side: Prismatic joint with linear motor (piston)
6+
//!
7+
//! Controls:
8+
//! - Arrow Up/Down: Adjust left motor target velocity
9+
//! - A/D: Adjust center motor target angle
10+
//! - W/S: Adjust right motor target position
11+
//! - Space: Toggle motors on/off
12+
13+
use avian2d::{math::*, prelude::*};
14+
use bevy::prelude::*;
15+
use examples_common_2d::ExampleCommonPlugin;
16+
17+
fn main() {
18+
App::new()
19+
.add_plugins((
20+
DefaultPlugins,
21+
ExampleCommonPlugin,
22+
PhysicsPlugins::default(),
23+
))
24+
.insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.1)))
25+
.insert_resource(Gravity(Vector::NEG_Y * 1000.0))
26+
.add_systems(Startup, setup)
27+
.add_systems(Update, (control_motors, update_ui))
28+
.run();
29+
}
30+
31+
#[derive(Component)]
32+
struct VelocityMotorJoint;
33+
34+
#[derive(Component)]
35+
struct PositionMotorJoint;
36+
37+
#[derive(Component)]
38+
struct PrismaticMotorJoint;
39+
40+
#[derive(Component)]
41+
struct UiText;
42+
43+
fn setup(mut commands: Commands) {
44+
commands.spawn(Camera2d);
45+
46+
// === Velocity-Controlled Revolute Joint (left side) ===
47+
// Static anchor for the wheel
48+
let velocity_anchor = commands
49+
.spawn((
50+
Sprite {
51+
color: Color::srgb(0.5, 0.5, 0.5),
52+
custom_size: Some(Vec2::splat(20.0)),
53+
..default()
54+
},
55+
Transform::from_xyz(-200.0, 0.0, 0.0),
56+
RigidBody::Static,
57+
))
58+
.id();
59+
60+
// Spinning wheel
61+
let velocity_wheel = commands
62+
.spawn((
63+
Sprite {
64+
color: Color::srgb(0.9, 0.3, 0.3),
65+
custom_size: Some(Vec2::splat(80.0)),
66+
..default()
67+
},
68+
Transform::from_xyz(-200.0, 0.0, 0.0),
69+
RigidBody::Dynamic,
70+
Mass(1.0),
71+
AngularInertia(1.0),
72+
SleepingDisabled, // Prevent sleeping so motor can always control it
73+
))
74+
.id();
75+
76+
// Revolute joint with velocity-controlled motor
77+
// Default anchors are at body centers (Vector::ZERO)
78+
commands.spawn((
79+
RevoluteJoint::new(velocity_anchor, velocity_wheel).with_motor(AngularMotor {
80+
target_velocity: 5.0,
81+
max_torque: 1000.0,
82+
motor_model: MotorModel::AccelerationBased {
83+
stiffness: 0.0,
84+
damping: 1.0,
85+
},
86+
..default()
87+
}),
88+
VelocityMotorJoint,
89+
));
90+
91+
// === Position-Controlled Revolute Joint (center) ===
92+
// Static anchor for the servo
93+
let position_anchor = commands
94+
.spawn((
95+
Sprite {
96+
color: Color::srgb(0.5, 0.5, 0.5),
97+
custom_size: Some(Vec2::splat(20.0)),
98+
..default()
99+
},
100+
Transform::from_xyz(0.0, 0.0, 0.0),
101+
RigidBody::Static,
102+
))
103+
.id();
104+
105+
// Servo arm - also positioned at anchor, rotates around its center
106+
let servo_arm = commands
107+
.spawn((
108+
Sprite {
109+
color: Color::srgb(0.3, 0.5, 0.9),
110+
custom_size: Some(Vec2::new(100.0, 20.0)),
111+
..default()
112+
},
113+
Transform::from_xyz(0.0, 0.0, 0.0),
114+
RigidBody::Dynamic,
115+
Mass(1.0),
116+
AngularInertia(1.0),
117+
SleepingDisabled, // Prevent sleeping so motor can always control it
118+
))
119+
.id();
120+
121+
// Revolute joint with position-controlled motor (servo behavior)
122+
//
123+
// Using spring parameters (frequency, damping_ratio) for stable behavior.
124+
// This provides predictable spring-damper dynamics across different configurations.
125+
// - frequency: 5 Hz = fairly stiff spring
126+
// - damping_ratio: 1.0 = critically damped (fastest approach without overshoot)
127+
commands.spawn((
128+
RevoluteJoint::new(position_anchor, servo_arm).with_motor(
129+
AngularMotor::new(MotorModel::SpringDamper {
130+
frequency: 5.0,
131+
damping_ratio: 1.0,
132+
})
133+
.with_target_position(0.0)
134+
.with_max_torque(Scalar::MAX),
135+
),
136+
PositionMotorJoint,
137+
));
138+
139+
// === Prismatic Joint with Linear Motor (right side) ===
140+
let piston_base_sprite = Sprite {
141+
color: Color::srgb(0.5, 0.5, 0.5),
142+
custom_size: Some(Vec2::new(40.0, 200.0)),
143+
..default()
144+
};
145+
146+
let piston_sprite = Sprite {
147+
color: Color::srgb(0.3, 0.9, 0.3),
148+
custom_size: Some(Vec2::new(60.0, 40.0)),
149+
..default()
150+
};
151+
152+
// Static base for the piston
153+
let piston_base = commands
154+
.spawn((
155+
piston_base_sprite,
156+
Transform::from_xyz(200.0, 0.0, 0.0),
157+
RigidBody::Static,
158+
Position(Vector::new(200.0, 0.0)),
159+
))
160+
.id();
161+
162+
// Moving piston
163+
let piston = commands
164+
.spawn((
165+
piston_sprite,
166+
Transform::from_xyz(200.0, 0.0, 0.0),
167+
RigidBody::Dynamic,
168+
Mass(1.0),
169+
AngularInertia(1.0),
170+
SleepingDisabled, // Prevent sleeping so motor can always control it
171+
Position(Vector::new(200.0, 0.0)),
172+
))
173+
.id();
174+
175+
// frequency = 20 Hz, damping_ratio = 1.0 (critically damped)
176+
commands.spawn((
177+
PrismaticJoint::new(piston_base, piston)
178+
.with_slider_axis(Vector::Y)
179+
.with_limits(-100.0, 100.0)
180+
.with_motor(
181+
LinearMotor::new(MotorModel::SpringDamper {
182+
frequency: 20.0,
183+
damping_ratio: 1.0,
184+
})
185+
.with_target_position(50.0)
186+
.with_max_force(Scalar::MAX),
187+
),
188+
PrismaticMotorJoint,
189+
));
190+
191+
commands.spawn((
192+
Text::new("Motor Joints Demo\n\nArrow Up/Down: Velocity motor speed\nA/D: Position motor angle\nW/S: Prismatic motor position\nSpace: Reset motors\n\nVelocity: 5.0 rad/s\nPosition: 0.00 rad\nPrismatic: 50.0 units"),
193+
TextFont {
194+
font_size: 18.0,
195+
..default()
196+
},
197+
TextColor(Color::WHITE),
198+
Node {
199+
position_type: PositionType::Absolute,
200+
top: Val::Px(10.0),
201+
left: Val::Px(10.0),
202+
..default()
203+
},
204+
UiText,
205+
));
206+
}
207+
208+
fn control_motors(
209+
keyboard: Res<ButtonInput<KeyCode>>,
210+
mut velocity_motors: Single<&mut RevoluteJoint, With<VelocityMotorJoint>>,
211+
mut position_motors: Single<
212+
&mut RevoluteJoint,
213+
(With<PositionMotorJoint>, Without<VelocityMotorJoint>),
214+
>,
215+
mut prismatic_motors: Single<&mut PrismaticJoint, With<PrismaticMotorJoint>>,
216+
) {
217+
// Velocity-controlled revolute joint motor
218+
if keyboard.just_pressed(KeyCode::ArrowUp) {
219+
velocity_motors.motor.target_velocity += 1.0;
220+
}
221+
if keyboard.just_pressed(KeyCode::ArrowDown) {
222+
velocity_motors.motor.target_velocity -= 1.0;
223+
}
224+
225+
// Position-controlled revolute joint motor
226+
if keyboard.just_pressed(KeyCode::KeyA) {
227+
position_motors.motor.target_position += 0.5;
228+
}
229+
if keyboard.just_pressed(KeyCode::KeyD) {
230+
position_motors.motor.target_position -= 0.5;
231+
}
232+
233+
// Position-controlled prismatic joint motor
234+
if keyboard.just_pressed(KeyCode::KeyW) {
235+
prismatic_motors.motor.target_position += 25.0;
236+
}
237+
if keyboard.just_pressed(KeyCode::KeyS) {
238+
prismatic_motors.motor.target_position -= 25.0;
239+
}
240+
241+
// Toggle motors on/off
242+
if keyboard.just_pressed(KeyCode::Space) {
243+
velocity_motors.motor.enabled = !velocity_motors.motor.enabled;
244+
position_motors.motor.enabled = !position_motors.motor.enabled;
245+
prismatic_motors.motor.enabled = !prismatic_motors.motor.enabled;
246+
}
247+
}
248+
249+
fn update_ui(
250+
velocity_motor: Single<&RevoluteJoint, With<VelocityMotorJoint>>,
251+
position_motor: Single<&RevoluteJoint, With<PositionMotorJoint>>,
252+
prismatic_motor: Single<&PrismaticJoint, With<PrismaticMotorJoint>>,
253+
mut ui_text: Single<&mut Text, With<UiText>>,
254+
) {
255+
ui_text.0 = format!(
256+
"Motor Joints Demo\n\n\
257+
Arrow Up/Down: Velocity motor speed\n\
258+
A/D: Position motor angle\n\
259+
W/S: Prismatic motor position\n\
260+
Space: Toggle motors\n\n\
261+
Velocity: {:.1} rad/s\n\
262+
Position: {:.2} rad\n\
263+
Prismatic: {:.1} units\n\
264+
Enabled: {}",
265+
velocity_motor.motor.target_velocity,
266+
position_motor.motor.target_position,
267+
prismatic_motor.motor.target_position,
268+
// We can pick any of the motors here since they are toggled together
269+
velocity_motor.motor.enabled
270+
);
271+
}

crates/avian3d/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ required-features = ["3d", "default-collider"]
173173
name = "revolute_joint_3d"
174174
required-features = ["3d", "default-collider"]
175175

176+
[[example]]
177+
name = "joint_motors_3d"
178+
required-features = ["3d", "default-collider"]
179+
176180
[[example]]
177181
name = "gyroscopic_motion"
178182
required-features = ["3d", "default-collider"]

0 commit comments

Comments
 (0)