仿真里学会走路,怎么搬到真机上不摔?sim2real 全流程
- 说清现实差距的五个来源,遇到真机异常时知道该往哪个方向查
- 理解域随机化、观测噪声注入、执行器建模等手段各自在补哪个洞
- 看懂部署代码的组织方式:为什么要遥控器、为什么要状态机、为什么生产版本用 C++
有一个场景,做过强化学习控制的人多半都经历过:
训练曲线漂亮,奖励一路上扬。Play 里的机器人步态稳健,你甚至能看出它学会了在被推一下之后调整重心。你信心满满地把导出的网络拷到机器上,接好网线,敲下命令。
机器人抖了两下,摔了。
这中间隔着的东西有个名字,叫现实差距(reality gap)。它不是某一个 bug,而是仿真世界和物理世界之间一整片系统性偏差的总称。整个 sim2real 这套工程,做的就是一件事:把这片偏差压到策略能容忍的范围内。
unitree_rl_gym 那篇已经把 Train → Play → Sim2Sim → Sim2Real 这条流水线串了一遍,重点在「每一步是什么」。这篇往下挖一层,讲差距具体从哪来、有哪些办法对付它、真到了部署那一天工程上要处理什么。
先说清楚边界:本文基于宇树几个公开仓库的文档与代码结构写成,我没有真机可以部署,所以文中不会出现任何「实测如何」的描述。涉及运行行为的地方,以官方仓库文档的说法为准;涉及方法层面的部分,我会明确标出哪些是这个领域的通用做法、哪些是仓库里白纸黑字写了的。
差距从哪来:五个方向
把「现实差距」拆开,大致落在这五处。它们的性质完全不同,对应的解法也不同,混在一起谈是没法排查的。
一、物理参数对不上
仿真器搭出来的机器人,是按 URDF 或 MJCF 里的数值建的:每个连杆的质量、惯量张量、质心位置,每个关节的运动范围、阻尼、摩擦。
这些数值哪来的?多半来自 CAD 模型。而 CAD 模型和你手上这台实物之间,永远隔着装配公差、线束走向、电池实际重量、螺丝拧紧程度、以及某个工程师换过一次的脚垫材料。
摩擦系数尤其麻烦。它不是机器人的属性,是机器人和地面这一对组合的属性。同一台机器,在实验室环氧地坪上和在会议室地毯上,脚底摩擦差得很远。仿真里你填了一个数,现实里这个数每天都在变。
二、执行器不是理想力矩源
仿真里的关节驱动通常是这样的:你给一个目标力矩,下一个物理步这个力矩就作用上去了。干净、瞬时、无损。
真实电机不是。它有电流环的响应时间,有力矩上限(而且这个上限随转速下降),有减速器的回差和摩擦,有温度升高后的性能衰减——连续跑一段时间之后的电机,和刚上电时的电机不是同一个电机。
这是我认为最容易被低估的一项。物理参数偏差好歹是个静态偏差,执行器动态是一整条被忽略掉的传递函数。策略在仿真里学会的「猛地给一个大力矩把身体拽回来」,到真机上可能只兑现了一部分,而且晚到了几个控制周期。想理解这层的读者可以顺着电机与关节驱动那篇往下看。
三、观测是干净的,真机的不是
仿真里读一个 IMU,拿到的是求解器算出的真值。真机上读 IMU,拿到的是真值加上噪声、加上随温度漂移的零偏、再加上因为传感器没装在标称位置而引入的固定偏差。
关节编码器同理,接触力传感器更甚。
还有一类更隐蔽的观测差异:仿真里有的量,真机上未必读得到。 unitree_mujoco 的 README 里就写了这么一条——真实硬件在关闭内置运控服务之后,SportModeState 这条消息是读不到的,但模拟器仍然保留了它,好让用户拿位置和速度信息来分析自己写的控制程序。
这句话是个很好的提醒。仿真器为了方便你调试,会慷慨地给你一堆真值;如果你的观测向量里不小心混进了一个真机上拿不到的量,训练时一切正常,部署时它就是个洞。躯干姿态、线速度这类量在真机上往往需要估计而不是直接测量,足式机器人的状态估计本身就是一个独立课题。
四、时间不是零
仿真里,读状态和发指令之间没有时间。你在一个 step 里拿到观测、算出动作、写回去,物理引擎才推进下一步。
真机上,这中间横着一整条链路:传感器采样、板载打包、DDS 传输、你的进程被操作系统调度到、网络推理、指令打包、再传回去、电机驱动器解析并执行。每一段都有延迟,而且延迟不是常数。
延迟对控制的杀伤力,学过PID 与相位裕度的人有直觉:延迟等价于在开环里插入了一个相位滞后,它会吃掉稳定裕度。原本刚好稳定的控制律,链路上多出一段滞后就可能开始振荡。而足式机器人的支撑腿控制,本来裕度就不宽裕。
五、接触力学——最难的那块
脚和地面接触的那一瞬间发生了什么?
刚性碰撞还是有弹性的?形变多少?切向摩擦是库仑模型还是带黏滑过渡?多点接触时法向力怎么分配?脚底橡胶被踩扁的那一层非线性算不算?
物理引擎给出的答案是一套近似——不同引擎的近似还不一样。而足式机器人全部的运动能力都建立在这套近似之上:它推地面,地面推它,这是唯一的动力来源。
所以接触模型的偏差不是「某个次要环节不太准」,它直接作用在命门上。这也是为什么后面那道「换个引擎再验一次」的工序值得单独存在。
怎么弥合:五类手段
下面五种是足式机器人强化学习领域内被广泛使用的做法。我讲原理,不声称某个具体仓库用了哪一种——unitree_rl_gym 的 README 主要写的是使用流程,没有展开训练侧的随机化配置,我不去替它作答。想知道某个型号的配置具体怎么写,去读对应的 *_config.py。
域随机化:不追求准,追求「对不准不敏感」
思路上有个漂亮的转折。
传统做法是想办法把仿真调得更准——测质量、辨识摩擦、拟合模型。但你永远调不到完全准,而且调准的是「这一台」,换一台又不对了。
域随机化换了个问法:既然参数注定对不准,那就在训练时让参数一直在变。每次重置环境,随机抽一组质量、摩擦、阻尼、外部扰动。策略面对的不再是一台确定的机器人,而是一整族参数不同的机器人。
要在这一族上都拿到高奖励,策略就没法依赖任何一个具体的参数值,只能学出一种对参数变化鲁棒的行为模式。真机不过是这一族里的又一个样本——只要它落在训练时覆盖的范围内。
代价是存在的:随机化范围铺得越宽,策略越保守,性能上限越低。这中间的取舍没有标准答案,具体范围怎么定属于调参工作,我不在这里编数字。
观测噪声与延迟建模:把不完美搬进训练
同样的思路用在观测侧:训练时就往观测里加噪声、加零偏、加安装误差。
延迟更值得单独说。做法是在训练环境里人为地把观测延后若干步再喂给策略,或者把动作延后若干步再施加。这一步做与不做,差别很大——没有延迟建模的策略,本质上是在一个「因果关系瞬时成立」的世界里长大的,它对滞后毫无准备。
顺带一提,很多策略在观测里会包含上一步的动作。这在真机上是很有价值的一项:它让网络能感知到自己刚才发了什么指令,从而对「指令还没完全兑现」这件事有一定的内部补偿能力。
执行器建模:用真实数据替掉理想模型
对付第二类差距最直接的办法,是别再假装电机是理想力矩源。
一种在文献里被反复使用的做法是:在真机上采集大量「指令 → 实际输出」的数据对,训练一个小网络去拟合这个映射,然后在仿真里用这个网络替代理想执行器模型。仿真于是不再回答「给 5 牛米就出 5 牛米」,而是回答「按这台机器人历史上的表现,此刻给这个指令大概会出多少」。
这个方法的美感在于它承认了一件事:电机的动态特性很难写成解析式,但它是可测的。 测不明白的东西交给数据。
动作限幅与平滑:给策略上一道保险
神经网络的输出没有任何物理常识约束。训练收敛得再好,也可能在遇到没见过的状态时吐出一个剧烈跳变的动作。
工程上的对策很朴素:限制动作幅值、限制相邻两步之间的变化率、必要时过一道低通。奖励函数里通常也会带上对动作剧烈变化的惩罚项,从源头压抑抖动。
这类措施会牺牲一点响应速度,但换来的是真机上不会突然给出一个把关节打到限位的指令。在硬件损坏的代价面前,这笔交易划算。
unitree_rl_lab 的部署代码里有个 LinearInterpolator.h,从命名看是做线性插值的。控制程序里出现插值器,通常是为了让状态之间的过渡不出现阶跃——这类平滑处理在部署侧是常规配置。
课程学习:难度是逐步加上去的
直接把机器人扔进碎石堆里练,多半什么都学不会——它一开始连站都站不稳,拿不到任何有效的正反馈,梯度信号是一团噪声。
课程学习的做法是让地形跟着能力走:先平地,站稳了给斜坡,斜坡稳了给台阶,再往上是随机起伏。每一级都建立在上一级的基础上。
这两个仓库都为地形做了准备。unitree_rl_gym 的 legged_gym/utils/ 下有 terrain.py;unitree_mujoco 则专门提供了一个 terrain_tool 目录,README 里说明它能参数化地生成台阶、粗糙地面和高度图三类地形。地形能被参数化生成,恰恰是课程学习能自动化的前提——难度是个可以拧的旋钮。
中间那道工序:Sim2Sim 到底在把什么关
流水线里的第三步经常被当成走过场,其实它是整条链路上性价比最高的一道检查。
道理很直白:Isaac Gym 和 MuJoCo 是两套独立实现的物理引擎,接触模型、求解器、数值积分方式都不同。一个策略如果只在其中一个里能站住,换一个就废,那它学到的多半不是走路,而是某个引擎的数值特性。
这层过滤不需要碰硬件,不冒摔机的风险,成本几乎为零,但能挡掉一大批「在仿真里作弊成功」的策略。
unitree_rl_gym 里这一步的入口是:
python deploy/deploy_mujoco/deploy_mujoco.py g1.yaml
配置放在 deploy/deploy_mujoco/configs/ 下,有 g1、h1、h1_2 三份 YAML。要换成自己训的模型,改配置里的 policy_path——默认它指向仓库自带的预训练模型,路径规则是 deploy/pre_train/{robot}/motion.pt。
而 unitree_mujoco 这个仓库,把这道工序又往前推了一步。
它不只是个 MuJoCo 场景,README 里说得很清楚:这是一个基于 unitree_sdk2 和 mujoco 开发的仿真器,用 unitree_sdk2、unitree_ros2、unitree_sdk2_python 写的控制程序可以直接接进去。当前版本只支持底层开发,主要就是用来做控制器的 sim to real 验证。
关键在于它在仿真侧复刻的是 DDS 消息接口本身——LowCmd(电机控制指令)、LowState(电机状态)、SportModeState、以及 G1 的躯干 IMU 话题。你的控制程序面对的,是和真机一模一样的那套消息接口。
这带来一个很干净的结果。README 里给的 stand_go2 例子把接缝暴露得清清楚楚:
if (argc < 2)
{
// 没传网卡名 —— 用仿真的 domain id 和本地回环
ChannelFactory::Instance()->Init(1, "lo");
}
else
{
// 传了 —— 用指定网卡连真机
ChannelFactory::Instance()->Init(0, argv[1]);
}
同一份控制程序,跑仿真还是跑真机,区别只是命令行有没有多一个网卡名。这就是「seamless transition」这个说法的实际含义:sim2real 的接缝被压缩到了一个初始化参数上。
配置文件里还藏着一条安全设计。C++ 版的 config.yaml 里,domain_id 的注释建议设成和真机不同的值(真机默认是 0),网卡建议用本地回环 lo。这不是随手写的默认值——它保证你在调试仿真时,指令包不会因为某次误操作跑到旁边那台真机上去。同一间实验室里既有仿真又有真机的时候,这条隔离能救命。
部署侧:deploy_real 的构成告诉你要处理什么
真机部署的入口是:
python deploy/deploy_real/deploy_real.py {net_interface} {config_name}
第一个参数是连接机器人的网卡名,第二个是 deploy/deploy_real/configs/ 下的配置文件。目录结构长这样:
deploy/deploy_real/
README.md
config.py
deploy_real.py
configs/
g1.yaml
h1.yaml
h1_2.yaml
common/
command_helper.py
remote_controller.py
rotation_helper.py
cpp_g1/
...
common/ 下这三个文件,各自对应一类真机才有的麻烦。
command_helper.py——指令构造。 策略网络输出的是一串数,这串数要变成机器人能接受的底层指令包:填对每个电机的目标值、增益、模式位,字段顺序不能错,电机编号要和实际硬件对应。unitree_mujoco 的文档特意提醒了一句,仿真器里电机的编号是和实际机器人硬件一一对应的。这类映射错了不会报错,只会让机器人做出莫名其妙的动作。
remote_controller.py——遥控器接入。 这个文件的存在本身就是一条工程原则的体现,下一节展开讲。
rotation_helper.py——姿态旋转换算。 四元数、欧拉角、旋转矩阵之间的转换,以及仿真和真机之间坐标系约定的对齐。这类代码不难写但极易出错,错了通常也不抛异常,只是机器人的动作看起来「有点怪」。抽成独立文件,是为了让它能被单独测试。
配置文件按型号分开,也不只是整理癖。同一套部署逻辑要面对不同的关节数量、不同的观测维度、不同的默认姿态和增益,这些差异用配置吸收掉,主程序才能保持一份。
为什么要有一个 C++ 版本
仓库里还有一份 C++ 部署实现,在 deploy/deploy_real/cpp_g1/ 下,依赖 LibTorch,用 CMake 构建。
Python 版已经能跑了,为什么还要再写一遍?
我的判断是:实时性和确定性。控制程序是硬周期任务,它必须在每个周期内稳定地完成「读状态 → 推理 → 发指令」。Python 在这件事上有两个不太好控的因素——垃圾回收可能在任意时刻插进来暂停一下,解释器本身的开销也让每次循环的耗时波动更大。
平时看,多几毫秒无所谓。但对一台正在单腿支撑的人形机器人来说,一次意外的停顿意味着这个周期里没有新指令发出去,关节维持着上一拍的输出——而它的重心正在往前倒。控制程序里的抖动,代价是硬件,不是一条日志。
再看这个目录里的文件构成:
deploy/deploy_real/cpp_g1/
main.cpp
Controller.cpp / Controller.h
DataBuffer.h
AtomicLock.h
joystick.h
utilities.cpp / utilities.h
CMakeLists.txt
DataBuffer.h 和 AtomicLock.h 同时出现,基本可以确定这是个多线程程序:一个线程按固定周期接收机器人状态,另一个线程跑策略推理,两者通过一块带锁保护的缓冲区交换数据。
这个设计解决的问题是解耦周期。状态接收是被动的,数据什么时候到你说了不算;推理是主动的,你希望它按自己的节拍走。如果把两件事塞进一个循环,接收阻塞一下,推理就跟着晚一拍。分成两个线程、中间垫一块缓冲,接收线程只管往里写最新值,推理线程每次取当前值——即使某一帧没到,推理拿到的是稍旧的数据,而不是干脆卡住。
对控制程序来说,用略微过时的数据继续跑,通常好过等待。这个取舍值得记住。
至于用 AtomicLock 而不是标准互斥量,从命名看是自旋等待。控制线程里持锁时间极短的场景下,自旋能避免线程被内核挂起再唤醒带来的调度不确定性。我不去猜作者具体的考虑,但这个选择和「控制程序不能有不确定的停顿」是一致的。
上电之前:这一节请认真读
前面所有内容都是为了让策略在真机上表现更好。这一节是为了让它表现不好的时候,人还来得及反应。
unitree_rl_gym 的 README 在 Sim2Real 那一节开头就写了一句:部署到实物机器人之前,确保它处于调试模式,详细步骤见部署指南。unitree_rl_lab 的说明里也有对应的一条:使用这个程序直接控制机器人之前,确认板载的控制程序已经关闭。
这两句话看着像免责声明,实际是硬要求。原生控制程序和你的策略同时往电机发指令,结果是不可预期的。
在此基础上,几条我认为不该省的准备:
必须有人握着遥控器,并且知道怎么切断。 remote_controller.py 不是给你遥控机器人走路用的娱乐设施,它是急停通道。策略是个黑盒,它在遇到训练分布之外的状态时会做什么,没人能保证。整个部署过程中,遥控器必须在人手上,手指必须在那个键上。
unitree_rl_lab 的部署代码给出了一种更结构化的处理方式。它的 deploy/include/FSM/ 下有一组状态:
FSM/
CtrlFSM.h
FSMState.h
BaseState.h
State_Passive.h
State_FixStand.h
State_RLBase.h
被动态、固定站立态、强化学习态,由一个状态机管理,通过手柄组合键切换。README 里给的 sim2sim 操作顺序是:先按组合键让机器人站起,再让脚接触地面,然后按另一组键才开始跑策略。
策略不是一上电就接管的。 它是一个需要显式进入、也能显式退出的状态。这个设计比「跑起来就一直跑」安全得多,值得在自己的项目里照抄。
先悬吊,再落地。 unitree_mujoco 里有个细节很能说明问题:它实现了一根「虚拟弹力带」,配置项叫 enable_elastic_band,README 说明它主要用来模拟人形机器人初始化时的悬吊过程,按 9 激活或释放,按 7 降低、按 8 抬升机器人。
仿真里都要模拟悬吊,真机上就更是必需。人形机器人第一次跑一个新策略,脚不该直接踩在地上。吊起来、让它在空中把动作跑一遍、确认关节没有异常运动、再慢慢放下去让脚接触地面——unitree_rl_lab 的操作步骤正是这个顺序。
从保守参数起步。 第一次上机不要用训练时的完整增益和完整速度指令。把增益调低、速度指令给零或极小值,先看它能不能安静地站住。站住了再往上加。跳过这一步省下的十分钟,可能要用一次维修来还。
场地要清干净。 周围留出足够空间,地面不要有会绊到的线缆,人不要站在机器人的正前方和正后方——那是它摔倒时最可能去的方向。
别一个人做。 一个人操作、一个人盯着机器人、必要时第三个人负责断电。这不是小题大做,这是标准做法。
收束:这套思维方式能带到哪去
sim2real 表面上是机器人领域的一个具体问题,但它的解法结构非常通用。
第一,承认模型和现实必然有偏差,然后设计对偏差不敏感的系统。 这比追求「把模型做准」更现实。域随机化的本质是把不确定性当成训练信号,而不是当成待消除的误差——这个思路在做任何需要跨环境部署的系统时都成立:跨设备的算法、跨网络条件的应用、跨用户习惯的产品。
第二,在昂贵的验证之前,插一道廉价的独立验证。 Sim2Sim 就是干这个的。换一个独立实现的环境重跑一遍,成本极低,却能筛掉一大批「只在特定环境下成立」的结果。你手上的项目里,有没有这样一道可以插进去的过滤网?
第三,把不可控的东西挡在人和硬件之间。 遥控器、状态机、悬吊、保守起步,这几层加起来构成的是一个前提假设:策略会失败,而且会在你没预料到的时刻失败。 所有安全措施都建立在这个假设上,而不是建立在「应该没问题」上。
这三条里,最后一条最容易被跳过,也最贵。
想接着往下读的话,强化学习和 MPC 两条控制路线的对比可以帮你看清 sim2real 这类问题在传统控制里是怎么被处理的——那边的模型是显式的,误差来源也就换了个位置,但没有消失。整卷的路线图在宇树与足式机器人专题。