wandres.dev
EL GRAFO DE ESCENA · Object3D y la jerarquía

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.

⏱ 17 min

El grafo de escena es un árbol donde cada nodo tiene como mucho un padre y donde la transformación de un nodo se expresa siempre en el sistema de coordenadas de ese padre. Esa doble restricción —un padre, coordenadas relativas— es lo que hace que mover un brazo mueva la mano sin que nadie lo programe, y también lo que produce el salto desconcertante de un objeto que cambia de padre y aparece en otro sitio.

🎯 Al terminar esta lección sabrás
  • Construir y modificar una jerarquía con add, remove, attach y clear.
  • Predecir qué le ocurre a la posición de un objeto al cambiar de padre.
  • Usar los eventos de adición y eliminación para reaccionar a cambios del árbol.
  • Evitar el error de modificar el array de hijos mientras se recorre.

Un árbol con coordenadas relativas

flowchart TB
escena[Scene] --> sol[Grupo sistema]
escena --> luz[DirectionalLight]
escena --> camara[PerspectiveCamera]
sol --> orbita[Grupo orbita terrestre]
sol --> estrella[Mesh estrella]
orbita --> tierra[Mesh tierra]
orbita --> lunar[Grupo orbita lunar]
lunar --> luna[Mesh luna]
style escena fill:#cba6f7,color:#11111b
style sol fill:#89b4fa,color:#11111b
style orbita fill:#89b4fa,color:#11111b
style lunar fill:#89b4fa,color:#11111b
style estrella fill:#f9e2af,color:#11111b
style tierra fill:#a6e3a1,color:#11111b
style luna fill:#94e2d5,color:#11111b
style luz fill:#fab387,color:#11111b
style camara fill:#94e2d5,color:#11111b

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)); // false
console.log('ahora en B:', grupoB.children.includes(objeto)); // true

objeto.removeFromParent();
console.log('sin padre:', objeto.parent);                     // null

grupoB.clear();
console.log('hijos de B:', grupoB.children.length);           // 0

add 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 suelo
scene.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);      // 1
scene.remove(a);
console.log('indexados:', indice.size);      // 0

Conviene 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.

Quitar hijos dentro de un bucle sobre children se salta la mitad de los objetos

Este error se escribe solo y es prácticamente invisible en revisión de código. El array children es el array real que mantiene la biblioteca, no una copia, y remove opera sobre él con un corte que desplaza todos los elementos posteriores una posición hacia atrás. Si estás recorriéndolo con un índice creciente y eliminas el elemento actual, el siguiente ocupa el hueco y el índice avanza por encima de él sin visitarlo. El resultado es que se elimina exactamente la mitad de los hijos, la mitad de las posiciones pares, y quedan vivos los demás. Lo mismo ocurre con forEach, que itera sobre el array vivo. Y lo peor del caso es que el síntoma engaña: como cada pasada quita la mitad, llamar dos o tres veces a la función parece arreglarlo, con lo que es fácil concluir que hay un problema de sincronización y añadir un aplazamiento que no soluciona nada. Hay tres formas correctas y todas valen. Recorrer de atrás hacia delante, porque entonces el desplazamiento afecta a posiciones ya visitadas. Iterar sobre una copia del array, con el operador de propagación o con slice. O, cuando hay que vaciarlo entero, usar clear, que está pensado para eso y hace lo correcto. La regla general de la que este caso es un ejemplo: nunca modifiques una colección mientras la recorres, y en Three.js hay que tenerla especialmente presente porque children, geometry.attributes y las listas internas del renderer son estructuras vivas y no instantáneas.

// 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.