日期:2026-11-10 GitHub Trending 日榜第 4 位;快照采集于北京时间 16:00:26。
让机械臂在屏幕里动起来并不难。难的是夹爪碰到物体以后:摩擦力怎么算,控制器拿到的是哪一时刻的接触信息,换一批控制参数后,轨迹差异究竟来自策略,还是来自仿真设置。
MuJoCo 把这些问题放在同一条计算链里处理。它的名字来自 Multi-Joint dynamics with Contact,关注有关节结构的动力学以及结构与环境的接触。理解它,最有用的入口不是先打开一个漂亮的人形机器人场景,而是看清一份模型、一份运行状态和一次 mj_step 各自负责什么。
本文围绕 MuJoCo 4.0.0 的原生 C 引擎与官方 Python 绑定展开,以普通刚体和单自由度控制为例。MJX、Warp、柔性体与新的离散积分路径有各自的计算条件,不能直接套用这里的全部细节。
📋 项目概览
| 项目 | 内容 |
|---|---|
| 主仓库 | google-deepmind/mujoco |
| 维护方 | Google DeepMind |
| 定位 | 面向机器人、控制、机器学习等任务的通用物理引擎 |
| 核心接口 | 原生 C API;官方 Python 绑定提供模型、状态和计算函数 |
| 模型入口 | MJCF XML、受支持的 URDF,以及程序化的 mjSpec |
| 本文示例版本 | mujoco==4.0.0,Python 3.11 或更高版本 |
| 核心对象 | 编译模型 mjModel 与每个仿真实例的工作区 mjData |
| 许可 | 源代码 Apache-2.0;仓库 doc 内的文档、图片和视频使用 CC BY 4.0 |
这里有一个容易忽略的包装变化:4.0.0 的 mujoco Python 包本身是组合入口,依赖同版本的 mujoco-core、mujoco-studio,并引入 NumPy 与图形相关依赖。不能仅从顶层 wheel 的体积判断整个引擎有多轻。具体依赖写在官方的 Python 包清单里。
🔥 为什么值得关注
仿真环境经常被包装成 observation → action → reward。这层接口适合训练,但它也遮住了很多故障来源。一次动作能否准确落到执行器,取决于传动、控制范围、积分步长;一次接触能否支撑物体,取决于几何、约束和求解设置。策略效果不好时,继续加大模型并不能自动解决这些问题。
MuJoCo 值得读的部分,是它允许开发者沿计算链向下排查。输入动作保存在 ctrl,执行器产生的广义力有自己的数组,约束力有自己的数组,接触数量和求解器诊断也能读出来。你不必把所有异常都归结为"物理引擎不稳定"。
另一个实用之处是控制时序。控制器若只依赖当前 qpos 和 qvel,通常可以先设置输入再调用 mj_step。但如果控制律依赖刚更新的接触、雅可比或位置传感器,就需要在动力学计算的正确阶段插入控制。MuJoCo 为此提供控制回调和 mj_step1 / mj_step2,并明确限制这些接口与积分器的组合方式。
这个设计对非机器人开发者也有参考价值:把一个复杂循环拆成可观察的阶段,比给外部暴露一个只能成功或失败的黑盒,更容易定位状态过期和调用顺序错误。
🏗️ 核心特性
模型先编译,仿真循环使用紧凑数据
MJCF 用层级结构描述刚体、关节、几何、执行器和传感器。运行时却不需要在每个时间步重新遍历 XML。加载过程把模型解析、编译成 mjModel,后续计算使用已确定的维度、索引和数组。
这不是说模型永远不可修改。开发者可以通过 mjSpec 构造和编辑模型,也可以调整部分运行选项;但"改变模型结构"和"推进一个实例的状态"是两种操作,不能用往状态数组末尾追加元素的方式给机器人增加关节。
动力学与可视化分开
无需创建窗口,就可以加载模型、设置输入、执行物理步进并读取结果。图形界面适合观察姿态和接触,却不是 mj_step 的前置条件。这使纯数值回归与交互调试能共用模型,而不必共用一条渲染循环。
反过来,打开查看器也不代表完成了一次控制验证。相机帧率、物理步长和策略更新周期是不同的量:画面流畅,不能证明控制输入在期望的仿真时刻生效。
接触是约束求解的一部分
碰撞检测得到接触几何之后,MuJoCo 还要构造约束、生成参考量、求出约束作用并更新加速度。球体与地面的重叠检测只是开始,不能把碰撞检测时间当作整段接触计算成本。
其经典接触模型采用软约束与凸优化思路,而不是要求所有接触都绝对刚性、毫无穿透。接触参数会影响行为;"物体略微陷入地面"既可能是模型和参数的结果,也可能提示设置需要调整,不能只看截图便判断求解失败。具体建模依据可看官方的 接触与动力学说明。
暴露中间结果,也暴露使用责任
Python 可以直接访问 qpos、qvel、ctrl 等底层数组。这减少了复制,但数据所有权没有因此消失:视图会随仿真更新而改变,多个线程也不能无锁地同时改写同一份 mjData。
这类 API 很适合研究与高频循环。不过,把它放到团队服务中时,还需要自己约定实例隔离、状态快照、错误上报和资源上限,不能期待绑定层把所有误用都转换成友好的业务异常。
🔬 技术架构深度解析
1. 加载链:Python 字符串如何进入 C 引擎
下面是 4.0.0 中 MjModel.from_xml_string 的主要调用关系。图只展开普通加载和刚体步进,不包含所有插件与特殊积分路径。
text
MJCF 字符串 / 可选资源字典
│
▼
MjModel.from_xml_string
│ Python 绑定把字符串加入内存资源
▼
mj_loadXML
├── ParseXML → mjSpec
└── mj_compile → mjModel
│
MjData(model) / mj_makeData
│
每实例一份 mjData
│
▼
mj_step:检查状态
│
mj_forward / forwardSkip
│
┌─────────────────┴──────────────────┐
│ 位置阶段:运动学、质量矩阵、碰撞、约束 │
│ 速度阶段:被动力、偏置力、参考量 │
└─────────────────┬──────────────────┘
▼
控制回调 → 执行器 → 平滑加速度
│
约束求解 → 加速度相关传感器
│
数值积分推进状态与时间
绑定层不是自己另写了一套物理计算。python/mujoco/structs_wrappers.cc 的 LoadXML 将字符串放入资源集合,再调用 mj_loadXML;后者在 src/xml/xml_api.cc 里先解析为 mjSpec,再调用 mj_compile。编译失败时,C 接口返回空指针并提供错误信息,Python 这一加载入口把失败转为 ValueError。
这条链解释了为何"XML 语法正确"仍不等于"模型可以仿真"。例如几何尺寸为零,未必是 XML 格式问题,却会在模型检查时被拒绝。资源字典和 VFS 也有自己的互斥约束,Python 加载函数不会允许同时指定二者。
加载相关的实现可直接从 字符串加载绑定和 XML 入口往下读。
2. mjModel 与 mjData:共享描述,不共享可变轨迹
mjModel 放模型描述、维度和计算所需的结构信息;mjData 放状态、控制、接触与计算中间量。后者不是一个只有位置和速度的小对象,它同时也是仿真的工作区。
假设要比较同一台机械臂的两组控制器,通常可以创建一份模型、两份数据:
python
# 前提:model 已通过 MjModel.from_xml_string 等接口创建。
a = mujoco.MjData(model)
b = mujoco.MjData(model)
改变 a.qpos 不会同时改变 b.qpos。但如果改的是共享 model.opt,两组计算看到的模型选项都会变化。因此,常规并发模式应当是只读共享模型、每个工作者独占自己的数据;需要独立改模型参数时,再分离模型或协调修改时机。
还有两个尺寸不能混用:nq 是位置坐标维度,nv 是速度自由度,nu 是控制维度。下面的滑块恰好三者都是 1,但含四元数的关节可能让 nq 与 nv 不同,执行器数量也未必等于关节自由度。把 qpos 与 qvel 按相同长度直接拼装控制矩阵,不是通用做法。
3. 一次前向计算:接触为何出现在控制之前
engine_forward.c 的位置阶段先处理运动学与质量矩阵,再执行碰撞检测、约束构造和投影。速度阶段继续计算速度相关量、被动力与偏置力。随后才进入控制回调、执行器和加速度计算。
这是一种依赖顺序。接触位置依赖当前构型,雅可比依赖结构和姿态,控制律可能又依赖这些量。把回调放在依赖量更新之后,可以避免控制器使用上一步留下的派生结果。
mj_forward 本身负责计算前向动力学,不推进仿真时间 。推进状态和时间的是 mj_step 后面的积分阶段。手动改过 qpos 后,如果要立即读取世界坐标等派生量,可以调用 mj_forward 刷新,而不是为了"刷新显示"额外推进一个物理步。
同样,一次 mj_step 完成后,位置、速度已经推进,但若随后要把所有派生量当作新状态下的一致快照读取,显式刷新更容易保证语义清楚。后面的示例在打印球心高度前就做了这件事。
4. 平滑动力学与约束作用:别把所有力都叫动作
对本文的普通刚体、Euler 路径,可以用下面的概念关系理解计算,省略特殊有效度量分支:
text
平滑广义力 = 被动力 - 偏置力 + 用户广义力 + 执行器广义力
+ 外部笛卡尔力投影
质量矩阵求解 → qacc_smooth
约束求解 → 满足所用软约束模型的 qacc、qfrc_constraint
积分 → 下一时刻的速度、位置与时间
这是计算关系说明,不是要求每一步显式求一个稠密逆矩阵。实现会利用质量矩阵的结构和分解来做求解。
ctrl 是执行器的输入,qfrc_actuator 才是执行器映射到广义坐标后的作用;二者通常不能直接画等号。不同执行器具有不同传动和力生成规则。示例特意选 gear="1" 的简单 motor,让输入与这一个滑动自由度的施力关系容易理解。如果换成位置伺服或带内部激活动态的执行器,就需要重新读其输入语义。
约束阶段也有快速路径:没有活动约束时,不必跑完整约束求解器,可以直接采用平滑加速度。接触数量增加、摩擦约束变化后,性能和收敛行为可能一起变,不能把一个空场景的速度线性外推到复杂抓取任务。
5. 求解器与热启动:复现不止保存关节角
在原生约束求解路径里,MuJoCo 按配置选择 PGS、CG 或 Newton;存在适用的约束岛时还能按岛组织求解,但不能假设所有模式、所有模型都走相同的并行分支。
热启动会利用 qacc_warmstart 等已有信息。代码还会比较初始化候选的代价,而不是无条件接受上一步的加速度。它的意义在于减少重复求解的工作,不是保证每次迭代次数完全相同,也不是把求解变成没有误差的查表。
这会影响回放设计。只保存 qpos 和 qvel,不足以通用地恢复包含执行器内部状态、历史量、用户输入和求解初始化的完整仿真条件。需要精确恢复时,应根据任务选择 MuJoCo 的状态接口及状态掩码,并同时保存外部控制器的内部状态、随机数状态和模型设置。
更换软件版本、积分器或求解容差后,也应重新建立回归基线。可复现不意味着任意平台、任意二进制之间都承诺逐位相同。
6. 两阶段步进的陷阱:RK4 不会被原样保留
一个常见接法是:
python
mujoco.mj_step1(model, data)
# 此处根据已更新的位置/速度相关量计算并写入控制。
data.ctrl[:] = control
mujoco.mj_step2(model, data)
这里的 control 是应用计算出的控制向量,上面只是接口顺序示意。
对 Euler、implicit、implicitfast 这类单步积分方案,这个拆分提供了明确的输入插入点。4.0.0 的实现还处理了 discrete 分支。但 RK4 不支持把一次完整的状态反馈计算简单拆成上述两段:它要在一个时间步内多次评估动力学,闭环控制应通过控制回调参与各阶段计算。
更容易误判的是降级方式。查看 mj_step2 的实际分支,会发现当模型设置为 RK4 时,这条拆分路径落到 Euler,而不是报一个显眼的"不支持"异常。代码仍会运行,结果却已不再是预想的 RK4 轨迹。
官方测试 Step1Step2MatchesStep 对 Euler、implicit、implicitfast 和 discrete 比较完整步进与拆分步进的轨迹,没有把 RK4 放进这组等价断言。这个范围比一句"两个 API 等价"准确得多。相关分支和测试分别位于 前向计算实现与 前向计算测试。
7. 失败语义:还能继续跑,不代表这一段轨迹有效
模型加载失败、数值状态失效和资源不足,不是同一种错误。
加载阶段的非法几何尺寸会导致 Python ValueError。步进阶段则有 mj_checkPos、mj_checkVel、mj_checkAcc 检查。遇到非法或过大的数值时,默认允许自动重置,记录对应 warning;这不保证表现为一次 Python 异常。
因此,训练或批量回归不能只用"函数没有抛异常"判定一个 episode 有效。至少要检查关键状态是否有限、warning 计数是否增加、仿真时间是否按预期推进。自动重置能够防止坏状态继续扩散,但它也意味着原轨迹发生了中断;怎样计入奖励、失败率和样本有效性,要由应用明确决定。
内存也有类似取舍。引擎在初始化时准备工作区,降低常规步进期间反复堆分配的成本。相应地,接触和约束所需空间必须有容量规划。达到资源边界时,应查看具体 warning 或错误路径,不能假定引擎会自动扩容并完整保留这一帧结果。给复杂场景预留容量,要比只在一个小模型上成功加载更有意义。
8. NumPy 视图:一行日志可能保存了同一个地址
这是 Python 用户很容易踩的坑:
python
# 错误的历史记录方式:保存的是底层数组视图。
history.append(data.qpos)
# 真正要留住这个时刻的数据,应复制。
history.append(data.qpos.copy())
mj_step 原地修改底层内存,不会为了日志给旧数组生成快照。第一种写法可能得到许多指向同一份最新状态的引用。训练曲线异常一致、回放看起来全部停在终点时,先检查这里,比怀疑求解器更省时间。
同样,data.body('slider').xpos 是命名访问得到的视图;把它存入列表也需要考虑复制。官方 Python 数组语义说明明确提醒了这一点。
📖 README 核心内容摘要
README 给了几条不同的进入路线:浏览器体验、本机查看器、Python 教程笔记本,以及源码构建。选哪一条,取决于你要看场景、写控制器,还是修改引擎。
对于需要编程的开发者,Python 包是一条短路径,包含原生引擎和绑定,不需要先自己编译 C/C++ 工程。源码开发则由 CMake 组织,4.0.0 顶层构建清单分别控制示例、经典 simulate、Studio 和测试等目标;运行一个 Python 数值例子不等于已经验证这些原生构建目标。
教程也按任务分开:基础模型、程序化模型编辑、多线程 rollout、LQR 控制、最小二乘和 MJX 等。尤其要注意,MJX 是面向 JAX 的另一条实现路线;原生 C API 的数组布局、线程模型和运行成本,不能不加区分地套到加速器批处理上。
官方说明 main 分支可能不稳定。因此下面固定 4.0.0,避免安装命令取一个版本,示例却依赖另一个版本的接口。许可证也需要分对象看:源码的 Apache-2.0 与文档媒体的 CC BY 4.0 是不同条款,不能把前者概括成整个仓库内所有素材的唯一许可。
🚀 快速上手
建立独立环境
以下命令适用于已安装 Python 3.11 的 macOS / Linux 终端。例子不创建窗口,也不加载外部机器人模型。
bash
mkdir mujoco-control-demo
cd mujoco-control-demo
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install "mujoco==4.0.0"
将下面代码保存为当前目录下的 pd_demo.py,再执行 python pd_demo.py。模型只有一颗 1 kg 球体,沿 z 轴滑动;目标是从初始球心高度 0.6 m 移到约 0.25 m。滑动关节的 qpos=0 对应初始构型,因此目标关节位移是 -0.35,不是球心世界坐标 0.25。
python
import mujoco
import numpy as np
XML = """
<mujoco model="vertical_control">
<option timestep="0.002" integrator="Euler" gravity="0 0 -9.81"/>
<worldbody>
<geom name="floor" type="plane" size="1 1 0.1"/>
<body name="slider" pos="0 0 0.6">
<joint name="z" type="slide" axis="0 0 1" damping="0.1"/>
<geom name="ball" type="sphere" size="0.05" mass="1"/>
</body>
</worldbody>
<actuator>
<motor name="lift" joint="z" gear="1" ctrllimited="true" ctrlrange="-20 20"/>
</actuator>
</mujoco>
"""
model = mujoco.MjModel.from_xml_string(XML)
data = mujoco.MjData(model)
target = -0.35
for _ in range(500):
mujoco.mj_step1(model, data)
force = 80.0 * (target - data.qpos[0]) - 12.0 * data.qvel[0] + 9.81
data.ctrl[0] = np.clip(force, -20.0, 20.0)
mujoco.mj_step2(model, data)
mujoco.mj_forward(model, data)
print("version:", mujoco.__version__)
print("dimensions:", model.nq, model.nv, model.nu)
print(f"time={data.time:.3f}, q={data.qpos[0]:.4f}, v={data.qvel[0]:.4f}")
print(f"ball_z={data.body('slider').xpos[2]:.4f}, contacts={data.ncon}")
assert np.isfinite(data.qpos).all()
assert abs(data.time - 1.0) < 1e-12
assert abs(data.qpos[0] - target) < 0.01
这组参数在 Python 3.11、MuJoCo 4.0.0 的无窗口运行中得到:
text
version: 4.0.0
dimensions: 1 1 1
time=1.000, q=-0.3491, v=-0.0029
ball_z=0.2509, contacts=0
它只说明这个小模型和这组控制参数能运行到预期附近,不是引擎吞吐基准,也不说明复杂机器人同样稳定。输出里的 1 秒是仿真时间,不能当作执行耗时。
看清模型和控制的配合
这里的控制力由比例项、速度阻尼项和重力补偿组成。9.81 对应本例 1 kg 质量、竖直方向与单位传动的重力补偿;它不是可以复制到任意机器人每个关节上的通用偏置。
np.clip 限制应用写入的输入,XML 的 ctrlrange 则声明执行器控制范围。即使两处范围一样,也应区分"控制器提出了多少力"和"最终允许执行多少力"。目标设得太低、增益设得太大时,输入限幅会改变闭环响应,而不是保持原设计的线性控制律。
最后的 mj_forward 用于刷新输出时刻的派生量。它会计算动力学相关结果,但不额外推进时间。把它放在打印前,是为了让球心世界坐标与打印的状态时刻一致。
用同一模型观察被动接触
在上面的脚本后继续添加下面几行,可以创建一个独立的无控制实例:
python
fall = mujoco.MjData(model)
for _ in range(1000):
mujoco.mj_step(model, fall)
mujoco.mj_forward(model, fall)
print(f"center_z={fall.body('slider').xpos[2]:.6f}, contacts={fall.ncon}")
新的 MjData 默认控制输入为零,不会继承前一个实例中的 PD 输出。同样的环境下,这个模型在 2 秒仿真后球心高度约为 0.049633 m,有 1 个接触,竖直约束广义力约为 9.81 N。球半径是 0.05 m,少量压入对应这里的软接触平衡;这个数值不能外推成所有几何和接触参数的固定穿透量。
排查更大的模型时,可以按这个顺序做:先验证无控制的重力和接触,再接入一个简单控制律,最后才接入学习策略。这样至少不会让模型错误、动作缩放错误和策略错误同时混在一次训练里。
📊 增长速度与社区热度
这次日榜中,MuJoCo 位于第 4 位。排名反映该次榜单的可见热度,不能单独推出 Star 日增或增长率。对物理引擎而言,更值得长期观察的是模型兼容性、版本迁移成本、可复现问题的修复,以及不同计算后端的能力边界。
MuJoCo 的官方仓库同时提供模型与控制教程、引擎测试和问题讨论入口。对准备采用它的团队,读一个与自己模型相关的测试案例,往往比只看总收藏量更有帮助:测试能给出具体输入、预期行为及断言范围,但测试存在并不等于已经覆盖你的接触场景。
以下是 2026-11-10 此次日榜快照的完整列表:
🎯 适用场景
做机器人控制原型时,可以用它反复比较同一模型上的控制律,读取关节状态、接触与执行器作用。要让比较有意义,应固定模型参数和仿真选项,并让不同控制器的外部状态分别初始化。
做强化学习环境时,可以把它放在环境步进的底层。上层仍需负责 observation、动作缩放、奖励、终止条件与失败处理,不能把"有物理步进函数"理解为已经具备完整的训练环境。
做数值回归时,无窗口运行与独立 mjData 很有用。可以保存关键状态、warning 和接触诊断,再检查升级或参数变化导致的差异;日志记录时尤其需要复制底层视图。
如果目标是大规模 GPU 批量训练,应进一步评估 MJX 或 Warp 路径,而不是仅根据原生 Python 小例子的运行感觉作判断。如果目标是逼真的最终画面,则还要独立评估渲染资产和工作流。物理建模、数值稳定与美术效果不是同一个验收项。
也不宜把仿真直接当作真实硬件的安全证明。质量、惯量、摩擦、控制延迟和执行器约束都可能与真实系统有偏差。仿真能缩小实验范围,却不能替代硬件上的受控验证与安全机制。
💡 总结
MuJoCo 最值得理解的是三处边界:模型编译与状态推进的边界,派生量更新与控制输入的边界,以及平滑动力学与接触约束的边界。沿这些边界排查,很多看似"物理不对"的问题会变成能够定位的接口、参数或时序问题。
第一次使用时,我更建议从上面的单自由度模型开始:确认坐标、力、时间和接触,再逐步增加复杂度。相比直接跑一个很大的机器人场景,这条路线更容易知道每一步到底改变了什么。