跳转至

第 9 章 Gazebo 仿真

Gazebo(现名 gz sim)是 ROS 生态中集成度最高的物理仿真器——原生支持 SDF 模型描述、插件化传感器模拟、以及与 ROS 2 的深度桥接。在仿真中检验控制算法与感知链路,再迁移到真机,是机器人开发的标准工作流。本章从 gz sim 的版本配对与安装讲起,经 SDF 建模、ros_gz 桥接、传感器仿真与 gz_ros2_control 通路,到与 MuJoCo 的选型对比,给出在 Humble 环境下把仿真回路搭起来的实操路径。

1. gz sim 基线与版本配对

1.1 谱系与命名

Gazebo 项目经历了分裂与更名:

名称 标识 状态
Gazebo Classic(gazebo) 11.x 及以前 已 EOL(2025-01),不再维护
gz sim(新架构) 作为 gz-<codename> 发行 活跃开发

新版采用代号命名:gz-fortress(LTS,与 Humble 配对)、gz-garden(与 Iron 配对)、gz-harmonic(与 Jazzy 配对)。Humble 仓库提供 gz-fortress 元包,其 libgz-transport11/libgz-msgs8 即 Fortress 代的 gz 库(早期包名沿用 libignition-transport11/libignition-msgs8,两者同代)。具体小版本配对以 Gazebo 配对表为准。

1.2 安装与启动

## Humble 下的 gz sim 本体 + ros_gz 桥接(ros-humble-ros-gz 0.244.26,含 sim/bridge/image 子包)
sudo apt install -y gz-fortress ros-humble-ros-gz

gz-fortress 是过渡元包,实体会拉进 libgz-sim6/libignition-gazebo6(Fortress 对应 gz-sim 6.x)。启动命令有个同名陷阱:若系统同时装了已 EOL 的 Gazebo Classic 11(gazebo 包),/usr/bin/gz 会被其占成 gz-11.x 软链,gz sim 会报“Invalid arguments”;Fortress 的命令行入口是 ign gazebo(ros_gz_sim 的 launch 对 version<7 也自动选 ign gazebo)。无头启动示例:

ign gazebo -s empty.sdf        # -s 仅服务端(无 GUI);-v4 输出详细日志
gz sim -r                       # 未装 Classic 时,Fortress 的 `gz sim` 亦可

