Ackermann Tutorial#

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:

\[\tan(\delta_\text{inner}) = \frac{b}{R - t/2}, \quad \tan(\delta_\text{outer}) = \frac{b}{R + t/2}\]

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#

Front-wheel steering, with the control point at the front axle midpoint

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.

Rear-wheel steering, with the control point reflected ahead of the front axle

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.velocities

  • position 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.

../_images/isim_6.0_full_tut_external_ackermann_leatherback.webp

Leatherback driven with speed and turning-angle sliders (front-wheel steering).#

../_images/isim_6.0_full_tut_external_ackermann_forklift.webp

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:

  1. Velocity commands: Stating the velocity of a point and letting the wheels resolve the geometry

  2. Direct-command mode: Taking explicit control of the steering angle, including at zero speed

  3. Front vs rear steering: Configuring steerable_wheels_at_rear for forklifts

  4. Output structure: Applying velocity and position targets from forward()

Next Steps#

Back to Gallery View