跳转至

第 7 章 渲染与感知器件

物理计算完成后,结果既要可供观察,也要能作为数据被读取。这一章先讲 MuJoCo 的两阶段渲染——抽象可视化器把模型整理成绘图原语,渲染后端再把原语画进帧缓冲区——以及分组显隐、离屏缓冲与后端选择;再讲四件挂在这条管线上的感知器件:相机(三条出图通路与深度/分割读数)、射线与激光雷达(mj_ray)、IMU(三件套的拼法与读取时机)、六维力/力矩传感器(dummy body 的拼法与重力补偿);最后交代查看器快捷键与 Web 嵌入的覆盖边界。传感器侧的通用机制(类型清单、地址表、公共属性、成本)在第 8 章。

1. 两阶段渲染

可视化分两步:mjv 把模型整理成抽象场景,mjr 把场景画成像素——两阶段的开关与排查入口各自独立。

visualization 文档把可视化定义成两个阶段:阶段一"抽象可视化与交互"——与 OpenGL 无关,把模型整理成 mjvScene 里的绘图原语,API 前缀 mjv;阶段二"OpenGL 渲染"——把 scene 画进帧缓冲区,API 前缀 mjr。

sample 里 basic.cc 的调用链:mjv_defaultCamera / mjv_defaultOption / mjv_makeScene(m, &scn, 1000) → mjr_makeContext(m, &con, mjFONTSCALE_100) → 每帧 mjv_updateScene(m, d, &opt, NULL, &cam, mjCAT_ALL, &scn);mjr_render(viewport, &scn, &con)。细粒度入口:"mjv_updateCamera 仅更新相机"、"mjv_addGeoms,它将对应于当前仿真状态的 geom 添加到 mjvScene 已有的列表中"(配合 mjtCatBit 位掩码:mjCAT_STATIC/mjCAT_DYNAMIC/mjCAT_DECOR/mjCAT_ALL=7)。两阶段的分界与各自的入口:

flowchart LR
    subgraph p1["阶段一:抽象可视化(mjv,与 OpenGL 无关)"]
        mdl["mjModel + mjData"] --> up["mjv_updateScene<br/>按 mjtCatBit 过滤"]
        up --> scn[("mjvScene<br/>绘图原语列表")]
    end
    subgraph p2["阶段二:渲染(mjr,画进帧缓冲)"]
        ctx["mjr_makeContext"] --> ren["mjr_render"]
    end
    scn --> ren

⇒ 工程含义:"看不见"要分成两种——没进 scene(阶段一被过滤)与进了 scene 但没画出来(阶段二)。第 2 节的两帧之差就是区分这两者的手段。

同时澄清三个容易写错的名字:mjv_makeRenderer、mjv_updateOverview 不存在;视口类型叫 mjrRect 而不是 mjrRectangle;枚举叫 mjtCatBit(不是 mjtCatFlag)、mjtVisFlag(MjvOption 里的字段是 flags[mjNVISFLAG],不叫 visflags)。

2. 可视化分组 group

默认可见范围是 0/1/2 三组:写成 group="4" 的参考系不报错,只是静默不显示。

group 的语义有两段:对物理的唯一影响发生在编译期(哪些 geom 参与惯量推断),运行时则纯粹是可视化工具按组开关渲染——默认只有 0、1、2 组可见,其余不可见(XML reference)。

site 的说明只写了“可视化工具也使用它来启用和禁用整组站点的渲染”(没重复默认可见组),所以 site 的默认值得靠读引擎:MjvOption() 构造出的 geomgroup/sitegroup/jointgroup/actuatorgroup/tendongroup 全部是 [1, 1, 1, 0, 0, 0](共 mjNGROUP=6 组),即 site 与 geom 用同一套默认可见范围。

这解释了那个经典症状——“viewer 里没有查看法兰坐标系的选项”:挂载点 site 写了 group="4" ⇒ 默认可见组不含 4 ⇒ 静默不显示。同时它不影响物理,编译照收——site_group=[4] 与 geom_group=[0, 4] 都是合法写法。

⚠ 另有一处容易反直觉:group 对物理的唯一影响在编译期惯量推断,而那批 geom 由 compiler/@inertiagrouprange(默认 "0 5")决定,与可视化的 [1,1,1,0,0,0] 是两套独立开关。第 3 章已述:group="5" 的 geom 默认参与惯量推断,但默认不被渲染。用同一套直觉去推这两件事必错其一。

模型里放两只一模一样的球,只差 group:vis0 用 group="0"、hid4 用 group="4"; 默认 geomgroup = [1, 1, 1, 0, 0, 0](sitegroup 同样是前三位为 1)。

  • 确认过滤位置看帧间差:不传 scene_option 与显式传默认 MjvOption 渲出的帧逐位一致; 把 geomgroup[4] 置 1 后重渲,两帧之差为 164 个像素(240×320)——正是 hid4 被画出来占的面积。
  • 亮像素计数无效:对这三帧统计亮度阈值像素数,结果完全相同(本场景底色偏暗,阈值指标对显隐无差别)。 一个对所有方案都给出同一个数的指标,说明它量的不是想要的那件事; 换成两帧之差才有信息。这条否证是本章"两阶段"讨论里最实用的一条: 排查分组显隐时应先做两帧相减,而不是统计绝对亮度。

这组对照同时说明"不显示"发生在阶段一(mjv 过滤,geom 没进 scene),而不是阶段二丢帧—— 把 hid4 的 group 改回 0 就会看到它,而 <visual> 里的任何渲染开关都不需要动。

3. 离屏渲染与 headless 出图

3.1 后端选择与导入时机

离屏帧缓冲区的像素可以直接读写,但能不能读到取决于渲染后端。Linux 上引擎支持三种后端:GLX 渲染到 X11 窗口、OSMesa 做软件 headless、EGL 做硬件加速 headless(visualization 文档,3.11.0)。MUJOCO_GL 的三档按环境判定:

MUJOCO_GL 运行条件 典型场景
egl 驱动提供 EGL(NVIDIA/AMD 驱动自带);不依赖 X 会话 服务器、CI、容器里的批量出图
osmesa 需安装 libosmesa6;软件渲染,不依赖 X 会话与 GPU 无 GPU 环境的兜底
glfw 需有效的 X 桌面会话(GLX) 交互式查看器与桌面出图

⇒ 批量出图走 EGL:不读 DISPLAY,服务器、CI、容器里都能跑;glfw 离不开一个有效的桌面会话;OSMesa 报取不到 OpenGL 绑定对象时先查 libosmesa6 是否安装——属依赖缺失,不是路径不通。

后端在 import mujoco 时就选定。C 层的 OpenGL 符号在首次调用 mjr_makeContext 时经 GLAD 装载(visualization 文档,3.11.0);Python 绑定把这一步提前到导入时按 MUJOCO_GL 确定 GL 平台,因此先以 glfw 导入、再把 MUJOCO_GL 改成 egl,仍走旧后端并失败——切换必须写在进程启动之前的环境里:

MUJOCO_GL=egl python render_batch.py         # 须在导入前给定

3.2 出图判据与像素落盘

返回数组形状正确、没有异常,并不等于画面里有东西。最容易出问题的地方是相机朝向:视线是相机自身的 −Z 轴,手推 xyaxes 时只要把第二组数取错,物体就整个跑到画面外,而三帧渲染结果完全相同、形状与类型全对。可用的判据是三条一起看:

import numpy as np, mujoco          # 场景里被测球是红色的:rgba="0.85 0.3 0.2 1"

