跳转至

第 3 章 模型复用与物理属性

本章处理的工程问题十分具体:同一个夹爪需要安装到左臂与右臂。MuJoCo 为此提供了三种复用手段——<include>(解析期拼 XML)、<replicate>(编译期复制运动树)、<attach>(编译期装配子模型)——三者的命名规则、位置限制和适用场景各不相同。本章先厘清各自的边界,再回到动力学的基础:质量与惯量从哪来。

三种手段的作用点与命名空间处理如下,选择依据是“展开在哪个阶段、名字谁来管”:

flowchart LR
    inc["include<br/>解析期:文本级拼接<br/>同一片段仅能包含一次"] --> model[("同一个 mjModel")]
    rep["replicate<br/>编译期:复制运动树<br/>名字加 sep + 序号后缀"] --> model
    att["attach<br/>编译期:装配 asset 子模型<br/>prefix 必填,可装多份"] --> model

1. include 的位置与重复限制

1.1 include 的位置与片段内容

XML reference 对 include 只给了两条规则:可以用在 MJCF 里任何需要元素的地方,允许嵌套包含,但同一个 XML 文件在整个模型中最多只能被包含一次;被包含的文件必须有单一顶层元素,解析器会把这个顶层元素拆掉,只把里面的内容插到 include 所在位置。

即位置自由、内容受限。片段顶层的元素会被移除,其余内容原样插入 <include> 所处位置,因此片段内容必须与插入点匹配:

<!-- frag_body.xml:单一顶层元素 <mujoco>,内含一个 body;
     joint/geom/site 各一个,正好代表“一段可复用的子树” -->
<mujoco model="frag_body">
  <body name="frag_b" pos="0 0 0.5">
    <joint name="frag_j" type="slide"/>
    <geom name="frag_g" type="sphere" size="0.01" mass="0.1"/>
    <site name="frag_s" pos="0 0 0.02"/>
  </body>
</mujoco>

这段片段既可以放进 <worldbody>,也可以放进某个 <body> 之内,两种写法载入后都能拿到 frag_b/frag_j/frag_g/frag_s 这一整套名字。片段的根元素写成 <mujocoinclude> 同样可以(MuJoCo 专门为片段提供的根元素)。

失败的情形是内容与插入点不匹配。将带 <worldbody> 的片段置于 <worldbody> 之内,载入会被拒绝:失败信息指出该元素在此处不允许出现,而非位置有误,因此排查应针对片段的根元素与插入点的层级关系。

反之,并列多个 <worldbody> 是合法的——modeling 章明确写着"我们允许运动树具有多个根节点(即多个 worldbody 元素),解析器会自动合并它们",两个并列 worldbody 编译通过、得到 ['world', 'a', 'b']。同一段片段里放 <compiler> 再插进 <worldbody>,报错与上面同形(Element 'compiler'):配置类元素只能待在 <mujoco> 顶层。

那条"<include> 只能是 <mujoco> 的直接子元素"的流行说法,正是把"片段自带 worldbody"误读成了"位置受限"。

1.2 重复 include 限制与镜像装配

"最多包含一次"是硬限制:同一个片段被 include 两次,载入会以"这个文件已经包含过了"为由失败。

于是"同一个夹爪装两条臂"不能靠两次 include 解决。三条出路,按推荐顺序:

  1. <attach>——把夹爪做成子模型,在 <asset> 里登记一次,之后想装几份装几份,名字冲突由 prefix 自动处理。这是文档推荐的路,也确实走得通(第 4 节)。
  2. <replicate> 包 <include>——可行(第 3 节的用法即此形式),但一份片段只能被一个 replicate 块吃掉,左右镜像得靠 euler 旋转增量表达。
  3. 复制成两份片段文件(右/左各一份)——最笨,名字冲突全自己处理。

2. replicate 的后缀命名规则

XML reference 对 replicate 给了两条命名规则:它复制包裹的运动学子树并给每个元素加命名空间后缀,后缀是 [0...count-1] 内的整数、位数取"能表示总数所需的最小值"(复制 200 次就是 000, 001, …);sep 是命名空间分隔符,预先追加在后缀之前(XML reference)。

<!-- 一份定义复制三份:offset 是每份相对上一份的平移,sep 是名字里的分隔符 -->
<replicate count="3" offset="0.1 0 0" sep="-">
  <body name="Gripper"><joint name="j" type="slide" range="0 0.046"/>   <!-- 复制体共用这一份关节定义 -->
    <geom name="g" type="box" size="0.01 0.01 0.01" mass="0.05"/>       <!-- mass 写在 geom 上,三份各自带 0.05 -->
    <site name="s" pos="0 0 0.02"/></body>                              <!-- site 也参与后缀命名 -->
</replicate>

编译后 body、joint、geom、site 四类名字都被加了后缀:

sep count 得到的名字
"-" 3 Gripper-0 Gripper-1 Gripper-2(joint j-0…、geom g-0…、site s-0…)
未写 2 Gripper0 Gripper1——sep 有默认值(空串),后缀直接拼上去
"/" 2 Gripper/0 Gripper/1——名字/0 这种形式要靠显式 sep="/",不是版本差异

位数按 count 定:count="12" 时 joint 是 pj00…pj11、geom 是 pg00…pg11。这直接决定下游引用能不能省零——按一位数引用会当场报错:

<actuator><position name="a" joint="pj0"/></actuator>     <!-- 少写一位:真实关节名是 pj00 -->

<replicate> 里放 <include> 也是合法的。下面两份模型编译出的 geom 名字完全相同(['fg0', 'fg1']),说明 replicate 是先把 include 展开再复制,两者不冲突:

<!-- replicate 包 include:片段本身仍是一次包含,由复制机制展开成两份 -->
<replicate count="2" offset="0.1 0 0"><include file="frag_sphere.xml"/></replicate>

最后一点:euler 是加在每一份上的增量旋转,且受 compiler/@angle 约束(第 2 章)。

3. attach 与 asset 子模型声明

modeling 章的建议很明确:"优先使用 attach 而不是 include……在适用情况下,建议改用 attach 元素。"

但 attach 的 model 属性不是文件路径,而是 <asset> 里登记过的子模型名。缺少这一步时载入必然失败——无论写绝对路径、./ 前缀还是省略扩展名结果相同,因为引擎并不按文件路径解析该属性。XML reference 的规定是三句:想从另一个模型导入,子模型必须先定义为 asset;model 指的是那个子模型(省略时变成在当前模型内自附着的 self-attachment);prefix 必填,加在子模型所有元素名之前,专门用来避免与父模型撞名、以及多次附着同一子树。

补上 <asset><model>,同一个夹爪装两份就成了:

<mujoco>
  <asset><model name="arm" file="frag_arm.xml"/></asset>   <!-- 先登记子模型 -->
  <worldbody>
    <body name="host" pos="0.2 0 0">
      <geom name="host_g" type="sphere" size="0.02" mass="0.5"/>
      <frame name="L" pos="0 0 0.3"><attach model="arm" body="link" prefix="L_"/></frame>
      <frame name="R" pos="0 0 -0.3" euler="0 0 180"><attach model="arm" body="link" prefix="R_"/></frame>
    </body>
  </worldbody>
  <sensor><framepos name="fp" objtype="body" objname="L_link"/></sensor>
  <actuator><position name="p" joint="L_lj"/></actuator>
</mujoco>

编译结果:body ['world', 'host', 'L_link', 'R_link']、joint ['L_lj', 'R_lj']、geom ['host_g', 'L_lg', 'R_lg']、site ['L_tip', 'R_tip'],nq=2。传感器和执行器直接按带前缀的名字引用即可。子模型本身写起来就是一个普通 MJCF(frag_arm.xml 里 <worldbody> 下那个 link body),它单独也能载入仿真——这正是资产包该有的样子。

attach 认识四个属性:model、body、frame、prefix。写别的会被 schema 拒绝,包括 file、component、child、parentbody,也包括位姿类的 pos/quat/euler。三条由此而来的守则:

  1. 装在哪里由父 body 或包着它的 <frame> 决定——上面左右两份的位姿差就是用 <frame> 写的(<frame> 编译期消失,偏移累加到它的直接子元素上)。
  2. prefix 是必填项,不写就无法载入。
  3. 省掉 model 就是 self-attach:<attach body="arm" prefix="copy_"/> 把当前模型里的子树再复制一份,载入后得到 body ['world', 'base', 'arm', 'copy_arm']、joint ['aj', 'copy_aj']。

反之,将资产包中自带 <freejoint> 的子树 attach 到机器人上会被拒绝——freejoint 只能在世界刚体的直接子 body 上(第 4 章):

报错用的是 attach 之后带前缀的 body 名。子树里那个根关节得删掉;若删不掉(子树里另有其他关节),报错会变成 more than 6 dofs in body 'L_link'。

坑:attach 的 model 不是文件路径

子模型必须先在 <asset><model name=… file=…/></asset> 里登记,attach model= 指的是那个名字;漏写这一步时写绝对路径、./ 前缀或省略扩展名都会以同一种方式失败。

4. mesh 路径解析与质量推断

compiler/meshdir 的三条规则:"绝对文件名直接用;meshdir 为绝对路径则=meshdir+文件名;否则=主 MJCF 文件路径+meshdir+文件名。" 派生出来的示例或测试文件必须和 meshdir 保持同级关系。找不到文件时报错会把解析后的路径原样打出来,可直接当诊断信息:

