wandres.dev
GLTF · El formato estándar del 3D en la web

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.

⏱ 19 min

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.

🎯 Al terminar esta lección sabrás
  • Recorrer el grafo de un glTF desde scenes hasta buffers nombrando 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` );
Una primitiva es un draw call, y el exportador de Blender las multiplica sin avisarte

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.