m = mujoco.MjModel.from_xml_path("scene.xml")
d = mujoco.MjData(m)
with mujoco.Renderer(m, 240, 320) as r:      # 尺寸显式给,不继承模型的 offwidth/offheight
    r.update_scene(d, camera="cam0")
    a = r.render().astype(int)
    d.qpos[:3] = [1.2, 0, 0.6]               # 把被测物体挪开(freejoint 前三个分量是位置)
    mujoco.mj_forward(m, d)
    r.update_scene(d, camera="cam0")
    b = r.render().astype(int)

obj_px = int(((a[..., 0] > 120) & (a[..., 0] > a[..., 1] + 40)).sum())   # 按颜色数出物体
moved = int((np.abs(a - b).sum(-1) > 12).sum())                          # 挪开前后的像素差

三条判据同时成立:obj_px=61、moved=5907,且同一状态重复渲染的差分为 0(排除抖动)。作为对照,把相机换成手推的 xyaxes="-0.707 0.707 0 0.408 0.408 0.816",同一判据立刻给出 obj_px=0、画面均值 0.0——全黑但形状正常。因此固定相机优先用 mode="targetbodycom" + target,让引擎自己对准目标(相机的三种给法见「相机内参的两种写法」)。

同一进程里连续建、关三次上下文的结果稳定(三次画面均值同为 13.1),不必为省上下文而刻意复用同一个 Renderer。

落盘这一步在用户侧:render() 只返回内存里的 (H, W, 3) uint8 数组,Renderer 不写文件。存 PNG 用 Pillow 一行 Image.fromarray(a).save("frame.png");批量出图时拼缩略图(Image.fromarray 后 thumbnail/resize)也走它。深度图是 float32,不能直接存 PNG——先换算成 uint16(毫米)或按量程映射到 8 位再交给 Pillow;这与 3.12 起"纹理仅 PNG/KTX"是两件独立的事:后者是引擎读纹理的格式收窄(第 1 章版本表),这里是出图端的数组编码,不要混为一谈。

4. 相机的三条出图通路

两条通路各配一座最小场景,目标表面都落在整数分米上,读数可以直接对回几何。

光栅化用的场景只有相机与目标——两只相机共用一只 body,正前方一面前表面在 x=1.0 的墙;blocker 与相机同 body,是「两条通路的读数差异」那一节唯一的变量:

<mujoco model="camera_scene">
  <compiler angle="radian"/>              <!-- 不改变 cam_fovy 的度数语义(见「相机内参的两种写法」) -->
  <statistic extent="2"/>                 <!-- 未命中像素 = extent × vis/map/zfar = 2 × 50 = 100 m -->
  <worldbody>
    <body name="rig" pos="0 0 0">
      <!-- 针孔内参写法:fx = focal/sensorsize[0] × width = 0.0289/0.040 × 640 = 462.4 px -->
      <camera name="pinhole" xyaxes="0 -1 0 0 0 1" resolution="640 480"
              sensorsize="0.040 0.030" focal="0.0289 0.0289"/>
      <!-- 只给 fovy 的常规写法;resolution 只定射线网格(16×12=192 束),不定出图像素 -->
      <camera name="wide" xyaxes="0 -1 0 0 0 1" resolution="16 12" fovy="58"/>
      <!-- 前表面 x=0.18,压住光轴:光栅化把它算进深度,射线通路把同 body 的 geom 排除 -->
      <geom name="blocker" type="box" pos="0.2 0 0" size="0.02 0.02 0.4" rgba="0.9 0.1 0.1 1"/>
    </body>
    <body name="wall" pos="1.5 0 0">      <!-- 前表面 x=1.0:中心像素应读到这个值 -->
      <geom name="gwall" type="box" size="0.5 3 3" rgba="0.2 0.8 0.2 1"/>
    </body>
    <!-- 两只形状与颜色各异的标靶,供分割图按 (objid, objtype) 逐对象辨认 -->
    <body name="target_a" pos="2.5 0.6 0"><geom name="ga" type="sphere" size="0.15" rgba="0.1 0.1 0.9 1"/></body>
    <body name="target_b" pos="2.5 -0.6 0"><geom name="gb" type="capsule" size="0.1 0.3" rgba="0.9 0.9 0.1 1"/></body>
  </worldbody>
  <sensor>
    <rangefinder name="wide_ray" camera="wide" data="depth"/>   <!-- dim = 16 × 12,逐像素一维展平 -->
  </sensor>
</mujoco>

光线投射用的场景把四类器件装在同一座塔上——相机、仰角通道 site、IMU 的三个敏感轴 site,再加一只带 freejoint 的落体:

完整场景:塔 + 四类器件(点击展开)
<mujoco model="perception_scene">
  <compiler angle="radian"/>
  <!-- magnetic 是 magnetometer 的输入场;timestep 0.002 s 即 500 Hz,比器件的 1 kHz 慢一档(见「器件单位、噪声与采样节拍」) -->
  <option gravity="0 0 -9.81" magnetic="0.2 0.0 -0.4" timestep="0.002"/>
  <statistic extent="2"/>
  <worldbody>
    <!-- 塔离地 0.6 m:贴地会让向下的通道把地面读成 0 -->
    <body name="tower" pos="0 0 0.6">
      <geom name="tower_box" type="box" pos="0 0 -0.3" size="0.06 0.12 0.15"/>  <!-- 塔体在传感器下方,不压光轴 -->
      <camera name="rgb"    xyaxes="0 -1 0 0 0 1" resolution="16 12" fovy="58"/>   <!-- 彩色头:垂直 58°(D435i 量级) -->
      <camera name="depth"  xyaxes="0 -1 0 0 0 1" resolution="8 6"/>               <!-- 深度头:取小格以便逐像素核对 -->
      <camera name="intrinsics" xyaxes="0 -1 0 0 0 1" resolution="8 6"
              sensorsize="0.040 0.030" focal="0.0289 0.0289"/>                     <!-- 针孔写法:sensorsize 接管视场,fovy 被忽略 -->
      <camera name="ortho"  xyaxes="0 -1 0 0 0 1" resolution="5 1" projection="orthographic" fovy="0.6"/>
      <!-- 4 个仰角通道 = 机械旋转雷达的一帧:zaxis 即该通道的出射方向,±15° 内均分 -->
      <site name="beam_+15" size="0.008" zaxis="0.9659 0 0.2588"/>
      <site name="beam_+5"  size="0.008" zaxis="0.9962 0 0.0872"/>
      <site name="beam_-5"  size="0.008" zaxis="0.9962 0 -0.0872"/>
      <site name="beam_-15" size="0.008" zaxis="0.9659 0 -0.2588"/>
      <!-- IMU = 三个重合的 site,敏感轴即器件坐标系;装反就读反 -->
      <site name="imu_z" size="0.006" zaxis="0 0 1"/>
      <site name="imu_x" size="0.006" zaxis="1 0 0"/>
      <site name="imu_y" size="0.006" zaxis="0 1 0"/>
      <geom name="self_block" type="box" pos="0.4 0 0" size="0.02 0.02 0.4"/>     <!-- 与传感器同 body:射线通路把它排除,光栅化不会 -->
    </body>
    <body name="wall"  pos="1.5 0 0"><geom name="gwall"  type="box" size="0.5 2 2"/></body>      <!-- 前表面 x=1.0 -->
    <body name="ball"  pos="3 0 0">   <geom name="gball"  type="sphere" size="0.5"/></body>      <!-- 最近点 x=2.5 -->
    <body name="ghost" pos="0.8 0 0"> <geom name="gghost" type="box" size="0.2 0.2 1" rgba="0 0 0 0"/></body>  <!-- alpha=0,前表面 x=0.6:不可见几何体同样被射线通路排除 -->
    <body name="faller" pos="0 -1.2 1.5">                                          <!-- 自由落体:比力语义读 0 -->
      <freejoint/>
      <site name="fall_z" size="0.006" zaxis="0 0 1"/>
      <geom name="gfall" type="box" size="0.04 0.04 0.04"/>
    </body>
  </worldbody>
  <sensor>
    <!-- data="depth" 是垂直距离(z-buffer 语义),data="dist" 是斜距(LiDAR 报的数) -->
    <rangefinder name="depth_image" camera="depth" data="depth"/>              <!-- dim = 8×6 = 48 -->
    <rangefinder name="slant_image" camera="depth" data="dist"/>
    <rangefinder name="rgb_rich"   camera="rgb"   data="dist point normal"/>   <!-- 每射线 7 个数;多个字段必须按声明顺序 dist, dir, origin, point, normal, depth -->
    <rangefinder name="ortho_grid" camera="ortho" data="dist origin"/>         <!-- dim = 5×1×2 -->
    <rangefinder name="ch_+15" site="beam_+15" data="dist"/>                   <!-- 单束挂在 site 的 +Z 上,其余三个仰角通道同此写法 -->
    <accelerometer name="acc_z" site="imu_z"/>                                 <!-- 含重力的线加速度,局部系 3 个数 -->
    <gyro name="gyro_z"         site="imu_z"/>                                 <!-- 角速度 3 个数 -->
    <magnetometer name="mag_z"  site="imu_z"/>                                 <!-- option/@magnetic 投到 site 系 -->
    <accelerometer name="fall_acc" site="fall_z"/>
  </sensor>
