四足开发实战(九):仿真篇——MuJoCo 与 sim2sim
四足开发实战(九):仿真篇——MuJoCo 与 sim2sim
将第七篇编写的 URDF 直接载入 MuJoCo 时,加载通常能够成功,但模型会固定在地板上纹丝不动,nu 为 0,没有 IMU,部分零件的尺寸甚至远超实际。这一现象并非 URDF 本身写错,而是 MuJoCo 读取 URDF 的过程只完成了前半部分:它能够解析这一格式,但解析结果距离「可用于仿真」还差四步改造。本文逐一说明这四步,随后继续展开执行器、接触、控制器在环与 sim2real 的桥接。
在开始动手之前,应先明确仿真的用途。软件在环能够节省摔机成本、电机烧毁成本与反复拆装带来的调参时间:步态参数、起立逻辑、急停逻辑在仿真中验证,成本远低于在实机上验证,力矩饱和与堵转保护可以安全触发,改动一版固件后沿同一条轨迹运行即可形成回归测试。仿真无法替代的是另一批内容,这些内容值得逐条记录,而非笼统地表述为「仿真不准」:软接触模型不保证零速黏滞,摩擦与蠕变需要在实机实测;电机的摩擦、死区、饱和、温漂在仿真中不存在;总线仲裁与调度抖动在仿真中同样不存在;连杆被假设为刚体,结构柔性只能通过实机观察;电池压降、热、EMC 同样只存在于真机。这五条差距并非缺陷,而是一份清单,后续每一步都在逐项补齐这份清单。为此,应确定固定的仿真节奏:每改动一版控制代码,运行三个场景——站立 10 秒、单腿 ±0.2 rad 阶跃、trot 五米——记录同一组指标(漂移、超调、横向偏差),三个场景全部通过才允许上机;「看着还行」不构成通过标准。
将上述差距整理为一张对照表,每一条「仿真中缺失的内容」对应一个实机现象:
| 差距 | 仿真里的表现 | 实机上的表现 |
|---|---|---|
| 质量/惯量不准 | 步频偏、站姿偏 | 同样指令下电流偏大/偏小 |
| 摩擦系数 | 打滑或推不动 | 草地/瓷砖差异巨大 |
| 电机带宽 | 力矩瞬间到位 | 有力矩上升时间 |
| 通信延迟 | 无 | 1 ms RS485 + 驱动器处理 |
| 传感器噪声 | 可设 0 | IMU 零偏、量化、振动 |
| 接触模型 | 点接触、软约束 | 足端形变、多点接触 |
「能省」一栏还应补充一条容易遗漏的内容:1 kHz 的 RS485 带宽与控制周期能否匹配,应先通过仿真计算清楚,成本远低于上机出现丢帧后再排查。表中每一行在后续各节中都有对应的补齐内容:摩擦与接触对应「接触抖动成因」一节,电机带宽与通信延迟对应执行器与控制器在环,噪声对应传感器;只有结构柔性与电池压降这两条无法由仿真补齐,只能保留在上机清单中。以上即为本文的结构。
转换只完成一半:URDF 进 MuJoCo 的四个必改点
环境要求较为简单:Python 3.10 加 MuJoCo 3.x 即可,纯 CPU 运行 12 自由度模型绰绰有余,无需显卡或 ROS。版本需要锁定在 3.x:2.x 时代的 mujoco_py 早已停止维护,网络上的大量旧教程仍使用它,其 API 与 3.x 完全不同,不应混用。安装命令如下,Windows 下将 python3.10 换成 py -3.10:
python3.10 -m venv .venv && source .venv/bin/activate |
目录按三层组织:sim/mjcf(存放 go1.urdf、转换产物 go1_raw.xml、手动修改后的 go1.xml)、sim/meshes(STL)、sim/scripts(自检脚本);模型路径使用全英文,不应使用反斜杠。安装阶段最常见的四个问题应提前说明:import mujoco 报 DLL 加载失败,是 Windows 缺少 VC++ 运行库;没有 mujoco.viewer 模块,是安装了 2.x 版本;在服务器上打开 viewer 报 GL 错误,是因为无显示设备,训练与自检应走 headless;加载报路径错误,多半是使用了中文路径。
实际工作流为:先用 MjModel.from_xml_path 读取 URDF,再用 mj_saveLastXML 将其另存为 MJCF,此后所有工程配置都在这份 MJCF 上修改,几何与惯量仍以 URDF 为源头,不再回头改动 URDF。读取操作仅完成了运动树的搭建,四个缺口仍需补齐。
这一工作流有三个容易被跳过的细节。第一,转换产物保存为 go1_raw.xml 且设为只读——它是转换时刻的快照,改动出错时可随时重新转换,手动修改始终发生在 go1.xml 上;两份文件并存,通过 diff 即可看出做过哪些改动。第二,另存后应先检查 mesh 路径:CAD 导出的 URDF 常带有绝对路径,更换机器或移动目录后即加载失败,应将 file 统一改为 meshes/xxx.stl 这类相对路径再继续。第三,Windows 的 PowerShell 不支持 heredoc,应将转换脚本保存为 scripts/convert.py,再通过 python scripts/convert.py 运行,不应在命令行中使用 << 语法。另一类做法也需说明:将 <mujoco> 块直接写入 URDF 的 <robot> 标签内,compiler、option、actuator、sensor 原样进入编译,menagerie 的官方模型均采用此做法;两类做法均正确,关键是编译器配置要显式写出、工作文件只能保留一份。
在动手修改之前,应先理解 MuJoCo 的对象模型:MjModel 是编译后的模型,只读;MjData 是每一步都在变化的状态;mj_step 推进一步。若需要在运行过程中观察,可使用被动 viewer——launch_passive 打开后不阻塞主循环,主循环中每步调用一次 viewer.sync()、按 timestep 睡眠一次以锁定到实时,便于人工观察;这只是调试手段,训练与自检一律使用 headless。
第一个缺口是浮动基座。URDF 中没有「在世界坐标系下自由运动的机器人」这一概念,根 link 转换后不带 joint,整机因此被固定在地面上。需要在根 body 中加入一行 <freejoint/>,加入后 nq 从 12 变为 19,多出的 7 维即位置姿态与四元数。第二个缺口是执行器,URDF 规范中本就没有 actuator,转换后 nu=0、data.ctrl 为空数组,12 个关节均无法运动。第三个缺口是传感器,IMU、关节角、触地信息均需自行补充,至少提供 gyro、accelerometer、framequat、jointpos 四类。
第四个缺口最容易让人困惑,也是「一导入就散架」的主要成因:compiler 的默认值在 URDF 输入下与手写 MJCF 相反。手写 MJCF 时 strippath 与 fusestatic 默认 false,读取 URDF 时却默认 true——前者会把 mesh 路径剥离得只剩文件名,两个目录中同名的 STL 会相互混淆;后者会把没有自由度的固定 link 融进父 body,挂在 imu_link 上的坐标系随之消失。因此 compiler 必须显式写为 strippath="false" fusestatic="false",同时把 angle 写为 radian——默认单位是度,关节限位若被当作度解释,关节一转就会撞到限位——再关闭 discardvisual 以保留外观,毫米单位导出的 CAD 网格需要加 scale="0.001 0.001 0.001",否则整机尺寸会是实际的 1000 倍。balanceinertia 建议保持 false:它为 true 时会静默配平不合法的惯量,模型能够编译,但动力学响应不再与 CAD 模型一致;编译报三角不等式错误时,应回 CAD 修正 <inertial>,仅在「先跑起来再说」时临时打开。
完成上述四项修改后,用最小脚本自检,四个数字缺一不可:
import numpy as np, mujoco |
四个数字的判读方式如下:nq=19 说明浮动基座已添加;nu=12 说明执行器已添加;stat.extent 在 0.5~1.5 米之间说明 mesh 单位正确,若毫米被当作米会直接达到 1000 量级;body_mass 求和约 12 kg 说明质量与单位没有遗漏。同时需要区分两个函数:mj_step 会执行正运动学、动力学并积分推进时间;只需按当前 qpos 计算一遍位姿而不推进时间时,应使用 mj_forward,FK 自检使用它,不应混淆。另外两个高频易错点也需一并记录:碰撞体不应使用 CAD 导出的整机网格,数万面的凸包会使仿真慢到一帧一秒,腿杆改用 capsule、脚改用 sphere 是标准做法;固定 link 被 fuse 之后,原本挂在它上面的 site 要么改为挂在根 body 上,要么通过关闭 fusestatic 保留坐标系。查找根 body 的方法较为直接——将 body 全部打印,parentid=0 且 jntnum=0 的即为被固定的根,freejoint 加在它上面;再确认 12 个 joint 名称与对照表一致。
还有一件事需要在编写代码前确定:关节名称的映射。网络模型的关节顺序通常是 FR/FL/RR/RL 乘 hip/thigh/calf,实机 SDK 的电机序号往往又是另一套,两者不一致时,仿真中看似正常的步态上机后就会表现为四条腿错乱。应先建立一张 name 到 index 的对照表,固件、仿真、Isaac 三处都引用这张表,这一步能省去后续所有「为什么左前腿接到了右后腿上」的排查时间。
两条执行器路线与抖动的成因
执行器有两种写法,对应两条路线。教学与传统控制使用 position 伺服,PD 由引擎自行计算,代码量最少,修改 XML 即可观察响应曲线;RL 与 sim2real 路线使用 motor 加外部 PD,PD 在自身的控制回路中计算。两种写法的 XML 差异直观可见:
<!-- 路线 A:position 伺服,kp 单位 N·m/rad,dampratio=1 表示临界阻尼自动配 kv --> |
motor 路线下,PD 在自身的 1 kHz 回路中计算:$\tau = kp(q_{des}-q) - kd,\dot q$,再 clip 到软限幅。后者才是与实机对齐的路线,原因在于实机主控执行的正是这一操作——宇树模组的 MIT 模式接收位置、速度、kp、kd、前馈力矩五元组,仿真中每一拍打印 $\tau$,上机后每一拍都能对应,这比「引擎内部隐式计算 PD」可控得多。Go1 级常用的起步增益为 kp 20~40 N·m/rad、kd 0.5~1.0;position 伺服的 kp 可以达到 60~100,因为它同时承担保持姿态的刚度。两套执行器不能同时存在,否则 nu 变为 24,一半是目标角、一半是力矩,排查将十分困难。
将两条路线放在一起比较,五点差别足以说明问题:
| position 伺服 | motor + 外部 PD | |
|---|---|---|
| PD 在哪算 | 引擎内部 | 你的 Python / C 代码 |
| 与实机对应 | 弱(实机是 1 kHz 外部 PD,结构不同) | 强(同 MotorCmd 五元组) |
| 代码量 | 少,改 XML 就见效 | 多,要自己搭控制回路 |
| 适合 | 传统步态、教学演示、快速验模型 | RL、sim2sim、sim2real |
| 调试 | 改 kp 看响应曲线 | 每一拍都能打印 τ |
position 写法还有三个细节:dampratio="1" 表示让引擎按临界阻尼自动配置 kv,自行编写 kv 时不应再写 dampratio,二者选其一;只要使用了 kv 或 dampratio,积分器就应换为 implicitfast,否则执行器阻尼按显式方式处理、容易发散;它还支持 inheritrange,可将关节 range 直接作为目标角范围,省去重复手写。两条路线编译后都会展开为 general 执行器,差别仅在于「由谁计算 PD」,因此不应在二者之间混用属性。另外,官方文档给出提醒:四足关节不应使用 velocity 执行器做内环——其本质是给系统增加阻尼,增益一高即不稳定。
两条路线的易错点也不应混淆。position 执行器必须写 forcerange,否则「目标角差 1 rad、kp=80」会瞬间算出 80 N·m,仿真中蹬得动、实机蹬不动,策略因此被带偏;ctrlrange 与 forcerange 一个限制控制输入、一个限制输出力,并非同一含义。力矩口径以第三篇定稿为准:执行器上限 ±23.7 N·m,控制侧软限 [12, 15, 20] N·m,依次为髋 roll、髋 pitch、膝。另有一个值得保留为习惯的小自检:给单个关节 +0.1 rad 目标,观察 qpos 增大的方向是否与实机一致,不一致则修改 URDF 的 joint axis——FK/IK 也依赖它,仅在 actuator 上翻转 gear 符号会留下「仿真对、解算错」的隐患。
将上述区别进一步说明:ctrlrange 限制的是控制输入,motor 下即为力矩上限(N·m),position 下则是目标角范围(rad);forcerange 钳制的是执行器输出力,position 下即堵转力矩上限。motor 下两者基本等价,position 下则是两个量纲完全不同的量,混写的结果是「以为限制了力矩,实际限制的是角度」。compiler 的 autolimits 能够从关节 range 推断 limited 标志,但显式写出 ctrllimited/forcelimited 始终更稳妥。
模型能够运动之后,第二个问题随之出现:站不住,高频抖动。应先记住接触的五个默认值——solref="0.02 1" 表示接触时间常数 20 ms、临界阻尼,friction="1 0.005 0.0001" 是滑动、扭转、滚动三档摩擦,condim=3 只有法向加两个切向。抖动的因果链通常如下:接触恢复使用 20 ms 的软弹簧,而位置环是 kp=80 的硬弹簧,脚陷入地面一点,位置环即大幅增加力矩,机身被弹起,脚离地,再次陷入,从而形成一个 5~20 Hz 的极限环。再加上方形脚掌四个角轮流接触、法向力在角点间跳变,condim=3 又没有扭转摩擦,脚还会绕法向自转,行走时如同踩在冰面上。
解决办法比调参更直接:脚一律换成球或胶囊,与地面写显式接触对——<pair geom1="floor" geom2="FL_foot" solref="0.005 1" friction="0.8 0.02 0.001"/>,四只脚各写一条——接触对中将 solref 收紧到 0.005 左右。经此修改,接触数从几十降到 4,步进速度常常快一个数量级,代价只是失去自碰撞,教学阶段完全可接受。若需细调,应按顺序进行:先 timestep=0.001 配 implicitfast 积分器,再换脚和接触对,再收 solref,再调 solimp,到了需要精确摩擦的斜坡场景,才切换到椭圆锥加 Newton 求解器。出现抖动时不应急于修改参数,应先查看「谁和谁在接触、陷入多深」——打印 d.ncon 与每条接触的两个 geom 名、穿透距离,站立时接触数应为 4,突然变为 8 以上说明腿或机身也在接触;穿透超过 5 mm 说明接触过软或质量偏大。另有一个能够解答多数疑问的结论:软接触模型不保证零速黏滞,脚在地面缓慢蠕变是模型设计使然,不应当作 bug 去调整摩擦。
这一顺序并非随意安排,每一步都在处理上一步遗留的问题。timestep 与积分器首先保证数值稳定:0.001 的步长下,0.02 s 的默认接触时间常数有 20 步可用于恢复穿透,足够使用;只把 timeconst 调小却不改步长,等于每步都在过冲。换脚与接触对将接触数从几十压到 4,是后续所有调参的基础——接触点一多,任何调整都会分摊到一批错误的接触上。solref 向下收敛处理的是站姿下陷,接触越硬陷入越浅;solimp 的五个数(dmin、dmax、width、midpoint、power)控制约束阻抗随穿透深度变化的软硬曲线,dmax 提到 0.99、width 调小,接触整体变硬,用于处理 solref 收紧后的残余抖动;solref 也可以写负值直接给定刚度和阻尼,两种格式不应在同一处混用。
| 顺序 | 动作 | 观察 |
|---|---|---|
| 1 | timestep=0.001、integrator=“implicitfast” | 不炸、步进耗时 |
| 2 | 脚换球/胶囊 + 显式 pair | ncon、抖动幅度 |
| 3 | solref 从 0.02 1 收到 0.005 1 | 站立 10 s 的高度波动 |
| 4 | solimp 的 dmax → 0.99、width 调小 | 残余抖动 |
| 5 | 需要精确摩擦再切椭圆锥 + impratio 调大 + Newton | 斜坡站立、打滑 |
换脚的原理需要单独说明:MuJoCo 对网格碰撞一律使用凸包近似,脚掌的真实弧面无法表达,而球与平面的碰撞有解析解,法向始终光滑;网格脚在边缘处法向突变,力矩尖峰即为抖动的直接来源。若希望连 pair 都不写,可使用 contype/conaffinity 位掩码——在 <default> 中把 geom 的 contype、conaffinity 设为 0,仅将 foot 这个 class 打开为 1,于是只有四只脚能接触物体,自碰撞也一并省去。condim 保持 3;在需要抗脚掌自转的上斜坡场景,再开 condim=4,并把 friction 第二项从默认的 0.005 提到 0.02。
反过来说,出现抖动后也不应随意修改参数,排查有固定顺序:先检查是否有关节撞到限位(jnt_limited 置位,限位软约束过硬会直接触发震颤),再看 qacc 是否存在 1e6 量级的尖峰,然后才轮到 ncon 与穿透深度那一套排查,最后才调整 solref——这一顺序能把相当一部分难以解释的抖动定位到具体对象上。
传感器这一层也一并确定。gyro 与 accelerometer 输出的是 site 坐标系下的量,不是世界系;加速度计包含重力,静止时读到 +9.81,控制器若需要纯线加速度则需自行减去重力分量。touch 输出一维标量,工程上仅用它做「触地/离地」二值判断,不把它当作精确三轴力,需要三轴力时使用 force。噪声统一在代码侧添加、量级采用实机静止数据的标准差,因为引擎自带的 noise 每次步进独立采样,既不可复现也不能按训练进度退火,只有代码侧添加的噪声才能与观测构造一同管理;MJCF 中的 cutoff 仅用于近似实机模拟电路的低通。
补充两个实操细节:代码侧噪声添加在构造观测向量的那一行,固定随机种子即可复现,也才能按训练进度做课程式退火;量级应按传感器分别取值,而非一刀切,示例为 gyro 0.005 rad/s、加速度计 0.05 m/s²、四元数 0.001,最终以实机静止 60 秒的标准差为准。MJCF 的 noise 属性是零均值高斯的标准差、默认 0,适合快速观察鲁棒性,训练与 sim2sim 不应使用。
读取传感器还有一条纪律:按名称取值、不应记数组下标。模型一旦修改,sensor 的地址和维度就可能变动,应编写一张 name 到 index 的映射表,用 sensor_adr 加 sensor_dim 切片取值,代码中不应出现裸下标,jointpos 与 jointvel 同样处理。
控制器在环:同一份代码,两种接法
仿真中最应避免的是一套控制代码用于仿真、另一套用于实机。让同一份代码在两端运行有两种接法。第一种将控制核心编成 C 共享库(.so/.dll),Python 用 ctypes 调用——ctrl_step(q, dq, q_des, kp, kd, tau, n) 一个函数进出,同进程微秒级延迟,最适合单机快速迭代,代价是数组必须是 float64、C 连续,结构体布局需与固件使用同一个头文件。第二种是主控板真机在环:主控通过 UDP 或串口收发定长帧,上行一帧可定为 seq(4B) + t(8B) + q[12] + dq[12] + tau[12] 共 300 字节,仿真进程收帧、步进、回包,适合将真实主控接入测试,代价是 0.1~1 ms 的序列化延迟需要计入控制回路。
选型时应先明确一个问题:需要运行的是同一份代码,还是同一块板子?前者使用 ctypes 即可,后者必须使用 UDP。取舍如下:
| ctypes 共享库 | UDP / 串口 | |
|---|---|---|
| 延迟 | 微秒级(同进程) | 0.1~1 ms(序列化 + 网络栈) |
| 复用 | 同一份 C 控制代码 | 同一份协议,跨语言/跨机器 |
| 调试 | 单进程,gdb 直接跟 | 两端独立,抓包方便 |
| 适合 | 单机 SIL、快速迭代 | HIL、主控真机在环 |
两种接法的数据流完全一致,时基对齐是共同的核心:
flowchart LR |
| 层 | 频率 | 每个仿真步执行 |
|---|---|---|
物理 mj_step |
1 kHz | 1 |
| 控制(PD / 步态) | 1 kHz | 1 |
| RS485 指令 | 1 kHz | 1 |
| 策略推理 | 50 Hz | 20 |
50 Hz 策略的动作需要零阶保持 20 ms,不应在仿真中逐步刷新动作,否则策略会学到「指令瞬间生效」,上机后即存在 20 ms 的相位差。实现上就是一个整除判断:
m.opt.timestep = 0.001 |
RS485 这一层在 1 kHz 下与控制同拍、每 1 ms 一帧保持 1 ms,仿真中不需要 decimation;真正需要补齐的是 1 ms 传输加驱动器处理的延迟,这属于 sim2real 桥接中动作队列的内容。仿真跑得比实时快是优点,RL 采样正依赖于此;但硬件在环必须锁定到墙钟,主控板不会等待慢放的仿真。锁钟的方法较为简单:每步之后计算仿真时间与真实时间的差,超前时通过 sleep 补偿该差值。另外,Python 主循环的抖动在 0.1~1 ms 量级,1 kHz 下已占掉一个周期,对时序敏感的实验应将控制律放入 C 共享库,先统计 1000 次循环的 p99,超过 0.5 ms 就不应在 Python 中做 1 kHz 控制。
从 sim2sim 到 sim2real:非理想因素的逐项补回
进入 sim2sim 阶段,同一份策略在两个引擎中跑出不同结果,九成原因是七项内容没有对齐:关节顺序、单位、PD 增益、初始姿态、观测归一化、动作缩放、控制频率。两个引擎的 body 与 joint 字典序几乎不可能一致,因此一切按名称取用、按名称重排,不应按数组下标——编写一个 reorder(src, names_src, names_dst) 的小函数,一行索引即可解决。观测同样需要在两边各打印一帧 47 维 obs 逐位对比,重点查看上电第一帧,gravity 与 last_action 最容易错位。整条 sim2sim 的流程如下:
flowchart TD |
| 检查项 | 要求 | 常见错法 |
|---|---|---|
| 关节顺序 | 两边都是 FR/FL/RR/RL × hip/thigh/calf | Isaac 侧按字典序,MuJoCo 侧按 XML 顺序 |
| 单位 | 米、千克、秒、弧度 | 一边用度一边用弧度 |
| 执行器模型 | 两边都是 motor + 外部 PD | MuJoCo 用 position,Isaac 用隐式执行器 |
| PD 增益 | 同一组 kp/kd,逐关节对齐 | 仿真调过、部署没同步 |
| 初始姿态 | 同一 q_default、同一站高 | 一个 0.40 m,一个 0.38 m |
| 观测归一化 | 均值/方差、裁剪范围一致 | 训练用 running mean,部署写死 0 |
| 控制频率 | 策略 50 Hz、物理 1 kHz、decimation 乘积 20 ms | 一边 50 Hz,一边 100 Hz |
判定通过的标准是同一策略运行 10 秒 trot:是否摔倒是一票否决项,其余看平均速度 ±10%、机身高度 ±2 cm、姿态 RMS ±3°、关节力矩 RMS ±15%,超出范围时按「关节顺序→单位→PD→初始姿态→观测」的顺序排查,不应一开始就调整增益。对齐也不应从 trot 开始,应先让两边静止姿态一致,再对齐单腿阶跃,然后才跑五米——一开始就跑五米,出错位置将无法判断。
为第十篇预留的接口此时就应确定,更换引擎只更换训练侧、不更换接口:观测 47 维的构成为 base_ang_vel(3) + gravity(3) + cmd(3) + q(12) + dq(12) + last_action(12) + phase(2),动作是关节位置偏移,action_scale 0.25 rad,PD 统一 kp=30、kd=0.6,力矩软限 [12, 15, 20] N·m。这些数值写入一份 policy_io.yaml,MuJoCo、Isaac、固件三处都读取它;任何一处另行复制一份,即成为下一次 sim2sim 对不上的根源。
sim2real 还需补齐四项非理想因素:执行器延迟用动作队列实现——一个先入先出的 buffer 存放前几个周期的指令,每周期 push 新值、pop 旧值,扫描 0/1/2/4 个控制周期,观察策略在哪一档开始不稳,实机的实际延迟需要落在扫描区间内才算有裕度,只测一个值等于没有测量;力矩限幅对齐实机软限,ctrlrange ±23.7 N·m、代码侧 [12, 15, 20] N·m,不存在「仿真专用」的放大;观测滤波使用与实机相同的截止频率,不应出现仿真多滤一道、实机少滤一道的情况;噪声按实测标准差注入,静止 60 秒的方差两边需要一致。归一化参数需要与权重一同迁移——只迁移 .pt 不迁移归一化,是「训练能跑、部署失效」的最主要原因。
四个补丁之外,还有三件常被问到「是否需要补齐」的事,此处一并说明:关节摩擦与死区,用 joint 的 frictionloss、damping、armature 三件套补齐,armature 代表减速箱折算惯量,量级同样采用实测值;电机带宽,用 general 执行器的 dyntype="filter" 加一阶滞后,dynprm 给定时间常数,比在指令上做延迟更接近真实电机;编码器量化,放在观测侧处理,不应写入 MJCF——传感器输出本就应在构造观测时处理。动作队列还有一个小细节:起步前用当前指令将队列填满预热,否则前几个周期没有延迟,等于补丁半路才生效。
上机前的检查表归根结底只有一条原则:仿真中的每个数字,都要能在实机上找到同一个来源。逐条核对:q_default 与实机站立姿态逐关节一致(误差 < 0.02 rad);12 个关节的 kp/kd 与实机 SDK 下发值一致;观测顺序与训练时逐位一致,尤其 gravity 与四元数不应混淆;注入 1~2 周期延迟后策略仍稳定。首次上机还需准备三样:吊架、限速 0.3 m/s、急停在手。
验收以四项收尾:模型按站姿 q=(0, 0.5284, 1.0567) 保持 10 秒,漂移小于 1 cm、高度波动小于 5 mm;随机 20 组关节角对比 FK,足端误差小于 1 mm,超出时先核对三组静态姿态再检查随机姿态;膝关节 0.2 rad 阶跃,超调小于 15%、调节时间小于 0.15 秒;再跑 trot 五米不摔,横向偏差小于 0.3 m。站立这一项不应依赖肉眼观察 viewer,应编写脚本自动判定:跑完 10 秒输出水平漂移、高度波动与机身朝向的垂直度,机身 z 轴在世界 z 上的投影低于 0.8 即视为失败。漂移一路单向增大时,先检查地面是否水平、solref 是否过软,再看摩擦;接触蠕变本身只会带来毫米级漂移,超过 1 cm 即不属于正常范围。四项全部通过后,本章交付的即是一台在仿真中可控的机器狗——一个唯一的 go1.xml、一个结构自检脚本、一个站立判定脚本、一张 name↔index 对照表和一份填写完成的 sim2sim 对比记录。下一篇将讨论策略的真正训练与部署:Isaac Lab 与 Isaac Gym 如何选择,4060 的 8 G 显存如何处理,答案在第十篇。


