用于MuJoCo的Three.js渲染
静态几何体仅构建一次,每帧仅更新变换。 MuJoCo采用Z-up坐标系,使用半尺寸、Z对齐的圆柱体和胶囊体,以及wxyz四元数。Three.js默认采用Y-up坐标系,使用全尺寸、Y对齐的圆柱体和xyzw四元数。几乎所有的渲染错误都源于这些差异。
1. 场景设置(Z-up,可用于捕获)
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 路径在0.164和0.18x版本中有效
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); // toDataURL需要
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100);
camera.up.set(0, 0, 1); // MuJoCo是Z-up的。这样设置后,任何地方都不需要进行轴转换。
camera.position.set(2, -1.5, 2.5);
const controls = new OrbitControls(camera, renderer.domElement);
const grid = new THREE.GridHelper(5, 50); grid.rotation.x = Math.PI / 2; // 将网格平放在XY平面上
务必在创建OrbitControls之前设置camera.up,因为控制器会缓存up向量。
2. 选择一种方法
| 方法 | 实现方式 | 使用场景 |
|---|---|---|
A. 每个刚体一个Group(Franka演示采用) | 每个刚体组在其局部geom_pos/geom_quat处保存几何体。每帧复制xpos/xquat。 | 默认方法。通过userData.bodyID进行拾取。适用于任何位姿来源。 |
B. mjv_updateScene(官方演示) | 为每个scene.geoms条目同步一个池化网格 | 当你需要MuJoCo自身的装饰(接触、肌腱、站点)时 |
| C. 远程Python | Python执行步骤,并通过WebSocket流式传输xpos/xquat。浏览器运行方法A。 | GPU、ROS或真实机器人桥接,计算密集型任务 |
几何体转换:references/geom-conversion.md。Python流式传输(经过测试的服务器):references/remote-python.md。
快照、感知相机、视频以及像素与3D的互相转换:references/perception-and-video.md。
3. 每帧同步(方法A)
for (let i = 0; i < model.nbody; i++) {
bodies[i].position.set(data.xpos[3*i], data.xpos[3*i+1], data.xpos[3*i+2]);
bodies[i].quaternion.set(data.xquat[4*i+1], data.xquat[4*i+2], data.xquat[4*i+3], data.xquat[4*i]); // wxyz -> xyzw
}
renderer.render(scene, camera);
循环顺序:输入 → mj_step(根据仿真时间预算执行尽可能多的次数)→ 同步 → 渲染
(参见MujocoSim.startLoop)。另一种方法是每几何体使用data.geom_xpos和data.geom_xmat
(行主序3×3 → Matrix4.set(r00,r01,r02,0, r10,…)),此时几何体处于单位位姿。
4. ER点 ↔ 3D
// ER [y, x] 0-1000 在捕获图像上 -> 从捕获该图像的同一个相机发出的射线
const ndc = new THREE.Vector2((x / 1000) * 2 - 1, 1 - (y / 1000) * 2);
raycaster.setFromCamera(ndc, captureCamera);
const hit = raycaster.intersectObjects(simMeshes, false)[0]; // 或 raycaster.ray.intersectPlane(table, out)
// 3D -> ER风格的 [y, x](用于绘制期望或构建提示)
const v = p.clone().project(captureCamera);
const yx = [(1 - v.y) * 500, (v.x + 1) * 500];
这在任意宽高比下都有效,前提是图像恰好是相机的完整视口(无裁剪、无黑边)且相机在捕获和射线投射之间没有移动。通过检查userData.bodyID跳过机器人或地板上的命中。命中的是顶面,因此需将其降低到抓取高度。
5. 常见问题
| 现象 | 原因 |
|---|---|
| 机器人侧倒,轨道控制翻转 | camera.up未设置为(0,0,1),或在创建OrbitControls之后设置 |
| 链接扭曲 | 缺少wxyz→xyzw的重新排序 |
| 方块大了一倍 | 忘记尺寸是半长 |
| 圆柱体或胶囊指向错误方向 | 缺少geometry.rotateX(Math.PI/2) |
| 空白或黑色快照 | preserveDrawingBuffer: false,或者像素在与渲染不同的任务中读取 |
| 感知图像比屏幕暗 | 渲染目标输出线性颜色(在three r186中已验证)。转换为sRGB(参见感知参考)。 |
| ER标记偏移 | 相机移动了,[y, x]交换了,图像被裁剪或添加了黑边,或者设备像素比画布大小调整不一致 |
| 重新加载时内存增长 | 需要调用geometry.dispose()、material.dispose()、renderTarget.dispose()并删除WASM模型和数据 |
| 高速时卡顿 | 每帧mj_step调用过多。限制步数,或将物理引擎移至Worker。 |
6. 在Franka演示中
Franka拾放演示是这些技能所基于的参考应用程序。以下文件名即指该演示。
RenderSystem.ts使用preserveDrawingBuffer: true、Z-up的PerspectiveCamera(45)、带阻尼的OrbitControls、simGroup(每个刚体一个组,userData.bodyID)、erGroup(上下浮动的锥形标记)、实例化接触标记、 相机缓动动画(moveCameraTo)和getCanvasSnapshot(w, h, mime)。project2DTo3D(x, y, camPos, lookAt)克隆主相机,将其定位在捕获视点 ((0, -0.01, 2.0)看向原点),并对simGroup网格进行射线投射。rendering/GeomBuilder.ts处理几何体到网格的工厂函数。组3的几何体被跳过,平面变为Reflector地板, 网格几何体读取mesh_vert/mesh_face。DragStateManager.ts和SelectionManager.ts通过xfrc_applied处理鼠标拾取和拖拽力。index.html的导入映射将three.js固定在0.164.1版本,而package.json指定了^0.181。请保持使用three/examples/jsm/...导入,它在两个版本中都有效。