跳转至

第 9 章 外部集成与训练

本章解决一个具体问题:已有一份 URDF 与一个 ROS 2 控制栈,需要使 MuJoCo 充当物理后端,向外发布状态、接收控制指令。 分三层——模型进得来(URDF)、进程跑得动(Python ABI)、闭环连得上(topic)。

1. URDF 的直接载入

MuJoCo 的加载器能直接吃 URDF:mujoco.MjModel.from_xml_path("robot.urdf") 就返回一个编译好的 MjModel,不需要先转 MJCF。compiler 一节对 URDF 有专门的默认值处理:"URDF 始终 radian"、"URDF 默认 discardvisual=true"、"URDF 默认 fusestatic=true"。拿一个两 link、一 revolute 一 fixed 的最小机器人试试:

<?xml version="1.0"?>
<robot name="twolink">
  <link name="base"><inertial><mass value="1"/>
    <inertia ixx="0.01" ixy="0" ixz="0" iyy="0.01" iyz="0" izz="0.01"/></inertial></link>
  <link name="l1"><inertial><mass value="0.5"/>
    <inertia ixx="0.005" ixy="0" ixz="0" iyy="0.005" iyz="0" izz="0.002"/></inertial>
    <visual><geometry><cylinder radius="0.03" length="0.4"/></geometry></visual></link>
  <link name="tip"><inertial><mass value="0.1"/>
    <inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/></inertial></link>
  <joint name="j1" type="revolute">
    <parent link="base"/><child link="l1"/><origin xyz="0 0 0" rpy="0 0 0"/>
    <axis xyz="0 1 0"/><limit lower="-1.5" upper="1.5" effort="10" velocity="1"/>
  </joint>
  <joint name="f1" type="fixed">
    <parent link="l1"/><child link="tip"/><origin xyz="0 0 -0.4" rpy="0 0 0"/>
  </joint>
</robot>

三个 link、两个关节载入后读到的是 nbody=2 njnt=1 ngeom=0 nq=1 nu=0 nsensor=0,每个数字都是一条映射规则在起作用:

载入结果 背后的规则
njnt=1(两个关节声明只剩一个) revolute→hinge;fixed 不建关节,被融合进父 body
jnt_type=[3] 3 = mjJNT_HINGE
jnt_range=[-1.5, 1.5] URDF 的 limit lower/upper 以弧度原样进 mjModel,compiler/@angle 对它不生效
ngeom=0 <visual> 被默认的 discardvisual=true 丢掉,只有 <collision> 建 geom
nu=0 URDF 的 <transmission>/<forceController> 不会被翻成执行器

还有一条 schema 约束:URDF 的惯量必须写满 ixx ixy ixz iyy iyz izz 六个分量,缺任何一个都编译不过——MuJoCo 不做"缺项按零补"的宽容处理。

这四条映射直接决定控制栈怎么接:

  1. 关节类型对应:revolute→hinge、continuous→hinge、prismatic→slide;jnt_range 用 URDF 的 limit lower/upper,单位是弧度([-1.5, 1.5] 原样进 model.jnt_range)。这与 MJCF 默认"度"不同,第 2 章的 compiler angle 对 URDF 不生效。
  2. fixed joint 不建关节,它被融合进父 body。

所以 URDF 里的虚拟附加刚体(ROS 常用来挂传感器)在 MuJoCo 里多会塌缩掉——这正是第 3 章提过的"MuJoCo 不需要虚拟刚体"。 3. <visual> 几何被丢,只有 <collision> 进 ngeom。想保留视觉几何要把 compiler discardvisual="false" 写进随附的 MJCF 扩展。 4. 不自动生成执行器。控制栈需要的执行器必须在 URDF 之外补一个 MJCF <actuator> 段,或用 MuJoCo 的 <extension>/parser hook。这条直接决定「单进程桥接闭环」里 data.ctrl 是谁。

第 4 条最容易被忽视:一个纯 URDF 读进来 nu=0,data.ctrl 是空数组,任何"下发力矩/位置目标"都无处落脚。要么给 URDF 配一份带 <actuator> 的 MJCF 包装,要么在桥接层把控制目标翻译成对 qfrc_applied/ctrl 的写入。

2. conda 与 Humble 的进程边界

这是将 MuJoCo 接入 ROS 2 时最先遇到的问题。用 conda 环境的 Python 3.14 执行 import rclpy:

