Vaibhav Shende Vaibhav Shende

Gazebo-ROS 2 Interface: Bridging Simulation and Control

A comprehensive guide to integrating Gazebo Ignition with ROS 2—communication patterns, physics simulation, sensor simulation, and real-to-sim workflows.

ROS 2 Robotics

Gazebo-ROS 2 Interface: Bridging Simulation and Control

The Problem

You’ve built a robot. You have ROS 2 code for navigation, manipulation, perception. How do you test it safely before deploying to hardware?

Gazebo is the answer. It’s a physics simulator that talks to ROS 2. Your robot code doesn’t know if it’s controlling a simulation or a real robot—it just publishes and subscribes to topics.

But how does this communication actually work?


Architecture Overview

Gazebo and ROS 2 speak different languages. Gazebo uses its own internal API. ROS 2 uses topics/services/actions. The bridge connects them:

Your ROS 2 Node (e.g., nav_controller)
    ↓
ROS 2 Topic/Service
    ↓
[gz_ros2_control Bridge]  ← The magic
    ↓
Gazebo Simulator
    ├─ Physics engine
    ├─ Sensor simulation
    └─ Actor control
    ↓
Simulated Robot

This is gz_ros2_control. It’s the middleware that translates ROS 2 commands into Gazebo motions.


Part 1: Installation & Setup

Install Dependencies

# Ubuntu 22.04 with ROS 2 Humble
sudo apt install -y \
    ros-humble-gazebo-ros \
    ros-humble-gazebo-ros2-control \
    ros-humble-joint-state-publisher \
    ros-humble-robot-state-publisher \
    ros-humble-diff-drive-controller \
    ros-humble-velocity-controllers

Basic World File

Create worlds/empty.sdf:

<?xml version="1.0"?>
<sdf version="1.10">
    <world name="empty">
        <!-- Physics -->
        <physics name="default_physics" type="ode">
            <max_step_size>0.001</max_step_size>
            <real_time_factor>1.0</real_time_factor>
        </physics>
        
        <!-- Ground -->
        <model name="ground_plane">
            <static>true</static>
            <link name="link">
                <collision name="collision">
                    <geometry>
                        <plane>
                            <normal>0 0 1</normal>
                            <size>100 100</size>
                        </plane>
                    </geometry>
                </collision>
                <visual name="visual">
                    <geometry>
                        <plane>
                            <normal>0 0 1</normal>
                            <size>100 100</size>
                        </plane>
                    </geometry>
                    <material>
                        <ambient>0.5 0.5 0.5 1</ambient>
                        <diffuse>0.5 0.5 0.5 1</diffuse>
                    </material>
                </visual>
            </link>
        </model>
        
        <!-- Lighting -->
        <light type="directional" name="sun">
            <pose>0 0 10 0 0 0</pose>
            <diffuse>1 1 1 1</diffuse>
            <specular>0.5 0.5 0.5 1</specular>
            <direction>-0.5 0.1 -0.9</direction>
        </light>
    </world>
</sdf>

Launch Gazebo

gazebo worlds/empty.sdf

Or headless (for CI/testing):

GZ_SIM_HEADLESS=1 gazebo worlds/empty.sdf

Part 2: URDF + ros2_control

Your robot needs two things:

  1. URDF — Physical description (links, joints, geometry)
  2. ros2_control config — Control interface (which joints, which controllers)

Simple 2-Wheel Robot URDF

robots/mobile_robot.urdf.xacro:

<?xml version="1.0"?>
<robot name="mobile_robot" xmlns:xacro="http://www.ros.org/wiki/xacro">
    <!-- Base link -->
    <link name="base_link">
        <inertial>
            <mass value="1.0"/>
            <inertia ixx="0.01" ixy="0" ixz="0" iyy="0.01" iyz="0" izz="0.02"/>
        </inertial>
        <collision name="collision">
            <geometry>
                <box size="0.3 0.3 0.1"/>
            </geometry>
        </collision>
        <visual name="visual">
            <geometry>
                <box size="0.3 0.3 0.1"/>
            </geometry>
            <material name="grey">
                <color rgba="0.5 0.5 0.5 1"/>
            </material>
        </visual>
    </link>
    
    <!-- Left wheel -->
    <link name="wheel_left">
        <inertial>
            <mass value="0.2"/>
            <inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
        </inertial>
        <collision>
            <geometry>
                <cylinder radius="0.05" length="0.02"/>
            </geometry>
        </collision>
        <visual>
            <geometry>
                <cylinder radius="0.05" length="0.02"/>
            </geometry>
            <material name="black">
                <color rgba="0 0 0 1"/>
            </material>
        </visual>
    </link>
    
    <!-- Right wheel (identical to left) -->
    <link name="wheel_right">
        <inertial>
            <mass value="0.2"/>
            <inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
        </inertial>
        <collision>
            <geometry>
                <cylinder radius="0.05" length="0.02"/>
            </geometry>
        </collision>
        <visual>
            <geometry>
                <cylinder radius="0.05" length="0.02"/>
            </geometry>
            <material name="black">
                <color rgba="0 0 0 1"/>
            </material>
        </visual>
    </link>
    
    <!-- Caster wheel -->
    <link name="caster">
        <inertial>
            <mass value="0.1"/>
            <inertia ixx="0.0005" ixy="0" ixz="0" iyy="0.0005" iyz="0" izz="0.0005"/>
        </inertial>
        <collision>
            <geometry>
                <sphere radius="0.02"/>
            </geometry>
        </collision>
        <visual>
            <geometry>
                <sphere radius="0.02"/>
            </geometry>
        </visual>
    </link>
    
    <!-- Joints -->
    <joint name="wheel_left_joint" type="continuous">
        <parent link="base_link"/>
        <child link="wheel_left"/>
        <origin xyz="0 0.15 0" rpy="-1.57 0 0"/>
        <axis xyz="1 0 0"/>
        <limit effort="10" velocity="10"/>
        <dynamics damping="0.1" friction="0.1"/>
    </joint>
    
    <joint name="wheel_right_joint" type="continuous">
        <parent link="base_link"/>
        <child link="wheel_right"/>
        <origin xyz="0 -0.15 0" rpy="-1.57 0 0"/>
        <axis xyz="1 0 0"/>
        <limit effort="10" velocity="10"/>
        <dynamics damping="0.1" friction="0.1"/>
    </joint>
    
    <joint name="caster_joint" type="ball">
        <parent link="base_link"/>
        <child link="caster"/>
        <origin xyz="-0.12 0 -0.05"/>
    </joint>
</robot>

ros2_control Configuration

config/controllers.yaml:

controller_manager:
  ros__parameters:
    update_rate: 100  # 100 Hz control loop
    
    use_sim_time: true  # Critical: use Gazebo's simulated time
    
    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster
    
    diff_drive_controller:
      type: diff_drive_controller/DiffDriveController
 
diff_drive_controller:
  ros__parameters:
    left_wheel_names: ["wheel_left"]
    right_wheel_names: ["wheel_right"]
    
    wheel_separation: 0.3  # Distance between wheels
    wheels_per_side: 1
    wheel_radius: 0.05
    
    # Limits
    linear:
      x:
        has_velocity_limits: true
        max_velocity: 1.0
        has_acceleration_limits: true
        max_acceleration: 0.5
    
    angular:
      z:
        has_velocity_limits: true
        max_velocity: 1.0
        has_acceleration_limits: true
        max_acceleration: 0.5
    
    # Commands
    cmd_vel_topic: cmd_vel
    odom_topic: odom
    publish_rate: 50

Part 3: Launch File

launch/gazebo.launch.py:

from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, DeclareLaunchArgument
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
from launch.substitutions import LaunchConfiguration
import os
 
