第 2 章 MJCF 建模基础:第一个模型与 compiler 约定¶
本章从零构建第一个 MJCF 模型:先界定 MJCF 与 URDF、USD 的分工,再写出一个带关节限位与执行器的小型机械臂,最后从编译产物中读取三项直接可用的结论——单位由谁决定、限位如何生效、命令为何未到位。
1. MJCF 与 URDF、USD 的分工¶
MJCF(MuJoCo Control File)是 MuJoCo 的原生建模格式。第 1 章已给三格式立像:URDF 是"机器人简历",USD 是"整个场景的剧本";这里补 MJCF 的位置——它描述"物理上成立的结构":惯量、限位、执行器环路、约束柔度都能在一份文件里写完,不需要外挂控制器脚本。主要差异归纳如下:
| URDF | MJCF | USD | |
|---|---|---|---|
| 拓扑 | 强制树 | 运动树 + tendon/equality 可表闭环 | DAG,可含闭环 |
| 单位 | 无强制约定 | 不指定单位制,自洽即可(默认值 MKS 风格) | UsdPhysics 米/千克/秒 |
| 执行器 | 无(<transmission> 插件) |
内置 <actuator> |
UsdPhysics DriveAPI |
| 朝向 | rpy 欧拉 |
quat (w,x,y,z) |
quat (w,x,y,z) |
转换以 URDF 为枢纽。URDF→MJCF 由引擎直读完成:from_xml_path 吃 .urdf,mj_saveLastXML 存出的就是 MJCF,往返保真(第 1 章「URDF → MJCF 往返实操」,逐条映射规则见第 9 章「URDF 的直接载入」);但三个默认值与 MJCF 相反——恒按弧度(compiler/@angle 对 URDF 不生效)、discardvisual=true(<visual> 几何丢弃)、fusestatic=true(fixed 关节融合、虚拟刚体塌缩),且 <transmission> 不会翻成执行器(nu=0),控制接入前必须补 <actuator>。
MJCF→URDF 引擎无内置导出器:执行器、default、接触语义在 URDF 侧没有对应槽位,第三方转换必有损。MJCF↔USD 是 3.11 起的实验性通路:导入在 <asset> 里写 <model file="x.usdz" content_type="text/usd"/>,导出走 Python 侧的 USDExporter,文档另推荐 Newton 的 mujoco-usd-converter 生成忠实副本,另有把 MJCF 当 USD layer 打开的文件格式插件;通用物理用标准 UsdPhysics,MuJoCo 特有语义靠 mjcPhysics 扩展 schema 承载(OpenUSD 章)。
文件结构本身相当宽松(XML reference):MJCF 只有一个顶层元素 <mujoco>,它的下一层元素叫节(section);节都是可选的,而且可以重复出现——这个可重复性正是 include 合并模型的入口(第 3 章)。<mujoco> 下常见的节有 option、compiler、size、statistic、asset、worldbody、tendon、actuator、sensor、equality、contact、visual、default、keyframe、extension,另有三个元元素 include/frame/replicate(composite/flexcomp/attach 也在 schema 之内)。
2. 顶层节的顺序规则¶
XML reference 的原话是:一个元素内属性的顺序任意,子元素的顺序也任意,只有四个例外。机制在编译器一侧——元素按名字收进 mjSpec,属性是与元素绑定的键值对,先后写入同一份规格,多数时候谁先写不影响结果;顺序只在复合、包绕、覆盖、继承这四种语义上才携带信息。节本身可以重复出现,重复正是 include 合并多个模型的入口(第 3 章「include 的位置与片段内容」)。
同一个 <body> 内的多个 <joint>——关节变换按书写顺序依次复合,两个不同轴的 hinge 换位置,同一组 (q1, q2) 就落到不同位姿;qpos 与 qvel 的分量顺序也按这个次序排,换顺序等于给控制器换了一套索引。
<!-- 两行的先后决定 (qpos[0], qpos[1]) 的含义:先绕 x,再绕 y -->
<body name="wrist" pos="0 0 -0.2">
<joint name="rx" type="hinge" axis="1 0 0"/>
<joint name="ry" type="hinge" axis="0 1 0"/>
<geom type="capsule" size="0.02 0.05"/>
</body>
互换这两行后模型照常编译、nq 仍是 2,但 qpos[0] 的含义从 x 轴角变成 y 轴角。
spatial 腱内的元素——顺序决定腱依次穿过或包绕哪些对象,也就是路径本身;换顺序就是换一条腱(「约束与接触:equality、tendon、contact」)。
重复节内的同名属性——同一属性被设置多次时,最后一次对整个模型生效。分层配置正是靠它:基础文件写一份 <option>,场景文件再写一份覆盖同名项,两份合起来才是一台机器的参数。
<!-- 两个 option 节不是二选一:同名属性以后写为准 -->
<option timestep="0.001"/> <!-- 基础:1 ms -->
<option timestep="0.0005"/> <!-- 覆盖:最终 0.5 ms -->
同一 default class 内的多个执行器快捷方式——一个 class 只有一个执行器默认,每个快捷方式都会重置 gain、bias 与动力学参数(modeling 章 Shortcuts 一节),后写的那个决定这个 class 的执行器默认。
<default class="drive">
<position kp="20"/> <!-- 这一行的 kp 会被下一行重置 -->
<motor/> <!-- class 的执行器默认因此是裸 motor -->
</default>
两种驱动行为都要用时,写两个 <actuator> 各取一组参数,而不是在一个 class 里叠两个快捷方式。
body 的声明顺序不在那四个例外里,却同样影响结果——不动物理,只动求解器速度:同时接触许多物体的 hub(托盘、平台、被推着走的容器)应写在它所托物体之前,稀疏分解从叶子向根消元、填充为零,耗时随物体数线性增长;写在最后则它先被消去,把 N 个物体耦合成一个稠密块,耗时按 N³ 涨(modeling 章)。直接挂在 worldbody 或 weld 体上的静态 geom 没有自由度,不受影响;含 flex 的模型由稀疏 Newton 自动做最小度重排。
顺序当契约的四处:关节次序(qpos/qvel 布局)、spatial 腱路径、重复节的覆盖关系、class 内的执行器默认。前两处改顺序等于换机器,后两处改顺序等于换配置;依赖覆盖关系时把覆盖段放在最后、注释标明出处,include 展开后按文本顺序生效。
3. 第一个模型¶
模型文件是 assets/models/01_pendulum.xml——一段两关节臂加一个棱动夹爪,覆盖了后面各章都要复用的构件。骨架如下:
<mujoco model="01_pendulum">
<!-- 运行时全局参数:2 ms 步长 + 隐式_fast 积分器(第 5、6 章都在这台上做实验) -->
<option timestep="0.002" integrator="implicitfast"/>
<!-- 默认值:只给 geom 上色、给 joint 加一点阻尼与臂惯量,
后面的 body 不必重复写;这是 MJCF 相对 URDF 最省笔的地方 -->
<default>
<geom rgba="0.7 0.7 0.75 1"/>
<joint damping="0.02" armature="0.01"/>
</default>
<worldbody>
<light pos="0 0 3" dir="0 0 -1"/>
<!-- 地板:plane 的 size 前两项是 xy 方向半宽,0 表示无限延伸 -->
<geom name="floor" type="plane" size="0 0 0.05" pos="0 0 -1.2"/>
<body name="upper_arm" pos="0 0 0">
<!-- range 用度写;limited="auto" 让它自动生效(见本节末的对照) -->
<joint name="shoulder" type="hinge" axis="0 1 0" range="-90 90" limited="auto"/>
<geom name="upper_arm_cap" type="capsule" size="0.03 0.2" pos="0 0 -0.2" mass="1.2"/>
<site name="elbow" pos="0 0 -0.4"/> <!-- 肘部参考点,供坐标对照用 -->
<body name="forearm" pos="0 0 -0.4">
<!-- pos 是相对父 body 的,不是世界坐标:小臂挂在肘下 -->
<joint name="elbow_hinge" type="hinge" axis="0 1 0" range="-120 20" limited="auto"/>
<geom name="forearm_cap" type="capsule" size="0.025 0.18" pos="0 0 -0.18" mass="0.8"/>
<body name="slider" pos="0 0 -0.36">
<!-- 夹爪沿 z 平移:这个 joint 是 slide,range 的单位是米而非度 -->
<joint name="prize" type="slide" axis="0 0 1" range="0 0.046" limited="auto"/>
<geom name="gripper" type="box" size="0.03 0.04 0.02" pos="0 0 -0.02" mass="0.3"/>
<site name="tcp" pos="0 0 -0.05"/> <!-- 工具中心点,下面所有观测都读它 -->
</body>
</body>
</body>
</worldbody>
<!-- 执行器:两路 position(纯比例,kp 决定 stiffness)+ 一路 motor(直接给关节力矩)。
URDF 里没有对应物,必须显式写出来,否则 nu=0、data.ctrl 是空数组 -->
<actuator>
<position name="shoulder_pos" joint="shoulder" kp="50" ctrlrange="-1.6 1.6"/>
<position name="elbow_pos" joint="elbow_hinge" kp="40" ctrlrange="-2.1 0.3"/>
<motor name="prize_motor" joint="prize" ctrlrange="-20 20"/>
</actuator>
<!-- 唯一一只传感器:framepos 读 tcp 的全局坐标,第 8 章的总账从这 3 个数开始 -->
<sensor><framepos name="tcp_pos" objtype="site" objname="tcp"/></sensor>
</mujoco>
载入后的计数是 nbody=4 njnt=3 ngeom=4 nsite=2 nq=3 nv=3 nu=3,三个关节的 jnt_range 存成 [-1.5708, 1.5708]、[-2.0944, 0.3491]、[0, 0.046],limited 全为 True。从这里能立刻读出三件事:
-
XML 里写
-90 90,模型里存-1.5708 1.5708。 编译器会把度换成弧度,角度在mjModel里永远是弧度(XML reference 的compiler/angle)。至于长度与质量,overview 明说 MuJoCo 不指定基本物理单位——只要自洽,厘米-克-秒制照样成立;默认值里只有两处按 MKS 给(gravity=-9.81、density=1000),全书的米与千克是约定不是强制。这一条会在「compiler的angle」一节放大成一个大坑。 -
棱动关节的 range 单位是米(
0.046进模型还是0.046)。同一列数字里混着弧度和米,程序里绝不能对jnt_range做统一换算——把第三行当成度去乘 180/π,会得到 2.636 这个毫无意义的数。 limited="auto"配range就自动生效。 文档只在一个方向上给出约束:若将compiler的autolimits设为false,则写了range而不写limited会直接导致失败。默认autolimits为 true,所以现代写法只需要range。
跑 2000 步(ctrl = [0.5, -0.5, 0],timestep=0.002,即 4.000 s 仿真时间)后,qpos = [0.6096, −0.3818, −0.0004],末端 TCP 落在 [-0.3207, 0.0, −0.7285] 米,积分器是 mjINT_IMPLICITFAST。qpos[0]=0.6096 而命令只有 0.5:<position> 是纯比例控制,重力载荷下必然留稳态误差,kp=50 不足以消掉它——这不是 bug,是第 6 章的入口。
该模型在后续各章反复复用。MuJoCo 安装自带的模型库(model/ 目录,humanoid、mug、car、balloons 等)在 3.11 全部加载即用(humanoid:nbody=17、nv=27),读源码学写法比自己从零琢磨快得多。这里给出两个可直接动手的验证:把 prize 的 limited="auto" 删掉、range 保留,模型仍能编译,这就是 autolimits 默认 true 的作用;再往 <sensor> 里补一只 <touch>(挂在 tcp site 上),看 nsensordata 从 3 变成 4。
4. 常用元素与属性¶
§3 的第一个模型用于起步;本节起的例子换为一台教学骨干机器人:mesh-free、全部由几何 primitive 拼成——差动底盘(两驱动轮 + ball 关节万向球)顶着 2 自由度臂 + 2 自由度腕与平行夹爪,头部有相机,底盘上有 2D 雷达与 IMU,约束层有 equality 镜像、腱传动与定制接触对。四种关节(hinge/slide/ball/free)、四类执行器(motor/position/velocity/muscle)与十个传感器在同一份文件里出现,各小节贴出与本章元素相关的切段并标注它在整机中的位置;整机文件可整段内联,也能直接进 viewer。骨干不覆盖的元素(mesh/贴图资产、weld/connect 约束、general 执行器)以独立最小示例补足。
元素的命名规则(唯一性、mj_name2id 的全名匹配)与 <custom>/user 自定义参数通道在 XML reference 的 naming 与 custom 两节,正文不展开。第 3 章讲复用与惯量推断、第 6 章讲执行器与约束参数,这里只把"写在哪、叫什么、默认是什么"钉清楚。
4.1 骨架:body 与 inertial¶
<body> 声明运动树上的一个刚体:位姿相对父 body,关节与几何都挂在它下面;<inertial> 是写在该 body 内的显式质量块,一旦出现就完全接管质量与惯量,不再从 geom 推断。整机的运动树如下,边上是各 body 带的关节:
flowchart TD
world(("world"))
world -->|"无关节:焊死"| base["base 底盘"]
base -->|"ball:caster"| caster["caster 万向球"]
base -->|"hinge:wheel_l / wheel_r"| wheels["两驱动轮"]
base --> torso["torso 躯干"]
torso --> head["head 头(head_cam)"]
torso -->|"hinge:shoulder"| link1["link1 大臂"]
link1 -->|"hinge:elbow"| link2["link2 小臂"]
link2 -->|"hinge×2:roll + pitch"| wrist["wrist 腕"]
wrist --> ft["ft_mount 力/力矩(无关节,自动焊接)"]
ft --> palm["palm 掌"]
palm -->|"slide:finger_l / finger_r"| fingers["平行双指"]
world -.->|"freejoint"| cube["cube 抓取目标(不在树上)"]
<!-- 骨干底盘:base 不带关节——无关节的 body 与父级(这里是世界)焊死,
相当于上架调试的整机;它的子 body 全部相对 base 定位 -->
<body name="base" pos="0 0 0.07"> <!-- 高度 = 轮半径:轮子着地,底盘腹面离地 4 cm -->
<geom name="chassis" type="box" size="0.13 0.09 0.03" mass="2.5"/>
<body name="caster" pos="-0.09 0 -0.052"> <!-- 嵌套即子树,父子关系不靠 name 约定 -->
<joint name="caster" type="ball"/>
<geom name="caster_g" type="sphere" size="0.018" mass="0.12"/>
</body>
<body name="torso" pos="0 0 0.03"> <!-- 孙 body:躯干与臂链从这里长出 -->
<geom name="torso_g" type="capsule" fromto="0 0 0 0 0 0.12" size="0.035" mass="1.8"/>
</body>
</body>
<body> 只负责"摆在哪、朝哪":pos/quat/euler 都相对父 body,另有 childclass(见「全局配置」一节)与 mocap。mass 不是 body 的属性——写在 body 上会被 schema 拒绝,质量只能来自 <inertial> 或 geom 推断。
骨干整机不写 <inertial>(惯量全部由 geom 推断,显式惯量的写法在第 3 章);它的最小形态是:
<inertial pos="0 0 -0.1" mass="1.2" diaginertia="0.02 0.02 0.003"/>
<!-- 写在 body 内:一旦出现就完全接管质量与惯量,同 body 的 geom 质量不再参与推断;
pos 是必填项(质心位置无处可推) -->
载入后 body_mass[1]=1.2、body_ipos=[0, 0, -0.1]:显式 inertial 存在时,即使 geom 上另有 mass 也不参与,这条在第 3 章的惯量推断一节展开。
4.2 关节:joint 与 freejoint¶
<joint> 在 body 上开一个自由度:不写关节的 body 与父级焊死,写几个 joint 就有几个自由度;<freejoint> 是 free 关节的快捷写法,六个自由度一次到位。
<!-- 四种关节类型都在骨干上;axis 在 body 局部系里解释,body 一转轴跟着转 -->
<joint name="shoulder" axis="0 1 0" range="-1.2 1.2" class="arm"/>
<!-- arm class 给出 type="hinge"、damping=0.5、armature=0.02;绕 +y 正转低头 -->
<joint name="wheel_l" axis="0 1 0" class="wheel"/>
<!-- wheel class:damping=0.05、frictionloss=0.4——变速箱静摩擦,
臂摆动的反作用推不走底盘;三个耗散项落到 dof_damping / dof_armature / dof_frictionloss -->
<joint name="finger_l" axis="0 1 0" class="finger"/>
<!-- finger class 给出 type="slide"、range="0 0.02":棱动关节的 range 单位是米,
指行程 2 cm;与 hinge 的弧度同在一个 jnt_range 列里,绝不能整列换算 -->
<joint name="caster" type="ball"/> <!-- ball:qpos 存四元数 4 个数,nv=3 -->
<freejoint/> <!-- cube 的自由关节:qpos 7 个、nv 6 个,
脱离运动树的物件标配,等价于 type="free" -->
同一 <body> 里还可以写多个 <joint>,顺序就是关节变换的复合顺序——骨干的腕部用 roll + pitch 两个 hinge 凑出 2 自由度:
<body name="wrist" pos="0.12 0 0">
<joint name="wrist_roll" type="hinge" axis="1 0 0" range="-3.14 3.14" damping="0.2"/>
<joint name="wrist_pitch" type="hinge" axis="0 1 0" range="0 1.7" class="arm"/>
<!-- 先 roll 后 pitch:pitch 转 90° 把夹爪垂到正下方,从上方俯抓地面的方块 -->
</body>
type 的取值与自由度代价:
type |
默认 | nq(广义坐标) |
nv(自由度) |
说明 |
|---|---|---|---|---|
hinge |
是默认值 | 1 | 1 | 绕 axis 旋转 |
slide |
1 | 1 | 沿 axis 平移 |
|
ball |
4 | 3 | 四元数存 qpos,不是欧拉角 |
|
free |
7 | 6 | 3 + 四元数 4 + 3 dof |
axis 默认 "0 0 1",且在 body 的局部坐标系里解释——body 一转,轴跟着转。限位与 autolimits 的细节在第 3 章,freejoint 与 type="free" 的继承差异在第 4 章。
4.3 几何与碰撞:geom¶
<geom> 是一个形状:同时承担碰撞、质量贡献与可视化三件事,类型由 type 选,size 的语义随类型变化。
<!-- 骨干几何:全部 primitive,一轮就把常用 type 覆盖完 -->
<geom name="floor" type="plane" size="0 0 0.05"/> <!-- plane 前两项为 0 = 无限延伸 -->
<geom name="chassis" type="box" size="0.13 0.09 0.03" mass="2.5"/> <!-- box 是三向半长,不是全长 -->
<geom name="link1_g" type="capsule" size="0.022" fromto="0 0 0 0.14 0 0" mass="0.8"/>
<!-- capsule + fromto:端点给轴向与长度,pos/quat 由引擎算 -->
<geom name="wheel_l_g" type="cylinder" size="0.07 0.025" quat="0.7071 0.7071 0 0" mass="0.4"/>
<!-- cylinder 默认轴向 z:绕 x 转 90° 把盘面立起来对准轮轴 y——
装轮子最常见的静默错误就是忘写这个 quat,盘面会躺平 -->
<geom name="head_g" type="sphere" size="0.045" mass="0.3"/> <!-- 不写 type 时默认就是 sphere -->
size 的含义按类型不同,载入后原样存入 mjModel.geom_size;同一张表也给出了每种 type 到底画出什么:
type |
画出来的形状 | size 的数 |
含义 |
|---|---|---|---|
sphere(不写 type 时的默认) |
球,球心即 geom 位置 | 1 | 半径(补零到 3 位) |
box |
长方体 | 3 | 三向半长(不是全长) |
ellipsoid |
椭球——球沿三根轴分别缩放 | 3 | 三向半轴 |
cylinder |
正圆柱,两端是平面 | 2 | 半径 + 半高 |
capsule |
胶囊——圆柱两端各接一个半球盖 | 2 | 半径 + 圆柱段的半长 |
plane |
无限平面(碰撞无边界) | 3 | 前两项半宽只决定渲染矩形的尺寸;第三项是网格线间距,0 表示按 extent 自动推(XML reference) |
mesh |
网格文件描述的形状 | 0 | 忽略;尺寸取自网格资产,缩放写在 <mesh scale> 上 |
hfield |
高度场——矩形基座上叠高程 | 0 | 忽略;半径/高程/基座四参写在 <hfield size> 上 |
形状与朝向各有一条约定:sphere 的渲染网格沿经纬线三角化、z 轴穿过南北极;cylinder 与 capsule 的轴向默认沿局部 z,box 与 ellipsoid 的三根半轴分别沿局部 x/y/z。要把轴向摆到 x 或 y 方向就给 quat,或在 fromto 里直接给两个端点。胶囊与圆柱的 size 都是「半径 + 半长」,差别在端盖:胶囊总长是 2 ×(半柱长 + 半径),同参的圆柱两端各少一个半径。
fromto 是个省笔写法:给两端点,引擎算出 pos(中点)、quat(朝向)与长度。上面那条 link1_g 的 fromto="0 0 0 0.14 0 0"(size 给半径 0.022)存成 pos=[0.07, 0, 0]、size=[0.022, 0.07, 0](0.07 = 半柱长 0.14/2)、quat=[0.7071, 0, -0.7071, 0]——capsule 的轴向本是 z,引擎自动把它旋到 +x。
碰撞相关的默认值一次列全(均写在 geom 上):group=0、contype=1、conaffinity=1、condim=3、margin=0、gap=0、solref=[0.02, 1]、solimp=[0.9, 0.95, 0.001, 0.5, 2]、friction=[1, 0.005, 0.0001](滑动/扭转/滚动)、rgba=[0.5, 0.5, 0.5, 1]。contype/conaffinity 的位与判据、margin/gap 的两段距离语义在第 6 章,group 的两套开关在第 7 章。
质量与密度:不写 mass 也不写 density 时按默认密度 1000(水)计。size="0.1 0.2 0.3" 的 box 得 body_mass=48.0 kg(体积 0.048 m³);density="2000" 翻倍成 96.0;写 mass="0.5" 则 0.5,density 被忽略。<site> 不贡献质量,带 site 的 body 质量仍是 geom 给的 1.0。
4.4 参考点与观测口:site、camera、light¶
<site> 是挂在 body 上有位姿、无物理的参考点,传感器、腱路径、坐标对照都以它为锚;<camera> 与 <light> 定义观测与照明,同样跟随 body 运动。
<!-- 参考点与观测口:都跟随所挂 body 运动 -->
<site name="imu" pos="0 0 0.03" size="0.006"/>
<!-- 器件原点:accelerometer/gyro 共用这一个 site;site 有形状但没有物理,
不参与碰撞、不贡献质量 -->
<site name="pad_l" type="box" pos="0.012 0.007 0" size="0.04 0.002 0.013"/>
<!-- 触觉面:贴内侧指面的薄盒——touch 传感器只统计落在 site 体积内的接触点,
点状 site 会漏掉整个侧面接触面 -->
<site name="cable_l" pos="0.012 0.005 0.013" size="0.003"/> <!-- 腱锚点:spatial 腱的路径点 -->
<camera name="head_cam" pos="0.065 0 0" mode="targetbodycom" target="cube" fovy="58"/>
<!-- 头相机视线自动对准 cube 质心;镜头探出头部球面(0.065 > 球半径 0.045),
否则视场被自己的头挡住 -->
<camera name="scan2d" pos="0.13 0 0.05" xyaxes="0 -1 0 0 0 1" resolution="32 1" fovy="1.8"/>
<!-- 32×1 网格 = 32 束水平扫描线(2D 雷达):fovy 是纵向视场角,
横向视场由它×宽高比 32:1 推出 ≈ 54°——压纵向、放大横向才能得到扫描扇面;
装在盒顶之上,避免边缘束打在自家底盘上 -->
<light pos="0.5 -0.8 1.2" dir="0 0.35 -1"/> <!-- 主光斜前方打向工作区 -->
xyaxes 必须是 6 个数(x 轴 3 + y 轴 3),z 轴由叉积给出,视线沿 −z;fovy 单位恒为度,不受 compiler/angle 影响;mode ∈ [fixed, track, trackcom, targetbody, targetbodycom],默认 fixed,head_cam 存成 cam_mode=[4]。rangefinder 传感器引用 scan2d 相机发射射线,机制在第 7 章「射线传感器与 mj_ray」。光的 type ∈ [spot, directional, point, image] 默认 spot,diffuse 是 real(3)、不带 alpha(这点与 geom 的 rgba 不同);相机的出图通路、sensorsize+focal 的写法在第 7 章「相机的三条出图通路」。
4.5 网格:mesh¶
网格资产与引用¶
<asset> 收纳全部可复用资源:网格、贴图、材质在这里定义一次,各 geom 按名字引用;资源路径相对 <compiler> 的 meshdir/texturedir 解析。骨干刻意不用 mesh(保持整机单文件 primitive,可内联可出图),本节为独立示例。
<compiler meshdir="meshes"/>
<asset>
<mesh name="link_geo" file="link.stl" scale="0.001 0.001 0.001"/>
<!-- scale 在编译期作用到顶点上:SolidWorks 导出的毫米制 STL 用这个换算成米;
分量允许负值,效果是沿对应轴镜像 -->
</asset>
<worldbody>
<body><geom type="mesh" mesh="link_geo" mass="0.4"/></body>
</worldbody>
两条运行结论:同一个 mesh 可被多个 geom 引用(nmesh=1 ngeom=2),几何只存一份;mesh 的 scale 确实作用在顶点上(scale="2 2 2" 后顶点从单位四面体变成 2 倍,同时引擎按自己的规则重新居中)。
同在 <asset> 下的几何类成员还有 <hfield>(高度场:nrow×ncol 的网格高程 + size 的半径/高程/基座四参,碰撞与渲染都支持);数据类成员 <numeric>/<text>/<tuple> 是自定义载荷,运行期落在 mjData.userdata 等处,不直接参与几何。
网格的位姿与运动¶
mesh 没有自己的位姿与自由度,它只是顶点数据;出现在哪里、怎么动,全由实例化它的 geom 与所在的 body 决定:geom 在 body 局部系里给出 pos 与 quat,body 的位姿再由父链与关节递推,正向运动学每帧把它们落成 mjData.geom_xpos 与 geom_xmat。mjModel.mesh_vert 存的是已按 scale、refpos、refquat 处理过的局部顶点,世界坐标要自己乘上 geom 的位姿:
gid = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_GEOM, "link_g")
mujoco.mj_forward(m, d) # 运动学在 forward 里算完,geom_xpos 才有值
mid = m.geom_dataid[gid] # mesh geom 的 dataid 指向 mesh 资产
v = m.mesh_vert[m.mesh_vertadr[mid]] # 局部顶点:已含 scale 与 refpos/refquat
world = d.geom_xpos[gid] + d.geom_xmat[gid].reshape(3, 3) @ v
运动也由 body 与关节决定:网格不产生自由度,关节给 qpos、mj_step 推进状态,下一帧 geom_xpos 随之更新;没挂关节的 body(直接挂在 worldbody 下)只参与碰撞与渲染,不动。质量与惯量由 geom 的 mass/density 给(体积按网格算),顶点集本身不进动力学;要让网格自身形变,那是 flex/skin 另一条路径。
文件格式与导出判据¶
数据来源上,文件路径只认三种扩展名(大小写不敏感):二进制 STL、OBJ、自定义二进制 MSH,或用 content_type(model/stl、model/obj、model/vnd.mujoco.msh)覆盖判定;其余格式(DAE、PLY 等)需先用 MeshLab 一类工具转出。
STL 与 OBJ 的存储结构差异决定了加载行为的差异,也决定导出时的选型(XML reference 的 mesh 一节):STL 是面片列表——每个三角形独立存三份顶点,没有跨面共享的顶点表,文档明确支持的是二进制形态;引擎载入时对 STL 自动去重顶点、重建面索引,且 STL 带不了顶点法向(法向由引擎按邻面加权自动生成,硬边靠 smoothnormal 控制)。OBJ 是共享顶点表——v/vt/vn/f 四类记录分开存坐标、UV、法向、面引用(逆时针,与 MJCF 内联 face 同约定),拓扑照原样使用,不再去重重索引;它是三种文件里通用工具都能产出、又能带 UV 的那一档(STL 带不了 UV,MSH 能带但不是通用格式),顶点法向也能带。由此得到三条导出判据:
- 只要碰撞与渲染几何、不需要 UV → 二进制 STL 即可;STL 网格要加贴图,唯一路子是转成 OBJ/MSH 或内联——两种格式之间不能混合携带。
- 外观不随文件进来:OBJ 自带的材料库指令不会进入模型,文档明确 mesh 的外观(含贴图映射)由引用它的 geom 的
material/rgba控制——这也是下文「贴图必须先经<texture>显式加载」的根源。 - 无论哪家,导出前三项检查:三角化(四边形与多边形面先拆三角,MeshLab 一键)、单位与原点(毫米制用
scale换算,CAD 世界原点用refpos/refquat搬正)、绕向一致(第 3 章「mesh 路径解析与质量推断」给了绕向混乱的质量偏差账目)。
MSH 是 MuJoCo 自己的二进制格式,顶点、面、法向、UV 一次装进一个文件,是三者中唯一"自带全部信息"的选项;XML reference 给出了它的字节级布局,适合自编导出器对接自有 CAD 流程,MeshLab 一类通用转换器不产这种格式。
内联顶点与编译期处理¶
不用文件也可以在 XML 里内联几何:vertex 给顶点、face 给面(每面三个顶点索引,逆时针给出外法向);只给 vertex 不给 face 也行,引擎自动取凸包构面。另有三个编译期处理属性:refpos/refquat 在加载时平移旋转顶点(把 CAD 文件的世界原点搬到 MJCF 约定),smoothnormal 控制自动法向是否平滑跨面(默认不平滑,保留硬边),贴图坐标 texcoord 只有 OBJ/MSH 和内联写法带得动——STL 不带 UV,要贴图的网格得换格式或内联。<mesh> 还有 builtin 程序化网格一族(sphere/hemisphere/cone/supersphere/supertorus/wedge/plate + params),其中 wedge/plate 专为 tactile 传感器的 taxel 排布设计(第 8 章)。
凸包、惯量与面片质量¶
面片质量决定渲染与碰撞的下限:三角面积小于 mjMINVAL(1e-14)直接编译失败;翻面(flip)在 OBJ/XML 路径会被检查并报错;重复面造成阴影走样,导入前应清理。引擎不设面数上限,真正的约束在凸包顶点数与最小面积。
碰撞与渲染用的是两套几何:任意三角集合都能渲染;碰撞只对凸包(qhull 构造,maxhullvert 限其顶点数,默认 -1 不限;正数须大于 3)。非凸形状的凹腔对碰撞不可见,关键凹腔要用多块 geom 拼;凹网格直接做碰撞体是静默错误,不是报错错误。惯量推断走哪套几何由 inertia 属性决定:
| 取值 | 算法 | 前提 | 备注 |
|---|---|---|---|
legacy |
旧 union-of-pyramids | — | 当前默认,仅为兼容;非凸件会体积多算 |
convex |
凸包体积与惯量 | 无 | 文档推荐值 |
exact |
按实际体积精确算 | well-oriented + watertight,否则拒绝编译 | 凹件的正确选择 |
shell |
质量集中在表面 | — | 壳假设,均匀面密度 |
行为差异可以直接对账:L 形件(0.1×0.1×0.04 减角,真实体积 0.000256 m³)在面片绕向一致时,legacy 与 exact 都给 body_mass=0.256000 kg,与解析体积一致;convex 给 0.328000 kg——凸包顶点集缺被减去的角,是五棱柱体积而非完整方块 0.4 kg。同一网格若绕向不一致,legacy 给出 0.242652 kg 的偏差值,而 exact 直接拒绝编译(faces inconsistent orientation)。⇒ exact 以 well-oriented + watertight 为前提,绕向乱、有破面的 CAD 网格要么修好要么改用 convex。惯量推断与凸包的进一步对照见第 3 章「mesh 路径解析与质量推断」。
4.6 CAD 内核到网格:STEP 与三角化¶
前面几节都从"网格文件已经到手"开始;上游还有一环——零件本身的精确几何。机械设计侧的叙述单位是 B-rep(boundary representation):实体由解析面与 NURBS 曲面加拓扑边界面拼成,布尔、倒角、抽壳都在精确几何上做,误差只来自数值容差。进仿真与渲染之前,才按公差把它逼近成三角网格——这一步叫三角化(tessellation),也正是本节要讲的接口。
OCCT 是这条链上的开源内核。Open CASCADE Technology 提供完整的 B-rep 建模:STEP/IGES 读写、布尔与倒角、几何求交、网格化;开源 CAD(FreeCAD 一类)以它为几何内核,商业 CAD 则通过 STEP 与它对接。Python 侧有两个入口:pythonocc-core 紧贴 OCCT 的类结构(STEPControl_Reader 读文件、BRepAlgoAPI_* 做布尔、BRepMesh_IncrementalMesh 三角化),适合把 CAD 处理嵌进脚本管线;CadQuery / build123d 是 OCCT 之上的声明式封装(搭形状、export 出 STEP/STL),适合程序化生成零件而不是处理别人的 CAD。
三角化有两个旋钮,直接决定面数与保真度(BRepMesh_IncrementalMesh(shape, linear_deflection, isRelative, angular_deflection),CadQuery/build123d 侧对应 tessellate(tolerance, angularTolerance)):线性偏转是弦高误差上限——平面与缓曲面给大值,面数少、圆弧处会棱起来;角偏转控制曲率大处的加密——圆柱面、倒角、螺纹的面数基本由它支配。同一零件可以按用途取不同公差:渲染网格取松、仿真接触面取紧;碰撞侧看的是凸包而不是抽稀网格(与「凸包、惯量与面片质量」的两套几何、maxhullvert 同一套预算逻辑)。
交换格式的取舍:STEP(ISO 10303,常见 AP203/AP214/AP242 子集)带装配结构、颜色与单位,是 CAD 之间的通行证;IGES 更老、以曲面为主,反复转换容易退化成散面、丢实体拓扑,除非对接方只认它,否则不选。读 STEP 进 pythonocc 后先确认文件声明的单位——CAD 习惯毫米,而 MuJoCo 按米,scale 该不该给就在这一步定(「网格资产与引用」的 scale、「内联顶点与编译期处理」的 refpos/refquat 就是干这件事的)。
四条收束:程序化生成零件用 CadQuery/build123d,处理既有 STEP 用 pythonocc;三角化按用途取公差而不是一刀切;布尔与倒角必须在三角化之前做完(三角化之后只剩近似壳,再改就回到网格编辑,见「文件格式与导出判据」);导出时按网格侧规则验收——三角面、绕向一致、无退化面,毫米件用 scale 换算成米。
4.7 表面:texture 与 material¶
外观是两级对象:texture 管"像素内容从哪来",material 管"光学属性与映射方式";geom 只引用 material(或直接写局部 rgba)。贴图与材质在同一处定义、各引用一次:
<asset>
<texture name="floor_tex" type="2d" builtin="checker" rgb1="0.3 0.3 0.35" rgb2="0.7 0.7 0.75"
width="128" height="128"/> <!-- 程序生成的贴图用 builtin,不需要文件 -->
<material name="floor_mat" texture="floor_tex" texrepeat="4 4"
shininess="0.5" specular="0.3" emission="0.1" rgba="1 1 1 1"/>
</asset>
<worldbody>
<geom type="plane" size="1 1 0.1" material="floor_mat"/> <!-- primitive 形状也能挂 material -->
</worldbody>
material 不依赖贴图也能用——只给 rgba/shininess/specular/emission 就改变外观。
类型上,type ∈ [2d, cube, skybox],默认 cube,它决定映射方式与哪些属性相关:
- cube:把立方体贴图"收缩包裹"到物体——GPU 从物体中心向每个像素构射线,取射线与立方体表面的交点颜色。数据四选一:单文件重复贴六面;单文件合成图按
gridsize(行列数,乘积≤ 12)与gridlayout(R/L/U/D/F/B字符按行优先指认哪格贴哪面,.弃用,缺的面用rgb1填)拆出六方;六个文件fileright/fileleft/…各贴一面;或builtin程序生成。 - skybox:数据写法与 cube 完全相同,差别在用途——模型里第一只 skybox 贴图被可视化器用来画随相机平移的"天穹",适合环境背景而非物体表面;若被普通 material 引用,效果退化为 cube map。
- 2d:按 UV 坐标映到网格;primitive 没有 UV,改按局部 XY 沿 Z 轴投影——所以 2d 贴图实际只适合 plane 和 hfield 的顶面。可以是矩形(cube 每面必须正方)。
程序化贴图由 builtin ∈ [none, gradient, checker, flat] + rgb1/rgb2 生成:gradient 沿 +Y 从 rgb1 到 rgb2 做 sigmoid 插值;checker 是 2×2 棋盘(地面与旋转对称标记的常用手段);flat 全填 rgb1、只有 cube/skybox 的底面填 rgb2。mark ∈ [none, edge, cross, random] + markrgb 可在图案上叠 1 像素宽的边界/十字/随机点(random 属性给概率,配合 gradient 可做星空)。程序贴图的尺寸由 width/height 控制(cube 的 height 强制 = 6×width),从文件加载时两者忽略。另有两个色彩相关属性:colorspace ∈ [auto, linear, sRGB](默认从图片文件判,判不出按 linear);nchannel(默认 3,可装 RGBA 或单通道 PBR 纹理);hflip/vflip 只对文件贴图生效。命名规则:贴图必须有名才能被引用,但单文件加载时可省 name,文件名自动成贴图名(解析后仍为空且非 skybox 则编译报错)。
材质侧的引用与映射:texture 属性填的是贴图资产名而非文件名;贴图必须先经 <texture> 元素显式加载。几何侧的分工:texrepeat(仅对 2d 有意义)给重复次数 N,texuniform 决定 N 的参照物——false 时 N 次铺满物体自身,true 时 N 次铺满一个空间单位(与物体大小无关;cube 贴图则对应"先按单位物体贴再缩放")。光学属性是 OpenGL 固定管线语义的子集:specular([0,1])、shininess(×128 后送 OpenGL,越大高光越锐)、emission(自发光强度,乘材质 RGB)、reflectance(>0 且贴在 plane 或 box 的 +Z 面时模拟反射——近似算法,全模型只有第一只反光材质被渲染,每只多一次全 geom 渲染 pass)。rgba 与贴图颜色逐分量相乘(默认 1 1 1 1 即不改贴图);引用 material 的 geom 若自带局部 rgba(含从 defaults 继承的),局部值优先,其余材质属性仍生效——所以调颜色用局部 rgba 就够,调"颜色以外的外观"才值得建 material。
PBR 扩展不在内置渲染器:metallic/roughness(默认 -1)与 layer 子元素(role ∈ rgb/normal/occlusion/roughness/metallic/opacity/emissive/orm/rgba)只对 Filament 一类外部渲染器有意义;内置渲染器下写了也不改变画面。texture 属性与 layer 子元素互斥,同写报错。material 的引用方除 geom 外还有 site、tendon 与 skin;可引用的元素都有局部 rgba 快捷路径——只改颜色不必建 material。
4.8 驱动:actuator 家族¶
<actuator> 把控制信号接进动力学:每个子元素是一条执行器通道,把 data.ctrl 的一个数变换成广义力;四个快捷元素覆盖常见伺服,特殊动力学回到 <general>。
<!-- 骨干执行器:ctrl 向量按书写顺序排——轮_l、轮_r、肩、肘、腕俯仰、腕滚转、夹持 -->
<actuator>
<motor name="wheel_l" joint="wheel_l"/> <!-- wheel class 给 ctrlrange="-8 8":控制量是力矩 -->
<motor name="wheel_r" joint="wheel_r"/>
<position name="shoulder" joint="shoulder" class="arm"/> <!-- kp=40 kv=2 取自 arm class -->
<position name="elbow" joint="elbow" class="arm"/>
<position name="wpitch" joint="wrist_pitch" class="arm"/>
<velocity name="wrist" joint="wrist_roll" kv="2" ctrlrange="-3 3"/>
<!-- 速度伺服:只有 kv、没有 kp——它阻尼速度,不跟踪位置 -->
<muscle name="grip" tendon="grip_cable" force="30"/>
<!-- 驱动对象是腱而不是关节:muscle 收缩拉动 grip_cable,双指被动闭合(见「约束与接触」一节) -->
</actuator>
执行器的 class 引用与 joint/geom 各自独立:arm class 里的 <position> 只对自身写了 class="arm" 的执行器生效——漏写 class,kp/kv 落在全局默认(kp=1)上,重力会把臂压到关节限位里,模型不报任何错,只有画面不对。「被引用才生效」这条继承边界在「全局配置」一节还有对照。
想要执行器带内部状态(延迟、积分、肌肉模型),快捷元素不够用,回到 <general>(快捷元素不暴露 dyntype,写上去会被 schema 拒绝):
<general joint="elbow" dyntype="filter" dynprm="0.1"
gaintype="fixed" gainprm="30" biastype="affine" biasprm="0 -30 0"/>
<!-- filter 的时间常数是 dynprm[0],等效于一阶低通 -->
四个快捷元素其实是 <general> 的参数预设,载入后可从 mjModel 里读出真实映射:
| 写法 | dyntype |
gaintype |
biastype |
gainprm |
biasprm |
出的力 |
|---|---|---|---|---|---|---|
<motor> |
none | fixed | none | 1 0 0 |
0 0 0 |
gear × ctrl |
<position kp=30 kv=2> |
none | fixed | affine | 30 0 0 |
0 -30 -2 |
30(ctrl − qpos) − 2 qvel |
<velocity kv=5> |
none | fixed | affine | 5 0 0 |
0 0 -5 |
5 ctrl − 5 qvel |
<general> |
自定 | 自定 | 自定 | 自定 | 自定 | 按第 6 章的公式 |
这张表也解释了「第一个模型」里出现的稳态误差:<position> 的 kp 就是刚度,重力力矩必须靠 kp·(ctrl − qpos) 顶住,所以命令与稳态值必然差一截;把误差压下去要么加 kp,要么补积分项(第 6 章)。
钳位是两处独立的开关:ctrlrange="-1 1" 让 ctrllimited 自动为 true,喂进 ctrl=5 时出力按钳位后的 1 计算,得 30 N·m(而不是 150);forcerange="-5 5" 让 forcelimited 自动为 true,ctrl=100 时出力被钳在 5。gear 存在 actuator_gear[0],是传动比而不是缩放 ctrl。
4.9 约束与接触:equality、tendon、contact¶
<equality> 声明必须成立的运动学关系(焊死、对齐、齿轮比),<tendon> 定义力的传递路径,<contact> 手工增删接触对——三者都是运动树之外的约束层。
<!-- 骨干的约束层:镜像等式 + 腱传动 + 定制接触 -->
<equality>
<joint joint1="finger_l" joint2="finger_r" polycoef="0 1 0 0 0"/>
<!-- 双指镜像:q_r = q_l。两指 slide 轴相反(+y / −y),正 q 都是向内闭合,等式取同号;
写成 0 -1 会让腱的拉拢与等式互相顶死,手指永远合不上 -->
</equality>
<tendon>
<spatial name="grip_cable" width="0.003" range="0.04 0.09" limited="true" stiffness="50">
<site site="cable_l"/><site site="cable_r"/> <!-- 路径由两指内侧锚点串成,顺序有意义;
两指靠拢 ⇒ 路径变短 -->
</spatial>
</tendon>
<contact>
<pair geom1="finger_l_g" geom2="cube_g" solref="0.005 1" friction="1 0.005 0.0001"/>
<pair geom1="finger_r_g" geom2="cube_g" solref="0.005 1" friction="1 0.005 0.0001"/>
<exclude body1="link1" body2="base"/> <!-- 臂下摆时 link1 扫过底盘上方:祖孙 body 默认参与碰撞 -->
<exclude body1="palm" body2="finger_l"/> <!-- 指根插在掌内(滑轨结构):掌-指常驻接触是建模噪音 -->
</contact>
equality 的另外两种形态(weld/connect)骨干未用,最小写法是:
<weld body1="mount" body2="part"/> <!-- 焊死:对齐建模时刻的相对位姿 -->
<connect body1="a" body2="b" anchor="0 0 0.2"/> <!-- 球铰:anchor 是 body1 局部系里的偏移 -->
<connect> 没有 distance 属性:它做的是两点合一(等效一个运动树外的球铰),只能指定在哪一点合,不能指定合起来之后保持多远。两种写法的目标不同:body1 + anchor 以建模位姿 qpos0 为准(约束在那里天然满足,要改距离就得改 body 的建模位置);site1 + site2 则无论默认位姿如何都会把两个 site 拉到一起。真的需要距离约束,用的是带 range/limited 的 spatial 腱(再加 stiffness 就是弹性距离)——这一处最容易凭 URDF/Gazebo 的经验写错。
编译产物里(mjModel.eq_data 每条约束占 11 列):weld 的 eq_type=[1],body 相距 0.2 而未写任何参数时存成 [0,0,0, 0,0,0.2, 1,0,0,0, 1],依次是 anchor(body1 局部系)、相对位置、相对四元数 (w,x,y,z)、torquescale;connect anchor="0 0 0.2" 把 anchor 存进前三列 [0,0,0.2, …];joint 等式把 polycoef 原样存进前五个分量 [0,2,0,0,0, …]。
腱不增加自由度:两 body 各带一个关节的模型是 nv=2 njnt=2,加一条 spatial 腱之后 nv 仍是 2——腱是力传递元件,不是新自由度;骨干加了腱与镜像等式后 nq=19 nv=17 与不加时一致。contact/pair 的 pair_dim 默认 3(1 法向 + 2 切向),改 solref/friction 只影响这一对。约束的软硬度、solref/solimp 的语义在第 6 章,weld 的 relpose 陷阱在第 4 章。
4.10 全局配置:option、default、size、statistic、visual、keyframe¶
这几个节不描述具体物体,而是给整台模型立规矩:option 管积分器与求解器,default 管属性继承,size 管内存容量,statistic/visual 管渲染缩放与外观,keyframe 存命名初始态。
<!-- 骨干的全局配置:这几样都不描述具体物体,而是给整机立规矩 -->
<option timestep="0.002" integrator="implicitfast"/> <!-- 带接触模型的常规起点;
flag 是位图开关,写在 option 里,如 <flag contact="disable"/> -->
<default>
<geom rgba="0.75 0.75 0.78 1"/> <!-- 全局默认:不需要被引用就生效 -->
<default class="arm"> <!-- 命名 class:被引用才生效 -->
<joint type="hinge" damping="0.5" armature="0.02"/>
<geom type="capsule" size="0.022" mass="0.8"/>
<position kp="40" kv="2" ctrlrange="-3.14 3.14"/> <!-- class 里也能给执行器默认 -->
</default>
<default class="finger">
<joint type="slide" damping="0.3" range="0 0.02"/>
<geom type="box" size="0.04 0.006 0.012" mass="0.06"/>
</default>
</default>
<statistic extent="0.7" center="0.2 0 0.2"/> <!-- 渲染缩放基准:按工作区而非全模型包围盒 -->
<visual>
<global azimuth="135" elevation="-22" offwidth="640" offheight="640"/>
<!-- viewer 默认视角;离屏缓冲尺寸 -->
</visual>
<keyframe>
<key name="home" ctrl="0 0 0 0 0 0 0"/>
<key name="reach" ctrl="0 0 -0.4 0.9 1.57 0 0"/> <!-- 悬停:夹爪停在 cube 正上方 -->
<key name="grasp" ctrl="0 0 0.05 0.5 1.57 0 1"/> <!-- 俯抓:下探到 cube 侧面中部并闭合 -->
</keyframe>
内存容量另有 <size memory="10M"/>(3.11 用 memory;njmax/nconmax 已是 legacy),骨干未写——容量由编译器按模型估算。
default 的继承规则如下:class 里的属性只对被引用者生效(执行器侧的同一个坑见「驱动」一节),childclass="arm" 让整个子树免写 class(该 body 的 geom 得到 mass=0.8、joint 得到 damping=0.5);同一 body 里既继承 childclass="a"(sphere,mass=1)又给第二个 geom 显式 class="b"(box,mass=9)时,显式的赢:载入后 geom_type=[2, 6]、body_mass=[0, 10],即 1 + 9。class 之间的差异与命名空间在第 3 章展开。
keyframe 是"可命名的初始态":mj_resetDataKeyframe 载入指定键后 ctrl 落到键值、qpos 落到 qpos0,与 key_ctrl/key_qpos 一致。骨干的三把键只锁 ctrl(执行器空间)——含 ball/free 关节的模型里手写 qpos 要猜四元数排序,ctrl-only 的键最稳;home/reach/grasp 三姿态只在肩、肘、腕俯仰与夹持四路上不同。
5. MuJoCo Viewer 窗口¶
写完 XML 最直接的验证方式是把模型载入交互查看器。一条命令打开:
python -m mujoco.viewer --mjcf=assets/models/01_pendulum.xml
写进脚本时换成 mujoco.viewer.launch()(托管模式,viewer 自己跑循环)或 launch_passive(model, data, key_callback=...)(被动模式,用户代码驱动步进);被动句柄上挂着 cam/opt/pert/user_scn 四个可调结构以及 lock()/sync()/is_running()/close() 四个方法,想边跑边改可视化参数或注入回调,就用这个入口。
窗口布局、鼠标手势与完整快捷键表在第 7 章「查看器与 Web 嵌入通路」一节列全;这一章先记住三条最常用的:Space 播放/暂停、Ctrl+L 改完 XML 重新加载、0–5(配合 Shift 是 site 组)切分组显隐。
6. compiler 的 angle¶
MJCF 里每一个数字都有单位,而单位由 <compiler> 统一裁决。angle 的取值是 [radian, degree]:MJCF 默认按度解释,URDF 永远是弧度;编译器在加载期把度换算成弧度,mjModel 里只存弧度(XML reference)。
其作用范围大于名称所暗示的:joint/@range、axisangle、euler 等所有角度类属性一起被重新解释。文档另举了一个反例——camera/@fovy 以度为单位表示,但不受 compiler/angle 影响。因此"哪些属性受 angle 支配"必须逐个查证,不能依靠推断。
错位的典型现场:在一个用度解释的模型里写 euler="90 0 0",quat 存成 (w,x,y,z) = (0.525322, 0.850904, 0, 0),实际生效的转角是 116.62°(90 个弧度取模),不是预期的 90.00°。整条链路不报任何错:schema 通过、编译通过、mj_forward 通过,只有画面里的零件歪了。这类静默的语义错位最难查——所有工具都说没问题,只有几何本身不对。
两条核对清单:
- 只要一份模型里出现
angle="radian",就要假设所有角度属性都被重新解释,包括从 include 片段里继承来的。 - 反过来,从 URDF 带过来的习惯(那里永远是弧度)最容易在这里出错。
若要复现这条错位:把 shoulder 的 range 改成 -3.14 3.14,分别在不加与加 <compiler angle="radian"/> 两种情况下读 mjModel.jnt_range——不加时得到 [-0.0548, 0.0548],因为 3.14 被当成度换掉了;加了才是 [-3.14, 3.14]。再用 angle="radian" 写一个 euler="1.5708 0 0" 的 body,打印 body_quat 验证转角约 90.00°。最后故意在一个用度写的模型里加上 angle="radian",只用画面判断哪里歪了——这一步训练的是从几何异常反推单位错误的能力。
坑:同一列 range 混着度与米
jnt_range 里 hinge 存弧度、slide 存米,绝不能对整列做单位换算;把 slide 的 0.046 当度乘 180/π 只会得到无意义的数。
7. compiler 属性清单与归属校验¶
7.1 compiler 的 inertiafromgeom¶
这个属性有三个取值 false / true / auto,默认是 auto。它的定义容易读反:auto 不是"总是推断",而是"仅当刚体定义中缺少 <inertial> 元素时,才自动推断质量和惯量"(XML reference 的 inertiafromgeom 一节)。3.11 下还有一层行为:inertiafromgeom="false" 关掉的是整条 geom 推断路——即使 geom 上写了 mass,运动 body 依旧因为质量与惯量低于 mjMINVAL 下限而编译不过;只有静态 body(没有关节)不受影响。因此"设为 false 之后每个运动 body 都必须自带 <inertial>"这一常见说法成立,完整的条件推导见第 3 章「mjMINVAL 的触发条件」。
7.2 其余常用 compiler 属性¶
一份清单,按用得上的频率排:autolimits、boundmass、boundinertia、settotalmass、balanceinertia、strippath、fitaabb、eulerseq、texturedir、assetdir、discardvisual、usethread、alignfree、saveinertial、conflict,外加子元素 compiler/lengthrange。其中 boundmass/boundinertia/inertiagrouprange 的惯量语义在第 3 章展开,assetdir/texturedir/strippath 的路径语义在 mesh 一节展开。
和写作风格直接相关的是 eulerseq,默认 "xyz"。它的大小写不是风格问题,而是两种不同的旋转:
小写字母表示随坐标系旋转的轴(内在旋转),而大写字母表示在父坐标系中保持固定的轴(外在旋转)。URDF 中使用的 'rpy' 约定对应于 MJCF 中的 'XYZ'。
所以 euler="a b c" 的含义完全取决于这个字符串,两份写着一模一样数字的模型可以表示不同朝向。跨项目对接(从 Blender 或 URDF 导入)时,第一件事是把 eulerseq 对齐,第二件事才是第 4 章的 det 检查。想看清差别:把 eulerseq 从 xyz 改成 XYZ,同一组数字下比较 xmat;再改成 ZYX,能看出哪两个互为转置。
7.3 已取消与移位的属性名¶
以下三个属性名在旧文档中存在、在现文档中已取消,是引用外部示例时最常见的错误:
| 想写的 | 实际的名字 | 现在写的结果 |
|---|---|---|
compiler/@coordinate_system |
不存在,对应机制是 coordinate : [local, global],默认 local |
写 coordinate="global" 会被拒,错误信息里直接给了迁移方法:用 MuJoCo 2.3.3 或更早版本载入再保存 |
compiler/@autouppercase |
已不在文档里 | 写上去会被 schema 拒绝,属性名无法识别 |
compiler/@inertiaignoregeom |
已不在文档里,机制改由 inertiagrouprange : int(2) 承担(默认 "0 5") |
同样被 schema 拒绝 |
density 放在 <compiler> 下 |
它属于 <default><geom> 或单个 <geom> |
— |
coordinate 是唯一在失败信息中给出迁移路径的属性,其余属性名只会提示无法识别。因此引用旧教程之前先执行一次 schema 校验,比通读文档更有效。
8. 要点¶
- 建模期最常踩的是三条默认值:
<joint>不写type默认hinge、<geom>不写type默认sphere、geom 不写质量就按默认密度 1000 折算(半长 0.1/0.2/0.3 的 box 实得 48 kg)。显式<inertial>一旦写上就完全接管,而mass并不是<body>的属性。 - 元素名与属性名要对照 XML reference 核:
<connect>没有distance、<contact>的exclude按 body 而非 geom、<velocity>只有kv、快捷执行器不暴露dyntype、3.11 的<size>用memory(njmax/nconmax已是 legacy)。 - MJCF 的节是"可选 + 可重复"的;顺序只在四处例外里重要,而例外全都是运行语义(关节复合顺序、重复节后者覆盖前者)。
- 不指定单位制(overview "units are unspecified"),自洽即可;两处默认值(
gravity=-9.81、density=1000)按 MKS 给,角度一律折成弧度。angle只决定 XML 里怎么写。 - 单位写在属性旁边:
range="-90 90"是度,range="0 0.046"是米。绝不对jnt_range做整列换算。 autolimits默认为 true,写了range就自动限位;把它设成 false 之后,漏写limited会变成错误。- 纯比例执行器(
<position>)在重力场里必然留稳态误差,kp不够大就是命令与稳态对不上。 - class 继承只对被引用者生效,且 joint/geom/actuator 各自独立:执行器漏写
class,kp 落在全局默认 1 上,重力把臂压进限位也不报错;镜像等式的符号要对着 slide 轴的配置推,推反了腱与等式互相顶死。 angle是全局开关,覆盖面要逐个属性查,fovy就是反例;116.62° 那种错位不报错,只歪画面。- 属性名会随版本消失(
coordinate、autouppercase、inertiaignoregeom、global坐标系);引用外部示例应先执行一次 schema 校验再使用。 inertiafromgeom的默认值是auto(缺<inertial>才推断),设为false会切断整条 geom 推断路。eulerseq决定同一串数字的语义,小写 = 内在旋转、大写 = 外在旋转;URDF 的rpy对应 MJCF 的XYZ。- URDF→MJCF 引擎直读即转,但带三个相反默认(恒弧度、丢
<visual>、融fixed)且nu=0;MJCF→URDF 无内置逆向,主动语义必丢;MJCF↔USD 通路自 3.11 起可用但标实验性。 - Viewer 里改模型最实用的三条快捷键:
Ctrl+L重新加载、Space暂停后拖 Control slider、0–5切 geom 分组看惯量推断结果。被动模式下步进/暂停快捷键无效,节奏完全由用户代码控制。









