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 平面に配置
camera.up は OrbitControls を作成する前に設定してください。コントロールが up ベクトルをキャッシュするためです。
2. アプローチの選択
| アプローチ | 方法 | 使用する状況 |
|---|---|---|
A. ボディごとに 1 つの Group(Franka デモ) | 各ボディグループは自身の geom_pos/geom_quat でジオメトリを保持。フレームごとに xpos/xquat をコピー。 | デフォルト。userData.bodyID によるピッキング。任意のポーズソースで動作。 |
B. mjv_updateScene(公式デモ) | scene.geoms エントリごとに 1 つのプールされたメッシュを同期 | MuJoCo 独自の装飾(接触、腱、サイト)が必要な場合 |
| C. リモート Python | Python がステップ実行し、xpos/xquat を WebSocket でストリーミング。ブラウザは 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 -> キャプチャに使用した 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 の並び替え忘れ |
| ボックスが 2 倍の大きさ | サイズが半サイズであることを忘れている |
| 円柱やカプセルの向きが逆 | 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(ボディごとに 1 つのグループ、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 0.164.1 を固定しており、package.jsonは^0.181です。three/examples/jsm/...インポートはそのまま維持してください。これらは両方で動作します。