Recorrer la escena: traverse y las recetas que resuelve
En qué orden visita traverse los nodos, qué diferencia hay con traverseVisible y traverseAncestors, y las cuatro operaciones que todo proyecto acaba escribiendo.
Casi todo lo que se hace sobre una escena completa —contar triángulos, activar sombras en todo, liberar recursos, aplicar un material de depuración, encontrar los nodos que cumplen una condición— es un recorrido del árbol. Three.js ofrece tres formas de recorrerlo con semánticas distintas, y una advertencia en la documentación que conviene tomarse en serio porque el bug que previene es de los que no dan la cara.
- Describir el orden en que
traversevisita los nodos y qué incluye. - Elegir entre
traverse,traverseVisibleytraverseAncestorssegún el caso. - Escribir las cuatro operaciones de recorrido que aparecen en cualquier proyecto.
- Evitar el fallo silencioso de modificar el árbol durante el recorrido.
El orden del recorrido
traverse visita en profundidad y en preorden: primero el nodo sobre el que se llama, después su primer hijo con todo su subárbol, después el segundo, y así sucesivamente. El nodo de partida se incluye en el recorrido, cosa que sorprende la primera vez que alguien llama a objeto.traverse esperando visitar solo los descendientes.
flowchart TB n1[1 Scene] --> n2[2 Grupo vehiculo] n1 --> n6[6 Grupo escenario] n2 --> n3[3 Mesh carroceria] n2 --> n4[4 Grupo ruedas] n4 --> n5[5 Mesh rueda] n6 --> n7[7 Mesh suelo] style n1 fill:#cba6f7,color:#11111b style n2 fill:#89b4fa,color:#11111b style n3 fill:#a6e3a1,color:#11111b style n4 fill:#89b4fa,color:#11111b style n5 fill:#a6e3a1,color:#11111b style n6 fill:#89b4fa,color:#11111b style n7 fill:#a6e3a1,color:#11111b
Los números indican el orden de visita. Que sea preorden importa en un caso concreto: cuando la operación que haces sobre un nodo depende de algo que has calculado en su padre, el preorden garantiza que el padre ya se ha procesado. Si necesitas lo contrario —procesar los hijos antes que el padre, por ejemplo para acumular volúmenes envolventes— traverse no te sirve y hay que escribir el recorrido a mano.
Las otras dos variantes cambian qué se visita. traverseVisible se salta los nodos con visible desactivado y todos sus descendientes, que es exactamente lo que hace el renderer al recoger objetos, así que es la que hay que usar cuando quieres razonar sobre lo que se está dibujando. traverseAncestors va en dirección contraria, de padre en padre hasta la raíz, y no incluye el nodo de partida; sirve para preguntar cosas como en qué grupo está contenido algo o si algún ancestro está oculto.
import * as THREE from 'three';
const scene = new THREE.Scene();
const grupo = new THREE.Group();
grupo.name = 'vehiculo';
const malla = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial());
malla.name = 'carroceria';
grupo.add(malla);
scene.add(grupo);
const visitados = [];
scene.traverse((nodo) => visitados.push(nodo.type));
console.log('recorrido completo:', visitados); // Scene, Group, Mesh
const ancestros = [];
malla.traverseAncestors((nodo) => ancestros.push(nodo.type));
console.log('ancestros:', ancestros); // Group, Scene
grupo.visible = false;
const visibles = [];
scene.traverseVisible((nodo) => visibles.push(nodo.type));
console.log('solo visibles:', visibles); // Scene
Las cuatro recetas
Contar el coste real de la escena. Antes de optimizar nada conviene saber qué hay dentro. Este recorrido da las cifras que importan y no depende de ninguna herramienta externa.
import * as THREE from 'three';
export function inventario(raiz) {
const datos = {
nodos: 0, mallas: 0, triangulos: 0,
geometrias: new Set(), materiales: new Set(), texturas: new Set(),
};
raiz.traverse((nodo) => {
datos.nodos += 1;
if (!nodo.isMesh) return;
datos.mallas += 1;
datos.geometrias.add(nodo.geometry);
const posicion = nodo.geometry.attributes.position;
const cuenta = nodo.geometry.index ? nodo.geometry.index.count : posicion.count;
const instancias = nodo.isInstancedMesh ? nodo.count : 1;
datos.triangulos += (cuenta / 3) * instancias;
for (const material of [].concat(nodo.material)) {
datos.materiales.add(material);
for (const clave of Object.keys(material)) {
const valor = material[clave];
if (valor && valor.isTexture) datos.texturas.add(valor);
}
}
});
return {
nodos: datos.nodos,
mallas: datos.mallas,
triangulos: datos.triangulos,
geometriasUnicas: datos.geometrias.size,
materialesUnicos: datos.materiales.size,
texturasUnicas: datos.texturas.size,
};
}
El uso de conjuntos no es un detalle: la diferencia entre el número de mallas y el de geometrías únicas te dice cuánta reutilización hay, y la diferencia entre mallas y materiales únicos es un indicador directo de cuántos cambios de estado va a tener que hacer el renderer.
Aplicar una configuración a todo. Activar sombras, ajustar el filtrado de texturas o poner una capa concreta es siempre el mismo patrón.
export function configurarSombras(raiz, { emitir = true, recibir = true } = {}) {
raiz.traverse((nodo) => {
if (!nodo.isMesh) return;
nodo.castShadow = emitir;
nodo.receiveShadow = recibir;
});
}
Liberar recursos. Ya apareció en el nivel anterior y es el caso donde el recorrido es imprescindible, porque hay que tocar cada geometría, cada material y cada textura del subárbol.
Construir un índice. Y esta es la que cambia el rendimiento de una aplicación entera.
export function indexar(raiz) {
const porNombre = new Map();
const mallas = [];
const animables = [];
raiz.traverse((nodo) => {
if (nodo.name) porNombre.set(nodo.name, nodo);
if (nodo.isMesh) mallas.push(nodo);
if (nodo.userData.animable) animables.push(nodo);
});
return { porNombre, mallas, animables };
}
Un recorrido al cargar, y a partir de ahí las consultas son de coste constante. La alternativa —llamar a getObjectByName dentro del bucle de render— es una búsqueda lineal sobre toda la escena por cada consulta y por cada fotograma, y es una de las causas más habituales de que una aplicación con un modelo grande vaya lenta sin que la GPU esté haciendo nada.
La documentación de traverse desaconseja modificar el grafo dentro de la función de visita, y el motivo es el mismo que en la lección de jerarquía pero con una consecuencia peor: el recorrido es recursivo y avanza sobre el array vivo de hijos de cada nodo. Si al visitar un hijo lo eliminas de su padre, todos los siguientes se desplazan una posición, el índice del bucle avanza, y el hermano que ocupó el hueco no se visita nunca. Con un subárbol grande, el resultado es que se procesa aproximadamente la mitad de los nodos, y cuáles concretamente depende de la forma del árbol, con lo que el fallo se manifiesta de forma distinta en cada modelo. El caso más común es también el más peligroso: un bucle de limpieza que recorre la escena eliminando y liberando a la vez. Se ejecuta, no lanza ningún error, y deja la mitad de los recursos de GPU vivos y desconectados del árbol, donde ya no hay forma de encontrarlos. Es una fuga de memoria de vídeo con causa invisible. El mismo problema aparece al añadir nodos durante el recorrido, con el matiz de que ahí puedes provocar una recursión infinita si lo que añades cumple la condición que dispara la adición. La disciplina que lo resuelve es siempre la misma y no cuesta nada: el recorrido recoge, el bucle posterior actúa. Una pasada que llena un array con los nodos afectados, y después un bucle sobre ese array que hace el trabajo. Dos líneas más, y elimina una clase de fallos que son muy difíciles de reproducir porque dependen del orden de los hijos.
// Mal: modifica el arbol mientras lo recorre.
scene.traverse((nodo) => {
if (nodo.userData.temporal) nodo.removeFromParent();
});
// Bien: recoger primero, actuar despues.
const aEliminar = [];
scene.traverse((nodo) => {
if (nodo.userData.temporal) aEliminar.push(nodo);
});
for (const nodo of aEliminar) nodo.removeFromParent();
Cierre del bloque de fundamentos
Con esto termina el recorrido por los cimientos: qué es un motor de render y qué no, cómo la GPU convierte triángulos en píxeles, la geometría vectorial que describe el espacio, las matrices que transforman entre espacios, las rotaciones sin singularidades, el primer proyecto que se mueve como debe, y el grafo que organiza la escena.
Merece la pena señalar qué se ha ganado exactamente, porque no es visible. No hay nada terminado que enseñar. Lo que hay es la capacidad de leer código ajeno de Three.js y entender por qué está escrito así; de mirar un shader y saber qué espacio es cada variable; de ver un objeto negro, deformado o ausente y tener una hipótesis en lugar de una lista de cosas que probar; y de reconocer, cuando algo va lento, si el problema está antes o después del rasterizador.
Todo lo que viene a partir de aquí —geometrías, materiales, luz, texturas, shaders, post-procesado, interacción, rendimiento— se apoya en estas siete piezas. Ninguna se volverá a explicar, y ninguna hará falta explicarla otra vez.