def generate_launch_description():
    pkg_share = FindPackageShare(package='my_robot').find('my_robot')
    
    # Arguments
    use_sim_time = LaunchConfiguration('use_sim_time', default='true')
    world_file = LaunchConfiguration('world', 
        default=os.path.join(pkg_share, 'worlds', 'empty.sdf'))
    
    # Gazebo
    gazebo = IncludeLaunchDescription(
        os.path.join(FindPackageShare('gazebo_ros').find('gazebo_ros'), 
                     'launch', 'gazebo.launch.py'),
        launch_arguments=[('world', world_file)]
    )
    
    # Spawn robot
    spawn_robot = Node(
        package='gazebo_ros',
        executable='spawn_entity.py',
        arguments=[
            '-topic', 'robot_description',
            '-entity', 'my_robot',
            '-x', '0',
            '-y', '0',
            '-z', '0.1'
        ],
        output='screen'
    )
    
    # Robot state publisher
    robot_state_pub = Node(
        package='robot_state_publisher',
        executable='robot_state_publisher',
        parameters=[
            {'robot_description': 
             open(os.path.join(pkg_share, 'urdf', 'robot.urdf')).read()},
            {'use_sim_time': use_sim_time}
        ]
    )
    
    # ros2_control
    ros2_control = Node(
        package='controller_manager',
        executable='ros2_control_node',
        parameters=[
            {'use_sim_time': use_sim_time},
            os.path.join(pkg_share, 'config', 'controllers.yaml')
        ],
        output='screen'
    )
    
    # Joint state broadcaster
    joint_state_broadcaster = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['joint_state_broadcaster', '-c', '/controller_manager'],
        output='screen'
    )
    
    # Diff drive controller
    diff_drive = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['diff_drive_controller', '-c', '/controller_manager'],
        output='screen'
    )
    
    return LaunchDescription([
        DeclareLaunchArgument('use_sim_time', default_value='true'),
        DeclareLaunchArgument('world', default_value=world_file),
        
        gazebo,
        robot_state_pub,
        spawn_robot,
        ros2_control,
        joint_state_broadcaster,
        diff_drive
    ])

Launch

ros2 launch my_robot gazebo.launch.py

Part 4: Communication Patterns

Pattern 1: Command Velocity (Mobile Robot)

Your navigation node publishes velocity commands. The controller consumes them:

# navigation_node.py
import rclpy
from geometry_msgs.msg import Twist
 
class NavigationNode(rclcpp.Node):
    def __init__(self):
        super().__init__('navigation_node')
        self.pub = self.create_publisher(Twist, 'cmd_vel', 10)
    
    def move_forward(self):
        msg = Twist()
        msg.linear.x = 0.5   # 0.5 m/s forward
        msg.angular.z = 0.0   # No rotation
        self.pub.publish(msg)
 
node = NavigationNode()
node.move_forward()

The diff_drive_controller:

  1. Receives Twist message on /cmd_vel
  2. Converts linear/angular velocity to wheel velocities
  3. Sends commands to Gazebo physics engine
  4. Simulates wheel motion, odometry

Pattern 2: Odometry (Position Feedback)

The controller publishes odometry (estimated pose):

# listener_node.py
import rclpy
from nav_msgs.msg import Odometry
 
def odom_callback(msg):
    x = msg.pose.pose.position.x
    y = msg.pose.pose.position.y
    theta = msg.pose.pose.orientation.z  # Simplified
    
    print(f"Robot at ({x:.2f}, {y:.2f}), heading {theta:.2f}")
 
node = rclcpp.create_node('odom_listener')
sub = node.create_subscription(Odometry, 'odom', odom_callback, 10)
rclcpp.spin(node)

Gazebo simulates wheel encoder feedback → diff_drive_controller computes odometry.

Pattern 3: Joint State (All Joint Angles)

The joint_state_broadcaster publishes all joint positions/velocities:

# Check robot pose in RViz
# ros2 topic echo /joint_states
# Shows: positions=[0.1, 0.15], velocities=[0.5, 0.5]

Used by RViz for visualization, URDF tree updates.

Pattern 4: TF Transforms

The controller publishes coordinate frame transformations:

# View TF tree
ros2 run tf2_tools view_frames.py
 
# Result:
# world → odom → base_footprint → base_link
#                               → wheel_left
#                               → wheel_right

Critical for multi-sensor fusion (camera → base_link → wheel).


Part 5: Sensor Simulation

Camera Simulation

Add to URDF:

<link name="camera">
    <inertial>
        <mass value="0.05"/>
        <inertia ixx="0.0001" ixy="0" ixz="0" iyy="0.0001" iyz="0" izz="0.0001"/>
    </inertial>
    <collision>
        <geometry><box size="0.05 0.05 0.05"/></geometry>
    </collision>
    <visual>
        <geometry><box size="0.05 0.05 0.05"/></geometry>
    </visual>
</link>
 
<joint name="camera_joint" type="fixed">
    <parent link="base_link"/>
    <child link="camera"/>
    <origin xyz="0.15 0 0.05" rpy="0 0 0"/>
</joint>

In Gazebo world SDF, add camera plugin:

<model name="camera_model">
    <link name="camera_link">
        <!-- ... URDF collision/visual ... -->
        
        <sensor name="camera" type="camera">
            <camera>
                <image>
                    <width>640</width>
                    <height>480</height>
                </image>
                <clip>
                    <near>0.1</near>
                    <far>100</far>
                </clip>
            </camera>
            
            <!-- Output to ROS 2 -->
            <plugin filename="libgazebo_ros_camera.so" name="camera_plugin">
                <ros>
                    <namespace>robot</namespace>
                    <remapping>image_raw:=camera/image_raw</remapping>
                    <remapping>camera_info:=camera/camera_info</remapping>
                </ros>
                <camera_name>camera</camera_name>
                <frame_name>camera</frame_name>
            </plugin>
        </sensor>
    </link>
</model>

Subscribe in ROS 2:

from sensor_msgs.msg import Image
import cv2
from cv_bridge import CvBridge
 
def image_callback(msg):
    bridge = CvBridge()
    cv_image = bridge.imgmsg_to_cv2(msg, "bgr8")
    cv2.imshow('Camera Feed', cv_image)
    cv2.waitKey(1)
 
node = rclcpp.create_node('vision_node')
sub = node.create_subscription(Image, 'robot/camera/image_raw', image_callback, 10)
rclcpp.spin(node)

LiDAR Simulation

<sensor name="lidar" type="lidar">
    <lidar>
        <scan>
            <horizontal>
                <samples>720</samples>
                <resolution>1</resolution>
                <min_angle>-3.14159</min_angle>
                <max_angle>3.14159</max_angle>
            </horizontal>
        </scan>
        <range>
            <min>0.08</min>
            <max>10.0</max>
        </range>
    </lidar>
    
    <plugin filename="libgazebo_ros_lidar_gpu.so" name="lidar_plugin">
        <ros>
            <remapping>scan:=lidar_scan</remapping>
        </ros>
        <frame_name>lidar</frame_name>
    </plugin>
</sensor>

Subscribe:

from sensor_msgs.msg import LaserScan
 
def lidar_callback(msg):
    ranges = msg.ranges
    print(f"Min range: {min(ranges):.2f}m")
 
sub = node.create_subscription(LaserScan, 'lidar_scan', lidar_callback, 10)

Part 6: Real-to-Sim Workflow

The Goal

Write code once, run on simulation AND real hardware.

The Key: Topic Names Don’t Change

# Same code for simulation and hardware
class RobotController:
    def __init__(self):
        self.cmd_pub = self.create_publisher(Twist, 'cmd_vel', 10)
        self.odom_sub = self.create_subscription(
            Odometry, 'odom', self.odom_callback, 10
        )
    
    def move(self, linear_x, angular_z):
        msg = Twist()
        msg.linear.x = linear_x
        msg.angular.z = angular_z
        self.cmd_pub.publish(msg)

Simulation: Gazebo publishes to /cmd_vel → diff_drive controller → wheels
Hardware: Real motor driver subscribes to /cmd_vel → wheels move

Same code. Different backends.

The catch: Time

Simulation runs at Gazebo’s clock, not wall-clock. Always use use_sim_time:

# Tell ROS 2 to use Gazebo's simulated time
# In launch file:
'use_sim_time': True
 
# In nodes:
self.get_clock().now()  # Returns simulated time, not wall time

Without this, timing is wrong (e.g., delays publish too fast).

Integration Testing

# test_navigation.py
import pytest
import rclpy
from geometry_msgs.msg import Twist
 
@pytest.fixture
def node():
    rclpy.init()
    yield rclpy.create_node('test_node')
    rclpy.shutdown()
 
