四篇笔记写到物理仿真这一块,恰好是我个人最喜欢聊的内容。前面几篇反复出现一个词:在仿真环境里验证。说白了,控制算法和强化学习策略写得再漂亮,最终都要回答一个基本问题——这个物体在真实物理规则下,能不能按你预期的方式动起来。MuJoCo就是目前圈内绕不开的物理引擎之一,配上Python接口之后,你可以在几行代码之内完成“加载模型—读取关节信息—施加控制—推进物理世界”的完整循环。
这篇笔记适合两类人:一是刚接触机器人仿真,想知道MJCF模型、关节、体、几何体这些概念到底怎么串起来的新手;二是已经跑过一些强化学习示例,但内核是“黑盒”,想搞清楚每一步到底发生了什么的人。我尽量不堆参数表,而是把一个最小可运行的项目拆开,讲清楚背后“为什么这么做”,再把我实际踩过的坑一并交底。
1. 先想明白:MuJoCo 加 Python 到底是解决什么问题
很多新手一开始把 MuJoCo 当成“会动的3D模型播放器”,这个理解偏差会耽误不少时间。物理仿真要做的不是让几何体按预设动画运动,而是根据力学规律实时计算出每个物体的加速度、速度、位置。你在代码里改了控制输入,下一帧物体的运动就要在物理规则约束下响应。这跟动画系统有本质区别:动画是“播放”,仿真是“计算”。
1.1 机器人学习和控制验证场景里的核心需求
在机器人学习和控制实验里,我们通常要做这么几件事:给一个机器人模型定义刚体、关节和约束;每个仿真步更新一次全局状态;从状态里抽出观测数据;根据观测算出一个控制信号;把控制信号写回仿真器;重复到实验结束。
如果用真实机器人做这套循环,成本高、危险大、周期又长。所以绝大多数研究者和开发者会把第一步、第二步和第五步丢给物理引擎,自己只负责写策略和奖励。这里就需要引擎满足几个条件:数值稳定、快、可编程、高精度。MuJoCo 在接触建模和快速数值求解上非常突出,这不是我说好话,而是它被广泛用于强化学习研究的直接原因。
1.2 为什么我最后选了 MuJoCo,而不是继续用其他引擎
我之前也在一套开源物理引擎上写过项目,功能不算少,整体效果也说得过去。但有两个问题一直让我难受:一是接触约束的稳定性,机器人脚刚落地时不断出现“悬浮”或“穿透”;二是提成效率,在批量采样强化学习数据时速度跟不上。后来换到 MuJoCo,最直观的感受就是同样的模型和 PC 配置,单位时间内可以跑出明显更多的仿真步数,而且接触处理的“抖动”少很多。
当然,选它也有代价。MuJoCo 有一套自己的模型文件格式 MJCF,字段非常多,刚开始看文档容易被吓住。但好消息是Python接口已经封装得很干净了,我们可以先用内置模型或最简XML跑通流程,再逐步增加细节。先用起来,后面再深入。
1.3 这篇笔记你能带走什么
我不会写一堆“查文档就会”的条目。你读完应该能掌握三件事:第一,mjModel 和 mjData 这两个对象到底存什么数据,为什么一个不能改、一个每步都要刷新;第二,从 XML 到仿真闭环的最小代码长什么样,每一个 API 调用在物理层面做了什么;第三,遇到状态读取不对、关节乱飞、速度太慢这些问题时,至少知道该往哪几个方向排查。
后面每一节我都会给出可复现的代码片段,建议你照着敲一遍。仿真这种东西,看十遍不如亲手让一个小摆锤动起来。
2. 环境准备与最小模型装载
安装这一块几乎没什么可犹豫的。只要是 Python 3.8 以上的常见环境,直接装官方发布的 Python 包就行。MuJoCo 从某版本开始已经把引擎本体都打包进 Python 安装流程里了,不需要再单独折腾二进制文件,这对新手友好很多。
2.1 安装步骤与验证方式
我经常在虚拟机里建干净环境排查依赖问题,所以推荐你也用虚拟环境隔离,别图省事直接装全局。以 venv 为例:
python -m venv mujoco_env source mujoco_env/bin/activate pip install mujoco numpy刚安装完不要急着写代码,先在命令行验证一下引擎是否正常加载:
python -c "import mujoco; print(mujoco.__version__)"如果能看到版本号输出,说明引擎本体的动态库已经成功链接到 Python。我遇到不少人卡在这一步,最后发现是虚拟环境里装了多个互不兼容的 numpy 版本。MuJoCo 对 numpy 版本有一定要求,如果导入时报“找不到符号”之类的错误,优先检查 numpy 和包版本是否匹配。
2.2 直接加载内置模型
MuJoCo 官方包自带了一批测试用模型,可以在不写任何模型文件的情况下快速验证环境。这时候路径问题最容易坑人。示例:
import mujoco model = mujoco.MjModel.from_xml_path("humanoid.xml") data = mujoco.MjData(model) print("关节数量:", model.njnt) print("自由度:", model.nq) print("身体数量:", model.nbody)这里有一个非常关键的点:from_xml_path里的路径是相对你当前工作目录的,不是包内置的。如果你只想跑内置模型,可以直接调用包里的现成路径,避免自己找文件。我在教学时经常看到新below报错“FileNotFoundError”,其实就是当前工作目录里没有这个 XML 文件。
为了减少干扰,我建议你自己手写一个极小的模型。这样每一步发生了什么,心里都有数。
2.3 写一个只有单个摆锤的 XML
下面这个模型是我每次讲基础都会用到的,它简单到只有一根杆子和一个旋转关节,但足够解释清楚 MuJoCo 的模型结构:
<mujoco> <option timestep="0.01"/> <worldbody> <body name="pendulum" pos="0 0 0"> <joint name="pivot" type="hinge" axis="1 0 0"/> <geom name="pole" type="capsule" fromto="0 0 0 0 0.3 0" size="0.02" mass="0.1"/> </body> </worldbody> </mujoco>这段XML定义了一个世界(worldbody),里面放了一根胶囊形状的杆,杆和世界之间由一个沿 x 轴旋转的铰链关节连接。物理上它就等于一个单摆。我们把这段 XML 字符串直接传给 Python:
import mujoco xml = """ <mujoco> <option timestep="0.01"/> <worldbody> <body name="pendulum" pos="0 0 0"> <joint name="pivot" type="hinge" axis="1 0 0"/> <geom name="pole" type="capsule" fromto="0 0 0 0 0.3 0" size="0.02" mass="0.1"/> </body> </worldbody> </mujoco> """ model = mujoco.MjModel.from_xml_string(xml) data = mujoco.MjData(model) print("nq:", model.nq) print("nv:", model.nv)输出结果里,nq是广义坐标维度,铰链关节贡献 1 个角度值,所以是 1;nv是广义速度维度,也对应 1 个角速度。这两者的差别新手容易忽略:位置 q 可能包含四元数等非最小坐标,速度 v 始终是线性空间的量。后面读关节状态时,千万别默认 qpos 的长度等于 qvel 的长度。
3. 仿真循环里到底发生了什么
装好模型,接下来就是核心中的核心:仿真循环。很多初学者拿别人代码,看见mj_step就往下跑,并不知道这个函数内部已经完成了积分、碰撞检测、约束求解等一系列工作。这一步不搞懂,后面调试会非常痛苦。
3.1 先跑起来:一个非常小的控制循环
我们继续用上面的单摆模型,给它一个初始关节角度,再施加一点简单的比例阻尼控制,看它如何摆动:
import mujoco import numpy as np xml = """...上面那段XML...""" model = mujoco.MjModel.from_xml_string(xml) data = mujoco.MjData(model) data.qpos[0] = 0.5 # 初始摆角,单位弧度 data.qvel[0] = 0.0 # 初始角速度 duration = 2.0 # 仿真时长,单位秒 steps = int(duration / model.opt.timestep) for i in range(steps): data.ctrl[0] = -5.0 * data.qpos[0] - 0.5 * data.qvel[0] mujoco.mj_step(model, data) if i % 20 == 0: print("t=%.2f qpos=%.3f qvel=%.3f time=%.3f" % ( i * model.opt.timestep, data.qpos[0], data.qvel[0], data.time ))这里有一个细节很多人第一次会踩坑:单摆模型只有 1 个自由度,为什么data.ctrl也能用?因为 MuJoCo 里每个关节默认会有一个执行器吗?并不是。上面XML里根本没有 actuator 定义,所以data.ctrl[0]实际上是越界访问。正确的做法是给模型补一个执行器,比如在<mujoco>里加:
<actuator> <motor joint="pivot" ctrlrange="-5 5" /> </actuator>加了执行器之后,仿真循环里data.ctrl [0]才有了意义。这正好解释了一个容易混淆的概念:data.qpos、data.qvel是物理状态,data.ctrl是执行器输出,前者由引擎更新,后者由你的控制器写入。
3.2 mj_forward 和 mj_step 的关系
在 MuJoCo 的 Python 接口里,最常用的两个函数是mj_forward和mj_step。它们的区别非常关键。
mj_forward做的是“正向运动学与动力学计算”:给定当前状态和控制输入,计算加速度、约束力、传感器输出等,但不会把状态往前积分。也就是说,调用它之后,data 里的位置和速度不变,只是力和加速度等中间量被更新。常用于需要“看一步”的场景,比如求解析梯度或者做逆动力学。
mj_step做的事情更多:它先做一次mj_forward拿到加速度,再把位置和速度按积分步长更新,同时更新仿真时钟data.time。这是真正“往前走一步”的接口。强化学习环境里每 frame 调用一次的就是它。
如果你想要更高阶的数值积分方法,MuJoCo 里还有mj_Euler和mj_RungeKutta可以选,在 XML 里通过 option 标签的integrator字段配置。绝大多数场景用默认的半隐式欧拉法就够了,改积分器反而容易出稳定性问题。
3.3 常用状态量一览
我把仿真中最常读取的数据列成一张速查表,后面调试时可以直接对照:
| 数据字段 | 含义 | 说明 |
|---|---|---|
data.time | 当前仿真时间 | 从 0 开始,步进自动累加 |
data.qpos | 广义位置坐标 | 包含关节角度、位置,长度等于model.nq |
data.qvel | 广义速度坐标 | 角速度/线速度,长度等于model.nv |
data.qacc | 广义加速度 | 上一步计算出的加速度 |
data.xpos | 各刚体的世界坐标位置 | 常用于获取杆末端的实际位置 |
data.xquat | 各刚体的世界坐标四元数 | 读取物体姿态时用 |
data.cfrc_ext | 施加在刚体上的外力 | 外部力接口,长度需要对齐 |
有一句话我想写在显眼位置:data.xpos才是物体在世界坐标中的真实位置,data.qpos是关节坐标下的配置。不少新手直接用 qpos 里的数据算末端位置,结果怎么都对不上,就是因为 qpos 要经过运动学链的变换才能变成世界坐标。如果你想拿机械臂末端抓取点坐标,直接用data.body("link_name").xpos这种按名字读取的接口最稳。
4. 从一个示例工程里看懂控制与观测
前面讲了一堆原理,到这里该结合一个完整例子把概念串起来了。很多人学仿真喜欢直接跑人形机器人,我建议刚上手先用带滑块与关节组合的模型,因为它“状态多、控制简单、物理意义直观”。下面我会写一个带一条线性滑块和一根立杆的模型,相当于一个小型倒立摆台车。
4.1 带执行器的完整模型
<mujoco> <option timestep="0.02"/> <worldbody> <body name="cart" pos="0 0 0"> <joint name="slider" type="slide" axis="1 0 0"/> <geom name="cart_geom" type="box" size="0.2 0.05 0.05" pos="0 0 0.05" mass="1.0"/> <body name="pole" pos="0 0 0.05"> <joint name="hinge" type="hinge" axis="0 1 0"/> <geom name="pole_geom" type="capsule" fromto="0 0 0 0 0 0.4" size="0.02" mass="0.1"/> </body> </body> </worldbody> <actuator> <motor joint="slider" ctrlrange="-50 50"/> </actuator> </mujoco>这个模型的运动学结构是:大地上有一个只能沿 x 轴平移的小车,小车上有一根绕 y 轴转动的杆。执行器只能推小车,杆没有直接驱动。经典倒立摆任务就是通过移动小车让杆保持竖直。这个结构把“欠驱动系统”的特征体现得很清楚。
4.2 观测是怎么构造的
构造观测向量是强化学习里的常见步骤。我们用这个模型,把仿真状态变成算法能吃的向量:
import mujoco model = mujoco.MjModel.from_xml_string(xml) data = mujoco.MjData(model) data.qpos[0] = 0.0 # 小车位置 data.qpos[1] = 0.1 # 杆角度,初始略微倾斜 # 构造观测向量 def get_obs(data): return np.array([ data.qpos[0], data.qpos[1], data.qvel[0], data.qvel[1], data.xpos[model.body("pole").id][0], data.xpos[model.body("pole").id][2], ]) obs = get_obs(data)这里我特意把xpos也加进观测,目的是演示一个关键思路:你既可以直接用关节坐标,也可以用世界坐标。不同算法对状态表示敏感,有的用关节坐标归一化后效果更好,有的直接吃末端位置更好。调试时如果发现策略泛化差,不妨先在观测里把关节坐标和世界坐标都加上,做做特征筛选。
4.3 一个最简单的稳定控制器
我们不用强化学习,先写一个非常朴素的PD控制器,把杆稳定在竖直位置附近。控制量作用在小车上,反馈是杆角度和角速度:
import numpy as np import mujoco steps = 500 kp, kd = 20.0, 2.0 for i in range(steps): angle = data.qpos[1] angle_vel = data.qvel[1] force = kp * angle + kd * angle_vel # 杆向右倒,小车就向右加速 data.ctrl[0] = np.clip(force, -50.0, 50.0) mujoco.mj_step(model, data) if i % 50 == 0: print("angle:", angle, "cart:", data.qpos[0])如果初始角不大,这套PD控制器有可能把杆稳住。你亲手调一次就会明白:kp太小,杆会慢慢倒下;kp太大,系统会振荡;kd的作用是抑制振荡。这个调参体验跟真机调试很接近,但仿真的好处是随便折腾,过冲了重置就好。这就是为什么我之前说学控制最好先玩仿真——它可以让你低成本积累“手感”。
5. 让实验结果看得见:渲染与图像输出
很多人跑仿真跑到这一步,看着终端里一堆数字,总觉得“没见证奇迹”。给仿真加上可视化渲染,不仅为了好看,也是为了验证物理行为是否符合直觉。比如杆有没有穿地、关节有没有飞出去,看一眼画面就知道。
5.1 三种可视化方式的取舍
MuJoCo Python 接口下,常用三种方式:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 内嵌查看器窗口 | 即时交互,能手动拖拽物体 | 依赖GUI,远程服务器难用 | 本地调试模型 |
| 离屏渲染 | 返回像素数组,方便记录视频 | 需要额外内存和显卡/软件渲染 | 算法训练、批量生成数据 |
| 第三方离线渲染 | 画质更好,材质丰富 | 速度慢,配置复杂 | 展示级效果图 |
如果只是快速验证模型,我建议直接开一个查看器窗口。MuJoCo 的 Python 包里提供了mujoco.viewer模块,打开一个交互式窗口非常方便:
import mujoco import mujoco.viewer model = mujoco.MjModel.from_xml_string(xml) data = mujoco.MjData(model) with mujoco.viewer.launch_passive(model, data) as viewer: while viewer.is_running(): data.ctrl[0] = 0.0 mujoco.mj_step(model, data) viewer.sync()注意这里用的是launch_passive,意思是查看器不会自己驱动仿真,你必须手动调用mj_step后同步渲染。另一种launch模式会自动推进仿真,更适合纯展示,但控制逻辑需要写在回调里,代码结构反而绕。
5.2 离屏渲染拿Numpy图像
如果跑在远程服务器上,没有显示器,那么离屏渲染是标准选择。MuJoCo 专门提供了mujoco.Renderer:
renderer = mujoco.Renderer(model, height=480, width=640) for i in range(100): mujoco.mj_step(model, data) if i % 10 == 0: frame = renderer.render(data) # frame 是一个形状为 (480, 640, 3) 的 numpy 数组 # 可以保存成图片或写到视频里 renderer.close()这一步有两个容易踩的坑。第一,renderer对象最后记得close(),否则在长时间训练循环里可能堆积资源,最后崩掉;第二,每次渲染前,data不能处于“未更新”状态。也就是说,新加载完模型最好先调用mujoco.mj_forward把中间量算出来,再渲染。否则画面里的物体可能不在正确位置。
5.3 训练过程中别高频渲染
这个经验非常值钱。我早期在训练循环里为了“看效果”,每个仿真步都调用离屏渲染,结果训练速度一落千丈。原因是渲染要把几何体和纹理推到渲染管线,即使是离屏渲染也非常耗时。标准做法是每隔几百步渲染一帧,或者训练完重新加载策略跑一次性酐渲染。仿真调试阶段和批量训练阶段应使用完全不同的渲染频率,这个区分对效率影响非常大。
6. 踩坑实录与避坑清单
基础内容讲完了,我把实际操作里遇到频率最高的几个问题集中整理一下。这些问题在官方文档里分散在各处,第一次遇到时真的很费时间。
6.1 常见报错与排查思路
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 导入mujoco报DLL/库加载失败 | 动态库版本不匹配、numpy冲突 | 升级numpy和包;确认系统缺少的运行库 |
from_xml_path报找不到文件 | 相对路径不对 | 用绝对路径或检查当前工作目录 |
| 模型加载成功但画面空白 | data状态未初始化 | 先调用mj_forward再渲染 |
| 关节乱飞、数值爆炸 | timestep太大或执行器范围过大 | 调小option timestep,限制ctrl范围 |
| 仿真速度越来越慢 | 渲染循环调用太频繁 | 降低渲染帧率,把渲染和step解耦 |
| 修改数据后模型没变化 | 忘了重新加载model | MuJoCo模型是编译结果,修改XML要重载 |
6.2 关于性能的几个实用技巧
物理引擎的性能直接决定强化学习采样效率。同一个模型,有人能跑很高通量,有人只能慢悠悠跑,差别往往不在硬件,而在用法。
第一,避免在仿真循环里做不必要的对象创建。例如np.array([...])每步都创建新数组,可以通过预分配缓冲区来改进。仿真循环是热点,每多一次内存分配都会拉低速度。
第二,学会只更新需要的传感器。MuJoCo 有传感器接口,但默认计算所有需要的数据。如果只关心某些关节状态,优先从data.qpos、data.qvel读取,而不是新增一堆 sensor 定义。
第三,并行训练时注意随机数问题。MuJoCo 仿真本身没有随机性,但如果你用 Python 给模型位置加随机扰动,多个进程时会重复生成相同序列。建议进程之间隔离随机种子,或者把随机化放到不同进程中用不同初始种。
6.3 最后一个建议:从最简模型开始扩展
我见过太多人上来就调一个几十自由度的走路机器人模型,结果连“为什么腿没抬起来”都定位不了。物理仿真调试最大的敌人是“不确定性叠加”。模型越复杂,状态和参数越多,一输出错就难分因果。
个人建议始终保留一个最简模型作为“信号基准”。遇到复杂模型行为不对,先把控制器日志输出到文件,再回到简单模型验证同样的逻辑。这个习惯帮我省掉了非常多排查时间,也推荐你试一试。今天这些基础操作,说到底都是为了让你在 MuJoCo 里建立起“物理状态—控制信号—观测反馈”的心智模型,剩下的就是根据需求不断增加真实感,逐步把仿真调成你实验需要的版本。