</mujoco>

4.1 四类器件的最小配方

机器人开发里最常用的四类感知器件——RGB 相机、深度相机、2D LiDAR、3D LiDAR——在 MuJoCo 里分属两条通路:光栅化与光线投射。下表给出每类器件的 MJCF 元素、Python 入口、输出形态与未命中约定,机制与陷阱由后面的小节逐个展开。

器件 通路 核心 MJCF 元素 Python 入口 输出与单位 未命中
RGB 相机 光栅化 body/camera Renderer.update_scene(...).render() (H,W,3) uint8 —
深度相机 光栅化 同上 + enable_depth_rendering() Renderer.render() (H,W) float32,米 stat.extent × vis.map.zfar(zfar 默认 50,乘编译期推算的 extent;本场景 100 m)
深度相机 光线投射 sensor/rangefinder + camera= + data="depth" sensordata[adr:adr+W·H] 一维数组,米 —
2D LiDAR 光线投射 sensor/rangefinder + site= 或 camera resolution="N 1",data="dist" sensordata[adr:adr+N] 米(斜距) -1
3D LiDAR 逐波束 mj_ray 循环 + geomgroup 排自机 mujoco.mj_ray(m, d, pnt, vec, ...) 米 -1

RGB 相机——<camera> 挂在传感器所在的 body 上,mode="targetbodycom" + target 让引擎自己对准目标(避免手推 xyaxes 把物体推到画面外,参见「离屏渲染与 headless 出图」)。

<body name="head" pos="0 0 0.5">
  <!-- 自动对准名为 scene 的 body;fovy 不受 compiler/@angle 影响,永远是度数 -->
  <camera name="cam0" mode="targetbodycom" target="scene"
          resolution="640 480" fovy="58"/>
</body>
import mujoco
m = mujoco.MjModel.from_xml_path("scene.xml")
d = mujoco.MjData(m); mujoco.mj_forward(m, d)         # 取图前必须前向一次算出相机位姿
with mujoco.Renderer(m, 480, 640) as r:               # 尺寸由 Renderer 参数决定,不读 camera/@resolution
    r.update_scene(d, camera="cam0")
    rgb = r.render()                                   # (480, 640, 3) uint8

出图前提:进程启动前设 MUJOCO_GL=egl——后端在 import mujoco 时选定,进程内改环境变量无效。

深度相机——两条通路选一条。光栅化与真实相机同语义(同 body 的机架会遮挡),rangefinder 则把同 body 的 geom 排除在外(理想化的距离传感器)。

<sensor>
  <!-- 一维展平输出:dim = camera/@resolution[0] × resolution[1],字段顺序同光栅化像素 -->
  <rangefinder name="depth_img" camera="cam0" data="depth"/>
</sensor>

一帧从场景到像素的调用顺序:

sequenceDiagram
    participant App as 应用
    participant Mjv as mjv_updateScene
    participant Mjr as mjr_render
    participant Buf as 帧缓冲
    App->>Mjv: m, d, option, camera, mjCAT_ALL
    Mjv-->>App: mjvScene:按位掩码过滤后的图元表
    App->>Mjr: render(viewport, scene, context)
    Mjr->>Buf: 光栅化写像素
    Buf-->>App: uint8 RGB / float32 depth / int32 seg
    Note over App,Buf: 取 depth / seg 前必须先 forward:相机位姿是运动学量
## 通路 A——光栅化(真实图像语义,含同 body 遮挡)
with mujoco.Renderer(m, 480, 640) as r:
    r.enable_depth_rendering()                          # 显式开关,没有 render(return_depth=True)
    r.update_scene(d, camera="cam0")
    depth = r.render()                                  # (H,W) float32,单位米
    depth[depth >= m.stat.extent * m.vis.map.zfar] = np.nan   # 未命中判到远裁剪面后手动置无效

## 通路 B——光线投射(同 body 排除)
sid = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_SENSOR, "depth_img")
adr, dim = int(m.sensor_adr[sid]), int(m.sensor_dim[sid])   # dim = W×H = 307200
depth = d.sensordata[adr:adr+dim].reshape(480, 640)

两条通路对同一场景可以给出完全不同的读数,机制见「两条通路的读数差异」。data="depth" 是垂直距离(z-buffer 语义),斜距要写 data="dist"。实时相机内参与 sensorsize+focal 的写法见「相机内参的两种写法」。

2D LiDAR——平面扫描。单束用 site,一整圈用 camera resolution="N 1" 的网格(N 为水平角分辨率对应的束数)。

<body name="lidar2d" pos="0 0 0.3">
  <joint name="spin" type="hinge" axis="0 0 1"/>            <!-- 机械旋转驱动 -->
  <site   name="lidar_front" pos="0.05 0 0" zaxis="1 0 0" size="0.01"/>
  <camera name="lidar_grid" pos="0.05 0 0" resolution="360 1" fovy="60"/>
</body>
<sensor>
  <rangefinder name="l0"     site="lidar_front" data="dist"/>      <!-- 单束 -->
  <rangefinder name="l_ring" camera="lidar_grid" data="dist"/>     <!-- 360 束一帧 -->
</sensor>
mujoco.mj_step(m, d)                                        # 先推进,rangefinder 属于位置阶段传感器
sid = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_SENSOR, "l_ring")
adr, dim = int(m.sensor_adr[sid]), int(m.sensor_dim[sid])
scan = d.sensordata[adr:adr+dim]                            # (360,) 米;-1 表示未命中
mask = scan > 0                                             # 判命中只能看 dist > 0,-1 不是 NaN

dist 是斜距而非垂直深度——正对一面墙时,dist 从 1.0048 m 涨到 1.1630 m。量程上限必须自己按 dist > range_max 过滤(mj_ray 掠射能打到 68 m)。未命中时的 dir/point/normal 都是 (0,0,0) 而不是 NaN,机制见「挂在相机上的 rangefinder」。

