Renderizado Three.js para MuJoCo
Construye geometría estática una vez, luego actualiza solo las transformaciones en cada fotograma. MuJoCo usa Z hacia arriba, medios tamaños, cilindros y cápsulas alineados con Z, y cuaternios wxyz. Three.js usa Y hacia arriba por defecto, tamaños completos, cilindros alineados con Y y cuaternios xyzw. Casi todos los errores de renderizado provienen de una de estas diferencias.
1. Configuración de escena (Z hacia arriba, lista para captura)
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // ruta válida en 0.164 y 0.18x
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); // necesario para 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 es Z hacia arriba. Con esto, no se necesita conversión de ejes en ningún lado.
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; // coloca la cuadrícula en XY
Establece camera.up antes de crear OrbitControls, porque los controles almacenan en caché el vector hacia arriba.
2. Elige un enfoque
| Enfoque | Cómo | Cuándo usarlo |
|---|---|---|
A. Un Group por cuerpo (el demo de Franka) | Cada grupo de cuerpo contiene sus geoms en sus geom_pos/geom_quat locales. Copia xpos/xquat en cada fotograma. | Por defecto. Selección mediante userData.bodyID. Funciona con cualquier fuente de pose. |
B. mjv_updateScene (demo oficial) | Sincroniza una malla agrupada por cada entrada de scene.geoms | Cuando quieras decoraciones propias de MuJoCo (contactos, tendones, sitios) |
| C. Python remoto | Python avanza la simulación y transmite xpos/xquat por WebSocket. El navegador ejecuta A. | Puentes con GPU, ROS o robots reales, cómputo intensivo |
Conversión de geoms: references/geom-conversion.md. Streaming Python (servidor probado): references/remote-python.md.
Instantáneas, cámara de percepción, video y píxel↔3D: references/perception-and-video.md.
3. Sincronización por fotograma (enfoque 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);
Orden del bucle: entradas → mj_step (tantas veces como permita el presupuesto de tiempo de simulación) → sincronización → renderizado
(ver MujocoSim.startLoop). La alternativa por geom usa data.geom_xpos y data.geom_xmat
(matriz 3×3 por filas → Matrix4.set(r00,r01,r02,0, r10,…)), con la geometría en la pose identidad.
4. Puntos ER ↔ 3D
// ER [y, x] 0-1000 en la imagen capturada -> rayo desde la MISMA cámara que la capturó
const ndc = new THREE.Vector2((x / 1000) * 2 - 1, 1 - (y / 1000) * 2);
raycaster.setFromCamera(ndc, captureCamera);
const hit = raycaster.intersectObjects(simMeshes, false)[0]; // o raycaster.ray.intersectPlane(table, out)
// 3D -> ER-style [y, x] (para dibujar expectativas o construir prompts)
const v = p.clone().project(captureCamera);
const yx = [(1 - v.y) * 500, (v.x + 1) * 500];
Esto es válido para cualquier relación de aspecto siempre que la imagen sea exactamente el viewport completo de la cámara (sin recorte, sin letterbox) y la cámara no se haya movido entre la captura y el raycasting. Omite los impactos en el robot o el suelo recorriendo los userData.bodyID. El impacto está en la superficie superior, así que bájalo a la altura de agarre.
5. Problemas comunes
| Síntoma | Causa |
|---|---|
| El robot está acostado, la órbita se tambalea | camera.up no es (0,0,1), o se estableció después de crear OrbitControls |
| Eslabones torcidos | Falta el reordenamiento wxyz→xyzw |
| Cajas del doble de tamaño | Olvidaste que los tamaños son medios tamaños |
| Cilindros o cápsulas apuntan en dirección incorrecta | Falta geometry.rotateX(Math.PI/2) |
| Instantánea en blanco o negra | preserveDrawingBuffer: false, o los píxeles se leyeron en una tarea diferente al renderizado |
| Imagen de percepción más oscura que la pantalla | Los render targets generan color lineal (verificado en three r186). Convierte a sRGB (consulta la referencia de percepción). |
| Marcadores ER desplazados | La cámara se movió, [y, x] está invertido, la imagen está recortada o con letterbox, o el lienzo de relación de píxeles del dispositivo se redimensionó de manera diferente |
| La memoria crece al recargar | geometry.dispose(), material.dispose(), renderTarget.dispose(), y elimina el modelo y datos WASM |
| Tirones a alta velocidad | Demasiadas llamadas mj_step por fotograma. Limita los pasos o mueve la física a un Worker. |
6. En el demo de Franka
El demo de pick-and-place de Franka es la aplicación de referencia a partir de la cual se construyeron estos skills. Los nombres de archivo a continuación se refieren a ella.
RenderSystem.tsusapreserveDrawingBuffer: true, unaPerspectiveCamera(45)con Z hacia arriba,OrbitControlsamortiguados,simGroup(un grupo por cuerpo,userData.bodyID),erGroup(marcadores cónicos oscilantes), marcadores de contacto instanciados, interpolaciones de cámara (moveCameraTo), ygetCanvasSnapshot(w, h, mime).project2DTo3D(x, y, camPos, lookAt)clona la cámara principal, la posiciona en el punto de vista de captura ((0, -0.01, 2.0)mirando al origen), y realiza raycasting sobre las mallas desimGroup.rendering/GeomBuilder.tsmaneja la fábrica de geom→malla. Las geoms del grupo 3 se omiten, los planos se convierten en un sueloReflector, y las geoms de malla leenmesh_vert/mesh_face.DragStateManager.tsySelectionManager.tsmanejan la selección con el ratón y las fuerzas de arrastre a través dexfrc_applied.- El mapa de importación de
index.htmlfija three 0.164.1, mientras quepackage.jsontiene^0.181. Mantén las importaciones dethree/examples/jsm/..., que funcionan en ambas versiones.