Grupos y pivotes: rotar alrededor de lo que no es el origen
Qué aporta Group frente a Object3D, cómo se resuelve un pivote descentrado con jerarquía en lugar de matrices, y qué cuesta de verdad ocultar un subárbol.
La pregunta «cómo hago que este objeto gire alrededor de aquel punto» tiene una respuesta con matrices que es correcta y farragosa, y otra con jerarquía que es correcta y trivial. Elegir la segunda no es pereza: convierte un problema de composición de transformaciones en un problema de estructura, que es más fácil de leer, de depurar y de modificar. Los grupos son la herramienta, y su coste y sus límites conviene conocerlos.
- Resolver una rotación alrededor de un punto arbitrario con un nodo contenedor.
- Construir un sistema de órbitas anidadas sin componer matrices a mano.
- Diferenciar
GroupdeObject3Dy saber qué aporta realmente. - Medir qué ahorra y qué no ahorra ocultar un subárbol con
visible.
El pivote como problema y como estructura
Una rotación siempre ocurre alrededor del origen del sistema de coordenadas en el que se aplica. Si quieres que un objeto gire alrededor de un punto distinto, tienes dos caminos.
El camino matricial: trasladar el objeto de modo que el punto deseado quede en el origen, rotar, y deshacer la traslación. Son tres matrices en el orden correcto, y funciona, pero hay que componerlas a mano, mantenerlas actualizadas y volver a razonar el orden cada vez que alguien toca el código.
El camino estructural: crear un nodo contenedor en el punto de pivote, meter el objeto dentro desplazado en sentido contrario, y rotar el contenedor. La jerarquía compone las matrices por ti, en el orden correcto, y el código dice literalmente lo que hace.
import * as THREE from 'three';
const scene = new THREE.Scene();
// Una puerta que gira sobre su bisagra izquierda, no sobre su centro.
const ANCHO = 1.2;
const bisagra = new THREE.Group();
bisagra.position.set(-ANCHO / 2, 0, 0); // aqui esta el eje de giro
scene.add(bisagra);
const hoja = new THREE.Mesh(
new THREE.BoxGeometry(ANCHO, 2, 0.06),
new THREE.MeshStandardMaterial({ color: 0xf9e2af })
);
hoja.position.x = ANCHO / 2; // desplazada para que su borde caiga en la bisagra
bisagra.add(hoja);
// Abrir la puerta es rotar el contenedor, no la hoja.
export function abrir(gradosAbiertos) {
bisagra.rotation.y = THREE.MathUtils.degToRad(gradosAbiertos);
}
Ese patrón resuelve una lista larga de casos que aparecen constantemente: una rueda que gira sobre su eje pero está montada en un brazo, una tapa con bisagra, un brazo articulado, una cámara que orbita alrededor de un objetivo, un péndulo, un satélite. En todos ellos el nodo contenedor es el pivote, y su posición es el dato geométrico que quieres tener explícito.
Órbitas anidadas
Cuando hay varios niveles, la jerarquía es la única solución razonable. Un sistema solar con rotación propia y traslación por cada cuerpo tiene una estructura evidente y ninguna matriz escrita a mano.
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x11111b);
const camera = new THREE.PerspectiveCamera(
55, window.innerWidth / window.innerHeight, 0.1, 200
);
camera.position.set(0, 9, 14);
camera.lookAt(0, 0, 0);
scene.add(camera);
scene.add(new THREE.AmbientLight(0xffffff, 0.25));
const luzSolar = new THREE.PointLight(0xffffff, 200, 0, 2);
scene.add(luzSolar);
function cuerpo(radio, color) {
return new THREE.Mesh(
new THREE.SphereGeometry(radio, 32, 16),
new THREE.MeshStandardMaterial({ color, roughness: 0.8 })
);
}
// Estrella en el origen.
const estrella = cuerpo(1.4, 0xf9e2af);
estrella.material.emissive = new THREE.Color(0xf9e2af);
estrella.material.emissiveIntensity = 0.6;
scene.add(estrella);
// Nivel 1: el contenedor que define la orbita del planeta.
const orbitaPlaneta = new THREE.Group();
scene.add(orbitaPlaneta);
const planeta = cuerpo(0.7, 0x89b4fa);
planeta.position.x = 6; // radio de la orbita
orbitaPlaneta.add(planeta);
// Nivel 2: la orbita de la luna cuelga del planeta y viaja con el.
const orbitaLuna = new THREE.Group();
planeta.add(orbitaLuna);
const luna = cuerpo(0.22, 0xcdd6f4);
luna.position.x = 1.5;
orbitaLuna.add(luna);
const reloj = new THREE.Timer();
reloj.connect(document);
renderer.setAnimationLoop((tiempo) => {
reloj.update(tiempo);
const dt = Math.min(reloj.getDelta(), 0.1);
estrella.rotation.y += 0.2 * dt; // rotacion propia de la estrella
orbitaPlaneta.rotation.y += 0.35 * dt; // traslacion del planeta
planeta.rotation.y += 1.2 * dt; // rotacion propia del planeta
orbitaLuna.rotation.y += 2.0 * dt; // traslacion de la luna
renderer.render(scene, camera);
});
Fíjate en un detalle que ilustra la potencia del modelo: orbitaLuna cuelga de planeta, no de orbitaPlaneta. Eso significa que la órbita de la luna hereda también la rotación propia del planeta, con lo que la luna se ve arrastrada por el giro del planeta además de por su propia órbita. Si eso no es lo que quieres —y en un sistema solar real no lo es— la solución es colgar orbitaLuna de orbitaPlaneta y darle al planeta un contenedor propio. La estructura del árbol es el modelo físico, y cambiarla es cambiar el comportamiento.
Group frente a Object3D
Group extiende Object3D y no añade ni un método ni una propiedad de comportamiento: lo único que cambia es la cadena de type y una bandera isGroup. Es un marcador semántico.
Su valor está justo ahí, en la semántica. Un Group en el árbol comunica que ese nodo existe para agrupar, y varias herramientas —el inspector del navegador, los exportadores, el editor— lo tratan como contenedor. Un Object3D desnudo comunica lo mismo con menos claridad. Usa Group cuando el nodo esté para agrupar, y Object3D cuando esté para representar una entidad que no se dibuja.
Hay un comportamiento del renderer que merece conocerse porque es específico de los grupos: cuando se establece renderOrder en un grupo, todos sus descendientes se ordenan y se dibujan juntos respetando ese valor. Es la forma limpia de forzar que un conjunto entero de objetos se dibuje después de otro, cosa que hace falta con transparencias y con capas de efectos.
La forma natural de quitar algo de la vista es objeto.visible = false, y funciona: el renderer, al recoger lo que hay que dibujar, se salta ese nodo y todo su subárbol. No se emite ninguna orden de dibujo, no se prueba contra el frustum, no se ordena. Lo que mucha gente asume de ahí es que el subárbol oculto deja de costar, y no es cierto. La actualización de matrices no consulta la visibilidad: recorre el árbol completo, recompone la matriz local de cada nodo con actualización automática y multiplica por la del padre, esté visible o no. Con un inventario de mil objetos ocultos, o una ciudad entera oculta mientras el jugador está en interiores, estás pagando cada fotograma el coste de actualizar transformaciones que nadie va a mirar. En una escena grande eso es perfectamente medible y aparece en el perfil como tiempo en la actualización de matrices sin ningún dibujado que lo justifique, que es justo el síntoma que hace pensar que el problema está en otra parte. Hay tres remedios según cuánto dure la ocultación. Para algo que se oculta y se muestra a menudo, dentro del mismo fotograma o del siguiente, visible es lo correcto y el coste es asumible. Para algo que va a estar oculto durante segundos o minutos, desactiva además matrixWorldAutoUpdate en la raíz del subárbol, y el recorrido automático dejará de entrar en él. Y para algo que puede no volver a mostrarse, quítalo del árbol con removeFromParent y guárdate la referencia; volver a añadirlo cuesta una llamada. La regla de fondo es que en Three.js el coste de un objeto tiene dos componentes, el de dibujado y el de estructura, y visible solo apaga el primero.
Cuándo la jerarquía deja de ser la respuesta
Los grupos son excelentes hasta que dejan de serlo, y conviene reconocer los dos casos.
Cuando la relación no es de arrastre. Si un objeto tiene que seguir a otro con retraso, con amortiguación o con una restricción parcial —solo en horizontal, solo en rotación—, la jerarquía no lo expresa: la jerarquía es rígida y total. Ese tipo de vínculo se resuelve con código en la fase de actualización, leyendo la posición global de uno y escribiendo la del otro.
Cuando hay miles de instancias. Un bosque con veinte mil árboles no debe ser veinte mil nodos, cada uno con su matriz recompuesta por fotograma y su orden de dibujo. Debe ser una malla instanciada con veinte mil matrices en un búfer, dibujada de una vez. Ese es el salto que cambia el orden de magnitud, y tiene su propio nivel en el track.
Entre ambos extremos, la jerarquía es la herramienta correcta y conviene usarla sin complejos: es más legible que componer matrices y el motor está optimizado para ella. Lo que falta para dominar el grafo es saber recorrerlo, que es la última lección del nivel.