导入失败的原因是 pybind11 那个 C 扩展找不到:系统装的是 cpython-310 版的 .so,被 3.14 的解释器去加载时对不上——扩展文件名里的版本 tag 与当前解释器不一致,加载器就不会去读它。

rclpy 是编译到特定 CPython ABI 的 C 扩展:Humble 的二进制按 cpython-310 构建,conda 环境是 3.14,.so 名字里的 tag 对不上,直接找不到模块。

解法是反过来——在带 rclpy 的系统 Python 3.10 里再装一份 MuJoCo,让两者同进程共存:同一个 python3(3.10.12)里 import mujoco(3.13.0)与 import rclpy 都成功,sensor_msgs.msg.JointState 也能正常构造——即一个进程里同时拿到两边的库是可行的,不需要跨进程网关。

于是桥接节点必须用 python3(系统)跑,而不是 conda 环境里的 python。这条约束会一直存在,直到 conda 环境与 ROS 发行版的 Python 版本对齐(或从源码重编 rclpy)。第 8、9 章的传感器与训练脚本用 conda 的 3.11/3.14 跑,本章桥接脚本用系统 3.10 跑——同一份书,两个解释器,各取所需。

3. 单进程桥接闭环

有了"系统 py3.10 同时有 mujoco 和 rclpy",闭环就不需要额外中间件:一个 rclpy 节点里持有 MuJoCo 世界,定时器推进物理并发布,订阅写回控制。完整闭环的核心骨架:

class MujocoBridge(rclpy.node.Node):
    def __init__(self):
        super().__init__("mujoco_bridge")
        self.model = mujoco.MjModel.from_xml_path(MODEL)   # assets/models/01_pendulum.xml
        self.data  = mujoco.MjData(self.model)
        self.pub_js   = self.create_publisher(JointState, "/joint_states", 10)
        self.pub_sens = self.create_publisher(Float64MultiArray, "/mujoco/tcp_pos", 10)
        self.sub_cmd  = self.create_subscription(
            Float64MultiArray, "/cmd_pos", self.on_cmd, 10)
        self.create_timer(1.0 / CTRL_HZ, self.tick)         # 100 Hz

    def on_cmd(self, msg):                                  # 写回 data.ctrl
        n = min(len(msg.data), self.model.nu)
        self.data.ctrl[:n] = np.asarray(msg.data[:n], dtype=np.float64)

    def tick(self):
        for _ in range(SIM_PER_TICK):                       # 每拍推进 5 子步(0.01 s)
            mujoco.mj_step(self.model, self.data)
        js = JointState()
        js.header.stamp = self.get_clock().now().to_msg()
        js.name      = self.joint_names
        js.position  = [float(self.data.qpos[a]) for a in self.qposadr]
        js.velocity  = [float(self.data.qvel[a]) for a in self.qveladr]
        self.pub_js.publish(js)
        arr = Float64MultiArray()
        sid = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_SENSOR, "tcp_pos")
        arr.data = [float(v) for v in self.data.sensordata[
            self.model.sensor_adr[sid]:
            self.model.sensor_adr[sid] + self.model.sensor_dim[sid]]]
        self.pub_sens.publish(arr)

四个设计决定,都能对应到前章:

  • 控制频率与物理频率解耦:timestep=0.002 是物理步(第 5 章),定时器 CTRL_HZ=100 是发布/控制周期,一个控制拍里跑 SIM_PER_TICK=5 个物理子步。真机控制器就是按这个"多子步/控制周期"节奏走。
  • /joint_states 用 sensor_msgs/JointState:这是 ROS 2 里关节反馈的标准话题,robot_state_publisher、tf2、joint_state_broadcaster 全认它。名字从 mjModel 反查(joint_names/qposadr),不写死。
  • 传感器话题是 Float64MultiArray:演示把第 8 章的 framepos 直接映射成一条 topic——MJCF 传感器 → ROS topic 就是 sensordata[adr:adr+dim] 的搬运,地址同样在初始化时查好(呼应第 8 章「读数通路与成本」"别在每拍 mj_name2id")。
  • 执行器落在 data.ctrl:on_cmd 写 ctrl,前提是「URDF 的直接载入」第 4 条说的"模型得有执行器"(01_pendulum.xml 有 3 路,nu=3)。

在系统 py3.10 + Humble 下跑一次完整闭环(下面这些数产自那个解释器,base 的 conda py 里 import rclpy 就成不了,见「conda 与 Humble 的进程边界」):向 /cmd_pos 发一条 shoulder→1.0, elbow→-1.0, prize→0,桥接收到 1 条命令,36 秒仿真里一个探针节点分别收到 360 条 /joint_states 与 300 条 /mujoco/tcp_pos,关节真的动了:

topic 消息类型 周期
/cmd_pos std_msgs/msg/Float64MultiArray 输入,10 Hz
/joint_states sensor_msgs/msg/JointState 100 Hz × 5 子步
/mujoco/tcp_pos std_msgs/msg/Float64MultiArray 100 Hz,来自 framepos

闭环后 shoulder 停在 qpos=+1.3214 rad、elbow_hinge 在 −1.1685 rad,最后一帧 /mujoco/tcp_pos = [−0.4519, 0.0, −0.4997](单位米)。

一个闭环讲完了两件事:cmd 进去、关节动起来、状态出来(shoulder 目标 1.0,收敛到 1.32——position 执行器 kp=50 在重力场里留稳态误差,与第 2 章「要点」"纯比例执行器在重力场里必然留稳态误差"完全一致);传感器读数以 topic 形式流式输出(tcp_pos 是末端全局坐标)。topic 清单用节点自身的 get_topic_names_and_types() 枚举,不依赖 ros2 CLI 与 daemon——这保证脚本在单进程里自证闭环。

4. ros2_control 通路

手写桥接适合快速试错与轻量仿真,机器人工程里更规范的做法是走 ros2_control:controller_manager 按固定周期驱动一组硬件接口插件,插件实现 read()(把当前状态交给框架)与 write()(把控制器算出的命令下发);功能以控制器挂载——joint_state_broadcaster 把关节状态汇成 /joint_states,forward_command_controller/position_controllers 把目标写进命令接口。硬件接口既可以是真实驱动,也可以是仿真器。

MuJoCo 侧的仿真硬件接口是 mujoco_ros2_control(PAL Robotics 维护,已并入 ros2_control 生态;Humble 起有 apt 包 ros-humble-mujoco-ros2-control,不必再 colcon 构建)。它把一个 MuJoCo 模型实现成 SystemInterface:read() 里推进物理并把 qpos/qvel 填进状态接口,write() 里把位置/力矩命令接口写进 data.ctrl。

于是一条位置命令的路径是:控制器写命令接口 → write() 灌进 MuJoCo → 下一拍 read() 读回仿真结果 → broadcaster 发 /joint_states。接入只需给 URDF 加一段 <ros2_control> 声明插件 mujoco_ros2_control/MujocoSystemInterface,再在 MJCF 里配好 actuator:有 actuator 的关节拿到命令接口,没有的(如被驱动侧的 gripper_right_finger_joint)自动降为被动关节;camera/lidar 这类像素/点云传感器不走硬件接口,另有渲染通路(见第 7 章「相机的三条出图通路」)。

ros2 launch mujoco_ros2_control_demos 01_basic_robot.launch.py headless:=true

两个使用约束:无显示环境必须 headless:=true,默认值会让插件起 MuJoCo 的被动查看器(需 GL),拿不到显示时硬件初始化会失败;controller_manager 的 FIFO 实时调度在普通用户进程里会降级为普通线程(硬实时需 CAP_SYS_NICE),功能不受影响。

包名:社区常写的 mujoco_ros2 并不存在,准确名目是 mujoco_ros2_control(上游 ros2_control)。

4.1 接口声明与名称对齐

ros2_control 用一段 <ros2_control> 把每个关节的命令接口 / 状态接口声明出来;mujoco_ros2_control 再按关节名去 MJCF 找同名 actuator 接线:

<!-- URDF:声明硬件接口(ros2_control 标准写法) -->
<ros2_control name="MujocoSystem" type="system">
  <hardware>
    <plugin>mujoco_ros2_control/MujocoSystemInterface</plugin>
    <param name="mujoco_model">$(find pkg)/model/scene.xml</param>   <!-- 要载入并推进的 MJCF -->
  </hardware>
  <joint name="shoulder">
    <command_interface name="position">
      <param name="min">-3.14</param><param name="max">3.14</param>   <!-- 命令量程 -->
    </command_interface>
    <state_interface name="position"/>
    <state_interface name="velocity"/>
  </joint>
</ros2_control>
<!-- MJCF:必须有 joint 名同为 shoulder 的 actuator,该关节才拿到命令接口 -->
<position joint="shoulder" kp="50" kv="2" ctrlrange="-3.14 3.14" forcerange="-30 30"/>