3D LiDAR——多线机械旋转(如 16 线 VLP-16)。MJCF 侧可以用 N 只 site 声明 N 个仰角通道、写 sensor/rangefinder 取静态读数;真实时序(旋转一圈才成一帧)则靠 mj_ray 在用户侧循环。

<body name="tower" pos="0 0 1.8">
  <!-- 16 个仰角通道:zaxis 定该通道的出射方向;数组按器件 datasheet 列 -->
  <site name="ch00" zaxis="1 0 0.268"/>   <!-- +15° -->
  <site name="ch07" zaxis="1 0 0.026"/>   <!-- ≈ 0° -->
  <site name="ch15" zaxis="1 0 -0.268"/>  <!-- -15° -->
</body>
import numpy as np, mujoco
elev = np.deg2rad(np.linspace(-15, 15, 16))                 # 16 通道仰角(VLP-16 手册 ±15°)
azim = np.deg2rad(np.arange(0, 360, 0.2))                   # 水平角 0.2° 步进
origin = np.array([0.0, 0.0, 1.8])                          # 传感器光心
gg = np.array([1, 1, 1, 0, 1, 1], dtype=np.uint8)          # 关闭自机 geom 所在 group
gid, nrm = np.zeros(1, dtype=np.int32), np.zeros(3)         # 命中 geom id 与法向的回写缓冲

points = []
for e in elev:
    for a in azim:
        vec = np.array([np.cos(e)*np.cos(a), np.cos(e)*np.sin(a), np.sin(e)])
        dist = mujoco.mj_ray(m, d, origin, vec, gg, 1, -1, gid, nrm)
        if dist > 0:                                        # 未命中 = -1,直接丢弃
            points.append(origin + dist * vec)

mj_ray 不会自动排除传感器自己的 body;bodyexclude 只接受单个 body id,装在多 body 平台上时无解,必须靠 geomgroup 把自机 geom 统一归到一个 group、发束时把该位清零。双 body 平台上的过滤维度机制见「逐波束扫描与 mj_ray」;机械旋转式与非重复扫描式(如 Livox)在两条通路上的分工见「真机参数到模型参数」。

同一台相机有两条读法与三档输出,取哪一种由下游用途定:

flowchart LR
    cam["相机 + mjvScene"] --> raster["通路 A:光栅化<br/>含同 body 遮挡"]
    cam --> ray["通路 B:逐像素射线<br/>可排除自机 geom"]
    raster --> o1["RGB:uint8"]
    raster --> o2["depth:float32,单位米"]
    raster --> o3["segmentation:int32<br/>(objid, objtype)"]

4.2 渲染器的开关形态与三档输出

3.11 的 Python 侧不提供 render(return_depth=True) 这种参数,深度与分割是显式开关:enable_depth_rendering() / disable_depth_rendering() / enable_segmentation_rendering()。开关之后照常 update_scene() + render(),返回的数组形态就变了:

模式 形状 dtype 说明
默认 (120, 160, 3) uint8 RGB 三通道
depth 开 (120, 160) float32 单位是米,未命中取远裁剪面距离
segmentation 开 (120, 160, 2) int32 两通道;背景为 (-1, -1)

两个通道装的是什么,源码里写得很明确(mujoco/rendering/classic/renderer.py 的分割分支: segid2output[:,0] = geom.objid、[:,1] = geom.objtype):第一通道是对象在其类型内的编号, 第二通道是 mjtObj 枚举——GEOM=5、SITE=6、CAMERA=7、LIGHT=8、BODY=1, 背景(segid 0)被重映射成 (-1, -1)。所以第二通道不是 m.geom_type(那是 mjtGeom 形状枚举, SPHERE=2、BOX=6),两者数值会撞车但含义无关,读到 5/6 应按类型枚举解释,不按形状。 以一只含两只 geom 与一只 site 的场景做分割渲染,分割图给出四种组合——背景 (-1,-1)、两只 geom 的 (0,5) / (1,5)、site 的 (0,6),编号与名字一一对应。

坑:取深度图/分割图前必须先 forward

相机与 site 的世界位姿是运动学量:没 forward 过时 cam_pos 还是零向量,update_scene 出来的整帧都是背景——形状、类型、返回值全都正常,只有画面是空的。

深度以米为单位这一点需要单独强调,因为大量在线教程仍按"归一化到 [0,1]"处理。渲染器内部做的是 reverse-Z 反解,用的是模型自己的裁剪面:默认 vis/map/znear=0.01、zfar=50 乘上 stat.extent=2 之后,真正的近、远裁剪面是 0.02 m 与 100 m——即读数 1.0 只表示"在 100 m 内命中",并非传感器的量程。

znear/zfar 挂在 <visual><map> 上,语义是模型 extent 的乘数,不是绝对距离。于是未命中像素既不是 0 也不是 NaN,而是远裁剪面:

未命中像素的取值即远裁剪面距离 extent*zfar(本例约 100 m);同一视角的分割图只出现背景 (-1, -1) 与 geom 的 (0, 5) 两种取值。

判据:先剔除远裁剪面像素

深度图读者 1.0 只表示“在 extent·zfar 内命中”(本例约 100 m),不是传感器量程;转毫米整型前必须把 >= extent*zfar 的像素判为无效,否则 100 m 的“墙”会混进点云。

4.3 两条通路的读数差异

把一面前表面在 1.0 m 的墙放在相机正前方,并在相机自己的 body 上加一块 0.18 m 的遮挡板:光栅化给出的中心像素深度是 0.180000 m(遮挡板前表面的几何位置),而同一台相机的射线通路 depth 只能读到 1.0 m 一个值。

差异来自一条排除规则:rangefinder 排除挂在同一 body 上的 geom(XML reference §sensor/rangefinder:"Geoms attached to the same body as the sensor site/camera are excluded"),而光栅化不会——图像里遮挡就是遮挡。所以"自己家的机架会不会挡住传感器"这件事,取决于你走哪条通路:做真实相机就用光栅化,做"理想化的距离传感器"就用射线。

4.4 相机内参的两种写法

相机的内参面在 3.11 是完整的:resolution、fovy、projection、sensorsize、focal/focalpixel、principal/principalpixel、ipd、output。三条要留意的行为:

  1. fovy 单位恒为度,与全局角度开关无关。compiler angle="radian" 与 angle="degree" 两种写法下 cam_fovy 都是 58.0000(这一条与第 2 章「compiler 的 angle」是同一类陷阱:开关管不到它)。
  2. sensorsize 与 fovy 互斥,内参一旦给出就由它接管视场。只给 sensorsize="0.040 0.030" focal="0.0289 0.0289" 时,默认 fovy 不再参与,视场由焦距与传感器尺寸算出;两者都显式写出会被解析器拒绝(either 'fovy' or 'sensorsize' ... not both)。
  3. focalpixel 不能单独用:

——focal 必须与 sensorsize 成对出现,单独给 focalpixel 会被拒(失败信息里点出是哪一只相机)。 像素坐标可以直接由内参算出,而 sensor/camprojection 给的就是它。内参 fx = focal/sensorsize[0] × width = 0.0289/0.040 × 640 = 462.4 px、cx=320、cy=240,拿三个目标点对表:

目标点(相机系 X, Y, Z) u = cx + fx·Xc/Zc v = cy − fy·Yc/Zc
(1.0, 0.0, 0.5) 320.00 240.00
(1.0, 0.2, 0.5) 1244.80 55.04
(2.0, −0.1, 0.9) 1347.56 291.38

