第 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。三条要留意的行为:
fovy单位恒为度,与全局角度开关无关。compiler angle="radian"与angle="degree"两种写法下cam_fovy都是58.0000(这一条与第 2 章「compiler的angle」是同一类陷阱:开关管不到它)。sensorsize与fovy互斥,内参一旦给出就由它接管视场。只给sensorsize="0.040 0.030" focal="0.0289 0.0289"时,默认fovy不再参与,视场由焦距与传感器尺寸算出;两者都显式写出会被解析器拒绝(either 'fovy' or 'sensorsize' ... not both)。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 束;命中数与耗时取决于场景与是否排除自机,点云半径还会被墙壁的掠射回波拖到远超雷达量程——量程上限必须自己截。
两个必须知道的性质:
mj_ray不会自动排除传感器自己的 body,得手动传bodyexclude;不排时射线先打中自机 geom,排掉后才是目标表面。同一座塔上的site版 rangefinder 走的是另一套规则(同 body 的 geom 自动排除),两条通路的结果因此可能不一致。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]。
三个结论:
- 必须在
mj_step之后取 IMU 读数,mj_forward之后拿到的 0 不是"没有运动",是还没算。 - 加速度计输出的是比力语义(与真实器件一致):静止受支撑读 1 g,自由落体读 0。所以 IMU 装在焊接于 world 的 base 上永远读 0,装在带
freejoint的浮动基座上才会读到重力——这条在搭固定基座机械臂的观测时特别容易踩。 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>
三条建模规则:
site所在 body 是被测“子”,其父 body 是传感器安装面。读数方向从子指向父(XML reference §sensor/force/@site:"points from the child towards the parent");若把site改放在父 body 上,读数符号取反。fusestatic不会融合带 force/torque site 的静态 body。compiler 节明文列出例外:"They contain a site which is referenced by a force or torque sensor"——即使 dummy body 没有 joint,也不会被融合掉。这是它敢当"传感器安装板"的前提。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)。