La estructura de un glTF: escenas, nodos, mallas, primitivas
El grafo completo del formato desde la raíz hasta los bytes, qué es una primitiva y por qué corresponde a un draw call, y cómo mapea cada nivel a los objetos de Three.js.
Un fichero glTF es un JSON que describe un grafo dirigido acíclico de referencias por índice. Nada apunta por nombre y nada está anidado más de lo imprescindible: cada array de nivel superior contiene objetos que se refieren a otros por su posición en otro array. Esa uniformidad hace que el formato sea trivial de recorrer y que un cargador pueda resolver dependencias en cualquier orden, y es también lo que lo hace confuso la primera vez que abres uno en un editor de texto.
- Recorrer el grafo de un glTF desde
sceneshastabuffersnombrando cada nivel. - Explicar qué es una primitiva y por qué determina el número de draw calls.
- Correlacionar cada concepto de glTF con su equivalente en Three.js.
- Leer un glTF a mano e identificar dónde vive un dato concreto.
El grafo completo
flowchart TB S[scenes lista de nodos raiz] --> N[nodes transformacion local y jerarquia] N --> N2[nodes hijos recursivamente] N --> M[meshes] N --> C[cameras] N --> K[skins esqueleto] M --> P[primitives una por material] P --> MA[materials modelo PBR] P --> AC[accessors tipo cantidad y rango] MA --> T[textures] T --> IM[images] T --> SA[samplers filtrado y wrapping] AC --> BV[bufferViews trozo con offset y stride] IM --> BV BV --> BU[buffers bytes crudos] style S fill:#cba6f7,color:#11111b style N fill:#89b4fa,color:#11111b style N2 fill:#89b4fa,color:#11111b style M fill:#89b4fa,color:#11111b style C fill:#89b4fa,color:#11111b style K fill:#89b4fa,color:#11111b style P fill:#fab387,color:#11111b style MA fill:#f9e2af,color:#11111b style AC fill:#a6e3a1,color:#11111b style T fill:#f9e2af,color:#11111b style IM fill:#f9e2af,color:#11111b style SA fill:#f9e2af,color:#11111b style BV fill:#94e2d5,color:#11111b style BU fill:#94e2d5,color:#11111b
Tres observaciones sobre el grafo que no son evidentes.
Es un grafo, no un árbol. Dos nodos pueden apuntar a la misma malla, dos primitivas al mismo material, dos accesores al mismo bufferView. Esa reutilización es el mecanismo de deduplicación del formato y es lo que hace que un bosque de cien árboles idénticos ocupe lo que uno.
Los buffers están al final y son opacos. Un buffer es solo un bloque de bytes con su longitud y su URI. Toda la interpretación —qué son esos bytes, de qué tipo, cuántos— vive en los bufferViews y accessors que apuntan a él.
Las imágenes y los accesores comparten mecanismo. Una imagen puede vivir en un fichero aparte o dentro de un bufferView, exactamente igual que la geometría. En un .glb todas están dentro.
Nivel por nivel
scenes y scene. El array scenes contiene escenas, cada una con una lista de índices de nodos raíz. La propiedad scene de nivel superior indica cuál mostrar por defecto. Casi todos los ficheros tienen exactamente una.
nodes. El grafo de escena. Cada nodo tiene una transformación —o bien matrix con dieciséis números, o bien la terna translation, rotation en cuaternión y scale, pero nunca las dos cosas—, un array children de índices, y opcionalmente una referencia a mesh, camera o skin. Es exactamente un Object3D de Three.js.
meshes. Un mesh es un contenedor de primitivas. No tiene transformación propia: la posición la aporta el nodo que lo referencia, que es lo que permite instanciar la misma malla en varios sitios.
primitives. Aquí está el concepto que hay que interiorizar. Una primitiva es un conjunto de atributos de vértice, un índice opcional, un material y un modo —triángulos por defecto—. La regla que la define: una primitiva tiene exactamente un material. Si un objeto de Blender tiene tres materiales asignados a distintas caras, se exporta como un mesh con tres primitivas.
Y de ahí sale la consecuencia de rendimiento más directa del formato: una primitiva es un draw call. Un modelo con doscientas primitivas son doscientos draw calls, independientemente de cuántos triángulos tenga. Contar primitivas es contar draw calls.
accessors. Un accesor dice cómo interpretar un trozo de buffer: cuántos elementos (count), de qué tipo escalar (componentType: 5126 es float, 5123 es unsigned short, 5125 unsigned int) y con qué forma (type: SCALAR, VEC2, VEC3, VEC4, MAT4). Incluye además min y max, que son obligatorios para el accesor de posiciones y sirven para calcular la caja envolvente sin recorrer los datos.
bufferViews. Una ventana sobre un buffer: byteOffset, byteLength y opcionalmente byteStride cuando los atributos están intercalados. Es el equivalente exacto de un InterleavedBuffer de Three.js.
buffers. Bytes. Con byteLength y un uri que puede ser un fichero .bin, un data URI en base64, o nada en absoluto si es el chunk binario de un .glb.
Cómo se lee un fragmento real
{
"meshes": [{
"primitives": [{
"attributes": { "POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2 },
"indices": 3,
"material": 0
}]
}],
"accessors": [
{ "bufferView": 0, "componentType": 5126, "count": 24,
"type": "VEC3", "min": [-1,-1,-1], "max": [1,1,1] },
{ "bufferView": 1, "componentType": 5126, "count": 24, "type": "VEC3" },
{ "bufferView": 2, "componentType": 5126, "count": 24, "type": "VEC2" },
{ "bufferView": 3, "componentType": 5123, "count": 36, "type": "SCALAR" }
],
"bufferViews": [
{ "buffer": 0, "byteOffset": 0, "byteLength": 288 },
{ "buffer": 0, "byteOffset": 288, "byteLength": 288 },
{ "buffer": 0, "byteOffset": 576, "byteLength": 192 },
{ "buffer": 0, "byteOffset": 768, "byteLength": 72 }
],
"buffers": [{ "byteLength": 840, "uri": "cubo.bin" }]
}
Es un cubo. 24 vértices porque cada esquina se repite tres veces, una por cara, para que las normales sean duras. 36 índices porque son 12 triángulos. Y las cuentas cuadran: 24 posiciones por 3 floats por 4 bytes son 288; 36 índices de unsigned short son 72.
Los nombres de atributo son de un vocabulario cerrado y en mayúsculas: POSITION, NORMAL, TANGENT, TEXCOORD_n, COLOR_n, JOINTS_n, WEIGHTS_n. Los personalizados llevan guion bajo delante: _MI_ATRIBUTO.
La correspondencia con Three.js
| glTF | Three.js |
|---|---|
scene |
Group que devuelve gltf.scene |
node |
Object3D, o Mesh si tiene mesh |
mesh con 1 primitiva |
Mesh |
mesh con N primitivas |
Group con N Mesh dentro |
primitive |
Mesh con su BufferGeometry y su material |
accessor |
BufferAttribute o InterleavedBufferAttribute |
bufferView con byteStride |
InterleavedBuffer |
material |
MeshStandardMaterial o MeshPhysicalMaterial |
TEXCOORD_0 / TEXCOORD_1 |
atributos uv / uv1 |
animation |
AnimationClip en gltf.animations |
skin |
Skeleton con su SkinnedMesh |
La fila que más confusión causa es la cuarta. Un objeto de Blender con varios materiales no llega como un Mesh: llega como un Group con un Mesh por material. Cualquier código que asuma gltf.scene.children[0].material se rompe con ese modelo, y es el motivo por el que el patrón correcto siempre es traverse.
const gltf = await new GLTFLoader().loadAsync( '/modelos/silla.glb' );
let primitivas = 0, triangulos = 0;
gltf.scene.traverse( ( o ) => {
if ( ! o.isMesh ) return;
primitivas ++;
const g = o.geometry;
triangulos += ( g.index ? g.index.count : g.attributes.position.count ) / 3;
} );
console.log( `${primitivas} primitivas, ${triangulos} triangulos` );
La cuenta de primitivas es la métrica que hay que vigilar en un glTF y casi nadie la mira, porque la intuición dice que lo que cuesta son los triángulos. En una escena web limitada por CPU es al revés: 200 000 triángulos en una primitiva se dibujan en una fracción de milisegundo, y 2 000 triángulos repartidos en 200 primitivas cuestan diez veces más, porque cada draw call implica validar estado, cambiar bindings y una llamada al driver. Lo que hace peligroso este punto es que el número de primitivas crece sin que lo pidas. Cada material distinto que asignes en Blender parte el mesh; cada objeto separado en el outliner es un mesh distinto; los linked duplicates generan nodos separados aunque compartan malla, y eso está bien, pero los duplicados normales generan mallas separadas idénticas que además no se deduplican. Un interior modelado con la disciplina normal de Blender —cada mueble su objeto, cada material su slot— sale con doscientas o trescientas primitivas con toda naturalidad, y en un móvil eso son entre 8 y 15 milisegundos de CPU solo en emitir draw calls, antes de dibujar un solo píxel. Las dos herramientas que lo arreglan están en la línea de comandos y son de una sola pasada: gltf-transform join fusiona primitivas que comparten material, y gltf-transform palette va más lejos: convierte los materiales que solo difieren en el color base en un único material con un atlas de paleta minúsculo, lo que permite fusionar objetos que antes eran incompatibles. En modelos de arquitectura, donde hay veinte materiales que solo cambian de color, palette seguido de join suele bajar de doscientas primitivas a menos de diez. Mide siempre antes y después con gltf-transform inspect, que imprime la cuenta de primitivas y de draw calls directamente.