Skip to main content

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
PackageContents
ros_gz_bridgeType-mapped bridge between ROS 2 and Gazebo transports
ros_gz_simgz_ros2_control plugin, launch helpers, world APIs
ros_gz_imagesensor_msgs/Image ↔ gz::msgs::Image bridge
ros_gz_interfacesCustom 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.0

Complete 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