三条硬对应:① <ros2_control> 的 <joint name> = MJCF actuator 的 joint 名——名字对不上则该关节拿不到命令接口,只能声明 <state_interface>(读不能写),即“被动关节”;② command interface 的 min/max 与 MJCF ctrlrange 一致;③ forcerange 与 effort 上限一致。任一处错位,轻则命令被上游量程截断,重则找不到接口。

4.2 控制律归属与双闭环

MuJoCo 执行器自带反馈增益(<position kp kv>、<velocity kp kv>),ros2_control 的控制器也能算 PID——同一关节的控制律只能有一处拥有闭环,否则两级串联会争抢同一个 ctrl:

谁闭环 控制器(controllers.yaml) MJCF 执行器 改增益在哪
模型侧 forward_command_controller(直通目标) <position>/<velocity> 带 kp/kv 改 MJCF
控制侧 pid_controllers/JointPidController 或 effort 接口 <motor>/<general>(gainprm 直通命令值) 改 ros2 参数文件

直通模式下,位置命令经 write() 落到 data.ctrl,mj_step 里 <position> 的 kp 把它换成力矩——“位置执行器在重力场里约 \(0.005\) rad 的稳态微垂”就是这条 MJCF 侧 P 环的表现(与「单进程桥接闭环」那条“纯比例执行器在重力场里必留稳态误差”同源);要消它,在 MJCF 加 kv/积分,或改用控制侧 PID。两边都设 kp 是最常见的“参数对不齐”事故:ros2 侧输出已是力矩、MJCF 又把它当位置目标二次比例,环路会发散或增益含义不明。

4.3 时钟与频率对齐

两处周期要对齐:controller_manager 的 update_rate(典型 100 Hz–1 kHz)是控制器节拍,MJCF 的 timestep(典型 1–2 ms)是物理单步。mujoco_ros2_control 在每个 update 周期内 mj_step 若干子步填满 \(1/\text{update\_rate}\)。仿真时间由插件按累计 data.time 发 /clock,所有 ROS 节点须开 use_sim_time:=true 才能与控制器时间轴一致;否则话题打的是墙钟、与仿真时间脱节,下游做时序/滤波会错(呼应「conda 与 Humble 的进程边界」的时钟约束)。update_rate 低于被控对象带宽、或 timestep 大到显式积分发散,都是“参数看着都对、跑起来却漂”的来源。

4.4 状态反馈通路

read() 每周期把 mjData.qpos/qvel(及 effort)写进各关节 state interface,joint_state_broadcaster 汇总成 /joint_states(带 header.stamp=仿真时间)。这构成命令闭环的返回段:控制器 write() 命令 → MuJoCo 推进 → 下一拍 read() → state interface → broadcaster → topic。

其余反馈分两类:六维力/IMU 等可作为 state interface 由插件从 MJCF <sensor> 读出(力经 ft_sensor 硬件、IMU 经 imu_sensor 组件),再交各自 broadcaster 发 /wrench、/imu;相机/点云类像素输出不进硬件接口,走独立渲染通路(第 7 章),不会出现在 /joint_states 里。(接口组件名与参数以 ros2_control 与 mujoco_ros2_control 的项目文档为准。)

5. gymnasium 自带环境的观测

第 8 章的传感器与前文的控制闭环在此接入训练场景:强化学习要的是一维数值观测向量,而 MuJoCo 的传感器正好产出它。需要先明确——gymnasium 自带的 MuJoCo 环境大多不读 MJCF <sensor>,直接用会错过整套传感器系统。

在装好的 gymnasium 1.4.0 + MuJoCo 3.11.0 里,以 InvertedPendulum-v5 为例:nsensor=0、njnt=2、nu=1,观测维数 4,正好等于 qpos 拼 qvel——MJCF 里写的 <sensor> 压根没进观测,因为 gymnasium 的 MuJoCo 基类读的是引擎状态字段,不是 sensordata。

InvertedPendulum-v5 的模型 nsensor=0——它压根没建 MJCF 传感器,观测 (4,) 是 qpos(2)+qvel(2) 直接拼的。这不是个别现象:gymnasium 的经典 MuJoCo 任务(Ant、Humanoid、HalfCheetah…)观测都由 qpos/qvel/qfrc 拼成,而不是 <sensor>。

结论:要用传感器做观测,得自己写 env。下面给一个能跑的最小实现,正好把第 8 章的三件事串起来——sensordata 的地址切片、噪声要用户自己加、重置后要前向。

6. 传感器接成观测

