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.
El grafo completo
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` );