第 10 章 程序化建模与 mjSpec:不写 XML 也能造模型¶
前面各章的模型均为手写的 MJCF 文本。本章讨论另一条路径:在代码中构建和修改模型。第 1 章的数据流已给出铺垫——mjSpec 是编译的输入,而且它是公开的(mjspec.h、Python mujoco.MjSpec)。本章先把这条路径走通,再补充说明几个引擎已提供、前文未展开的子系统。
1. mjSpec 的定位与边界¶
MJCF 之外还有第二条路:在代码里造对象树,再交给同一个编译器——两条路产出同一个 mjModel。
上游 overview(3.11.0)把这条流水线写成一行 (user code) → mjSpec → (MuJoCo compiler) → mjModel,并明确说:用户也可以用 mjSpec 以编程方式建模,之后存成 MJCF/MJZ 或直接编译;程序化建模与编辑另有 Model Editing 一章。
关键区分(承接第 1 章):mjSpec 是可编辑的模型规格(对象树,能 add_*、能改字段),mjModel 是编译产物(交叉索引、为运行优化的 C 结构,只读)。你编辑的一直是 mjSpec,compile() 之后才得到 mjModel。两条建模路径在同一个编译器处汇合:
flowchart LR
xml["MJCF / URDF 文本"] --> spec["mjSpec<br/>可编辑对象树"]
py["Python 代码 add_*"] --> spec
spec --> comp["compile()"]
comp --> model[("mjModel<br/>编译产物,只读")]
2. 从零建模型:MjSpec().compile()¶
不写一行 XML,用对象树拼一个带 hinge 的盒子臂(运行环境 MuJoCo 3.11):
spec = mujoco.MjSpec()
body = spec.worldbody.add_body(name="arm", pos=[0, 0, 1])
body.add_joint(name="h", type=mujoco.mjtJoint.mjJNT_HINGE, axis=[0, 1, 0])
body.add_geom(type=mujoco.mjtGeom.mjGEOM_BOX, size=[0.05, 0.05, 0.2], mass=1)
m = spec.compile() # nbody=2 njnt=1 nq=1 nv=1 ngeom=1
nbody=2 = worldbody + arm;一个 hinge ⇒ nq=nv=1。与手写 MJCF 编译出的 mjModel 完全等价——因为走的就是同一个编译器。
3. 载入既有模型再改字段¶
程序化建模的真正价值是改已有模型(批量调参、注入扰动、脚本化装配)。载入、改对象树上的一个属性、重编译,三步:
spec = mujoco.MjSpec.from_file("slide.xml") # 载入:njnt=1 nq=1 nu=0, jnt_range=[[0, 0]]
joint = spec.worldbody.first_body().first_joint()
joint.range = [-0.3, 0.3] # 改属性,不碰 XML
m = spec.compile() # jnt_range[0] == [-0.3, 0.3]
这个模型没写 limited="true",所以载入后 jnt_range 是 [[0, 0]];joint.range = [-0.3, 0.3] 再 compile(),mjModel.jnt_range[0] 立刻变成 [-0.3, 0.3]。注意 range 单位仍是弧度(第 2 章:编译器把角度类按 compiler/@angle 处理;这里是 -0.3~0.3)。
一个边界要记牢:add_* 系列的参数签名以 mujoco.MjSpec 的真实定义为准——签名不匹配会当场失败,正确形参要从 mujoco.MjSpec 的定义里查。本章未落实的 API 不写具体调用。
4. 序列化与文件形态¶
模型的"源码—归档—编译产物"各有形态,两条正交维度是源码还是编译产物、单文件还是带资源:
| 形态 | 后缀 | 是什么 | 载入入口 |
|---|---|---|---|
| 文本 MJCF | .xml |
XML 源码,资源走相对路径 | from_xml_path / MjSpec.from_file |
| 多文件组合 | 入口 .xml + <include> |
解析期原地展开,逻辑上一份模型(第 3 章) | 同上,加载入口文件即可 |
| MJZ 归档 | .mjz |
Zip 包:入口 XML + mesh/texture 等资源 | MjSpec.from_file → compile() |
| MJB | .mjb |
编译后的 mjModel 二进制 |
MjModel.from_binary_path(见下) |
| 内存规格 | — | MjSpec 对象树 |
compile() 出 mjModel |
| URDF 直读 | .urdf |
外来格式,子集语义(第 9 章) | 同 .xml |
六种形态之间的生成与读取关系:
flowchart LR
spec["mjSpec<br/>内存规格"] --> txt[".xml 文本<br/>可 diff、可回读"]
spec --> mjz[".mjz 归档<br/>自包含分发"]
spec --> comp["compile()"] --> model[("mjModel")]
model -->|"mj_saveModel"| mjb[".mjb 二进制<br/>版本绑定<br/>读回:from_binary_path"]
文本与内存规格双向可逆:to_xml() 把 spec 写回 XML 文本,from_string 再解析——一段 238 字符的模型往返后 nq/nv/nu 逐项不变(运行环境 MuJoCo 3.11)。程序化改完再落盘 diff,走的就是这条通路。
MJZ 是自包含分发形态,spec.encode("robot.mjz") 一步打包。含 <include> 的两文件模型经 encode 打包后,归档里只有展开后的单一入口 robot.xml(288 字节)——include 是解析期行为,打包前已经展开,不存在"忘了打包被包含文件"的问题;mesh/texture 等外部资源原样收入。入口文件名沿用原名,decoder 另接受 model.xml 与 <stem>/model.xml 两种布局(Model Editing 章)。读回 MjSpec.from_file("robot.mjz") 再 compile(),nbody 与原模型一致。两个边界:MjModel.from_xml_path 不认识 .mjz(按 XML 解析报错),MjSpec.from_file 才是正确入口;.zip 后缀同样走 MJZ 解码路径(解码器注册了 .mjz 与 .zip),但归档内仍须是合法的 MJZ 布局。
MJB 是编译产物快照,用上面那个 MjSpec 造的臂模型存盘,再翻一遍 API 面:
mujoco.mj_saveModel(m, "out.mjb") # 写出合法 MJB(臂模型 3325 字节、第 2 章那台 8616 字节)
m2 = mujoco.MjModel.from_binary_path("out.mjb") # 读回:nbody/njnt/ngeom/nu/nsensordata 全等
[x for x in dir(mujoco.MjModel) if x.startswith("from_")]
## → ['from_binary_path', 'from_xml_path', 'from_xml_string']
mujoco.MjModel.from_xml_path("out.mjb") # 走 XML 入口读 MJB 会失败:它只认文本
⇒ MJB 是读写双向的:写出用 mj_saveModel,读回用 MjModel.from_binary_path(from_binary_path 一直在 MjModel 上,只是不在 dir(mujoco) 顶层函数里——按 mj_load* 去顶层筛当然找不到)。两条入口不能混用:from_xml_path 读 MJB 会失败,而 from_binary_path 也不需要解析与编译,直接拿到 mjModel。
报错分期(承接第 1 章数据流):XML 语法错误在解析期抛出;名字冲突、惯量非法在 compile() 期抛出;接触穿透、抖动要等运行期才显现——按报错阶段定位问题,排查半径小一个量级。
坑:MJB 与 MJZ 的入口不能混用
.mjz 走 MjSpec.from_file 再 compile(),MjModel.from_xml_path 不认;.mjb 走 MjModel.from_binary_path,它同样不经解析与编译。两条入口互换会直接报错。
5. 未展开的子系统入口¶
MuJoCo 是一整套引擎,前面各章只覆盖了刚体建模/运行时/接触/传感器这条主线。上游文档(google-deepmind/mujoco,3.11.0)里还有几块分量足、前文没讲的,先列出入口与"字段确实存在"的证据:
| 子系统 | 文档出处 / 模型 | 一句话 | 依据 |
|---|---|---|---|
| 流体受力 | doc/computation/fluid.rst("Fluid forces") |
椭球等 geom 可受升力/阻力/粘滞 | geom_fluid 形状 (1, 12)(每 geom 12 个系数);fluidshape="ellipsoid" 编译通过 |
| 软体 / flex | doc/modeling.rst(flex 30 处)、model/flex/、plugin/elasticity/ |
质点网格、可变形体、软约束 | 已加载 jelly(nv=1536)/flag/pancake;flexcomp 网格布 10×10 → nv=297 |
| mocap(动捕体) | XMLreference(mocap 9 处) |
无关节、位姿被外部直接驱动的 body | nmocap=1、mocap_pos (1, 3)、mocap_quat (1, 4) |
| OpenUSD 导入/导出 | doc/OpenUSD/(import/export + mjcPhysics schema) |
MuJoCo 与 USD 双向 | 未展开(第 1 章只把它当外部生态对比);反向(USD→MJCF)由 NVIDIA 的 mujoco-usd-converter 一类外部工具承担 |
| Unity 插件 | doc/unity.rst |
Unity 引擎内嵌 MuJoCo 仿真 | 未展开:需 Unity 编辑器,标外部证据 |
| 插件体系 | doc/programming/extension.rst、plugin/ |
引擎插件:actuator/sensor/SDF/编解码/资源 | 已端到端:pip 包预注册 mujoco.pid,<extension> 声明 + actdim 即用 |
| MJZ 归档 / MJB | overview.rst 数据流、Model Editing 章 |
打包/二进制序列化 | 见「序列化与文件形态」一节 |
MJZ/MJB/插件/flex 已展开,其余主题各自成章。本章对未展开项只给“功能存在 + 入口”,具体数值与行为(如椭球升力系数的取值、flex 接触的计算方式)属待核实。
几条入口级的事实:
- flex/软体:
model/flex/下 32 份模型可直接载入并推进。网格规模与自由度对照:basket.xml是 260 顶点 / 490 单元 / 749 边、nq=1095 nv=1050(2 维壳单元,200 步后ncon=78);bunny.xml2503 顶点 / 4968 单元,但nq=nv=25(刚体挂载,网格只是几何);bunny_multicell.xml同一网格给到nq=nv=178,bunny_quadratic.xml82,bunny_shell.xml169——同一个.msh换一种<flexcomp>声明,自由度数相差 7 倍,这组数字是软体代价的最直接读数。 - composite:安装库自带
model/hammock/hammock.xml(吊床,nbody=112、nv=312、neq=1)加载即用——composite 系列以安装库里的模型为入口即可,不必手写。 - 插件端到端:
mujoco.pid已由 pip 包预注册——<extension>里声明实例(<config key="kp" …/>),执行器写<plugin joint="j" plugin="mujoco.pid" instance="pid" actdim="1"/>即可用;actdim随配置变化:带slewmax时内部状态 2 个,只有积分时 1 个,写错不猜,报错信息会提示期望值。imax按力限幅(i_max = imax/i_gain,源码 pid.cc)——竖直 slide(重力 9.81 N)上imax=1时积分项最多出 1 N,稳态误差 = (9.81−1)/kp = 0.2203;imax=15/25后静差清零。自编译配方:g++ 编plugin/actuator/pid.cc + register.cc,链 pip wheel 自带的libmujoco.so.3.11.0(用-l:libmujoco.so.3.11.0且加-Wl,-rpath,否则 dlopen 报“cannot open”实为依赖找不到),产物可被mj_loadPluginLibrary加载(需绝对路径);重复注册内置插件会被拒,配方用于非内置插件或无 pip 环境。 - 插件传感器端到端:安装库自带
model/plugin/sensor/touch_grid.xml(触觉网格)加载即用——nplugin=1、sensor_plugin[0]指向插件实例,1000 步压触后 dim=147 的 taxel 图像中 24 个非零、读数绝对值总和 0.9967(与压在其上的质量平衡)。 - fluid 的触发条件:气动力开关不在
<flag>,而在option/@density——它默认为 0,此时fluidcoef写什么都不会有气动力。给density=1.2并加wind="5 0 0"后,一颗椭球在 300 步内被吹到qvel=[2.825711, 0, 0]、位移 5.404262 m;无风时保持 0,density=0.001时末速度缩到 0.005394,wind=30时升到 26.603557。两点仍未定:mjModel.geom_fluid(12 槽)在density=1.2下仍全为零,说明它不再是这套系数的载体;而五个fluidcoef各自置 1 在该构型下末速度完全相同(都是 2.825711),要区分钝体/细长体/角阻力需要更长的仿真或带转动的构型。黏滞维度另补一组水介质对照(density=1000、viscosity=0.001,fluid.rst开头要求两者同时为正):inertia 模型把落体末速压到 −0.452 m/s、ellipsoid 模型 −0.395 m/s(真空 −19.62),两套模型的差别在此可见。
6. 要点¶
mjSpec是可编辑的模型规格、mjModel是编译产物;compile()是两者的边界(承接第 1 章数据流)。- 纯 Python 能建模型(
add_body/joint/geom→compile)也能载入既有模型改字段再 recompile——程序化建模的重点是"改"。 - 序列化三形态各司其职:文本 MJCF 可 diff、MJZ 自包含分发(读回走
MjSpec.from_file,from_xml_path不认)、MJB 快但版本绑定;MJB 的读回入口是MjModel.from_binary_path。 - fluid / flex / mocap / OpenUSD 是引擎有、本章未展开的子系统;MJZ/MJB 与插件体系已在前文展开。入口与字段级证据已给出,数值行为属待核实。
自己动手看到的对照:dir(mujoco) 顶层确实没有 mj_loadModel 这类函数,入口挂在类上——mujoco.MjModel.from_binary_path。按顶层函数名去找 MJB 载入器会得出反向结论。