以 assets/models/01_pendulum.xml(带一个 framepos 传感器)为物理后端,写一个最简 Gymnasium env,观测直接取传感器:

class SensorPendulum(gym.Env):
    def __init__(self):
        self.model = mujoco.MjModel.from_xml_path(MODEL)
        self.data  = mujoco.MjData(self.model)
        sid = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_SENSOR, "tcp_pos")
        self.adr, self.dim = int(self.model.sensor_adr[sid]), int(self.model.sensor_dim[sid])
        self.observation_space = spaces.Box(-np.inf, np.inf, (3,), np.float64)
        self.action_space      = spaces.Box(-1.6, 1.6, (2,), np.float64)

    def reset(self, *, seed=None, options=None):
        super().reset(seed=seed)
        mujoco.mj_resetData(self.model, self.data)
        mujoco.mj_forward(self.model, self.data)      # 关键:重置后必须前向,否则 sensordata 是旧值/0
        return self._obs(), {}

    def _obs(self):
        return np.array(self.data.sensordata[self.adr:self.adr + self.dim])

    def step(self, action):
        self.data.ctrl[:2] = np.clip(action, -1.6, 1.6)
        for _ in range(5):
            mujoco.mj_step(self.model, self.data)
        obs = self._obs()
        return obs, float(np.cos(obs[2])), False, False, {}

裸传感器的观测就是末端全局坐标:reset 后 tcp_xyz = [0, 0, −0.81](米),一步 step 后 obs = [−0.0153, −0.0022, −0.8219]、reward = 0.6899、sim_time = 0.010。

第 8 章的三处注意事项在此集中体现:

  • 地址切片:观测 = sensordata[adr:adr+dim],adr/dim 在 __init__ 里查一次存好,绝不在 step 里 mj_name2id(第 8 章「读数通路与成本」成本表)。
  • 重置后前向:mj_resetData 只清状态不算运动学,sensordata 还停在上一轮或 0。补 mj_forward 后 reset 才返回真实的 tcp_z=-0.81;漏掉这步,reset 观测会是 [0,0,0](脚本先前就是这样,加上 mj_forward 才修好)。
  • step 里多子步:控制周期 5×timestep,与前文桥接的节奏一致。

引擎已证明不再加 noise(3.1.4 移除)。训练里想让策略对传感器噪声鲁棒,就在 Gymnasium 的 Wrapper 里按 model.sensor_noise 的标准差自己加:

class NoiseWrapper(gym.Wrapper):
    """引擎不再加噪(3.1.4 移除),噪声在 env 侧自加;std 取自 model.sensor_noise。"""
    def __init__(self, env, rng):
        super().__init__(env); self.rng = rng; self.std = 0.01   # 带 seed 的随机源,保证可复现
    def reset(self, **kw):
        obs, info = self.env.reset(**kw)
        return obs + self.rng.normal(0, self.std, obs.shape), info   # 观测两处都要加
    def step(self, a):
        obs, r, t, tr, info = self.env.step(a)
        return obs + self.rng.normal(0, self.std, obs.shape), r, t, tr, info

拿同一个 wrapper 连跑 4 次 reset,x 分量分别是 0.0013、0.001、0.013、−0.0127——每次不同,确认噪声完全来自用户侧。

用 np.random.default_rng(seed) 而不是引擎内部 RNG,好处是噪声流跟着 env 的 seed 走,训练可复现——这正是 3.1.4 移除引擎噪声时给的迁移理由(changelog:"没有机制来设定随机噪声发生器的种子……添加噪声应该是用户的责任")。

7. 训练库与并行后端

拿到 env 后,接训练库和大规模并行是两步常规操作。base 环境(mujoco 3.11)里可用的只有 gymnasium 这一层——gymnasium 与 mujoco 可导入,mujoco.mjx、jax、mujoco_warp、stable_baselines3、torch、onnxruntime、mujoco_pinocchio 都不在其中。MJX 需要 mujoco≥3.17,硬装会顶掉全书基线,只能在隔离 venv 里用(下详);GPU 吞吐与 mujoco_warp 属需真机或未证实的边界,不写成数字。

7.1 MJX 与批量回放

逐条说明边界:

MJX(GPU 并行物理):MJX 把 mj_step 重写为可 jax 变换的 JAX 计算图,批量维就是 nworld(doc/mjx.rst)。base 的 3.11 轮子里没有 mjx,而 mujoco-mjx 依赖 mujoco>=3.17,直接在 base 装会顶掉全书基线,只能在隔离 venv 里用(python -m venv → 装 mujoco-mjx jax jaxlib)。在 venv(mujoco 3.17.0 / jax 0.12.0.1)里,接口与数值的一致性如下:

对照项 行为
mjx.put_model + mjx.make_data 可用;Data.ctrl.shape=(1,)、qpos.shape=(1,),与 nu=1 nq=1 对应
单步 qacc(CPU vs MJX) 486.0713994 对 486.0714722,差 7.3e-05
500 步累积 qpos 0.697644481 对 0.697644472,差 9.2e-09
jax.vmap 批量 8 个 ctrl 批走,输出 qpos 形状 (8, 1),首维就是 nworld
可用设备 jax.devices() = [CpuDevice(id=0)]——pip 装的 jaxlib 是 CPU 轮子

⇒ 两条关键行为:① MJX 在 CPU 上与原生实现逐位接近(单步 qacc 差约 7e-5、500 步累积 qpos 差约 9e-9);② 批量并行靠 jax.vmap,输出首维即 nworld。上千环境的 GPU 吞吐属需真机的量级——pip 的 jaxlib 不含 CUDA(Falling back to cpu),jax[cuda12] + Blackwell 兼容后才能给出 SPS 数字,此处不写。

Feature Parity 与 f32 两条边界(doc/mjx.rst):put 不预检,遇不支持特性才抛——dyntype="user" 的模型 mjx.put_model 直接拒绝,异常文本点名不支持的枚举([mjtBias.mjBIAS_USER: 5] not supported),Feature Parity 表(dyntype 仅 NONE/INTEGRATOR/FILTER/FILTEREXACT/MUSCLE,joint 仅 FREE/BALL/SLIDE/HINGE)就是 put 时的实际拦截面;

ELLIPSOID/CYLINDER 只与其他 primitive 碰撞——双椭球模型 put/step 正常,椭球-平面接触 ncon=3;jax 默认 f32——同一 500 步轨迹 CPU f64 末值 0.4998777644、MJX f32 0.4999991357,分叉 1.21e-4,两条轨迹都收敛同一目标但逐位对照必须同精度,实验前先 jax_enable_x64 或接受 f32 量级差异。

python Utilities 的主力工具是 mujoco.rollout:一次调用跑批量并行轨迹。单摆模型 3 条轨迹 × 250 步(初态经 mj_getState/mjSTATE_FULLPHYSICS 取得,输出 shape (3, 250, nstate)),三条相同 ctrl 的终态逐位一致、qpos 精确到 0.500000——MPC 与域随机化的批量回放不必自写循环。

以下生态组件按各自文档与仓库引用,性能数字以官方实现为准:

  • MuJoCo Warp(MJWarp,新一代 GPU 后端):正式名为 “MuJoCo Warp (MJWarp)”(文档 doc/mjwarp/index.rst,NVIDIA + DeepMind 共维,代码在 google-deepmind/mujoco_warp)。import mujoco_warp 需 NVIDIA Warp + CUDA,当前环境拿不到模块,标外部证据。
  • MJX 的版本对应:MJX 源码其实就在 MuJoCo 上游仓库里(mjx/、doc/mjx.rst),但 base 的 pip 轮子未打包 mujoco.mjx;上面「训练库与并行后端」一节用的是 mujoco 3.17(非基线 3.11)——因为 mujoco-mjx 依赖 mujoco>=3.17。引用 MJX 具体接口时要注清版本。
  • 训练算法(PPO/SAC):stable_baselines3、torch 未装 ⇒ "多少步收敛、日志字段名"这些数字不能写;PPO("MlpPolicy", env).train() 是 SB3 的 API 面,不在本章展开。
  • 部署(导 ONNX):onnxruntime 未装,“策略导 ONNX 再接回「单进程桥接闭环」做推理”这条从训练到部署的闭环,待装库后展开。
  • mujoco_pinocchio:未装;它是把 MuJoCo 模型接到 Pinocchio 动力学算法上的桥,同一模型的动力学量可与引擎互校。另一条路更直接:Pinocchio 3 起可读 MJCF(buildModelFromMJCF),不经桥也能拿到模型——差别在资产解析与引擎特性覆盖(接触、执行器、插件都在 MuJoCo 侧),按核对目的选。它也不是 pin:pin 是 Pinocchio 本体,mujoco_pinocchio 是桥接包。