三点代入与 camprojection 的输出在 1e-5 像素内一致,即该传感器给的就是这条针孔公式;u 越过图像宽度也照常返回,投影不裁剪。

坐标换算是 pc = Rᵀ(p_site − p_cam)(data.cam_xmat 是行主序 3×3),Zc = -pc[2],因为相机看向自身 −Z。这一条也解释了第 4 章四元数换基在相机上的一致性要求。背后目标仍然返回坐标(pos=(-1,0,0.5) 得到 (320.0000, 240.0000)),文档(XMLreference §sensor/camprojection):"Values are not clipped……points behind the camera are also projected",前后判断得自己做——用上面的 Zc 符号即可。

还有一处容易误解:camera/@resolution 不控制图像像素。文档(XMLreference §body/camera):"these values are not used for rendering since those dimensions are determined by the size of the rendering context";resolution="16 12" 只决定射线网格(192 束),出图尺寸由 Renderer(m, 120, 160) 这类调用参数决定,两者各管一头。想要 1280×720 的图就改渲染器尺寸,想要更密的射线网格就改 XML。

出图的后端前提见「离屏渲染与 headless 出图」:用 MUJOCO_GL=egl 即可(不依赖 DISPLAY),glfw 需要有效桌面会话,OSMesa 需安装 libosmesa6;后端在导入时选定,之后改环境变量无效。吞吐取决于后端、场景复杂度与 GPU;需要数字时分别测量 update_scene 与 render 的墙钟时间。

4.5 双目视差与 ipd 的边界

<camera ipd> 常被误当成“双目深度相机的基线开关”,实际它只服务立体渲染、不进单目投影(XMLreference §body/camera/@ipd:“only has an effect during stereoscopic rendering”)——作用是把左右视点各偏移 ±ipd/2(供立体出图给人眼观看),默认 0.068(约人瞳距)。引擎也不提供内建的“视差→深度”输出:渲染器仍是 RGB / depth / segmentation 三档,没有 disparity 通道。

要建模主动双目深度相机(如 D435i 那一类,基线见「真机参数到模型参数」),得自己铺两步:用同一内参、光心水平错开一个基线 B 渲染左右两张图,做立体匹配得到同名点像素差 d,深度按针孔几何 Z = f·B / d 反解——f 由内参给出(resolution 高度与 fovy 决定 f_y = (H/2)/tan(fovy/2))。这条正是「相机内参的两种写法」里那条针孔投影公式 u = cx + fx·X_c/Z_c 的直接推论:640×480、fovy=58° 时 f_y = 433 px,取 B=0.068 m、目标在 2.0 m,视差 d = f_y·B/Z = 14.72 px,反解 Z = f_y·B/d = 2.0000 m 分毫不差。工程上真正卡的是匹配质量与最短距离(近处无法三角化,对应 D435i 的 Min-Z 0.105 m 盲区),而不是这套几何。

5. 射线传感器与 mj_ray

5.1 挂在相机上的 rangefinder

这条通路不碰 GL,一行代码都不用渲染。维度就是相机分辨率乘以每射线的字段数:depth_image(8×6,每射线 1 个字段)→ sensor_dim=48;ortho_grid(5×1,每射线 4 个字段)→ 20;rgb_rich(16×12,每射线 7 个字段)→ 1344。

data 属性可以一次要多个字段,但必须按声明顺序 dist, dir, origin, point, normal, depth:写成 data="point origin" 这种颠倒顺序无法载入;重复的 data="dist dist" 与不存在的 data="rgb" 同样会被拒。这三类失败的信息量差别很大——顺序错了会把正确顺序回显给你,重复与拼错则几乎不说什么,只能自己比对属性值。

dist 与 depth 的差就是激光雷达与深度相机的差。 正对一面平面墙(前表面 x=1.0)时,depth 只有一个值(8 条光线全等于 1.0),而 dist 有 8 个不同值,从 1.0048 一路涨到 1.1630。同一面墙,depth 是常数(计算机图形学意义的 z 深),dist 随像素位置变(斜距,也就是 LiDAR 真正报的数)。把 dist 当深度图用,四角会系统性地偏大。

未命中与排除的行为:朝天那一束(前方无目标)的 dist 是 -1.0;同一根竖直光束上依次有自家 body 上的遮挡体(前表面 0.38 m)与 alpha=0 的隐形几何体(前表面 0.6 m),而四个仰角通道 +15°/+5°/-5°/+15° 的读数是 1.035273/1.003824/1.003824/1.035273——上下对称,说明两个遮挡体都被排除了:mj_ray 同样跳过不可见 geom(XML reference §body/geom 的 rgba:"raycasting reports distances to visual geoms")。未命中时 dir/point/normal 是 (0,0,0) 而不是 NaN——判命中只能看 dist > 0。

透视与正交的差别也直接可读:正交相机下五条射线 dist 全为 1.0,而 origin 在像平面上分散(y = 0.24 / 0.12 / 0 / −0.12 / −0.24)。做平面扫描雷达(如固态/面阵)用正交,做透视视场的用默认。

5.2 逐波束扫描与 mj_ray

机械旋转雷达要的是"每个时刻只发若干束、并随方位角旋转",这得自己写发射循环。mj_ray(m, d, pnt, vec, geomgroup, flg_static, bodyexclude, geomid, normal) 返回射线到最近表面的距离,无交点时返回 -1;bodyexclude 只能给一个 body id(按 body 排除其全部 geom),geomgroup 是 6 位布尔、按 geom 的 group 过滤(把自机 geom 单独归一组才能整批排掉);flg_static 控制静态 geom 是否参与。

按 16 通道 × 水平 1° 发一轮就是 5760 束;命中数与耗时取决于场景与是否排除自机,点云半径还会被墙壁的掠射回波拖到远超雷达量程——量程上限必须自己截。

两个必须知道的性质:

  1. mj_ray 不会自动排除传感器自己的 body,得手动传 bodyexclude;不排时射线先打中自机 geom,排掉后才是目标表面。同一座塔上的 site 版 rangefinder 走的是另一套规则(同 body 的 geom 自动排除),两条通路的结果因此可能不一致。
  2. bodyexclude 只接受单个 body。装在多 body 平台上时,排除一个 body 仍会被另一个 body 的 geom 挡住;此时按 geomgroup 过滤:把自机各 body 的 geom 统一归组,发束时把该位清零,一次排掉所有自机 geom。

bodyexclude 与 geomgroup 是互补的两个过滤维度:前者按 body 排(不看 group),后者按 group 排(不看 body)。

另外 flg_static=0 会让静态 geom 全部退出,目标都是静态 body 时只能得到 -1。

5.3 真机参数到模型参数

下表把器件字段对应到 MJCF / 代码的位置,右侧列出对应的引擎语义:

器件字段 对应到 MJCF / 代码的位置 对应引擎语义
垂直 FOV camera/@fovy(度) cam_fovy 不受 compiler/@angle 影响
水平 FOV 不直接写:由 resolution 宽高比与 fovy 推出;要精确就用 sensorsize+focal sensorsize 接管后最大偏轴角改变
角分辨率 / 线数 / 仰角排布 每通道一个 site(zaxis 定仰角),或 mj_ray 循环里的 vec 四通道读数 1.0038 / 1.0353 与几何一致
量程上限 自己按 dist > range_max 过滤 掠射射线可打到 68.8 m
无效回波 dist == -1(射线)或 depth >= extent*zfar(光栅化) 未命中读 -1 / 100.00341
逐束法向、强度 data="dist normal" 或 mj_ray(..., normal=) normal=[-1, 0, 0] 命中墙面时

