Jerarquía: add, remove, attach y el árbol de la escena
Cómo se construye el grafo de escena, qué diferencia hay entre añadir y adjuntar un objeto, y por qué quitar hijos dentro de un bucle se salta la mitad.
Un árbol con coordenadas relativas
En ese árbol, la posición de la luna está expresada respecto a su grupo de órbita lunar, que a su vez está respecto al grupo de órbita terrestre, que está respecto al grupo del sistema, que está respecto a la escena. Girar el grupo de órbita terrestre mueve la Tierra y arrastra consigo toda la mecánica lunar, sin que haya una sola línea que relacione la Luna con el Sol.
Esa es la propiedad que hace útil el grafo, y la que conviene tener presente al diseñar la estructura: la jerarquía no es organización, es transformación. Un nodo debería existir cuando hay una relación real de arrastre entre él y sus hijos.
Añadir, quitar y vaciar
add acepta varios objetos de una vez y hace algo importante antes de añadir: quita el objeto de su padre anterior. Un Object3D tiene como mucho un padre, y la biblioteca lo garantiza en lugar de dejarte crear un estado inconsistente.
remove acepta también varios. removeFromParent es la versión cómoda cuando no tienes a mano la referencia al padre. Y clear quita todos los hijos de golpe.
import * as THREE from 'three';const scene = new THREE.Scene();const grupoA = new THREE.Group();const grupoB = new THREE.Group();scene.add(grupoA, grupoB);const objeto = new THREE.Object3D();grupoA.add(objeto);console.log('padre:', grupoA.children.includes(objeto)); // true// Anadir a otro padre lo quita del primero automaticamente.grupoB.add(objeto);console.log('sigue en A:', grupoA.children.includes(objeto)); // falseconsole.log('ahora en B:', grupoB.children.includes(objeto)); // trueobjeto.removeFromParent();console.log('sin padre:', objeto.parent); // nullgrupoB.clear();console.log('hijos de B:', grupoB.children.length); // 0add frente a attach
Aquí está la distinción que más confusión produce y que casi nunca se explica hasta que alguien la sufre.
add cambia el padre y conserva la transformación local. Como esa transformación se interpreta ahora respecto a un padre distinto, la posición en el mundo cambia. Un objeto en (0, 0, 0) dentro de un grupo situado en (10, 0, 0) está en (10, 0, 0) del mundo; al moverlo con add a un grupo situado en el origen, salta a (0, 0, 0) del mundo. La transformación local no ha cambiado; el significado de esa transformación sí.
attach hace lo contrario: cambia el padre y conserva la transformación de mundo, recalculando la local para compensar. El objeto no se mueve visualmente.
Cuál quieres depende del caso, y ambos son legítimos. add es lo natural al construir una escena, donde las posiciones se piensan relativas al contenedor. attach es lo natural cuando un objeto que ya está colocado en el mundo cambia de dueño: una herramienta que un personaje recoge del suelo, una pieza que se acopla a un vehículo en marcha, un objeto que un editor mete en un grupo sin que el usuario vea ningún salto.
import * as THREE from 'three';const scene = new THREE.Scene();const mano = new THREE.Group();mano.position.set(2, 1.5, 0);scene.add(mano);const herramienta = new THREE.Mesh( new THREE.BoxGeometry(0.2, 0.2, 0.6), new THREE.MeshNormalMaterial());herramienta.position.set(-3, 0, 1); // tirada en el sueloscene.add(herramienta);scene.updateMatrixWorld(true);const antes = new THREE.Vector3();herramienta.getWorldPosition(antes);// attach conserva la posicion en el mundo: no hay salto visual.mano.attach(herramienta);scene.updateMatrixWorld(true);const despues = new THREE.Vector3();herramienta.getWorldPosition(despues);console.log('posicion de mundo conservada:', antes.distanceTo(despues) < 1e-6);console.log('posicion local recalculada:', herramienta.position.toArray() .map((n) => +n.toFixed(3))); // (-5, -1.5, 1)attach tiene la misma limitación documentada que lookAt: no funciona correctamente cuando algún ancestro tiene escala no uniforme, porque internamente descompone la matriz resultante y esa descomposición no es fiable cuando hay cizallamiento, tal como se explicó en la lección de composición.
Eventos del árbol
Object3D es un emisor de eventos y dispara cuatro que sirven para mantener índices o estructuras paralelas sincronizadas con el grafo sin tener que interceptar las llamadas.
added y removed se disparan sobre el objeto que cambia de padre. childadded y childremoved se disparan sobre el padre, con el hijo afectado en el evento.
import * as THREE from 'three';const scene = new THREE.Scene();const indice = new Map();scene.addEventListener('childadded', (evento) => { indice.set(evento.child.uuid, evento.child);});scene.addEventListener('childremoved', (evento) => { indice.delete(evento.child.uuid);});const a = new THREE.Object3D();scene.add(a);console.log('indexados:', indice.size); // 1scene.remove(a);console.log('indexados:', indice.size); // 0Conviene saber que estos eventos son de un solo nivel: se disparan por el hijo directo, no por los descendientes que vengan colgando de él. Si necesitas indexar subárboles completos, hay que recorrerlos al recibir el evento.
// Mal: elimina solo la mitad.grupo.children.forEach((hijo) => grupo.remove(hijo));// Bien: de atras hacia delante.for (let i = grupo.children.length - 1; i >= 0; i--) { grupo.remove(grupo.children[i]);}// Bien: sobre una copia.for (const hijo of [...grupo.children]) grupo.remove(hijo);// Mejor si es un vaciado completo.grupo.clear();Consultar el árbol
Para localizar nodos hay varios métodos que recorren el subárbol empezando por el propio objeto: getObjectByName, getObjectById, getObjectByProperty y su variante en plural getObjectsByProperty, que devuelve todos los que coinciden.
Todos son búsquedas lineales sobre el árbol completo. Usarlos una vez tras cargar un modelo es perfectamente razonable; usarlos dentro del bucle de render sobre una escena con miles de nodos no lo es. El patrón correcto es buscar una vez, guardar las referencias, y trabajar con ellas. Cómo recorrer el árbol de forma eficiente y qué recetas resuelve ese recorrido es el contenido de la última lección de este nivel.