Lesson 5 of 5 · 30 min
Simulation with Gazebo
Simulation is where you can be wrong cheaply. A planner that sends a robot into a wall costs nothing in Gazebo and a motor controller in real life. In this lesson you will describe a small robot in URDF, spawn it in Gazebo, connect its topics to ROS 2 with a bridge, and drive it with /cmd_vel. It brings together everything from lessons 1 to 4.
Gazebo and ROS 2 are separate worlds
Gazebo (the modern one, sometimes called Gz) is a physics simulator with its own messaging system, Gazebo Transport. It does not use ROS topics. The package ros_gz connects the two: it launches Gazebo, spawns models, and bridges messages. ROS 2 Jazzy is paired with Gazebo Harmonic, and the apt metapackage installs a matching set:
sudo apt install ros-jazzy-ros-gz ros-jazzy-teleop-twist-keyboard
The separation is useful: Gazebo knows nothing about ROS, so the same simulator works with any framework, and your ROS nodes stay unchanged when you swap the simulator for real hardware. Only the source of /odom and the sink of /cmd_vel change.
URDF in one page
URDF (Unified Robot Description Format) is an XML description of a robot as links (rigid bodies) connected by joints. Each link can have:
visual: what it looks likecollision: the shape physics uses (often simpler than the visual)inertial: mass and inertia, required for any link that moves
A joint names a parent, a child and a type: fixed (welded), revolute (limited rotation), continuous (unlimited rotation, like a wheel). Joint origins are relative to the parent, which is exactly the tree structure from the previous lesson: link names become tf frames.
Save this as ~/ros2_ws/bot.urdf. It is a chassis, two driven wheels, and a frictionless caster ball for balance.
<?xml version="1.0"?>
<robot name="bot">
<link name="base_link">
<visual>
<origin xyz="0 0 0.03"/>
<geometry><box size="0.4 0.2 0.06"/></geometry>
</visual>
<collision>
<origin xyz="0 0 0.03"/>
<geometry><box size="0.4 0.2 0.06"/></geometry>
</collision>
<inertial>
<origin xyz="0 0 0.03"/>
<mass value="5.0"/>
<inertia ixx="0.018" iyy="0.068" izz="0.083" ixy="0" ixz="0" iyz="0"/>
</inertial>
</link>
<link name="left_wheel">
<visual>
<origin rpy="1.5708 0 0"/>
<geometry><cylinder radius="0.05" length="0.04"/></geometry>
</visual>
<collision>
<origin rpy="1.5708 0 0"/>
<geometry><cylinder radius="0.05" length="0.04"/></geometry>
</collision>
<inertial>
<origin rpy="1.5708 0 0"/>
<mass value="0.5"/>
<inertia ixx="0.00038" iyy="0.00038" izz="0.000625" ixy="0" ixz="0" iyz="0"/>
</inertial>
</link>
<joint name="left_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="left_wheel"/>
<origin xyz="0.1 0.13 0"/>
<axis xyz="0 1 0"/>
</joint>
<link name="right_wheel">
<visual>
<origin rpy="1.5708 0 0"/>
<geometry><cylinder radius="0.05" length="0.04"/></geometry>
</visual>
<collision>
<origin rpy="1.5708 0 0"/>
<geometry><cylinder radius="0.05" length="0.04"/></geometry>
</collision>
<inertial>
<origin rpy="1.5708 0 0"/>
<mass value="0.5"/>
<inertia ixx="0.00038" iyy="0.00038" izz="0.000625" ixy="0" ixz="0" iyz="0"/>
</inertial>
</link>
<joint name="right_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="right_wheel"/>
<origin xyz="0.1 -0.13 0"/>
<axis xyz="0 1 0"/>
</joint>
<link name="caster">
<visual><geometry><sphere radius="0.05"/></geometry></visual>
<collision><geometry><sphere radius="0.05"/></geometry></collision>
<inertial>
<mass value="0.2"/>
<inertia ixx="0.0002" iyy="0.0002" izz="0.0002" ixy="0" ixz="0" iyz="0"/>
</inertial>
</link>
<joint name="caster_joint" type="fixed">
<parent link="base_link"/>
<child link="caster"/>
<origin xyz="-0.15 0 0"/>
</joint>
<gazebo reference="caster">
<mu1>0.0</mu1>
<mu2>0.0</mu2>
</gazebo>
<gazebo>
<plugin filename="gz-sim-diff-drive-system" name="gz::sim::systems::DiffDrive">
<left_joint>left_wheel_joint</left_joint>
<right_joint>right_wheel_joint</right_joint>
<wheel_separation>0.26</wheel_separation>
<wheel_radius>0.05</wheel_radius>
<topic>cmd_vel</topic>
<odom_topic>odom</odom_topic>
<tf_topic>/tf</tf_topic>
<frame_id>odom</frame_id>
<child_frame_id>base_link</child_frame_id>
</plugin>
</gazebo>
</robot>
The wheel joints sit 0.13 m either side of the centre and the wheel radius is 0.05 m, so the wheel bottoms are 0.05 m below base_link; the caster ball matches that height. The gazebo tags are extensions Gazebo reads and plain URDF ignores. The DiffDrive plugin is the part that matters: given a velocity command, it converts to left and right wheel speeds using the wheel separation and radius, and it integrates them to publish odometry and the odom to base_link transform.
Launch Gazebo and spawn the robot
Start an empty world, running immediately with -r, then in a second terminal create the robot from the file:
# terminal 1
ros2 launch ros_gz_sim gz_sim.launch.py gz_args:="-r empty.sdf"
# terminal 2
ros2 run ros_gz_sim create -file ~/ros2_ws/bot.urdf -name bot -z 0.1
The -z 0.1 drops the robot from a small height so it never starts inside the ground plane. You should see a grey box on two wheels sit down in the world.
Bridge the topics
Right now Gazebo has /cmd_vel and /odom topics that ROS 2 cannot see. parameter_bridge copies messages between the two systems. Each argument has the form topic@ROS_type DIRECTION Gazebo_type, where [ means Gazebo to ROS, ] means ROS to Gazebo, and @ means both ways:
ros2 run ros_gz_bridge parameter_bridge \
'/cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist' \
'/odom@nav_msgs/msg/Odometry[gz.msgs.Odometry' \
'/tf@tf2_msgs/msg/TFMessage[gz.msgs.Pose_V' \
'/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock'
The quotes matter, because the shell would otherwise treat the brackets as filename patterns. The bridge needs the message type on both sides because Gazebo and ROS use different serialisation formats. Bridging /clock lets ROS nodes follow simulated time; start them with --ros-args -p use_sim_time:=true so that their timestamps match the simulation.
Drive it
Test with one line first. Twist carries linear.x (forward m/s) and angular.z (turn rad/s):
ros2 topic pub -r 10 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.3}, angular: {z: 0.5}}"
The robot drives in an arc. Check the other direction with ros2 topic echo /odom --once and ros2 run tf2_ros tf2_echo odom base_link. Now the keyboard:
ros2 run teleop_twist_keyboard teleop_twist_keyboard
Use i to go forward, j and l to turn, k to stop. Finally, your own controller, which drives in a circle and stops itself:
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
class Circle(Node):
def __init__(self):
super().__init__('circle')
self.pub = self.create_publisher(Twist, 'cmd_vel', 10)
self.ticks = 0
self.create_timer(0.1, self.tick) # 10 Hz
def tick(self):
msg = Twist()
if self.ticks < 150: # 15 seconds of motion
msg.linear.x = 0.3
msg.angular.z = 0.4
self.pub.publish(msg) # zeros after 15 s = stop
self.ticks += 1
def main():
rclpy.init()
node = Circle()
try:
rclpy.spin(node)
except KeyboardInterrupt:
node.pub.publish(Twist()) # leave the robot stopped
finally:
node.destroy_node()
rclpy.try_shutdown()
if __name__ == '__main__':
main()
The controller publishes zeros rather than going silent, because a stream of commands should end with an explicit stop. This habit matters on real hardware, where a robot that keeps its last command after your node crashes is dangerous. Real bases also add a watchdog that stops the motors when commands disappear.
Check yourself
In the bridge argument /cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist, what does the closing bracket mean?
Check yourself
What does the DiffDrive plugin do with a Twist message?