具体器件的 datasheet 量级如下(数值取自各家产品规格 / 数据手册,按型号标注;属器件侧外部证据,与上一张表的 MuJoCo 语义分属两头):

器件(类型) 垂直视场 水平视场 角分辨率(垂直 / 水平) 量程 其它关键项 出处
RealSense D435i(主动双目深度相机) 58° 87° 不按下线计 理想 0.3–3 m Min-Z 0.105 m、基线 50 mm、深度精度 <2% @2 m、深度流 1280×720 @90 fps Intel RealSense D435i 产品规格
Velodyne VLP-16(16 线机械旋转) 30°(+15°~−15°) 360° 2.0° / 0.1–0.4° 100 m 测距精度 ±3 cm、波长 903 nm、转速 5–20 Hz Velodyne Puck(VLP-16)数据手册
Ouster OS1-64(64 线数字雷达) Rev6 31.6° / Rev7 45° 360° Rev6 0.52° / Rev7 0.7° Rev6 120 m / Rev7 200 m 水平分辨率 512/1024/2048 可配、精度 ±1.5–5 cm、波长 865 nm Ouster OS1 数据手册
Livox Avia(非重复扫描) 77.2°(非重复)/ 4.5°(重复线扫) 70.4° 无固定线数 450 m(低照度) 测距精度 2 cm、点率 ≥240k 点/秒、内置 BMI088 Livox Avia 产品页

机械旋转式(如 VLP-16、OS1-64 这一类多线机械雷达)适合 mj_ray 循环:把线数与仰角写成数组,按方位角步进发束、每个时刻只累积一小段,就能复现"旋转一圈才成一张完整点云"的时序。非重复扫描式(如 Livox 那一类棱镜/转镜方案)反过来更适合 rangefinder 网格:少量射线在时间上扫出不同采样位置,靠累积补齐视场——这一点在 sensor/rangefinder 上没有内置扫描机构,必须在用户侧循环里改相机姿态或射线方向。

6. IMU 三件套与安装朝向

MuJoCo 没有单一的 imu 元素,六轴/九轴惯导是拼出来的:accelerometer("the linear acceleration of the site (including gravity) in local coordinates",3 个数)、gyro(角速度,3 个数)、magnetometer(把 option/@magnetic 投到 site 系,3 个数)。三个 site 可以重合在同一个刚体原点上,就是器件的坐标系。

6.1 读取时机

needstage 说明了一切:三只加速度计读 needstage=3(mjSTAGE_ACC)、陀螺读 2(mjSTAGE_VEL)、磁强计只需 1(mjSTAGE_POS)——四个阶段枚举定义在 mjtype.h 的 mjSTAGE_NONE/POS/VEL/ACC。

而这一阶段在 mj_forward 里不完整——文档也写了 "The presence of this sensor in a model triggers a call to mj_rnePostConstraint during sensor computation"(XML reference §sensor/accelerometer;约束之后才算得出来)。于是一块与 world 冲接的静止塔上,只调 mj_forward、只补 mj_step1、只补 mj_step2、乃至连跑 50 步,acc_z 全是 [0, 0, 0]。

静态焊接在 world 上的 body 无论如何调整都是 0;同一块盒子被地面支撑时才读到 9.81,自由落体时又回 0。三个细节把这条语义钉牢:支撑静止时 zaxis="0 0 1" 读 [0, 0, 9.81]、zaxis="0 0 -1" 读 [0, 0, -9.81]、zaxis="1 0 0" 读 [-9.81, 0, 0];自由落体到 0.1 s 时 z=2.9500 m、qvel[2]=-0.9810 m/s(在加速)而读数仍是 0;直到 1.6 s 落地后,读数才变回 [0, 0, 9.81]。

三个结论:

  1. 必须在 mj_step 之后取 IMU 读数,mj_forward 之后拿到的 0 不是"没有运动",是还没算。
  2. 加速度计输出的是比力语义(与真实器件一致):静止受支撑读 1 g,自由落体读 0。所以 IMU 装在焊接于 world 的 base 上永远读 0,装在带 freejoint 的浮动基座上才会读到重力——这条在搭固定基座机械臂的观测时特别容易踩。
  3. site 的敏感轴决定读数落在哪个分量、带哪个符号。同一器件 zaxis="0 0 1" 得 [0,0,9.81],zaxis="0 0 -1" 得 [0,0,-9.81],zaxis="1 0 0" 得 [-9.81,0,0]:符号与分量位置全由 site 轴决定。装反就读反,建模时要把器件坐标系与 site 轴一一对齐。

陀螺与磁力计同样随 site 变化:绕 site +Z 以 2 rad/s 空转读出 [0, 0, 2](单位 rad/s);option/@magnetic="0.2 0 -0.4" 在 site 与 world 重合时读 [0.2, 0, -0.4],把 body 绕 world x 转 180° 后读 [0.2, 0, 0.4]——世界场不变,分量随姿态翻。用它做姿态解算前必须先确定安装朝向。

6.2 器件单位、噪声与采样节拍

引擎不再加噪(见第 8 章「共享属性 noise、cutoff、delay、interval」一节),噪声、零偏、随机游走全在用户侧。把 datasheet 的噪声密度换成传感器单位的流程是:σ = 噪声密度 × √(采样率/2),再乘量程换算系数。sensordata 的加速度是 m/s²、角速度是 rad/s:

将上述公式应用于一款 BMI088 量级的器件:加速度计噪声密度 150 μg/√Hz、1 kHz 采样,单轴 σ = 150e-6 × √500 ≈ 0.003354 g,即 0.032893 m/s²;将该噪声叠加到静止于 1 g 的读数上,得到的是 [0.004136, -0.004345, 9.827715] 这样的三元组。再叠上 0.01 g 级零偏,就变成 [0.053169, -0.033765, 9.925782]。陀螺按 0.005 °/s/√Hz、同样 1 kHz 采样,σ = 0.11180 °/s = 1.951e-03 rad/s。

输入噪声密度取自 Bosch BMI088 官方数据手册(BST-BMI088-DS001):陀螺噪声密度 <0.005 °/s/√Hz、量程 ±125…2000 °/s;加速度计噪声密度随量程在约 150–230 μg/√Hz 之间(±24 g 档标 230 μg/√Hz,本例取窄量程档 150 μg/√Hz)、量程 ±3/6/12/24 g。同档的 TDK ICM-20948 是九轴模块(三轴陀螺 + 三轴加速度 + 三轴磁力计 + DMP),加速度 ±2/4/8/16 g、陀螺 ±250…2000 dps。换算式为 σ = 噪声密度 × √(采样率/2)。零偏用固定 bias 向量、漂移用随机游走在 step 循环里累加即可,不需要动 MJCF。

采样节拍走公共属性:@interval/@delay/@nsample(见第 8 章「共享属性 noise、cutoff、delay、interval」)。这里有个真实的建模落差:器件常跑到 1 kHz,而仿真 timestep 是 2 ms(500 Hz)。要么把 interval 设成器件周期并保证有历史缓冲(nsample),要么在用户侧按仿真步重采样——后者更常见,因为 IMU 的姿态解算本来就靠离散积分,步长不一致会直接进误差。

读数通路与其它传感器一致,切片与视图指向同一块内存:

d.sensor(...) 返回的是绑定在同一块 sensordata 内存上的视图,与手工切片读到的是同一份数据(两处读数相等且底层内存相同)。区别只在是否每次重查地址。