上述通路(gymnasium 的 CPU env + 自定义传感器 env + 用户侧加噪 + MJX 的 CPU 数值一致性、vmap 批量、Feature Parity 拦截面与 f32 分叉、mujoco.rollout 批量回放、安装库 Model Gallery 本地加载)本章已展开;其余生态包(MJWarp 的 GPU 吞吐量、SB3 的训练收敛数字、ONNX 导出回接的推理闭环、mujoco_pinocchio)按各自文档与仓库说明引用。

7.2 dm_control:DeepMind 的环境栈

它不替代引擎,而是在 MuJoCo 之上补三件事——标准化控制任务(DeepMind Control Suite)、一套建在 dm_env 上的 RL 接口(比 Gym API 更早)、以及建模与查看器工具。PyPI 页面的定位是「physics-based simulation and Reinforcement Learning environments, using MuJoCo physics」。

版本配对是第一道判据:PyPI 元数据里 dm-control 1.0.24 依赖 mujoco>=3.2.1,1.0.51(2026-11 发布)依赖 mujoco>=4.0.0——与本章 3.11.0 基线配对的是前者,装最新版会连带顶掉基线(与第 1 章「次版本特性与破坏性变更」里 4.0 拆包同一条链)。

模块分工(职责与关键接口取自包内 docstring):

模块 定位与关键接口
dm_control.suite 任务集,自述「A collection of MuJoCo-based Reinforcement Learning environments」:19 个域(acrobot、cartpole、cheetah、walker、humanoid、quadruped、swimmer、reacher、point_mass、manipulator、stacker、dog、fish、finger…),任务清单 ALL_TASKS,另有 BENCHMARKING/EXTRA/REWARD_VIZ 三个 tag 子集(基准对比与录制专用);一行 suite.load('cartpole', 'balance') 起环境,签名 load(domain_name, task_name, task_kwargs=None, environment_kwargs=None, visualize_reward=False),返回 rl.control.Environment;随机化参数走 task_kwargs
dm_control.rl 接口层:control.Environment 是 dm_env.Environment 的子类,reset()/step() 返回 dm_env.TimeStep(step_type, reward, discount, observation),规格走 dm_env.specs——与 Gymnasium 是两套约定,互转需要适配层(如 shimmy)
dm_control.mjcf(PyMJCF) 对象模型库,自述「an MJCF object-model library」:element.RootElement() 起一棵 Python 对象树逐节点添加,Physics.from_mjcf_model(...) 直接步进,导出走 export_with_assets / export_with_assets_as_zip(与 MJZ 同思路)
dm_control.composer 组合式场景基类:Entity/Task/Arena/Initializer 与 observable 装饰器——把机器人、道具、观测项拼成复杂任务(带真机臂的操作研究环境多建在这一层)
dm_control.locomotion 完整 locomotion 研究套件(walkers、soccer 多智能体、tasks、arenas、mocap 回放),比 suite 里的单域任务重得多
dm_control.viewer 交互查看器:viewer.launch(environment_loader, policy=None, title='Explorer', width=1024, height=768)——environment_loader 可返回环境实例(也可直传 Environment),policy 接 TimeStep 返回符合 action_spec() 的动作数组;不写渲染循环也能把环境跑起来看

观测从哪来,是与 gymnasium 的真正分界(「gymnasium 自带环境的观测」一节)。gymnasium 的 MuJoCo 基类直接从 qpos/qvel 拼观测;dm_control 的观测由任务对象按命名量组织,且 suite 里 walker、cheetah、humanoid、quadruped、swimmer、manipulator、stacker 等域的模型 XML 均带 <sensor> 段——要观测带速度计、陀螺、触觉这类器件语义,这一层比 gymnasium 自带封装更靠近第 8 章的传感器体系。

与 MjSpec 的关系(第 10 章)。PyMJCF 与 mujoco.MjSpec 解决同一个需求——不手写 XML 而程序化生成/修改模型;PyMJCF 是先行者,MjSpec 是并入引擎 API 的后继(导出格式与编译器耦合都不同)。新代码优先 MjSpec,维护旧 dm_control 工程时再看 PyMJCF。

选型判据三行:跑基准/教学用 suite;要自定义任务逻辑但复用 dm_env 接口用 rl + 自己的 Task,或上 composer 拼场景;只要程序化改模型则直接用 mujoco.MjSpec,不必为了这一个功能引入 dm_control。

