Ackermann Tutorial#
This tutorial demonstrates how to use AckermannController to drive
car-like and forklift robots. The companion example supports two vehicles and
two command modes, all adjustable at runtime with sliders.
By the end of this tutorial, you’ll understand:
How the Ackermann geometric model is encoded as a velocity-vector setpoint
Where the control point sits, and why rear-wheel steering moves it off the vehicle
How to configure the controller for front-wheel or rear-wheel steering
When to command a site velocity, and when to command steering directly
Prerequisites
Review the Controllers and the RobotState tutorial to understand
the BaseController interface and RobotState.
To follow along, run:
./python.sh standalone_examples/api/isaacsim.robot_motion.controllers/ackermann.py
./python.sh standalone_examples/api/isaacsim.robot_motion.controllers/ackermann.py --forklift
./python.sh standalone_examples/api/isaacsim.robot_motion.controllers/ackermann.py --direct
./python.sh standalone_examples/api/isaacsim.robot_motion.controllers/ackermann.py --forklift --direct
Every code snippet below is taken from that script.
The script opens Isaac Sim with the selected robot and two slider windows — one for linear speed [m/s] and one for body turning angle [rad].
How It Works#
The Ackermann geometric model relates a body turning angle \(\theta\) to the individual inner and outer steering angles using the no-slip constraint:
where \(b\) is the wheel-base, \(t\) is the track width, and the turning radius is \(R = b / \tan\theta\). The steerable-wheel angular velocities are then derived from the per-wheel path radii. Non-steerable wheels, when present, are driven too: each turns at \((v\cos\theta - \omega d) / r\), where \(d\) is its signed offset from the centreline, so the inner and outer wheels differ across the axle.
The Control Point#
Like the other controllers, AckermannController interprets its command
at a named site rather than at the robot’s prim root. Where that site sits
depends on which axle steers.
Front-Wheel Steering#
With front-wheel steering the control point is the midpoint of the steering axle. The instantaneous centre of rotation (ICR) lies on the non-steering (rear) axle.#
For a front-steering vehicle the control_point is the point midway between
the two steering wheels, and the command is that point’s velocity:
\([v\cos\theta,\, v\sin\theta,\, 0]\) sends the front axle midpoint off at speed \(v\),
heading \(\theta\) from the vehicle’s forward axis. The rear axle midpoint travels
straight ahead at \(v\cos\theta\), with no lateral velocity — the ICR always lies on
the non-steering axle.
Rear-Wheel Steering#
Rear-steering vehicles — forklifts, mainly — are less obvious. Pass
steerable_wheels_at_rear=True and the controller negates the steering
angles internally, so positive \(\theta\) still curves left. But the control point is
no longer the steering-axle midpoint.
Instead, take the midpoint of the two steering (rear) wheels and reflect it across the non-steering axle. That lands one wheel-base ahead of the front axle: a point rigidly attached to the vehicle, but floating in space in front of it.
With rear-wheel steering the control point is the steering-axle midpoint mirrored across the non-steering axle, landing one wheel-base ahead of the vehicle. No point between the axles moves left, yet the vehicle as a whole curves left.#
Odd as that sounds, there is no better candidate. Command a rear-steering vehicle forward-and-left, then look for a point on it moving forward-and-left: there isn’t one. The front axle midpoint goes straight ahead, the rear axle midpoint swings right as the back end kicks out, and every point between them falls somewhere in between. The vehicle is still turning left — it is arcing about an ICR off to its left — and the reflected point is the one point rigidly attached to it whose velocity matches where the vehicle as a whole is going.
Note
The reflection is an interpretation, not a step in the code. The controller only negates the steering angles for rear-steer vehicles; the yaw rate and wheel speeds are computed identically either way. The reflected point is simply where that arithmetic puts the commanded velocity.
In both layouts the site name defaults to "control_point" and is
configurable via control_point_name, and the axes follow
forward_direction (default +X) and rotation_direction (default +Z).
Commanding a Velocity#
This controller works like every other controller in the API: you state the velocity you want a point to have, and the wheels take whatever angles and speeds produce it without slipping. You never specify a steering angle.
For an Ackermann vehicle that is a linear velocity on the "control_point"
site, with speed \(v\) and turning angle \(\theta\) packed into a single vector:
setpoint = mg.RobotState(
sites=mg.SpatialState.from_name(
spatial_space=["control_point"],
linear_velocities=(
["control_point"],
wp.array([[v * math.cos(theta), v * math.sin(theta), 0.0]], dtype=wp.float32),
),
)
)
The controller recovers the speed from that vector’s length and the turning angle
from its direction, atan2(v_y, v_x) — negating both components when reversing
so the angle stays measured from the direction of travel.
This is the interface you want most of the time: anything upstream that produces a site velocity — a planner, a path follower, another controller — can drive the vehicle without knowing it is Ackermann.
At Zero Speed#
At zero speed the command stops implying a steering angle: [0, 0, 0] carries
no heading, and atan2(0, 0) has no answer. Below a small speed threshold
the controller holds the last turning angle it computed, so the wheels stay
where they were last steered. This is also what keeps steering stable through a
forward↔reverse transition, where the velocity passes through zero.
Direct-Command Mode#
Holding the previous angle is the right default, but it means you cannot
choose a steering angle while the vehicle is stopped. When you need that —
pre-positioning the wheels before moving off, or teleop where the steering wheel
turns while parked — set direct_command=True and pass speed and angle as
kwargs to forward():
desired_state = controller.forward(
mg.RobotState(),
None,
0.0,
linear_speed=v,
turning_angle=theta,
)
setpoint_state is ignored and \(\theta\) is used as given, so
linear_speed=0.0, turning_angle=0.4 steers the wheels while the vehicle
stays put. The trade-off: you are commanding the mechanism rather than a point
in space, so the controller no longer chains behind anything that produces site
velocities.
Setting Up the Controller#
Front-wheel steering (Leatherback) — joint names and geometry:
_LEATHERBACK = dict(
# Asset
asset_subpath="/Isaac/Robots_Multiphysics/NVIDIA/Leatherback/leatherback.usda",
prim_path="/World/Leatherback",
# Geometry — wheel CoM offsets from base.usda
# Wheelbase: front axle X=0.150 m, rear axle X=-0.170 m → 0.320 m
# Track: wheel CoM Y = ±0.121 m (same front and rear) → 0.242 m
# Radius: wheel CoM Z = 0.052 m (confirm from physx collision cylinder)
wheel_base=0.320,
track_width=0.242,
wheel_radius=0.052,
# Motion defaults
v_linear=0.5,
turning_angle=0.3,
# Camera framing — the Leatherback turns with a radius of wheel_base/tan(theta) ≈ 1.0 m,
# small enough that the default viewport leaves it a speck. The forklift's
# radius is ~5 m, which the default camera already frames, so it sets none.
camera_eye=[2.4, -0.8, 1.6],
camera_target=[0.0, 0.6, 0.1],
# Joint names — front-wheel steering
left_steerable_wheel_joint="Wheel__Knuckle__Front_Left",
right_steerable_wheel_joint="Wheel__Knuckle__Front_Right",
left_steering_joint="Knuckle__Upright__Front_Left",
right_steering_joint="Knuckle__Upright__Front_Right",
left_non_steerable_wheel_joint="Wheel__Upright__Rear_Left",
right_non_steerable_wheel_joint="Wheel__Upright__Rear_Right",
steerable_wheels_at_rear=False,
)
Rear-wheel steering (ForkliftC) — note the larger wheel base, the separate rear-axle radius and track width, and the rear-steer flag:
_FORKLIFT = dict(
# Asset
asset_subpath="/Isaac/Robots_Multiphysics/IsaacSim/ForkliftC/forklift_c/forklift_c.usda",
prim_path="/World/Forklift",
# Geometry — collision cylinders from base.usda
# Wheelbase: front X=0.269 m, rear X=-1.383 m → 1.652 m
# Rear (steerable) track: cylinder centre Y = ±0.570 m → 1.140 m
# Front (NS) track: cylinder centre Y = ±0.522 m → 1.044 m
# Rear wheel radius: 0.5 × 51 cm × 0.01 = 0.255 m
# Front wheel radius: 0.5 × 65 cm × 0.01 = 0.325 m
wheel_base=1.652,
track_width=1.140,
non_steerable_track_width=1.044,
wheel_radius=0.255,
non_steerable_wheel_radius=0.325,
# Motion defaults
v_linear=1.5,
turning_angle=0.3,
# Joint names — rear-wheel steering (forklift)
left_steerable_wheel_joint="left_back_wheel_joint",
right_steerable_wheel_joint="right_back_wheel_joint",
left_steering_joint="left_rotator_joint",
right_steering_joint="right_rotator_joint",
left_non_steerable_wheel_joint="left_front_wheel_joint",
right_non_steerable_wheel_joint="right_front_wheel_joint",
steerable_wheels_at_rear=True,
)
Either set is passed to the same constructor:
controller = ctrl.AckermannController(
robot_joint_space=robot.dof_names,
left_steerable_wheel_joint=cfg["left_steerable_wheel_joint"],
right_steerable_wheel_joint=cfg["right_steerable_wheel_joint"],
left_steering_joint=cfg["left_steering_joint"],
right_steering_joint=cfg["right_steering_joint"],
left_non_steerable_wheel_joint=cfg.get("left_non_steerable_wheel_joint"),
right_non_steerable_wheel_joint=cfg.get("right_non_steerable_wheel_joint"),
non_steerable_wheel_radius=cfg.get("non_steerable_wheel_radius"),
non_steerable_track_width=cfg.get("non_steerable_track_width"),
steerable_wheel_radius=cfg["wheel_radius"],
wheel_base=cfg["wheel_base"],
track_width=cfg["track_width"],
steerable_wheels_at_rear=cfg["steerable_wheels_at_rear"],
max_linear_speed=v_max,
max_turning_angle=angle_max,
direct_command=args.direct,
)
steerable_wheels_at_rear=True inverts the turn direction internally, so
positive \(\theta\) still steers left — and it moves the control point, as described
above. The non-steerable
wheel parameters are optional, falling back to the steerable values unless the
two axles differ.
Controller Output#
forward() returns a RobotState with:
velocity targets for all wheel joints (steerable and non-steerable) in
desired_state.joints.velocitiesposition targets for the two steering joints in
desired_state.joints.positions
Apply both to the articulation each physics step:
if desired_state is not None and desired_state.joints is not None:
if desired_state.joints.velocities is not None:
robot.set_dof_velocity_targets(
desired_state.joints.velocities,
dof_indices=robot.get_dof_indices(desired_state.joints.velocity_names),
)
if desired_state.joints.positions is not None:
robot.set_dof_position_targets(
desired_state.joints.positions,
dof_indices=robot.get_dof_indices(desired_state.joints.position_names),
)
get_dof_indices() maps the controller’s output joint names onto the
articulation’s DOF ordering. Take the names from the output itself, since the
values in velocities and positions follow the controller’s own ordering.
That ordering is fixed for the lifetime of the controller, so you can cache both
lookups after the first step rather than repeating them.
Leatherback driven with speed and turning-angle sliders (front-wheel steering).#
ForkliftC driven with speed and turning-angle sliders (rear-wheel steering, --forklift).#
Note
The Leatherback and ForkliftC models cannot rotate their wheels or steer at arbitrary speeds (they model real robots). Therefore, if modifying the maximum speed and turning-angle values in the python examples, keep in mind that the wheels may saturate.
Summary#
This tutorial demonstrated:
Velocity commands: Stating the velocity of a point and letting the wheels resolve the geometry
Direct-command mode: Taking explicit control of the steering angle, including at zero speed
Front vs rear steering: Configuring
steerable_wheels_at_rearfor forkliftsOutput structure: Applying velocity and position targets from
forward()
Next Steps#
Differential drive tutorial — simpler two-wheel controller
Holonomic tutorial — full lateral mobility with mecanum and omni wheels