光栅化与射线两条通路的差异可以这样对照:把上面光栅化场景里 blocker 的 rgba 改成 0 0 0 0(变成 alpha=0),光栅化中心像素会从 0.18 跳到 1.0 附近,而射线读数不变——两条通路的排除规则由此分开。

7. 六维力/力矩传感器

与 IMU 的拼法同构——sensor/force(dim=3)和 sensor/torque(dim=3)共用同一个 site,合在一起就是六维力/力矩(F/T)传感器。定义(XML reference §sensor/force):"the interaction force between a child and a parent body, expressed in the site frame defining the sensor",方向从子指向父,已包含接触力与外力扰动。

建模时的核心技巧是 dummy body:真实 F/T 器件物理上装在两个刚体之间,仿真里用"无 joint 的子 body 自动与父焊接"表达这条焊路。把 site 放在 dummy body 上,force/torque 读的是 dummy body 和它父 body 之间焊接约束的传力——即 dummy body 以下全部负载(含末端执行器、所抓物体)在同一处的合力。

<sensor>
  <!-- 六维 F/T:两只传感器共用同一 site,各输出 3 个数 -->
  <force  name="ft_F" site="ft_site"/>
  <torque name="ft_T" site="ft_site"/>
</sensor>

完整模型片段(腕装 F/T 传感器):

<body name="forearm" pos="0 0 0.3">
  <joint name="wrist" type="hinge" axis="0 1 0"/>
  <geom name="forearm_g" type="capsule" size="0.025 0.1" mass="1.2"/>

  <!-- dummy body:无 joint ⇒ 自动与 forearm 焊接 -->
  <body name="ft_mount" pos="0 0 -0.12">
    <!-- site 轴向 = 传感器坐标轴;z 指向父 body(腕关节方向) -->
    <site name="ft_site" size="0.01" zaxis="0 0 1"/>
    <geom name="flange" type="cylinder" size="0.03 0.008" mass="0.05"/>

    <!-- 末端执行器挂在 dummy body 之下 -->
    <body name="gripper" pos="0 0 -0.02">
      <geom name="grip_g" type="box" size="0.04 0.03 0.05" mass="0.6"/>
    </body>
  </body>
</body>

三条建模规则:

  1. site 所在 body 是被测“子”,其父 body 是传感器安装面。读数方向从子指向父(XML reference §sensor/force/@site:"points from the child towards the parent");若把 site 改放在父 body 上,读数符号取反。
  2. fusestatic 不会融合带 force/torque site 的静态 body。compiler 节明文列出例外:"They contain a site which is referenced by a force or torque sensor"——即使 dummy body 没有 joint,也不会被融合掉。这是它敢当"传感器安装板"的前提。
  3. site 的轴向决定六分量的分配与符号,同「IMU 三件套与安装朝向」里 accelerometer 的规则一致:zaxis 指向哪里,正读数就往那里推。

读取时机与 accelerometer 完全相同:needstage = mjSTAGE_ACC,触发 mj_rnePostConstraint。mj_forward 后读到的都是零,必须 mj_step 之后才有有效值(同「IMU 三件套与安装朝向」一节的结论)。与 IMU 一样,做控制或记录时只在 step 之后取。

实际工程中最常见的问题是重力补偿:真机 F/T 器件也含有下游部件自重的影响,但控制算法关心的是"末端与环境的交互力"。在用户侧做单步减法即可:

## 静止无外接触时 ft_F 的读数 = gripper + ft_mount 的重力在 site 系中的投影
def compensate_ft(model, data, site_name="ft_site"):
    """减去已知下游重力,得到纯接触/交互力。"""
    sid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SENSOR, "ft_F")
    adr = int(model.sensor_adr[sid])
    # 标定:空载无接触时 mj_step 后读取 ft_F 即为重力分量
    gravity_ft = data.sensordata[adr:adr+3].copy()   # 应在空载静止下采集
    data.sensordata[adr:adr+3] -= gravity_ft          # 用户侧补偿,不改引擎

此补偿只针对静态工况。动态中 dummy body 以下各体加速时会引入惯性力,force/torque 读数随之变化(因为引擎算的是 cfrc_int,含全部约束与外力),但这是真实的动力学效应而非"误差"——真机器件同样会读到。

8. 查看器与 Web 嵌入通路

排查“画面为什么不对”有两条互补的路径:面板与快捷键负责改可见性,句柄 API 负责改场景参数与相机。

Python 侧的入口是 mujoco.viewer 的 launch() 与 launch_passive(model, data, key_callback=...);句柄对象上挂着 cam/opt/pert/user_scn 四个可调结构,以及 lock() / sync() / is_running() / close() 四个方法;命令行则是 python -m mujoco.viewer --mjcf=...(python 章)。键盘侧:Alt + 字母 展开/折叠 UI 分区(F 文件、G 组启用、R 渲染、V 可视化);数字 0…5 切换 geom 组 0…5 的可见性,Shift + 0…5 切换 site 组;F1 是帮助覆盖层。这张快捷键表对 mujoco.viewer 启动的 Python 查看器同样适用(programming/samples 的 simulate 一节)——所以分组显隐的问题就在这一排键上验证。

⇒ 排查"参考系看不见"时的最短路径:Shift+4(打开 site group 4),而不是去改 XML 再重载。

相机:mjvCamera 字段是 type/fixedcamid/trackbodyid/lookat[3]/distance/azimuth/elevation/orthographic(没有 freeCamera 这个字段名);mjtCamera 取值 mjCAMERA_FREE / mjCAMERA_TRACKING / mjCAMERA_FIXED / mjCAMERA_USER,其中 tracking 是跟踪模式——注视点与 mjvCamera.trackbodyid 指定的 MuJoCo 刚体耦合(visualization 文档)。取相机的正路:r.update_scene(data, camera=i) 再 r.render()。

<visual> 在 3.9.0 文档里只列了六个子元素:global、quality、headlight、map、scale、rgba;雾是 map 的 fogstart/fogend + rgba 的 fog: real(4),skybox 属于 asset/texture 的 type 取值——不是 <visual> 的直接子元素(旧示例里那样写会报 schema 错)。

8.1 窗口布局与鼠标操作

窗口分三块:

  • 3D 视口(中间)——模型的实时渲染画面。
  • 左侧面板——一组可折叠分区,双击分区标题可展开或全部折叠;Alt + 字母 直接跳到对应分区。
  • 右侧面板——性能统计、Profiler 图、传感器曲线、Watch 数据;Tab / Shift+Tab 切换左右面板的显隐。

左侧面板从上到下依次是:

分区 缩写键 管什么
File Alt+F 加载/重载模型、保存截图、退出
Option Alt+O 渲染质量、bloom、HDR、透明算法(OIT)
Simulation Alt+S 播放/暂停/单步、实时速度、timestep、integrator、cone
Watch Alt+W 选两个字段画实时折线图
Physics Alt+P 勾选/取消碰撞检测、约束、接触、限位的求解开关
Rendering Alt+R 可视化 flag(见「快捷键总表」)
Visualization Alt+V 相机、灯光、headlight、RGB 映射
Group enable Alt+G geom/site/joint/actuator/tendon 六组可见性
Logging Alt+L 视频录制与日志输出
Joint Alt+J 每个关节的 slider 与状态表
Control Alt+C 每个执行器的 ctrl slider(排查"命令有没有到位"最常用)
Equality Alt+E 等式约束列表

视口内的鼠标手势:

