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

Object3D: la anatomía del nodo

Todo lo que lleva dentro el objeto base de Three.js, qué propiedades son de solo lectura aunque no lo parezca, y qué comparte realmente un clon con su original.

⏱ 17 min

Casi todo en Three.js es un Object3D: las mallas, las luces, las cámaras, los grupos y la escena misma. Es un nodo de un árbol con una transformación, y su interfaz es pequeña, pero contiene tres o cuatro decisiones de diseño que conviene conocer de entrada porque explican comportamientos que de otro modo parecen caprichos: por qué no se puede reasignar position, por qué un clon cambia de color cuando cambias el original, y por qué un nodo vacío no es gratis.

🎯 Al terminar esta lección sabrás
  • Enumerar los cuatro grupos de propiedades de un Object3D y para qué sirve cada uno.
  • Explicar por qué las propiedades de transformación no se pueden reasignar.
  • Predecir qué comparte un clon con su original y qué no.
  • Estimar el coste real de un nodo sin contenido.

Los cuatro grupos de propiedades

Identidad y estructura. id es un entero autoincremental por instancia; uuid es un identificador único que se genera solo y que se usa en la serialización; name es una cadena libre y no tiene que ser única; type es la cadena que identifica la subclase. Y la estructura del árbol: parent, que es uno o ninguno, y children, que es un array.

Transformación. position, rotation, quaternion y scale describen la transformación local. up es el vector de referencia vertical que usa lookAt, con valor inicial tomado de Object3D.DEFAULT_UP, que es el eje Y.

Matrices. matrix es la transformación local ya compuesta; matrixWorld es la acumulada desde la raíz. Hay dos más que rellena el renderer justo antes de dibujar y que no debes tocar: modelViewMatrix y normalMatrix, las que se estudiaron en el nivel de matrices.

Banderas de dibujado. visible decide si el objeto y sus descendientes se dibujan; frustumCulled activa el descarte por volumen de visión; renderOrder fuerza el orden dentro de su cola; castShadow y receiveShadow controlan la participación en las sombras; layers permite filtrar qué cámaras lo ven y qué rayos lo alcanzan.

Y una propiedad que no encaja en ningún grupo y que se usa constantemente: userData, un objeto vacío donde colgar lo que quieras. Es el sitio correcto para asociar datos de tu aplicación a un nodo sin ensuciar el espacio de nombres de la biblioteca.

import * as THREE from 'three';

const malla = new THREE.Mesh(
  new THREE.BoxGeometry(),
  new THREE.MeshStandardMaterial({ color: 0x89b4fa })
);

malla.name = 'caja-principal';
malla.userData.tipo = 'obstaculo';
malla.userData.vida = 100;

console.log('id:', malla.id, 'tipo:', malla.type);
console.log('es un Object3D:', malla.isObject3D);      // true
console.log('es una malla:', malla.isMesh);            // true
console.log('padre:', malla.parent);                    // null hasta que se anade

Las propiedades del estilo isMesh e isObject3D merecen un apunte: Three.js las usa internamente en lugar de instanceof porque instanceof falla cuando hay dos copias de la biblioteca cargadas en la misma página, cosa que ocurre más de lo que parece con dependencias mal resueltas. Si escribes código que comprueba tipos, usa esas banderas por la misma razón.

Las propiedades que no se pueden reasignar

Este es el primer tropiezo de mucha gente:

// Esto NO funciona como esperas.
malla.position = new THREE.Vector3(1, 2, 3);

position, rotation, quaternion y scale están declaradas como propiedades de solo lectura: la referencia al objeto no se puede cambiar, aunque su contenido sí. La razón es de diseño interno: rotation y quaternion están enlazadas por sendas funciones de notificación que las mantienen sincronizadas, como se vio en el nivel de rotaciones, y sustituir el objeto entero rompería ese enlace dejando dos representaciones desincronizadas.

La forma correcta es modificar el contenido:

malla.position.set(1, 2, 3);
malla.position.copy(otroVector);
malla.position.x = 5;
malla.scale.setScalar(2);

En modo estricto, que es el que se aplica a los módulos de JavaScript, la asignación directa lanza un error; fuera de él, falla en silencio, que es peor. Merece la pena conocer el detalle porque es exactamente el tipo de código que se escribe sin pensar cuando se viene de otra biblioteca.

Lo que un clon comparte con su original