<compiler meshdir="sub"/>                       <!-- 基准目录:写在 compiler,不在 asset -->
<asset><mesh name="m" file="no_such.stl"/></asset>  <!-- file 相对 meshdir 解析,最终找的是 sub/no_such.stl -->

质量与惯量是动力学的根。有个常见误解是把 mesh geom 的质量也按凸包算(于是非凸件会被高估、互为镜像的两份网格算出不同质量)。实际上引擎把两条通路分开 (XML reference 的 mesh 一节):

  • 碰撞检测用凸包与凸分解(mesh_graph),所以凹件在碰撞侧确实会"被填满";
  • 质量与惯量走 union-of-pyramids——把每个多边形面与其顶点连成棱锥、按有向体积求并, 再解特征值得到主惯量轴。它是按网格实际体积算的,不走凸包。

用一个严格三角形化的 L 形凹件来看(12 顶点、20 个三角面、水密、法向朝外)。 截面是 0.2×0.2 m 的正方形挖掉 0.1×0.1 m 的一角,挤出 0.1 m,密度取默认 1000:

参照 质量
解析真值(凹件实际体积 0.003 m³) 3.000000 kg
凸包体积(0.0035 m³) 3.500000 kg
引擎给出的 body_mass 3.000000 kg

引擎与真值差 0.000%,与凸包差 14.286%——质量走的是实际体积。镜像件(顶点 x 取反) 无论是否相应反转绕组,质量都仍是 3.000000 kg,相对差 0.00%——质量与镜像无关。

那 convex-hull 的说法从哪来的?碰撞侧确实是凸包,两者容易混。真正会让质量出偏差的是面本身不平面: union-of-pyramids 假定每个面是平面多边形,把上例顶面一个顶点抬离平面 0.02 m 后, 质量立刻变成 3.033333 kg(偏高 1.11%)。此前那份 12 顶点、6 行 f 的 L 形 OBJ 正踩在这上面—— 它的第 4 个面 f 2 1 6 5 10 9 8 3 一行给了 8 个顶点且不在同一平面上,算出的 0.242652 kg 既不是真值 0.2560 kg 也不是凸包值 0.3280 kg(差 −5.2% / −26.0%),偏差来自非平面多边形而非凸包。

⇒ 由此得到两条可操作的规则:网格导出时务必三角形化(或至少保证每个面共面), 以及不可用凸包去预测 mesh 件的质量;把凹件当凸件用的改动应落在碰撞侧(凸包/凸分解), 质量那一侧引擎已经算对了。

可以确定的是负质量会被拒绝:mass="-0.5" 无法通过编译。

5. 惯量的两条来源路径

<inertial> 的语义有三层(XML reference):刚体里没有它,惯性特性才从挂在该刚体上的 geom 推导出来;一旦写了它,惯性坐标系的中心就是刚体质心、坐标轴就是刚体的主惯性轴;mass 必填且不允许为负,因为广义坐标下的惯性矩阵必须正定;pos 也必填——理由是"inertial 元素本身的存在就会禁用自动推导机制",既然接管了,质心位置就必须由你给出。

写了 <inertial> 就完全接管惯量,而 pos 同样是必填项——只写 mass 和 diaginertia 会因为缺属性而编译不过,补上 pos="0 0 0" 才通过。

fullinertia 六个数的顺序是 M(1,1), M(2,2), M(3,3), M(1,2), M(1,3), M(2,3)。……如果遇到非正特征值(即如果 M 不是正定矩阵),将产生编译错误。" 这条正定检查只验符号不验量级,两者都值得记住:

<inertial mass="1" pos="0 0 0" fullinertia="1 1 1 2 0 0"/>   <!-- M₁₂=2 ⇒ 特征值 3 与 −1 -->

而 fullinertia="1 1 0.0001 0 0 0" 三个值均为正、只是极小,编译通过(body_inertia=[1.0, 1.0, 0.0001])。不要将"数值很小"等同于"会失败"——它会在仿真里以另一种方式发作(第 6 章)。

6. mjMINVAL 的触发条件

"设了 inertiafromgeom="false" 之后每个运动 body 必须自带 <inertial>"这句话成立,但理由不是"缺 inertial",而是"这个 body 会动,又没有任何惯量来源"。两种开关各自的作用范围,看五行对照:

模型写法 结果
auto(默认)+ 运动 body,geom mass="0" 报 mjMINVAL
inertiafromgeom="false" + 运动 body,geom 带 mass="1" 仍报 mjMINVAL——false 关掉的是整条推断路,geom 上的 mass 不参与
inertiafromgeom="true" + 同上 通过,body_mass=[0.0, 1.0]
inertiafromgeom="false" + 静态 body(无关节) 通过,body_mass=[0.0, 0.0]——这个检查只管会动的 body
inertiafromgeom="false" + 运动 body + <inertial> 通过,body_mass=[0.0, 1.0]