无头 ign gazebo -s -v2 <ignition-gazebo6>/worlds/empty.sdf 会加载世界、启用 DART 物理,发布 gz transport 话题 /clock、/stats、/world/empty/*(物理默认 1ms profile)。

1.3 物理引擎

gz sim 支持可插拔物理引擎:默认 DART(面向关节体),可选 Bullet、TPE(轻量测试引擎)。切换在 SDF 的 <physics> 标签或环境变量 GZ_SIM_PHYSICS_ENGINE_LIBRARY 完成。DART 对关节体(机械臂、底盘)仿真质量稳定;TPE 适合简单场景但不保证接触精度。

坑:gz 命令可能指向 Classic

Classic 11 与现代 gz sim 共存时 /usr/bin/gz 指向 Classic,gz sim 无效;Fortress 的入口是 ign gazebo,ros_gz 的 launch 对旧版本会自动选它。

2. SDF 与建模链

2.1 模型与世界文件

SDF(Simulation Description Format)是 gz sim 的原生模型格式,描述比 URDF 更完整——包含物理属性、传感器、插件与多模型场景:

<?xml version="1.0" ?>
<sdf version="1.8">
  <model name="diff_drive_bot">
    <pose>0 0 0.1 0 0 0</pose>    <!-- 初始位姿 x y z roll pitch yaw -->

    <link name="base_link">
      <inertial>
        <mass>2.0</mass>
        <inertia><ixx>0.01</ixx><iyy>0.02</iyy><izz>0.03</izz></inertia>
      </inertial>
      <collision name="base_collision">
        <geometry><box><size>0.4 0.3 0.1</size></box></geometry>
      </collision>
      <visual name="base_visual">
        <geometry><box><size>0.4 0.3 0.1</size></box></geometry>
      </visual>
    </link>

    <link name="wheel_left">
      <inertial>
        <mass>0.1</mass>
        <inertia><ixx>0.0001</ixx><iyy>0.0001</iyy><izz>0.0001</izz></inertia>
      </inertial>
      <collision name="wheel_collision">
        <geometry><cylinder><radius>0.05</radius><length>0.03</length></cylinder></geometry>
        <surface><friction><ode><mu>1.0</mu><mu2>1.0</mu2></ode></friction></surface>
      </collision>
    </link>

    <joint name="wheel_left_joint" type="revolute">
      <parent>base_link</parent>
      <child>wheel_left</child>
      <pose relative_to="base_link">0 0.18 -0.05 0 0 0</pose>
      <axis>
        <xyz>0 1 0</xyz>
        <limit><effort>10</effort><velocity>5</velocity></limit>
      </axis>
    </joint>

    <!-- 右轮:与左轮对称(y 取反),DiffDrive 插件需两个关节都在 -->
    <link name="wheel_right">
      <inertial>
        <mass>0.1</mass>
        <inertia><ixx>0.0001</ixx><iyy>0.0001</iyy><izz>0.0001</izz></inertia>
      </inertial>
      <collision name="wheel_right_collision">
        <geometry><cylinder><radius>0.05</radius><length>0.03</length></cylinder></geometry>
        <surface><friction><ode><mu>1.0</mu><mu2>1.0</mu2></ode></friction></surface>
      </collision>
    </link>

    <joint name="wheel_right_joint" type="revolute">
      <parent>base_link</parent>
      <child>wheel_right</child>
      <pose relative_to="base_link">0 -0.18 -0.05 0 0 0</pose>
      <axis>
        <xyz>0 1 0</xyz>
        <limit><effort>10</effort><velocity>5</velocity></limit>
      </axis>
    </joint>

    <!-- 差速驱动插件 -->
    <plugin filename="gz-sim-diff-drive-system" name="gz::sim::systems::DiffDrive">
      <left_joint>wheel_left_joint</left_joint>
      <right_joint>wheel_right_joint</right_joint>
      <wheel_separation>0.36</wheel_separation>
      <wheel_radius>0.05</wheel_radius>
      <odom_publish_frequency>50</odom_publish_frequency>
    </plugin>

    <!-- 激光雷达传感器 -->
    <link name="laser_link">
      <sensor name="laser" type="gpu_lidar">
        <pose>0.1 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></range>
        </lidar>
      </sensor>
    </link>
  </model>
</sdf>

world 是最外层的容器,可包含多个 model、光照、地形与全局物理参数。步长与实时倍速写在 <physics> 里,被包含的模型按 uri 引用,同一份模型可被多个世界复用:

<sdf version="1.8">
  <world name="my_world">
    <physics name="1ms" type="ode">
      <max_step_size>0.001</max_step_size>   <!-- 仿真步长 1ms -->
      <real_time_factor>1.0</real_time_factor>  <!-- 实时倍速 -->
    </physics>
    <!-- 引用模型库中的模型;name 可覆盖模型自身名字,避免多份实例重名 -->
    <include>
      <uri>model://diff_drive_bot</uri>
      <name>diff_drive_bot</name>
    </include>
  </world>
</sdf>

2.2 URDF/xacro → SDF 转换

已有 URDF 模型时通过 sdformat 工具转换:

## gz 命令行转换(形态以 gz 文档为准)
gz model --convert my_car.urdf -o my_car.sdf

gz sdf -k my_car.sdf     # 检查 SDF 文件语法

URDF → SDF 的已知限制:URDF 中的 <transmission> 和 <gazebo> 扩展标签不被直接转换——ros2_control 的 <ros2_control> 标签需要通过 gz_ros2_control 插件在 SDF 中手动添加。

3. ros_gz 桥

ros_gz 包组是 gz sim 与 ROS 2 之间的消息桥接层,由三个子包组成:

子包 职责
ros_gz_sim 启动 gz sim、spawn 模型到世界、提供 /clock 发布
ros_gz_bridge 通用双向消息桥(gz transport ↔ ROS 2 topic)
ros_gz_image 图像专用桥(高效处理 sensor_msgs/Image ↔ gz::msgs::Image)

桥接的拓扑关系:仿真侧与 ROS 侧各跑各的图,ros_gz 在中间做双向消息翻译,图像与时钟各有专用通路:

flowchart LR
    subgraph gz["gz sim(Fortress)"]
        world["世界 / 物理 / 传感器"]
    end
    world -->|"gz transport"| br["ros_gz_bridge<br/>gz ↔ ROS 2 双向"]
    world -->|"gz 图像"| img["ros_gz_image<br/>图像专用桥"]
    world -.->|"gz.msgs.Clock"| br
    br -->|"/clock、/cmd_vel、/scan…"| ros["ROS 2 节点<br/>(use_sim_time)"]
    img --> ros

3.1 启动 gz sim 并接入 ROS 2

## gz_sim.launch.py 关键片段
from launch_ros.actions import Node

gazebo = Node(
    package='ros_gz_sim',
    executable='create',             # 向运行中的 gz sim 注入模型
    arguments=['-name', 'diff_drive_bot', '-topic', 'robot_description',
               '-x', '0', '-y', '0', '-z', '0.1'],
    output='screen',
)

bridge = Node(
    package='ros_gz_bridge',
    executable='parameter_bridge',
    parameters=[{
        'config_file': os.path.join(pkg_share, 'gz_bridge.yaml'),
    }],
)

3.2 消息映射配置

gz_bridge.yaml 定义 gz topic ↔ ROS topic 的双向映射:

完整 gz_bridge.yaml(点击展开)
## gz_bridge.yaml
- ros_topic_name: "/clock"
  gz_topic_name: "/clock"
  ros_type_name: "rosgraph_msgs/msg/Clock"
  gz_type_name: "gz.msgs.Clock"
  direction: GZ_TO_ROS

- ros_topic_name: "/scan"
  gz_topic_name: "/world/my_world/model/diff_drive_bot/link/laser_link/sensor/laser/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: "/model/diff_drive_bot/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: "/model/diff_drive_bot/odometry"
  ros_type_name: "nav_msgs/msg/Odometry"
  gz_type_name: "gz.msgs.Odometry"
  direction: GZ_TO_ROS

3.3 差速小车最小世界

完整步骤——从零到 /cmd_vel 控制仿真小车运动:

# 1. 启动 gz sim(ros_gz_sim 自带 gz_sim.launch.py)
ros2 launch ros_gz_sim gz_sim.launch.py gz_args:="-r my_world.sdf"

# 2. 模型注入 + 桥接(my_bringup 为自写 launch,内容见 3.1)
ros2 launch my_bringup gz_sim.launch.py

# 3. 检查桥接
ros2 topic list          # 应出现 /scan /odom /cmd_vel /clock
ros2 topic hz /scan      # 10 Hz 雷达数据
ros2 topic hz /odom      # 50 Hz 里程计

# 4. 发布速度命令
ros2 topic pub -r 10 /cmd_vel geometry_msgs/msg/Twist \
  "{linear: {x: 0.5}, angular: {z: 0.3}}"

# 5. RViz2 观察(需 TF:robot_state_publisher 发布 base_link → laser_link)
rviz2 --fixed-frame odom

4. 仿真时间与传感器

4.1 /clock 与 use_sim_time

gz sim 在每个仿真步发布 gz.msgs.Clock → ros_gz_bridge 转为 rosgraph_msgs/msg/Clock 发布到 ROS 话题 /clock。所有设置 use_sim_time := true 的节点从 /clock 获取时间戳,而非系统时钟。

一行桥接即可把 gz 的 /clock 转到 ROS 侧(无头 ign gazebo -s empty.sdf 起世界后):

ros2 run ros_gz_bridge parameter_bridge "/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock"
## ROS 侧 `ros2 topic hz /clock` 得到百赫兹量级(无头服务端快于实时);`[` 表 gz→ROS。Humble 的 ros_gz 对 `gz.msgs.*` 与 `ignition.msgs.*` 两名都接受。

需注意:纯 -s 无头服务端下,自带 diff_drive.sdf 的 odometry 发布器不逐帧产出数据(需 GUI 或外部步进);要跑 cmd_vel→odom 的驱动闭环,需在带 GUI 或 --run 的交互环境下进行。

## 全链路使用仿真时间(launch 注入)
ros2 launch my_bringup gz_sim.launch.py use_sim_time:=true

时间冻结问题:gz sim 暂停时 /clock 停止推进,依赖时间的 TF 查询超时、SLAM 卡住。解法:gz sim 必须处于 running 状态(GUI 中点播放或命令行 -r)。

4.2 real_time_factor 与步长

<real_time_factor> 控制仿真倍速:1.0 为实时,0.5 为半速(仿真时间比墙钟慢),>1.0 为加速(但受 CPU 算力限制达不到标称倍速)。<max_step_size> 决定数值积分精度——越小越精确但计算越慢。差速底盘建议步长 1ms、倍速 1.0;高频力控场景可能需要 0.5ms 步长。

4.3 传感器仿真对照真机

传感器 gz sim 插件/模型 输出话题 与真机驱动的差异
相机 gz-sim-camera-system /camera/image_raw + /camera_info 真机内参由标定得出;仿真内参写在 SDF 中
2D 激光雷达 gz-sim-lidar-system (ray) /scan 仿真无杂点与反射率缺失;真机有盲区与光强衰减
3D LiDAR gpu_lidar 多线模型 /points2 仿真点云无运动畸变;真机有(扫描期间底盘移动)
IMU gz-sim-imu-system /imu/data 仿真无零偏漂移(或需手动配噪声模型);真机有
GPS gz-sim-navsat-system /fix (sensor_msgs/msg/NavSatFix) 仿真精度远高于真机多路径误差(真机侧接入与融合见第 7 章「GNSS 接收机接入」、第 10 章「GNSS/RTK 观测与质量分档」)

仿真传感器话题名与真机驱动话题名不一致时,通过 remap 统一接口——下游算法不需要改动。

判据:全链路统一 use_sim_time

仿真里凡是对时间敏感的节点(TF、规划、状态估计)都要吃 /clock;漏一个节点就会出现“只差一拍”的诡异失配,且只在那一个节点上体现。

5. gz_ros2_control 与 MuJoCo 桥接选型

5.1 gz_ros2_control

gz_ros2_control 让 gz sim 中的模型使用 ros2_control 框架的控制器——与真机共用同一套 controller_manager + hardware_interface + controller 配置(Humble 包 ros-humble-gz-ros2-control):

<!-- SDF 中注入 gz_ros2_control 插件 -->
<plugin filename="gz_ros2_control-system"
        name="gz_ros2_control::GazeboSimROS2ControlPlugin">
  <parameters>$(find my_bringup)/config/controllers.yaml</parameters>
</plugin>

SDF 的 <ros2_control> 标签格式与 URDF 中完全一致(第 6 章)。仿真 hardware_interface 的 read() 从物理引擎获取关节状态,write() 将力矩/速度命令传给物理引擎——替换了真机的串口/CAN 通信层。

确认 gz_ros2_control 控制器已加载:

ros2 control list_controllers
## 期望输出:diff_drive_controller [diff_drive_controller/DiffDriveController] active

5.2 MuJoCo 桥接

MuJoCo 的 ROS 2 集成有两条路径。mujoco_ros2_control(Humble apt 包 ros-humble-mujoco-ros2-control)按 ros2_control 硬件接口接入,与 gz_ros2_control 同构,适合标准控制器栈(见 mujoco-textbook 第 9 章「外部集成与训练」中的 ros2_control 通路)。另一条是单进程 rclpy 桥接:在 MuJoCo Python 循环内直接调 rclpy publisher/subscriber,仿真与控制共享同一进程,适合快速试错与轻量仿真。

与 mujoco-textbook 第 9 章「外部集成与训练」中的 rclpy 桥接方案对照:

维度 gz sim + ros_gz MuJoCo + rclpy 单进程
进程模型 gz sim 独立进程 + bridge 进程 + ROS 节点 MuJoCo 与 ROS 节点在同一 Python 进程
通信路径 ROS topic → bridge → gz transport 直接 Python 调用(零序列化)
物理引擎 DART/Bullet(关节体优化) MuJoCo(接触丰富操作优化)
传感器仿真 内置(ray/LiDAR/Camera/IMU 插件) 需手动调 mjData.sensordata 并填充 ROS 消息
适用 导航、SLAM 全链路、多机器人 操作、力控、RL(需高频接触仿真)

5.3 选型判据

  • 需要多传感器 + 全链路仿真(导航、SLAM):gz sim——内置传感器仿真完善、ros_gz 生态成熟、社区模型丰富。
  • 需要高精度接触动力学 + 强化学习训练:MuJoCo——求解器精度高、并行仿真效率高、Python API 简洁。
  • 需要 GPU 大规模并行 + 照片级渲染:Isaac Sim/Lab——PhysX GPU 加速、USD 生态、合成数据管线。

三者不是互斥关系——gz sim 做导航测试、MuJoCo 做操作训练、Isaac 做 sim-to-real 合成数据是当前业界常见分工。

6. 要点

  • gz sim(Fortress)是 Humble 的配套仿真器;Classic 已 EOL,新项目应直接用 gz sim。SDF 是其原生格式,可从 URDF 转换但 <ros2_control> 等扩展标签需手动迁移。
  • ros_gz 三件套(sim/bridge/image)将 gz 内部话题映射为 ROS 话题;gz_bridge.yaml 是唯一的配置入口,每条映射需精确指定 gz 侧的层级话题名。
  • use_sim_time := true + /clock 发布是仿真全链路时间戳一致的前提;gz sim 暂停时 clock 冻结,所有依赖时间的模块同步停滞。
  • gz_ros2_control 让仿真与真机共用同一套 ros2_control 配置——换 hardware_interface 插件即可在仿真与真机之间切换,控制代码零改动。
  • 仿真传感器与真机的差异(噪声模型、运动畸变、盲区)决定了下游算法的仿真结论需在真机上重新确认。
  • 选型按"全链路集成度 → 接触精度 → GPU 并行规模"三条轴线在 gz sim、MuJoCo、Isaac 之间决策;三工具可分工并存。

参考文献与延伸阅读

  • Gazebo. 文档入口. 文档
  • ros_gz. ROS 2 与 Gazebo 桥接仓库. 仓库
  • SDFormat. 规范与工具. 规范