clone crea un nuevo objeto y, por defecto, clona también recursivamente sus descendientes. Lo que no duplica es lo caro: la geometría y el material se copian por referencia.

Es la decisión correcta, porque el caso normal es dibujar cien copias del mismo árbol y duplicar cien veces sus vértices sería absurdo. Pero produce un comportamiento que sorprende la primera vez.

import * as THREE from 'three';

const original = new THREE.Mesh(
  new THREE.SphereGeometry(1, 32, 16),
  new THREE.MeshStandardMaterial({ color: 0xa6e3a1 })
);

const copia = original.clone();
copia.position.x = 3;

console.log('misma geometria:', copia.geometry === original.geometry);   // true
console.log('mismo material:', copia.material === original.material);     // true

// Y por tanto, cambiar el color de uno cambia el de los dos.
copia.material.color.set(0xf38ba8);
console.log('color del original:', original.material.color.getHexString());  // f38ba8

// Si de verdad quieres materiales independientes, hay que clonarlos aparte.
const independiente = original.clone();
independiente.material = original.material.clone();
independiente.material.color.set(0xcba6f7);
console.log('ahora si difieren:', original.material.color.getHexString());

De aquí salen dos reglas operativas. La primera: si vas a personalizar el material de una copia, clónalo explícitamente, y asume el coste de una permutación de shader adicional si las características del material cambian. La segunda, más peligrosa: liberar la geometría o el material de una copia los libera para todas, porque el recurso de GPU es uno solo. Un bucle de limpieza que recorra la escena llamando a dispose sobre cada malla puede dejar inservibles objetos que siguen vivos si comparten recursos con los que se están destruyendo.

Hay un detalle más del clonado que conviene conocer: userData se copia en profundidad, pero mediante una serialización a JSON. Eso significa que las funciones, las clases y las referencias circulares que hubieras guardado ahí no sobreviven al clonado. Si necesitas asociar objetos vivos a un nodo, guárdalos en un mapa externo indexado por uuid en lugar de en userData.

Un nodo vacío no es gratis, y en escenas grandes la factura la paga la CPU

Es tentador usar Object3D y Group con generosidad para organizar la escena: un grupo por sistema, otro por capa lógica, un contenedor por cada entidad para poder moverla cómodamente. La estructura queda limpia y el coste parece nulo, porque un nodo vacío no dibuja nada. Pero sí cuesta, y cuesta en el sitio donde más duele. Cada fotograma, el renderer recorre el árbol completo y para cada nodo con actualización automática recompone su matriz local a partir de posición, cuaternión y escala, y después la multiplica por la de su padre para obtener la de mundo. Son dos operaciones matriciales por nodo y por fotograma, se dibuje o no, sea visible o no. Con mil entidades y tres niveles de contenedores vacíos por entidad estás pagando cuatro mil composiciones por fotograma para mover mil objetos, es decir, cuadruplicando el coste de actualización de la escena en trabajo puramente estructural. En un perfil eso aparece como un bloque de tiempo en updateMatrixWorld que nadie sabe explicar, porque no se corresponde con nada visible. Las tres contramedidas, en orden de preferencia: aplanar la jerarquía cuando el contenedor solo existe por comodidad de nombres, que es la mitad de los casos; desactivar la actualización automática en los nodos que no se mueven, con lo que la composición local desaparece; y en escenas con miles de objetos independientes, plantearse si la jerarquía aporta algo o si es más barato calcular las transformaciones a mano. La regla es simple de enunciar: un nodo debe existir porque hay una relación de transformación real entre padre e hijo, no porque ayude a leer el código. Para lo segundo están los nombres y los mapas.

Los que dibujan y los que no

Un Object3D puro no dibuja nada. Tampoco un Group. Los objetos dibujables son los que aportan geometría y material: Mesh para superficies, Line y sus variantes para trazos, Points para nubes de puntos, Sprite para cuadriláteros siempre orientados a cámara, y los especializados como InstancedMesh, SkinnedMesh y BatchedMesh.

La distinción importa por algo más que la clasificación: las funciones de intercepción que el renderer invoca antes y después de dibujar cada objeto —onBeforeRender y onAfterRendersolo se ejecutan en los objetos dibujables. Colocarlas en un grupo esperando que se disparen cuando se dibuje su contenido es un error silencioso y bastante frecuente.

Con la anatomía del nodo clara, lo siguiente es lo que convierte una colección de nodos en un grafo: las relaciones de padre e hijo.