ROS 2 Gazebo Harmonic Guide 2026
Gazebo Harmonic (gz-harmonic) is the LTS simulator paired with ROS 2 Jazzy. This guide covers installation, the ros_gz_bridge, ros2_control integration, SDF model authoring, and essential sensor plugins.
ROS 2 ↔ Gazebo Harmonic Architecture
Communication Layers
ROS 2 nodes ←──ros_gz_bridge──→ Gazebo Transport topics
↕ (bidirectional type conversion)
ros2_control ←──GazeboSystem──→ gz::sim::Entity state/cmd
SDF model ─── <plugin> tags ─── sensor publishers
Gazebo Harmonic uses its own internal transport (Ignition Transport). The ros_gz_bridge converts messages between ROS 2 and Gazebo transports at runtime.
Installation
# Gazebo Harmonic + ROS 2 Jazzy bridge packages sudo apt install ros-jazzy-ros-gz sudo apt install gz-harmonic # standalone gz CLI # Verify gz sim --version # should show Harmonic / 8.x ros2 pkg list | grep ros_gz
| Package | Contents |
|---|---|
| ros_gz_bridge | Type-mapped bridge between ROS 2 and Gazebo transports |
| ros_gz_sim | gz_ros2_control plugin, launch helpers, world APIs |
| ros_gz_image | sensor_msgs/Image ↔ gz::msgs::Image bridge |
| ros_gz_interfaces | Custom message types shared between ROS 2 and Gazebo |
ros_gz_bridge — Topic Bridging
# Launch the bridge node with a YAML config ros2 run ros_gz_bridge parameter_bridge /scan@sensor_msgs/msg/LaserScan[gz.msgs.LaserScan /odom@nav_msgs/msg/Odometry[gz.msgs.Odometry /cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist
Bridge direction syntax: [ = Gazebo→ROS, ] = ROS→Gazebo, @ = bidirectional.
YAML Config (preferred)
# bridge_config.yaml - ros_topic_name: "/scan" gz_topic_name: "/scan" ros_type_name: "sensor_msgs/msg/LaserScan" gz_type_name: "gz.msgs.LaserScan" direction: GZ_TO_ROS - ros_topic_name: "/cmd_vel" gz_topic_name: "/cmd_vel" ros_type_name: "geometry_msgs/msg/Twist" gz_type_name: "gz.msgs.Twist" direction: ROS_TO_GZ - ros_topic_name: "/odom" gz_topic_name: "/odom" ros_type_name: "nav_msgs/msg/Odometry" gz_type_name: "gz.msgs.Odometry" direction: BIDIRECTIONAL
# Launch bridge from YAML ros2 run ros_gz_bridge parameter_bridge --ros-args -p config_file:=/path/to/bridge_config.yaml
SDF Model — Differential Drive Robot
<?xml version="1.0" ?>
<sdf version="1.10">
<model name="my_diff_drive">
<link name="base_link">
<inertial><mass>5.0</mass></inertial>
<visual name="base_visual">
<geometry><box><size>0.5 0.3 0.1</size></box></geometry>
</visual>
<collision name="base_col">
<geometry><box><size>0.5 0.3 0.1</size></box></geometry>
</collision>
</link>
<!-- Left wheel -->
<link name="left_wheel">
<pose>0 0.18 0 -1.5707 0 0</pose>
<inertial><mass>1.0</mass></inertial>
<visual name="v"><geometry><cylinder><radius>0.1</radius><length>0.05</length></cylinder></geometry></visual>
<collision name="c"><geometry><cylinder><radius>0.1</radius><length>0.05</length></cylinder></geometry></collision>
</link>
<joint name="left_wheel_joint" type="revolute">
<parent>base_link</parent><child>left_wheel</child>
<axis><xyz>0 0 1</xyz></axis>
</joint>
<!-- Differential drive controller plugin -->
<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.36</wheel_separation>
<wheel_radius>0.1</wheel_radius>
<odom_publish_frequency>50</odom_publish_frequency>
<topic>cmd_vel</topic>
<odom_topic>odom</odom_topic>
<frame_id>odom</frame_id>
<child_frame_id>base_link</child_frame_id>
</plugin>
</model>
</sdf>Sensor Plugins (SDF snippets)
2D LiDAR
<sensor name="lidar" type="gpu_lidar">
<pose>0 0 0.15 0 0 0</pose>
<update_rate>10</update_rate>
<lidar>
<scan>
<horizontal><samples>360</samples><min_angle>-3.14159</min_angle><max_angle>3.14159</max_angle></horizontal>
</scan>
<range><min>0.12</min><max>12.0</max><resolution>0.01</resolution></range>
</lidar>
<plugin filename="gz-sim-sensors-system" name="gz::sim::systems::Sensors">
<render_engine>ogre2</render_engine>
</plugin>
</sensor>Depth Camera (RGBD)
<sensor name="depth_camera" type="rgbd_camera">
<pose>0.25 0 0.2 0 0 0</pose>
<update_rate>30</update_rate>
<camera>
<horizontal_fov>1.047</horizontal_fov>
<image><width>640</width><height>480</height></image>
<clip><near>0.1</near><far>10.0</far></clip>
</camera>
<topic>/camera</topic>
</sensor>IMU
<sensor name="imu" type="imu">
<pose>0 0 0 0 0 0</pose>
<update_rate>200</update_rate>
<imu>
<angular_velocity>
<x><noise type="gaussian"><mean>0</mean><stddev>0.009</stddev></noise></x>
</angular_velocity>
</imu>
<topic>/imu</topic>
</sensor>ros2_control + Gazebo — GazeboSystem Plugin
<!-- In SDF model — enable ros2_control via GazeboSystem -->
<plugin filename="gz_ros2_control-system"
name="gz_ros2_control::GazeboSimROS2ControlPlugin">
<parameters>$(find my_robot_description)/config/ros2_controllers.yaml</parameters>
<ros>
<remapping>~/robot_description:=/robot_description</remapping>
</ros>
</plugin>ros2_controllers.yaml
controller_manager:
ros__parameters:
update_rate: 100 # Hz
diff_drive_controller:
type: diff_drive_controller/DiffDriveController
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster
diff_drive_controller:
ros__parameters:
left_wheel_names: ["left_wheel_joint"]
right_wheel_names: ["right_wheel_joint"]
wheel_separation: 0.36
wheel_radius: 0.1
odom_frame_id: odom
base_frame_id: base_link
publish_rate: 50.0Complete Launch File
# launch/sim.launch.py
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, ExecuteProcess
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch_ros.actions import Node
from ament_index_python.packages import get_package_share_directory
import os
def generate_launch_description():
pkg = get_package_share_directory("my_robot_bringup")
# 1. Gazebo Harmonic
gz_sim = ExecuteProcess(
cmd=["gz", "sim", "-r", os.path.join(pkg, "worlds", "empty.sdf")],
output="screen",
)
# 2. Spawn robot from SDF
spawn = Node(
package="ros_gz_sim",
executable="create",
arguments=[
"-file", os.path.join(pkg, "models", "my_diff_drive", "model.sdf"),
"-name", "robot",
"-z", "0.1",
],
output="screen",
)
# 3. Bridge
bridge = Node(
package="ros_gz_bridge",
executable="parameter_bridge",
arguments=["--ros-args", "-p",
f"config_file:={os.path.join(pkg, 'config', 'bridge_config.yaml')}"],
output="screen",
)
# 4. robot_state_publisher
rsp = Node(
package="robot_state_publisher",
executable="robot_state_publisher",
parameters=[{"use_sim_time": True}],
)
# 5. Controller manager spawners
jsb = Node(
package="controller_manager",
executable="spawner",
arguments=["joint_state_broadcaster"],
)
ddc = Node(
package="controller_manager",
executable="spawner",
arguments=["diff_drive_controller"],
)
return LaunchDescription([gz_sim, spawn, bridge, rsp, jsb, ddc])Common Issues
gz sim: no display
Set DISPLAY=:0 or use headless: gz sim -s (server only, no GUI).
Bridge topic not visible in ROS 2
Bridge direction wrong — check [ vs ] syntax. Also verify gz topic -l shows the gz topic.
GazeboSystem plugin not loading
Check gz_ros2_control is installed: apt install ros-jazzy-gz-ros2-control. Plugin filename must match exactly.
Sensors not publishing
Add <plugin filename='gz-sim-sensors-system' ...> to the model. Without it, sensor update loop doesn't run.
use_sim_time mismatch
All Nav2 / TF nodes need use_sim_time: True. Clock is bridged via /clock topic — bridge it with gz.msgs.Clock.
Next Steps
- Add slam_toolbox for mapping in Gazebo with the simulated lidar.
- Connect Nav2 waypoint follower to send autonomous missions in simulation.
- Use MoveIt 2 GazeboSystem plugin for simulated arm manipulation.