仿真器怎么假装成一台真机器人?unitree_mujoco 的联调设计
- 看懂 unitree_mujoco 怎么用 DDS 让仿真器在网络上「冒充」一台真机器人
- 掌握仿真与真机切换的实际做法:域 ID 加网卡名两个参数决定你连的是谁
- 学会把「测试替身」这套软件工程思路搬到自己的硬件项目里,同时知道它的边界在哪
做机器人控制最难受的一段,不是写不出算法,是没法安全地试错。
你手上要么根本没有那台机器人——它在实验室、在同事手里、在采购流程里;要么有,但你不敢随便跑。一个符号写反、一个增益给大了,几十公斤的东西可能直接砸在地上。真机调试的每一次失败都带着硬件损坏的账单,于是你会本能地放慢、变保守,一天下来验证不了几个想法。
unitree_mujoco 想解决的就是这件事。但它解决的方式,比「提供一个仿真器」这句话有意思得多。
这个仓库最妙的地方在于:它让仿真器在网络上冒充一台真机器人。你的控制程序不知道自己连的是什么,它照常发指令、照常收状态,一行代码都不用改——今天连的是仿真,明天换成真机。
先把边界说清楚:这篇文章基于公开仓库的 README、配置文件和完整文件树,我没有真机,也没有跑过这套仿真环境,所有结论来自对文档和代码结构的阅读。涉及运行效果的地方,我只转述仓库文档里写了什么。
仓库里有什么
按 README 的说法,unitree_mujoco 是基于 unitree_sdk2 和 MuJoCo 开发的仿真器,仓库同时包含 C++ 和 Python 两个版本的实现。顶层目录是这样:
simulate/ —— C++ 版仿真器(README 标注为 recommended)
simulate_python/ —— Python 版仿真器
unitree_robots/ —— unitree_sdk2 支持的机器人的 MJCF 描述文件
terrain_tool/ —— 生成仿真地形的工具
example/ —— 示例程序
同一件事提供两套实现,这在开源项目里通常不是重复劳动,而是在照顾两类人:C++ 那套接的是 unitree_sdk2,Python 那套接的是 unitree_sdk2_python。你平时用哪个语言写控制程序,就用哪个版本的仿真器,环境依赖不用来回切。
unitree_robots/ 下面按型号分目录,从文件树里能数出来的有:a2、b2、b2w、g1、go2、go2w、h1、h1_2、h2、r1。每个目录里放的是模型 XML 加一堆网格资源,多数型号同时有 scene.xml 和 scene_terrain.xml 两个场景文件——一个平地,一个带地形。
这里有个细节值得停一秒:config.yaml 的注释里只列了四个型号名(go2、b2、b2w、h1),但 unitree_robots/ 目录下实际存在的模型目录比这多。我不去猜作者的考虑,但从工程角度看,最可能的解释是注释滞后于目录的更新。读仓库时碰到这种「文档说的」和「文件里有的」对不上的情况,一律以文件为准,同时以官方仓库当前文档为准。
g1 目录里还藏着一个很有用的东西:g1_joint_index_dds.md。关节编号这件事是仿真接真机时最容易翻车的地方——你在仿真里第 3 号关节是膝盖,真机上第 3 号是别的,整套控制就废了。README 里专门提了一句:电机的编号与实际机器人硬件一致。把编号对照单独写成一个文件放在模型旁边,是负责任的做法。
核心机制:它接的是同一条 DDS 总线
现在说这个仓库真正的设计眼。
如果你读过unitree_sdk2 的架构拆解,会记得整套 SDK 的地基是 DDS——一套不需要中间 Broker 的发布订阅系统。机器人往总线上发布自己的状态,控制程序订阅这些状态;控制程序往总线上发布指令,机器人订阅指令执行。总线两端谁也不认识谁,它们只认话题名和消息类型。
unitree_mujoco 干的事就是:把 MuJoCo 算出来的物理量,按真机的话题名和消息格式,原样发到这条总线上;同时订阅指令话题,把收到的指令喂给物理引擎。
README 里列出了当前支持的 unitree_sdk2 消息:
LowCmd —— 电机控制指令(仿真器订阅)
LowState —— 电机状态信息(仿真器发布)
SportModeState —— 机器人位置与速度数据
IMUState —— 躯干 IMU 状态,走 rt/secondary_imu 话题(仅 G1)
对上层程序来说,这就够了。它发 LowCmd,收 LowState,至于对面是一台真机器人还是一段跑在你笔记本上的物理仿真,它无从分辨、也不需要分辨。
干这件事的代码在仓库里叫 bridge(桥)——C++ 版是 simulate/src/unitree_sdk2_bridge.h,Python 版是 simulate_python/unitree_sdk2py_bridge.py。名字起得很直白:它是物理引擎和 DDS 总线之间的一座桥,两头各说各的语言,桥负责翻译。
(顺带记一笔:README 正文里提到手柄映射时写的路径是 simulate/src/unitree_sdk2_bridge/unitree_sdk2_bridge.cc,而我看到的文件树里是 simulate/src/unitree_sdk2_bridge.h。这种路径对不上的情况在活跃仓库里很常见,通常是重构后文档没跟着改,以仓库当前状态为准。)
还有两个设计值得单独说:
手柄也被桥接了。 README 写明,仿真器会用 Xbox 或 Switch 手柄模拟机器人的无线遥控器,按键和摇杆信息通过 rt/wireless_controller 话题发布。这意味着「人拿手柄兜底 + 程序自动控制」这套真机上的工作模式,在仿真里是原样成立的。
有一个消息是「刻意保留的假象」。 README 里有条注释我很喜欢:在真实硬件上,关掉内置运动控制服务之后 SportModeState 就读不到了;但仿真器仍然保留这条消息,好让你用里面的位置和速度信息来分析自己写的控制程序。
这是一个很清醒的取舍。仿真器在这里故意不完全等同于真机——它多给了你一份真机上拿不到的「上帝视角」数据,因为调试阶段你需要它。但它同时把这件事写在了文档里,你知道自己在用一个真机上没有的东西,就不会把它写进最终的控制逻辑。测试替身可以比被替身者多一些能力,前提是这些能力被明确标注出来。
切换只改两个参数
接口一致的价值,在 example/ 目录里体现得最直接。这个目录下是让 Go2 站起来再趴下的示例,按接口分成三份:
example/cpp/ —— 基于 C++,用 unitree_sdk2 接口
example/python/ —— 基于 Python,用 unitree_sdk2_python 接口
example/ros2/ —— 基于 ROS 2,用 unitree_ros2 接口
README 里管这一节叫 Sim to Real。C++ 版的关键代码就这么几行:
if (argc < 2)
{
// 没有传入网卡名,就用仿真用的域 id 和本地回环网卡
ChannelFactory::Instance()->Init(1, "lo");
}
else
{
// 否则使用指定的网卡
ChannelFactory::Instance()->Init(0, argv[1]);
}
Python 版是同样的逻辑,换成 ChannelFactoryInitialize(1, "lo") 和 ChannelFactoryInitialize(0, sys.argv[1])。
于是运行方式变成了:
./stand_go2 # 控制仿真里的机器人
./stand_go2 enp3s0 # 控制真机,enp3s0 是连接机器人的网卡名
整个控制逻辑一个字没动,变的只有初始化时的两个参数:域 ID 和网卡名。
这两个参数分别管什么,值得掰开讲。域 ID 是 DDS 的隔离机制:不同域之间的节点互相看不见,天然形成一个个独立的通信空间。README 建议仿真用 1、真机默认是 0,就是为了让「仿真的指令」永远不可能误跑到真机上去——这是一道很实在的安全护栏,不是洁癖。网卡名决定消息从哪张网卡走:仿真用本地回环 lo,数据不出本机;真机则用实际连着机器人的那张网卡。
ROS 2 那条路径也是同一套逻辑,只是换了个形式:仿真时 export ROS_DOMAIN_ID=1 并 source 本地网卡的配置脚本,真机时改成 0 并 source 连机器人的配置脚本。三种接口,三种写法,同一个道理。关于 DDS 域和话题这套机制本身,宇树的 DDS 通信那篇讲得更细。
配置项:能调的都在这里
C++ 版的配置在 simulate/config.yaml,Python 版在 simulate_python/config.py,字段基本一一对应。逐条过一遍(只列仓库里实际存在的):
| 配置项 | 作用 |
|---|---|
robot |
加载哪个型号 |
robot_scene |
加载该型号目录下的哪个场景文件,如 scene.xml |
domain_id |
DDS 域 ID,README 建议与真机区分开 |
interface |
网卡名,仿真建议用本地回环 lo |
use_joystick |
是否用手柄模拟无线遥控器;没有手柄时置 0 |
joystick_type |
手柄布局,支持 xbox 和 switch |
joystick_device |
手柄设备路径(Python 版是手柄序号) |
joystick_bits |
手柄精度位数,README 提到有些手柄精度较低 |
print_scene_information |
是否打印机器人的 link、joint、sensor 信息 |
enable_elastic_band |
是否启用虚拟弹性带 |
Python 版另外多两个只在它那边出现的字段:SIMULATE_DT(仿真时间步长)和 VIEWER_DT(可视化界面的刷新步长)。配置注释里写了一句很关键的约束:仿真步长需要大于渲染一次画面所需的时间,否则仿真的可靠性没有保证。这是一条典型的「踩过坑才会写进注释」的经验——物理步进和画面渲染抢的是同一份 CPU 时间,画面拖慢了物理,仿真结果就不可信了。具体默认值以仓库当前配置为准。
print_scene_information 这个开关也值得留意。它打印的是模型里的 link、joint、sensor 清单——你写控制程序时需要知道关节的顺序和传感器的名字,与其去 XML 里数,不如让仿真器启动时直接打给你看。这是给使用者省事的小设计。
给人形准备的那根橡皮筋
enable_elastic_band 是整份配置里最有画面感的一项。
README 的解释是:人形机器人不适合直接从地面上启动,所以设计了一根虚拟弹性带,用来模拟把人形机器人吊起来、放下去的过程。加载模型之后,按 9 启用或释放吊带,按 7 把机器人放低,按 8 把机器人提起。
如果你见过实验室里调试人形机器人的场面,会立刻明白这个功能在还原什么——真机调试时,人形机器人基本都挂在龙门架上,控制策略没调好的时候它就悬在那儿蹬腿,调好了才敢慢慢放到地面。仿真器把这套现实中的调试仪式也一并仿了进来。
这个细节透露的信息量比它本身大:一个仿真器的成熟度,不看它物理算得多准,看它有没有还原真实工作流里的那些「配套设施」。
地形工具与模型资源
terrain_tool/ 是一个参数化生成地形的工具,README 说它能创建阶梯、粗糙地面和高度图,具体用法在该目录自己的 readme 里。目录下是 terrain_generator.py 和一个 scene.xml。
这个工具的价值和 unitree_rl_gym 那篇里讲的域随机化是一路的:平地上走得好不算学会走路。 能参数化批量造地形,意味着你可以把控制器扔进各种各样的地面里筛,而不是反复在同一块平地上刷通过率。
模型资源里也有些东西能读出来。多数型号目录下都放着 height_field.png 和 unitree_hfield.png 这类图片——高度图正是 MuJoCo 描述起伏地面的常见方式,用图片的灰度值表示高度。有意思的是 simulate/src/ 下引入了第三方库 lodepng(一个 PNG 读写库)。仓库没有明说它的用途,但从「高度图是 PNG + 引入 PNG 解码库」这个组合看,最合理的推测是用来读取这些高度图。这是推测,不是仓库写明的事实。
a2 目录是另一个惊喜。它的网格资源里有一批一看就是道具的东西:
crate_board —— 板条箱面板
pallet_step —— 托盘台阶
pallet_step_with_pipe
ramp —— 坡道
stepover_1 / stepover_2 —— 跨越障碍
k_rail_diagonal
lane_floor
配套还有 qrc_map_flat.xml 和 qrc_map_sloped.xml 两个场景文件。这已经不是「一块地面」,而是一整套带障碍物的测试场地。
再看这些道具的组织方式:每个道具在 meshes/visual/ 下有一份用于显示的完整模型,在 meshes/convex/ 下有一堆编号的 _p0.obj、_p1.obj、_p2.obj。这是物理仿真里的通行做法——碰撞检测算法通常只对凸体高效,一个凹形的复杂物体必须先做凸分解,拆成若干个凸块,物理引擎才算得动。显示用一份精细模型,碰撞用一堆粗糙凸块,两者分开,这是仿真资源准备里的基本功。你自己往 MuJoCo 里塞模型时,如果碰撞行为诡异或者速度极慢,八成就是这一步没做。
这套思路的通用名字:测试替身
跳出宇树的语境,unitree_mujoco 做的事情在软件工程里有个成熟的名字:测试替身(Test Double)。
它的前提是面向接口编程,而不是面向实现编程。你的控制程序依赖的不是「那台机器人」,而是「一个会发布 LowState、会接收 LowCmd 的 DDS 端点」。这个契约一旦定死,谁来实现它就无所谓了——真机可以,仿真器可以,将来你写一个只会回放历史数据的假节点也可以。
这套思路值钱的地方在于它解锁了三件在没有硬件时做不了的事:
开发。 硬件还在路上、还在别人手里、还没修好,控制逻辑照写不误。团队并行度直接上去了。
调试。 仿真里你可以随意加断点、单步、反复重启,物理量还能从「上帝视角」直接读出来。真机上做同样的事,代价是每次重来都要人工把机器人扶起来。
回归测试。 这是最容易被忽略、长期收益最大的一条。改完代码跑一遍标准动作,看结果有没有变——这件事在真机上几乎没法自动化,在仿真里可以进 CI。
判断一个项目值不值得做测试替身,有个很简单的标准:看真实依赖的「一次失败成本」有多高。 调一个网页接口,失败了刷新一下就完事,不太需要替身;驱动一台几十公斤的机器人,一次失败可能是一笔维修费加三天工期,那替身的性价比就高得离谱。
但仿真永远不等于真机
上一节说完好话,这一节必须说清楚代价,否则这篇文章就是有害的。
仿真通过不代表真机能行。 差异至少来自这几个方向:
- 接触力学。 脚底和地面接触的那一瞬间——摩擦、形变、滑移、能量损耗——是整个足式机器人仿真里最难算准的部分。物理引擎用的是简化模型,真实地面是脏的、软的、不均匀的。
- 电机动态。 仿真里给一个力矩指令,电机就输出那个力矩。真实电机有响应延迟、有温度带来的特性漂移、有减速器的间隙和摩擦、有电流环自己的动态。
- 传感器噪声。 仿真里的 IMU 读数是干净的解析值;真机上的 IMU 有噪声、有零漂、有振动串扰。你的状态估计在仿真里稳如老狗,上真机可能直接发散。
- 通信延迟。 走本地回环
lo的延迟和走实际网络到机器人板子的延迟不是一回事。控制回路对延迟是敏感的。
所以准确的表述是:仿真只能筛掉明显的错误,不能证明正确性。 符号写反了、增益给到发散、状态向量顺序拼错了、关节编号对错了——这类问题仿真一跑就露馅。而「在真实接触条件下会不会打滑」这类问题,仿真给不了答案。
这也解释了 README 里那句限定:当前版本只支持低层开发,主要用于控制器的 sim to real 验证。它没打算替你验证高层运动服务,它的目标很聚焦——就是让你写的那个底层控制器,在上真机之前先过一道筛。仓库里没写的能力,我们不去猜。
还有一个具体的坑,README 也点了:不同型号用的 DDS 消息定义不一样。文档里写明,Go2、B2、H1、B2w、Go2w 用 unitree_go 这套 idl 做低层通信,G1 和 H1-2 用 unitree_hg。所以自带的测试程序发的是 unitree_go 消息,要测 G1 就得改成 unitree_hg。「接口一致」是有范围的,跨消息族的时候仍然要改代码。
和 rl_gym 里的 Sim2Sim 是什么关系
读过强化学习那条链路的人会问:unitree_rl_gym 里已经有一步叫 Sim2Sim,把策略搬到 MuJoCo 里再跑一遍,那和这个仓库是不是重复了?
不是。两者的位置完全不同:
- rl_gym 的 Sim2Sim 是训练框架内部的一道验证工序。 它要回答的问题是「这个策略是不是过拟合到了某一个物理引擎的特性上」,换一个独立实现的引擎跑一遍,看它还灵不灵。输入是训练出来的策略文件,输出是「这个策略能不能进入下一关」的判断。
- unitree_mujoco 是一个独立的联调环境。 它要回答的问题是「我这个控制程序,接在真机接口上能不能正常工作」。它不关心你的控制器是强化学习训出来的、是 MPC 算出来的,还是一段手写的 PD 控制——只要你说 SDK 的语言,它就能陪你联调。
一个是算法有效性的交叉验证,一个是接口正确性加行为合理性的联调。用途不重叠,但常常先后串起来用:策略在训练框架里过了 Sim2Sim,写成部署程序之后,再接到这个仿真器上,把整套 DDS 收发、关节编号、时序都对一遍,最后才轮到真机。
读完这个仓库该记住什么
第一,接口一致比仿真精度更重要。 这个仓库真正的设计眼不是 MuJoCo 算得多准——那是 MuJoCo 的事——而是它选择去实现一套和真机完全相同的 DDS 接口。正因为接口一致,切换真假只需要动域 ID 和网卡名两个参数,控制逻辑一个字不用改。一套仿真器如果需要你为它写一份专门的适配代码,它的价值立刻打对折,因为你在仿真里验证的和你在真机上跑的,已经不是同一份代码了。
第二,替身的能力边界必须写进文档。 SportModeState 那条注释是个好例子:仿真器故意多给了一份真机上拿不到的数据,但它明确告诉你这是仿真专有的。这句话的价值在于让你区分「调试时看看」和「写进控制逻辑」。你自己做 mock 时也该这么干——替身与真身的每一处不一致,都要么写在注释里,要么写在 README 里,别让它变成暗坑。
第三,这套思路可以直接搬。 回头看你自己的项目,问三个问题:我的控制逻辑和硬件驱动之间,有没有一层明确的接口?如果有,我能不能给这层接口写第二个实现?如果能,我的自动化测试能不能挂在这个实现上跑?三个都是「能」,你就拥有了一条能天天跑、零成本失败的验证链路。三个里有一个是「不能」,那个「不能」的地方,就是你项目里最值得重构的那一处。
想接着往下走的话,卷 U 里的其他文章还有 SDK 分层、DDS 通信、强化学习部署这几条线;如果你手上连仿真都还没有对象可仿,从机器人卷开始,用 ESP32 做一台自平衡车,把「传感器 → 控制律 → 执行器」这条最小闭环亲手跑通,再回来看这套联调设计,你会更清楚它到底在替你省什么。