def test_move_forward(node):
    # Publish move command
    pub = node.create_publisher(Twist, 'cmd_vel', 10)
    msg = Twist()
    msg.linear.x = 0.5
    pub.publish(msg)
    
    # Wait for odometry update
    rclpy.spin_once(node, timeout_sec=0.5)
    
    # Check robot moved
    # (requires subscribing to odom in fixture)
    assert robot_x > 0.0
 
# Run with:
# ros2 launch robot gazebo.launch.py
# pytest test_navigation.py

Part 7: Debugging Common Issues

Issue 1: Robot Doesn’t Move

Check:

# Is controller running?
ros2 control list_controllers
 
# Output:
# joint_state_broadcaster [joint_state_broadcaster/JointStateBroadcaster] active ✓
# diff_drive_controller [diff_drive_controller/DiffDriveController] active   ✓
 
# Is topic being published?
ros2 topic hz cmd_vel
# Should show ~50 Hz
 
# Check velocity is actually going out
ros2 topic echo cmd_vel
# Should see non-zero linear.x values

Issue 2: Wrong Odometry

Cause: Wheel radius mismatch between URDF and controller config.

# config/controllers.yaml
wheel_radius: 0.05  # Must match URDF!
 
# In URDF:
<cylinder radius="0.05" length="0.02"/>  # ← Must match

If they differ, odometry drifts.

Issue 3: Gazebo Crashes on Startup

# Check for URDF errors
check_urdf robot.urdf
 
# Should say "urdf is valid"
 
# Check for missing plugins
gazebo --verbose robot.sdf 2>&1 | grep -i "error\|plugin"
 
# Disable problematic plugins temporarily
<!-- <plugin filename="..." name="..."/> -->  <!-- Commented out -->

Issue 4: Joint Limits Not Enforced

Make sure <limit> is in URDF:

<joint name="wheel_joint" type="continuous">
    <!-- ... -->
    <limit effort="10" velocity="10"/>  <!-- Critical -->
</joint>

Also set in controller config:

linear:
  x:
    max_velocity: 1.0

Real-World Example: Navigation Testing

# Test: Can robot navigate around obstacle?
class NavigationTest:
    def __init__(self):
        self.node = rclcpp.create_node('nav_test')
        self.cmd_pub = self.node.create_publisher(Twist, 'cmd_vel', 10)
        self.odom_sub = self.node.create_subscription(
            Odometry, 'odom', self.odom_callback, 10
        )
        self.start_x = None
        self.current_x = None
    
    def odom_callback(self, msg):
        self.current_x = msg.pose.pose.position.x
        if self.start_x is None:
            self.start_x = self.current_x
    
    def test_forward_motion(self):
        # Command: move forward
        for _ in range(50):  # 50 steps at 100 Hz = 0.5 seconds
            msg = Twist()
            msg.linear.x = 0.5  # 0.5 m/s
            self.cmd_pub.publish(msg)
            rclpy.spin_once(self.node, timeout_sec=0.01)
        
        # Expected: moved ~0.25 meters in 0.5 seconds
        distance = self.current_x - self.start_x
        assert distance > 0.2, f"Moved only {distance:.2f}m, expected >0.2m"
        print(f"✅ Forward motion test passed: {distance:.2f}m")
 
test = NavigationTest()
test.test_forward_motion()

Key Insights

1. Gazebo Simulation Is Physics-Based

Unlike trajectory playback, Gazebo actually simulates forces. If you command too high acceleration, wheels slip.

2. use_sim_time Is Non-Negotiable

Forget it once, your timing is off forever. Always set it in launch files.

3. URDF and Controller Config Must Match

If URDF says wheel radius is 0.05m but config says 0.08m, odometry is garbage.

4. Sensors Require Plugins

Cameras, LiDARs, IMUs don’t appear magically. Each needs a Gazebo plugin + ROS 2 bridge.

5. Real-to-Sim Gaps Still Exist

Friction, air resistance, sensor noise—simulation idealizes. Real robots are messy. Test on hardware.


Resources


Last updated: June 2026 | Tested on Gazebo Ignition Fortress, ROS 2 Humble