不用 Isaac 也能训?unitree_rl_mjlab 的 MuJoCo 路线
- 看懂 unitree_rl_mjlab 的任务划分、目录组织,以及它和 unitree_rl_gym 在写法上的根本差别
- 理解物理引擎的接触模型差异为什么直接决定足式策略能不能迁移到真机
- 从工程角度想明白「同一家为什么维护多条训练路线」,以及自己该怎么选
你照着 unitree_rl_gym 那条链路一路读下来,多半会在某个地方卡住:训练侧绑着一套特定的 GPU 仿真栈,装环境本身就是一道门槛,而且这道门槛不完全在你控制之内——它取决于你手上有什么卡、驱动版本对不对、那套闭源工具链这一版有没有变。
然后你在同一个 GitHub 组织下发现了另一个仓库:unitree_rl_mjlab。同样是强化学习,同样是 Go2 和 G1,同样能导出策略、能上真机,但物理后端换成了 MuJoCo。
第一反应通常是困惑:这是新版本要替代旧的吗?还是有一个是「玩具」?
读完代码之后我的判断是:都不是。这是两条并行的路线,各自解决的问题不完全一样。这篇就把 unitree_rl_mjlab 拆开看看,顺便回答那个更有价值的问题——一家厂商为什么要同时养着多条训练路线。
先说边界:本文基于该仓库的公开文档与代码结构,我手上没有真机可以部署,所以不会出现任何「实测效果」的描述,凡是涉及运行结果的地方,一律以官方仓库文档的说法为准。
它站在谁的肩膀上
README 开头交代得很直接:这个项目建立在 mjlab 之上,用 MuJoCo 作为物理仿真后端。
mjlab 自己的定位也写在里面了:它把 Isaac Lab 那套已经被验证过的 API 设计,和 MuJoCo 的物理实现拼在一起,给强化学习研究和 sim-to-real 部署提供轻量、模块化的抽象。
这句话信息量很大,值得停一秒。它意味着 unitree_rl_mjlab 不是「另起炉灶重写一套」,而是保留了 Isaac Lab 的编程范式,只把底下的物理换掉。对一个已经熟悉那套写法的人来说,迁移成本被刻意压低了。你会在后面的目录结构里反复看到这个设计的痕迹。
仓库文档写明当前支持的型号包括 Go2、A2、As2、G1、R1、H1_2 和 H2。致谢部分列了几个上游:mjlab 提供训练与执行框架,whole_body_tracking 提供全身动作跟踪的框架,rsl_rl 提供强化学习算法实现,mujoco_warp 提供 GPU 加速的仿真与渲染接口,MuJoCo 提供刚体物理。
注意最后两条的分工:MuJoCo 是物理引擎本体,mujoco_warp 是把它搬到 GPU 上跑的那一层。强化学习需要成千上万个环境并行采样,纯 CPU 的物理引擎撑不起这个量级,所以「MuJoCo 路线」能成立,前提是有 GPU 加速的实现。这是最近几年才补齐的一环,也解释了为什么这条路线是现在才出现的。
许可证是 Apache-2.0。
跑起来长什么样
流程被概括成三个词:Train → Play → Sim2Real。训练,回放验证,然后部署到真机。
速度跟踪训练
python scripts/train.py Unitree-G1-Flat --env.scene.num-envs=4096
第一眼就和 rl_gym 不一样:任务名不是 --task=g1 这种短标识,而是 Unitree-G1-Flat 这样的复合名。README 列出的速度跟踪任务有:
Unitree-Go2-Flat
Unitree-G1-Flat
Unitree-G1-23Dof-Flat
Unitree-H1_2-Flat
Unitree-A2-Flat
Unitree-R1-Flat
命名规则一眼可读:厂商-型号-地形。G1 和 G1-23Dof 并列,对应仓库里那组区分不同关节配置的机器人描述文件——同一台机器人,训练时可以选择用哪一套关节配置。
多卡训练直接给了参数:
python scripts/train.py Unitree-G1-Flat \
--gpu-ids 0 1 \
--env.scene.num-envs=4096
参数是分组的,不是平铺的
这是我认为写法上最大的差别。README 里的参数说明长这样:
--env.scene 场景配置(环境数、步长、地面类型、重力、扰动)
--env.observations 观测空间配置(关节状态、IMU、指令等)
--env.rewards 用于策略优化的奖励项
--env.commands 任务指令(速度、位姿或动作目标)
--env.terminations 每个 episode 的终止条件
--agent.seed 随机种子
--agent.resume 是否从最近一次检查点继续
--agent.policy 策略网络结构配置
--agent.algorithm 强化学习算法配置(PPO 及其超参数)
--env.scene.num-envs 这种带点的层级路径,说明命令行参数是直接映射到配置对象的字段上的。你不是在「传一堆开关」,你是在从命令行改一棵配置树的某个节点。
这套写法的好处在做实验时才显出来:想调奖励,去 --env.rewards 那一支;想换网络,去 --agent.policy。它把「环境长什么样」和「用什么算法去学」在命名上就分开了,不需要读文档去猜某个参数归谁管。
训练产物路径也带着这个结构:
logs/rsl_rl/<robot>_(velocity | tracking)/<date_time>/model_<iteration>.pt
velocity 和 tracking 是两类任务,从日志目录一直到源码目录,这个二分贯穿全仓库。
动作模仿:先做数据转换
第二类任务是让 G1 模仿参考动作序列。它比速度跟踪多一个预处理步骤:
python scripts/csv_to_npz.py \
--input-file src/assets/motions/g1/dance1_subject2.csv \
--output-name dance1_subject2.npz \
--input-fps 30 \
--output-fps 50 \
--robot g1
CSV 转 NPZ,同时做帧率重采样,并指定目标机器人是 g1 还是 g1_23dof。
--input-fps 和 --output-fps 这两个参数暴露了一个真实的工程问题:动作数据的采集帧率和控制器的运行频率往往对不上。 采集设备按自己的节奏出数据,控制回路按自己的节奏跑,中间必须有一次重采样。同一份动作数据配不同的机器人,关节映射也不一样,所以还得指定 --robot。
转换完再训练:
python scripts/train.py Unitree-G1-Tracking-No-State-Estimation \
--motion_file=src/assets/motions/g1/dance1_subject2.npz \
--env.scene.num-envs=4096
任务名里的 No-State-Estimation 值得注意。它明确了这条任务线不依赖外部状态估计的输入——这是个诚实的命名,把假设写在了任务名里,你一看就知道这个策略拿不到什么信息。
回放与导出
python scripts/play.py Unitree-G1-Flat --checkpoint_file=logs/rsl_rl/g1_velocity/<时间目录>/model_xx.pt
有一条注记很关键:训练过程中会同时导出 policy.onnx 和 policy.onnx.data,供部署到真机使用。
这里和 rl_gym 分道扬镳了。rl_gym 的 C++ 部署走的是 LibTorch,而这个仓库选了 ONNX。差别在哪?ONNX 是一种与训练框架解耦的模型交换格式,推理侧只需要一个 ONNX Runtime,不需要把整个深度学习框架搬到机器人上。仓库 deploy/thirdparty/ 下同时放了 x64 和 aarch64 两份 ONNX Runtime 预编译库——x64 给你的开发机,aarch64 给机器人身上那台 ARM 计算单元,这一个细节就把部署目标交代清楚了。
目录结构:两条任务线,一套模式
Python 侧的组织是这样的:
src/
├── assets/
│ ├── motions/g1/ 参考动作 CSV
│ └── robots/
│ ├── unitree_a2/ a2_constants.py xmls/a2.xml xmls/scene_a2.xml
│ ├── unitree_as2/ as2_constants.py xmls/as2.xml
│ ├── unitree_g1/ g1_constants.py g1_23dof_constants.py
│ ├── unitree_h2/ ...
│ └── unitree_r1/ r1_constants.py xmls/r1.xml
└── tasks/
├── velocity/
│ ├── config/{a2,as2,g1,g1_23dof,go2,h1_2,h2,r1}/
│ │ env_cfgs.py rl_cfg.py
│ ├── mdp/
│ │ curriculums.py observations.py rewards.py
│ │ terminations.py velocity_command.py
│ ├── rl/runner.py
│ └── velocity_env_cfg.py
└── tracking/
├── config/{g1,g1_23dof}/ env_cfgs.py rl_cfg.py
├── mdp/ commands.py metrics.py observations.py
│ rewards.py terminations.py
├── rl/runner.py
└── tracking_env_cfg.py
三点值得说。
第一,资产是 MJCF 而不是 URDF。 rl_gym 里 resources/robots/ 下是 URDF 加 STL 网格;这里是 xmls/*.xml,MuJoCo 的原生描述格式。两者都描述连杆、关节、质量惯量,但 MJCF 能表达更多仿真侧的东西——接触参数、求解器设置、传感器定义,都写在同一份文件里。换引擎不只是换个求解器,模型描述这一层也跟着换。
第二,每个型号除了 XML 还有一个 *_constants.py。 关节名清单、默认姿态、执行器参数这类常量被抽出来单独放。这是个好习惯:模型文件给仿真器读,常量文件给 Python 代码读,两边不用互相解析。
第三,mdp/ 这个目录名暴露了整套设计哲学。 MDP 就是马尔可夫决策过程——强化学习的数学骨架:观测、动作、奖励、终止、课程。仓库把这几样东西各自拆成一个模块文件,而不是塞进一个巨大的环境类里。
这就是它和 rl_gym 最根本的写法差异:
- rl_gym 是「继承式」:有一个
legged_robot基类,每个型号继承它、覆写方法。想改奖励,你去环境类里找那个方法。 - mjlab 路线是「管理器式」:奖励、观测、终止都是一条条可配置的「项」,由管理器统一组装。想改奖励,你在配置里增删一项。
继承式的优点是直观,缺点是型号一多,覆写关系会缠成一团,你很难一眼看出某个型号最终生效的是哪份逻辑。管理器式的优点是组合灵活、配置即文档,缺点是初次上手要先理解那层抽象——你看到的不再是一份可以从头读到尾的环境代码,而是一堆项的声明。
没有哪种绝对更好。但如果你要维护八个型号乘以两类任务,管理器式在可组合性上的收益会越来越明显。而 config/<型号>/env_cfgs.py 加 rl_cfg.py 的固定搭配,也直接回答了「我要接一个新机器人进来要写什么」——照着已有型号复制一份,改常量和配置。
部署侧:C++ 里长出了一份训练框架的镜像
这是整个仓库我最想让你看一眼的地方。
deploy/include/
├── FSM/
│ ├── BaseState.h CtrlFSM.h FSMState.h
│ ├── State_Passive.h State_FixStand.h State_RLBase.h
├── isaaclab/
│ ├── algorithms/algorithms.h
│ ├── assets/articulation/articulation.h
│ ├── devices/keyboard/keyboard.h
│ ├── envs/manager_based_rl_env.h
│ ├── envs/mdp/actions/joint_actions.h
│ ├── envs/mdp/observations/observations.h
│ ├── envs/mdp/terminations.h
│ ├── manager/action_manager.h
│ ├── manager/manager_term_cfg.h
│ └── manager/observation_manager.h
├── unitree_articulation.h
└── unitree_joystick_dsl.hpp
看到 manager_based_rl_env.h、observation_manager.h、manager_term_cfg.h 这些名字了吗?Python 训练侧那套「管理器 + 配置项」的结构,在 C++ 部署侧被镜像实现了一遍。
这个设计在防什么坑,值得展开讲。
强化学习部署最阴险的一类 bug 是观测量对不上。训练时你的观测向量是「关节位置 + 关节速度 + 角速度 + 重力投影 + 指令 + 上一步动作」按某个顺序拼起来的,每一项还各带一个缩放系数。部署时你得在 C++ 里把这个向量原样重建一遍。顺序错一位、某一项漏乘系数、角速度的坐标系定义不一致——网络不会报错,它照样输出一组数,只是这组数完全没有意义。机器人会以一种看不出规律的方式失控。
而这类 bug 极难定位,因为两边代码是分别手写的,你只能靠肉眼比对。
把管理器结构在 C++ 里复刻一份,等于是让两边用同一套概念、同一种组装顺序去构造观测。它不能保证零错误,但把「隐式约定」变成了「显式的、结构对应的代码」,出错时你至少知道去哪找。
另外几个文件也各有故事:
FSM/:一套控制状态机。State_Passive(被动)、State_FixStand(固定站立)、State_RLBase(强化学习策略基类)。真机上策略网络不能一上电就接管,必须先经过安全的中间状态,出问题时也要能退回去。这和 sim2real 的通用做法是一致的。unitree_joystick_dsl.hpp:把手柄按键组合做成一套小型 DSL。真机上人必须随时能干预,按键映射写死在代码里不如做成可声明的规则。deploy/thirdparty/cnpy:读写 NumPy 的.npy/.npz文件的 C++ 库。动作模仿任务的参考动作是 NPZ 格式,C++ 侧要能直接读——这就是它出现在这里的原因。
再看每个型号的部署目录:
deploy/robots/g1/
├── CMakeLists.txt
├── config/config.yaml
├── config/policy/velocity/v0/exported/policy.onnx
├── config/policy/velocity/v0/params/deploy.yaml
├── config/policy/mimic/dance1_subject2/exported/policy.onnx
├── config/policy/mimic/dance1_subject2/params/deploy.yaml
├── include/State_Mimic.h include/Types.h
├── main.cpp
└── src/State_RLBase.cpp src/State_Mimic.cpp
config/policy/<任务>/<版本>/ 这个层级设计得很实用:策略是带版本的。同一台机器人可以并存多个策略,速度跟踪一个、动作模仿一个,各自带自己的 deploy.yaml。你训出新策略时不用覆盖旧的,放一个新版本目录就行。做过部署的人会明白这有多重要——你需要能快速回退。
对照着看,go2、a2、h1_2、r1 的目录下只有 State_RLBase.cpp,而 g1 和 g1_23dof 多一份 State_Mimic。这和 README 里「动作模仿任务当前只列了 G1 的两个配置」是对得上的。按仓库红线,这只说明该目录未暴露这项能力,不能反推别的型号做不到。
干货:接触模型为什么是足式的命脉
现在讲通用知识,这部分和具体仓库无关,但它是理解「为什么会有多条路线」的钥匙。
物理引擎要算的东西里,接触是最难的一类。刚体运动本身是微分方程,数值积分就能推进;但两个物体碰上的瞬间,方程变成了带约束的问题:不能互相穿透,切向有摩擦,法向力只能推不能拉。
难点在于这些约束是不连续的——接触点前一时刻不存在,后一时刻突然存在。不同引擎处理这种不连续性的思路不一样:有的把接触当成硬约束求解,有的用一个可以微小穿透的「软」模型换取数值稳定和可微性;摩擦锥是精确算还是用多边形近似;求解器迭代多少次、用什么算法。这些选择都会导致同一个场景在不同引擎里跑出不同的数。
MuJoCo 在这件事上的公开定位,是把接触建模作为核心关注点之一。它是 Google DeepMind 维护的开源项目,源码可读,模型格式是纯文本的 XML,接触相关的参数都能在模型文件里显式配置。开源和可配置这两点,对研究和调试的价值是实打实的——你能看到它在算什么,也能改。
那么这些差异为什么对足式机器人特别致命?
因为脚和地面的接触,就是足式控制的全部。机器人靠脚蹬地产生前进的力,靠脚底摩擦不打滑,靠触地时刻的冲击判断自己踩到了什么。整个策略网络学到的东西,本质上是「在什么状态下该给关节什么力矩,才能让脚和地的相互作用产生我想要的运动」。
接触模型稍有偏差,这个映射就变了。更麻烦的是,强化学习的策略是个彻底的机会主义者:你给什么奖励,它就想尽办法把奖励拿满,包括利用仿真器接触模型的近似误差。练出来的步态在仿真里漂亮极了,一到真机上就散架——因为它学的不是走路,是薅某个引擎的特定误差。
这就引出了那个关键推论:一个策略如果在两个独立实现的物理引擎里都成立,它更可能迁移到真机。
道理不复杂。两个引擎各有各的近似方式,各有各的误差。一个策略如果是靠钻某一个引擎的空子拿到高分,换个引擎那个空子就不存在了,分数立刻掉下来。反过来,能同时在两套不同接触实现下站得住的策略,说明它依赖的是那些在不同实现里都一致的东西——也就是真实物理里确实存在的规律。
这不是数学证明,是一道低成本的过滤网。但它便宜得离谱:换个引擎跑一遍不用碰真机、不冒摔坏硬件的风险,就能筛掉一大批「假装学会了」的策略。rl_gym 那条链路里专门有一步 Sim2Sim 做这件事;而在这个仓库里,README 直接推荐部署前先用 unitree_mujoco 仿真器做一次仿真部署,避免真机上出现异常行为,并且说明这套框架已经把它集成进来了。
cd simulate
mkdir build && cd build
cmake .. && make -j8
./simulate/build/unitree_mujoco
然后控制程序连本地回环:
cd deploy/robots/g1/build
./g1_ctrl --network=lo
真机部署时把 --network 换成实际网卡名,程序其他部分不变。同一个二进制、同一份配置,只换一个网络接口参数,就从仿真切到真机——这个设计让「仿真验证」和「真机运行」之间几乎没有代码差异,中间不会因为「部署时又改了点东西」引入新问题。
顺带一提,真机部署前的准备步骤写得很具体:悬挂启动、等待进入零力矩模式、按手柄组合键进入带关节阻尼的调试模式、用网线连接并按文档配置网段。这套顺序不是形式主义,每一步都在为「策略输出异常时人还能兜住」做准备。
从工程角度看,为什么维护多条路线
现在回到开头那个问题。我不去猜作者具体的考虑,但从工程角度看,同时养多条训练路线至少有这么几个理由。
第一,引擎各有长短,且长短是任务相关的。 接触求解的取舍、并行规模、渲染能力、可微性支持,不同实现的侧重点不一样。做速度跟踪、做动作模仿、做操作任务,对物理保真度的要求根本不在一个维度上。指望一个引擎在所有维度上都最合适,本身就不现实。
第二,避免被单一闭源工具链绑定。 这是最现实的一条。如果你的全部训练能力都压在一套闭源工具上,那么它的授权政策、版本节奏、硬件要求、支持周期,都成了你无法控制的风险。多养一条基于开源引擎的路线,是给自己留一个不受制于人的选项。对厂商是这样,对你自己的项目也是这样。
第三,团队的技术栈偏好是真实存在的。 有的团队熟悉某套 API,有的团队更信任源码可读的引擎。开源社区贡献者尤其如此——一个能在自己机器上装起来、能读到源码的栈,参与门槛低得多。维护第二条路线,本质上是在扩大能给你提交 PR 的人群。
第四,硬件门槛不同。 不同仿真栈对显卡型号、驱动、系统版本的要求不一样。多一条路线,就多一批人能跑起来。对一个希望别人拿自己机器人做二次开发的厂商来说,这是很直接的收益。
第五,前面讲的交叉验证。 两条独立路线本身就构成了互为对照的验证手段。这不是额外负担,是白捡的保险。
我想强调的是第二条和第五条:独立性本身就是一种能力。你手上有两条能跑通的路线,遇到的不只是「多一个选择」,而是「任何一条出问题时你还有退路,两条都通过时你的信心更足」。
那我该选哪条
不做排名,讲清各自适合什么情况。
倾向 rl_gym 那条路线,如果: 你需要复现的论文或教程基于那套栈;你的硬件和环境已经配好了;你要参考的社区资料大多是那边的;你只是想先跑通全流程,看看强化学习控制到底是怎么回事。
倾向 mjlab 这条路线,如果: 你被闭源工具链的环境问题反复卡住;你需要修改或深入理解物理层的行为;你的目标型号在这个仓库的支持列表里而在另一边没有;你要做动作模仿这类任务;你偏好「配置项 + 管理器」的组织方式,或者已经熟悉 Isaac Lab 那套 API。
两条都用,如果: 你真的打算把策略部署到硬件上。在一条路线上训练,在另一条独立实现里验证,是成本最低的那道保险。
还有一条不分路线的建议:不管你选哪边,部署前的仿真部署那一步别省。 两个仓库都提供了这一步,都推荐了这一步。跳过它省下的时间,远小于修一台摔坏的机器人的时间。
读完这个仓库你应该记住三件事
第一,换引擎不只是换个求解器。 模型描述格式从 URDF 变成 MJCF,参数组织从继承变成管理器,部署侧从 LibTorch 变成 ONNX——整条链路都跟着动。理解这一点,你在评估任何一次「换个底层实现」的提议时,都会把成本估得更准。
第二,训练侧和部署侧的结构对应,是防 bug 的设计而不是洁癖。 deploy/include/isaaclab/ 里那份 C++ 镜像,防的是观测向量拼错这类不报错、只让机器人诡异失控的问题。你自己写任何「训练在一边、推理在另一边」的系统时,都可以问一句:两边的组装逻辑,有没有一个共同的、显式的结构?
第三,多条路线不是重复建设。 引擎的接触模型差异对足式来说是命脉级的,而「在两套独立实现里都成立」是目前成本最低的迁移性检验。顺着这一卷继续读,足式机器人 sim2real 的通用方法会把这条思路讲得更完整,卷 U 的目录里也有传统控制路线的对照篇目,两条路对着看,各自的代价和好处会清楚很多。