8. 要点

  • MuJoCo 能直接 from_xml_path 读 URDF,但弧度、丢 visual、fixed 融合、不生成执行器是四条硬映射;控制栈的执行器必须自己补 MJCF <actuator>。
  • conda(py3.14)与 Humble(py3.10)的 rclpy ABI 不兼容,桥接要跑在装了 MuJoCo 的系统 py3.10 上,不能跑在带 rclpy 的 conda 里。
  • 最小闭环 = 一个节点里 mj_step 多子步 + 发 /joint_states 与传感器 topic + 订阅写 data.ctrl;控制拍与物理子步解耦,传感器→topic 就是 sensordata[adr:adr+dim] 的搬运。
  • ros2_control + mujoco_ros2_control(Humble apt 包,PAL 维护):把仿真器实现成 SystemInterface,read() 推进物理并回填状态、write() 灌命令;参数对齐看四件事——URDF/MJCF 关节名同名(否则变被动关节拿不到命令接口)、控制律只在一处闭环(别 MJCF 与 ros2 两侧各设 kp)、update_rate 对齐 timestep 且开 use_sim_time、状态经 broadcaster 汇成 /joint_states。无显示环境要 headless:=true;包名是 mujoco_ros2_control 不是 mujoco_ros2。
  • 单进程桥接闭环里把 cmd.data 改成 [0.0, 0.0, 5.0],prize 电机会把滑块顶到 range 上限、tcp_pos 的 z 随之变化。

  • gymnasium 自带 MuJoCo 环境基本不读 <sensor>(InvertedPendulum-v5 nsensor=0,obs=qpos+qvel 拼接);要用传感器做观测必须自写 env。

  • 自写 env 体现了第 8 章的三件事:sensordata[adr:adr+dim] 地址切片、reset 后必须 mj_forward、step 内多子步推进。
  • 传感器噪声在 env 侧用 Wrapper 按 model.sensor_noise 标准差自加(3.1.4 迁移法),随机源用带 seed 的 default_rng,训练可复现。
  • MJX 把 mj_step 重写成 JAX 计算图,需 mujoco≥3.17、只能在隔离 venv 用(base 3.11 不含 mjx):CPU 与 MJX 单步 qacc 差约 7e-5、500 步 qpos 差约 9e-9(逐位接近),批量靠 jax.vmap 首维=nworld;dyntype=user 被 put 拦截、jax 默认 f32;GPU 吞吐需 jax[cuda12] + Blackwell 才有数字。
  • 训练算法与其余后端(SB3/torch、mujoco_warp、onnxruntime)未装,其性能/收敛数字本章不写;conda 的 mujoco 3.11 轮子里没有 mujoco.mjx,MJX 必须靠独立 venv。
  • 把传感器接成观测:_obs() 从 tcp_pos 换成 framequat(dim=4)时,observation_space 要同步改成 (4,),策略输入维度随传感器类型而变。

把三章拼成一条真实工作流,数据只在这些点改形态:

flowchart TD
    sens["MJCF 的 sensor 声明<br/>第 8 章:49 类,sensordata[adr:adr+dim]"]
    obs["env._obs()<br/>本章:numpy 观测向量;NoiseWrapper 按 sensor_noise 加噪"]
    pol["policy(obs) → action<br/>训练库:SB3 待核实;批量物理 MJX 见「训练库与并行后端」一节"]
    ctrl["data.ctrl<br/>前文桥接:on_cmd 写回;或本地 step"]
    step["mj_step → 新状态"]
    sens -->|"reset 后 mj_forward;step 内多子步"| obs
    obs --> pol
    pol --> ctrl
    ctrl --> step
    step -->|"再读传感器"| obs

三个跨章一致性验收点:

  1. 观测维度 = nsensordata(第 8 章)= env 的 observation_space.shape(本章)= ROS topic 数组长度(前文桥接)。三处对不上就是接错了。
  2. 传感器读数归属本步开始时的状态(第 8 章「共享属性 noise、cutoff、delay、interval」 clock、第 5 章 mj_sensorAcc 在积分前)——做时序对齐/因果建模时按这个定义,别假设是步末。
  3. 名字→地址、adr/dim 在任何高频循环(env.step、桥接 tick、训练采样)里都只在初始化查一次(第 8 章「读数通路与成本」成本表)。

参考文献与延伸阅读

  • Todorov, E., Erez, T., & Tassa, Y. MuJoCo: A Physics Engine for Model-Based Control. IROS, 2012. DOI
  • MuJoCo. 文档入口. 文档
  • MuJoCo. MJX 文档. 文档