⇒ 规则得写成条件式,否则会误导人给静态壳也补 <inertial>。

compiler 上的两个下限属性可以避免这类失败,但仅设置 boundmass 无效:只给 boundmass="0.001" 时仍会触发 mjMINVAL(被拦截的是惯量),必须将 boundmass 与 boundinertia 一并设置(都为 "0.001" 时得到 body_mass=[0.0, 0.001]、body_inertia=[0.001, 0.001, 0.001])。

工程含义:把下限当补丁用是危险的——它会静默造出一个 1 g 的假零件。稳妥做法是朝向壳就设成零 DOF 的静态 body 或直接去掉(第 4 章),而不是给它编一个下限。

一条容易忽略的 schema 事实:mass 不是 <body> 的属性,写在 body 上会因为属性名无法识别而拒绝载入(mass 属于 <geom> 与 <inertial>),它属于 <inertial> 或 <geom>。

7. 惯量的分组过滤与网格积分

7.1 inertiagrouprange 分组过滤

哪些 geom 参与推断由分组决定。compiler 的定义:inertiafromgeom : [false, true, auto], "auto","默认设置 'auto' 意味着仅当刚体定义中缺少 inertial 元素时,才会自动推断质量和惯量";分组开关是 inertiagrouprange : int(2), "0 5"——默认包含全部六组(与第 7 章"可视化默认只显示 0/1/2"是两套开关,别互相推)。

<compiler inertiagrouprange="0 2"/>
...
<geom name="g" type="sphere" size="0.05" mass="1" group="5"/>   <!-- 落在范围外 -->

默认 "0 5" 时这段编译通过,body_mass=[0.0, 1.0]、body_inertia=[0.001, 0.001, 0.001]——sphere r=0.05、m=1 按 2/5·m·r² 手算正是 0.001,说明推断路正常。改成 "0 2" 后这只 geom 不再参与,运动 body 失去惯量来源,报 mjMINVAL。

结论:给参考几何(只用来对齐、不参与物理)单独设一个落在 inertiagrouprange 之外的 group 是干净做法,但它同时拿走了质量来源,得保证 body 另有 <inertial>。

7.2 从网格积分求惯量

编译器能从 geom 推断,也能从网格算,但这两条路给的都是"按密度均匀"的结果。要精确惯量,正路是自己积分再写进 <inertial>(第 7 章给代码):读网格 → 按三角面做体积分(质量、质心、惯性张量)→ 主轴分解(特征值与特征向量)→ 写 mass/pos/diaginertia 加 body 的朝向。

关键在于特征向量决定主轴坐标系:diaginertia 与主轴朝向必须成对写,否则正定检查能过、转轴方向却是错的。第 4 章的"转身不变量"正是用来抓这类错判据。

8. 要点

  • include 的位置自由,内容必须匹配插入点;一个文件全模型最多含一次。
  • 并列多个 worldbody 合法(解析器合并),嵌套 worldbody 不合法——这是那条错误结论的真正来源。
  • replicate 后缀 = sep + 按 count 定宽度的整数;sep 默认为空,名字/0 要显式 sep="/";下游引用不能按"一位数字"硬编码。
  • replicate + include 可用;attach 也可用,前提是把子模型先声明成 <asset><model>,位姿由外层 <frame>/父 body 给,prefix 必填。
  • mesh 路径以主文件目录为默认基准,meshdir 改写这个基准;asset 节本身不参与路径解析。
  • 惯量来源只有两条:<inertial>(写了就独占,pos 必填)或 geom 推断(受 inertiafromgeom 与 inertiagrouprange 双重约束)。
  • mjMINVAL 的真实条件是"会动 + 无惯量来源";inertiafromgeom="false" 关掉整条 geom 推断路,静态 body 不受该检查约束。
  • 正定检查只看特征值符号不看量级;小惯量能过编译,仿真里照样不稳定(第 6 章)。
  • boundmass/boundinertia 是安全网不是设计手段,必须成对给,且会静默造出假零件。
  • mesh 的质量按网格实际体积算(union-of-pyramids),不按凸包:严格三角形化的 L 形凹件为 3.000000 kg,与解析真值分毫不差,而凸包值应为 3.500000 kg;镜像件差 0.00%。凸包只在碰撞侧起作用。真正会引入误差的是非平面的多边形面(抬高一角即偏 1.11%)——导出时务必三角形化。

参考文献与延伸阅读

  • Todorov, E., Erez, T., & Tassa, Y. MuJoCo: A Physics Engine for Model-Based Control. IROS, 2012. DOI
  • MuJoCo. 文档入口(compiler 与 mesh 属性). 文档
  • MuJoCo. 源码仓库. 仓库