使用 Three.js 渲染 MuJoCo
靜態幾何體只建立一次,每幀僅更新變換矩陣。 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. 每個 body 一個 Group(Franka 示範) | 每個 body 群組在其局部 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
(row-major 3×3 → Matrix4.set(r00,r01,r02,0, r10,…)),幾何體位於單位姿態。
4. ER 座標 ↔ 3D
// ER [y, x] 0-1000 在擷取的影像上 -> 從拍攝該影像的 SAME 相機發射射線
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(每個 body 一個群組,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的 import map 將 three 的版本固定為 0.164.1,而package.json則有^0.181。請保留three/examples/jsm/...的匯入,此寫法在兩個版本中皆可運作。