动作 效果
左键拖动 自由相机环绕(orbit)
右键拖动或 Shift+左键拖动 平移画面(pan)
滚轮 / 中键拖动 缩放
双击物体 选中该 body(Joint/Control 分区里会跟着高亮)
右键双击 相机聚焦到选中的 body
Ctrl+右键双击 切换为跟踪相机
Ctrl+左键拖动 对选中的 body 施加平移扰动
Ctrl+Shift+左键拖动 对选中的 body 施加旋转扰动
Page Up 选中当前 body 的父 body
Esc 切回自由相机

8.2 快捷键总表

分类 键 动作
仿真控制 Space 播放 / 暂停
Backspace 重置到 qpos0
Right / Left 单步前进 / 后退(后退读历史缓冲)
+ / - 加速 / 减速
Ctrl+L 重新加载模型(改完 XML 一键重启)
面板与文件 Tab / Shift+Tab 显示 / 隐藏左侧 / 右侧面板
F1 帮助覆盖层(列最常用的键)
F2 信息叠加层(模型名、资源计数)
F3 Profiler 图
F4 传感器曲线
F5 全屏切换
F6 / F7 循环切换坐标架显示 / 标签显示
Ctrl+M / Ctrl+D 把 mjModel / mjData 打印成文本
Ctrl+C 复制当前状态到剪贴板
Ctrl+P 保存截图
Ctrl+Q 退出
相机 [ / ] 循环切换模型里定义的固定相机
Esc 切回自由相机
Ctrl+A 对齐自由相机
可见性分组 0 … 5 切换 geom 组 0…5 的显隐
Shift+0 … Shift+5 切换 site 组 0…5 的显隐
可视化 flag H 凸包 / J 关节 / U 执行器 / Z 灯光 / X 纹理 / Q 相机 对应 mjVISSTRING 中的开关
V 腱 / Y 雷达线 / E 等式约束 / I 惯量 / ' 惯量比例放大 同上
B 扰动力箭头 / C 接触点 / N 隔离岛 同上

两点使用规则。其一,完整列表以窗口里的 F1 帮助覆盖层为准,每个 UI 控件在右键悬停时也会显示它自己的快捷键;代码侧的 flag 名对照 mjVISSTRING。其二,被动模式(launch_passive)下所有"步进/暂停"类快捷键都不起作用——仿真节奏完全由用户代码的 step() 驱动;既想用键又想跑自己的循环,只能在 key_callback 里自行判断按键并决定是否调用 mj_step。

Web 通路不属于引擎文档的覆盖范围。相关表述只有两处:changelog 3.2.0 记了一句 "Added WebAssembly browser version of the simulate (experimental)",属于构建产物说明;以及 programming/visualization 的 Filament 一节说它"可在所有主流平台(Linux、Windows、macOS、Android、iOS、Web)上运行"——那讲的是渲染后端 Filament 的平台支持,不是"MuJoCo 能跑在浏览器里"的承诺。因此下面的 Web 通路都按外部资料对待。

  • 上游仓库 README 在特性列表里明确写有 WebAssembly 条目("WebAssembly: mujoco_wasm",emscripten 构建),并给了浏览器内运行的 live demo —— google-deepmind/mujoco。
  • npm 上的 mujoco-js 提供"pre-compiled WebAssembly module and JavaScript bindings",用法是 loadMujoco() 之后把 Emscripten 的 FS 挂成虚拟文件系统再写 XML ——但包内 README 自述 MJVERSION = '2.2.0',与 3.11 基线跨度很大(npm)。
  • 已有把这套 WASM 构建包成编辑器内实时预览的做法:VS Code 扩展 MuJoCo Viewer 自述 "Physics is real MuJoCo (the official mujoco-js WASM build), not a visual approximation"(Marketplace)。

三条一起说明:“在网页里跑真 MuJoCo”这条路存在且有上游仓库的 emscripten 构建支撑;不过 mujoco-js 自述仍停在 2.2.0,版本、体积与浏览器端的实际运行表现属待核实。

上游仓库本身即带有 wasm 源码:wasm/(含 package.npm.json、demo_app/、tests/)与 unity/(Unity 插件)。所以“web 内嵌”不再是只有第三方 npm 包在讲,而是仓库内即有上游实现;浏览器/Unity 端的运行时行为属待核实。

9. 要点

  • 两阶段:mjv 填 scene,mjr 画 scene;"看不见"要先分清是哪一阶段。
  • 默认可见组 = 0/1/2(geom/site/joint/actuator/tendon 全部如此;MjvOption() 构造即得 [1,1,1,0,0,0]),mjNGROUP=6;site 的默认值文档没写,读引擎。
  • group 的可视化开关与 inertiagrouprange 的惯量开关是两套,别互相推。
  • 指标要随条件变化才有意义:亮像素计数对分组显隐完全无差别,两帧之差才承载信息;出图判据同理——要"物体像素 > 0 且挪开后的像素差 > 0",形状正确不算通过。
  • headless 出图用 MUJOCO_GL=egl,不依赖 DISPLAY;glfw 要有有效桌面会话,OSMesa 需装 libosmesa6 才可用。后端在 import mujoco 时选定,之后再改环境变量无效。
  • 中文文档不含 Web 路线;mujoco_wasm/mujoco-js 是仓库自带的上游构建,运行表现需自行验证。
  • 版本一致性是硬要求:Web 侧的 MuJoCo 版本与基线不同时,第 3、6 章的行为结论不能直接沿用。
  • 感知类器件有两条独立通路:光栅化(Renderer 的 enable_depth_rendering()/enable_segmentation_rendering(),需可用 GL 后端)与光线投射(rangefinder 挂相机 / mj_ray 逐波束,全离线)。两者会给出不同的数,而且都对:同 body 的遮挡物在射线通路被排除(读 1.0 m),在光栅化里保留(读 0.18 m)。
  • 深度图用 data="depth"(垂直距离,平面墙只有一个值),激光雷达用 data="dist"(斜距,同一面墙从 1.0048 涨到 1.1630);两者都是米。未命中的表示各不相同:射线是 dist = -1、dir/point/normal 为 (0,0,0)(不是 NaN),光栅化是远裁剪面 extent×zfar(本例约 100 m),分割图背景为 (-1, -1)。
  • 相机内参用 sensorsize+focal 写实时 fovy 被接管,focalpixel 单独用会被拒;cam_fovy 永远是度数(与 compiler/@angle 无关);camera/@resolution 只定射线网格、不定图像像素。camprojection 就是这条针孔公式。
  • IMU 是 accelerometer+gyro+magnetometer 三个 site 拼出来的。accelerometer 的 needstage=ACC,必须在 mj_step 之后读:mj_forward/step1/step2 之后都是 [0,0,0]。它是比力语义(受支撑 9.81、自由落体 0),所以装在焊接于 world 的 base 上永远读 0;读数在 site 局部系,敏感轴装反就符号读反。
  • 六维力/力矩传感器由 force+torque 两只 site 传感器拼装(dim=3+3=6)。核心建模技巧是 dummy body(无 joint 的子 body 自动与父焊接,fusestatic 不会融合它);读数语义为子↔父通过约束传递的合力/合力矩,方向从子指向父;needstage=ACC、同 accelerometer 一样必须 mj_step 后才能取到有效值;下游自重含在内部,做交互力控制时需在用户侧减去重力分量。
  • 器件噪声与零偏全部在用户侧(引擎不加噪),换算用 σ = 噪声密度 × √(采样率/2)。

参考文献与延伸阅读

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