Accesores, buffer views y buffers: cómo viaja la geometría
Los tres niveles de indirección entre un vértice y sus bytes, qué es el byteStride y por qué existe el intercalado, la normalización de enteros y los accesores dispersos.
Entre «este vértice está en la posición 1.5, 0, -2» y los bytes del fichero hay tres niveles de indirección, y ninguno sobra. El buffer es memoria sin significado; el bufferView es una ventana con un paso; el accesor es la interpretación tipada. Esa separación es lo que permite que un mismo bloque de bytes contenga posiciones, normales y UVs intercaladas, que los índices compartan buffer con los vértices, y que un modelo comprimido pueda sustituir todo el bloque sin tocar el resto del JSON.
- Calcular la dirección exacta de un componente a partir de accesor, bufferView y buffer.
- Explicar qué es
byteStridey en qué caso el intercalado mejora el rendimiento. - Interpretar un accesor de enteros normalizados y saber cuándo se usan.
- Reconocer un accesor disperso y para qué sirve.
La aritmética completa
Para leer el componente c del elemento i de un accesor, la dirección es:
byteOffset del buffer del bufferView
+ bufferView.byteOffset
+ accessor.byteOffset
+ i * stride
+ c * tamano del componente
donde stride es bufferView.byteStride si está definido, y si no, el tamaño de un elemento completo del accesor. Los tres offsets se suman porque cada nivel puede desplazarse dentro del anterior, y eso es exactamente lo que permite meter varios accesores en un mismo bufferView.
Los tipos de componente son códigos de OpenGL:
componentType |
Tipo | Bytes |
|---|---|---|
| 5120 | BYTE |
1 |
| 5121 | UNSIGNED_BYTE |
1 |
| 5122 | SHORT |
2 |
| 5123 | UNSIGNED_SHORT |
2 |
| 5125 | UNSIGNED_INT |
4 |
| 5126 | FLOAT |
4 |
Y el número de componentes lo da type: 1 para SCALAR, 2 para VEC2, 3 para VEC3, 4 para VEC4, 16 para MAT4.
Hay una regla de alineación que se olvida al escribir exportadores y produce fallos raros: el byteOffset de un accesor tiene que ser múltiplo del tamaño de su componente, y si hay byteStride, este también. Un accesor de floats no puede empezar en el byte 5. Esa restricción viene directamente del hardware: leer un float desalineado es un error o una penalización severa según la plataforma.
byteStride y el intercalado
Sin byteStride, los elementos de un accesor van uno detrás de otro y el bufferView contiene un solo atributo. Es la disposición planar: todas las posiciones juntas, después todas las normales, después todas las UVs.
Con byteStride, los elementos van separados por un paso mayor que su tamaño, y en los huecos viven otros accesores. Es la disposición intercalada: posición, normal, UV del vértice 0; posición, normal, UV del vértice 1; y así.
{
"bufferViews": [
{ "buffer": 0, "byteOffset": 0, "byteLength": 3200, "byteStride": 32 }
],
"accessors": [
{ "bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 100, "type": "VEC3" },
{ "bufferView": 0, "byteOffset": 12, "componentType": 5126, "count": 100, "type": "VEC3" },
{ "bufferView": 0, "byteOffset": 24, "componentType": 5126, "count": 100, "type": "VEC2" }
]
}
Un byteStride de 32: doce bytes de posición, doce de normal, ocho de UV. Los tres accesores comparten el bufferView y se distinguen por su byteOffset.
¿Por qué molestarse? Por la caché de la GPU. Cuando el vertex shader procesa el vértice 47, necesita su posición, su normal y su UV. Con disposición planar, esos tres datos están en tres regiones de memoria separadas por kilobytes, y cada uno provoca una lectura de línea de caché distinta. Con intercalado, los tres caben en la misma línea de caché de 32 o 64 bytes, y una sola lectura los trae todos.
La ganancia es real y depende del caso: en geometría con muchos atributos y acceso poco coherente, el intercalado puede mejorar entre un 10 y un 30 %. En geometría simple y coherente, no se nota.
Three.js lo soporta directamente: GLTFLoader crea un InterleavedBuffer compartido y tres InterleavedBufferAttribute que lo referencian con distintos offsets. No hay copia ni desintercalado.
Si vas a tocar solo las posiciones de una geometría intercalada desde JavaScript, cada escritura salta 32 bytes en vez de 12, lo que destroza la coherencia de caché en la CPU y hace la operación varias veces más lenta. Para geometría que se modifica por código, planar es mejor. Para geometría que solo se dibuja, intercalado.
Enteros normalizados: la compresión que no cuesta nada
Un accesor puede llevar "normalized": true, y entonces sus enteros se reinterpretan como floats en un rango fijo:
| Tipo | Con normalized |
Rango |
|---|---|---|
BYTE |
max( v / 127, -1 ) |
-1 a 1 |
UNSIGNED_BYTE |
v / 255 |
0 a 1 |
SHORT |
max( v / 32767, -1 ) |
-1 a 1 |
UNSIGNED_SHORT |
v / 65535 |
0 a 1 |
Esto es una forma de cuantización gratuita, porque la conversión la hace el hardware al leer el atributo, no cuesta ni una instrucción de shader. Y los ahorros son sustanciales:
- Normales. Son vectores unitarios, así que caben perfectamente en
BYTEnormalizado: 3 bytes en vez de 12. La pérdida de precisión angular es de menos de un grado, invisible. - UVs. En
UNSIGNED_SHORTnormalizado, 4 bytes en vez de 8, con precisión de 1/65535 del espacio de textura: más que suficiente para cualquier textura de hasta 65536 píxeles. - Colores de vértice. En
UNSIGNED_BYTE, 4 bytes en vez de 16.
Las posiciones no se pueden normalizar directamente porque su rango no es [-1, 1], pero se resuelve con el mismo truco un nivel más arriba: se cuantizan a SHORT y se aplica una matriz de escala y traslación en el nodo. Eso es exactamente lo que hace EXT_meshopt_compression, y es la razón de que la cuantización de meshopt sea prácticamente gratuita en decodificación.
Accesores dispersos
Un accesor puede llevar una propiedad sparse que describe solo los elementos que difieren de una base:
{
"bufferView": 0, "componentType": 5126, "count": 5000, "type": "VEC3",
"sparse": {
"count": 12,
"indices": { "bufferView": 1, "componentType": 5123 },
"values": { "bufferView": 2 }
}
}
Cinco mil vértices, de los cuales solo doce tienen valor propio; el resto sale del bufferView base, o de ceros si no hay base. Su uso canónico son los morph targets: una expresión facial que solo mueve treinta vértices de una cabeza de veinte mil no tiene por qué almacenar veinte mil desplazamientos, casi todos cero.
GLTFLoader los soporta y los expande al cargar, porque BufferAttribute de Three.js no tiene representación dispersa. El ahorro, por tanto, es solo de descarga: en memoria ocupan lo mismo que un accesor denso.
Es fácil pensar que indices existe para no repetir vértices y ahorrar bytes, y ese es el efecto menor. El grande está en el hardware. Toda GPU tiene una caché post-transformada: una tabla pequeña —típicamente de 16 a 32 entradas— que guarda el resultado del vertex shader indexado por el índice del vértice. Cuando el ensamblador de primitivas encuentra un índice que ya está en la caché, se salta el vertex shader por completo y reutiliza el resultado. En una malla triangulada bien construida, cada vértice pertenece a unos seis triángulos, así que con indexado el vertex shader se ejecuta aproximadamente una vez por vértice en lugar de tres veces por triángulo: un factor de casi seis en trabajo de vértices. Sin índices no hay caché posible, porque cada vértice es distinto por construcción. Y aquí viene la parte que casi nadie sabe: el beneficio depende del orden de los índices. La caché es pequeña, así que si los triángulos que comparten un vértice están separados por miles de posiciones en el array de índices, cuando llega el segundo el vértice ya fue desalojado y hay que recalcularlo. Reordenar los triángulos para que los que comparten vértices estén cerca es un problema clásico con soluciones conocidas —el algoritmo de Tom Forsyth y el de Sander, Nehab y Barczak—, y la ganancia sobre una malla exportada tal cual de Blender puede llegar al 30 % del tiempo de vértices. gltf-transform reorder y gltfpack lo hacen por defecto usando meshoptimizer, que además optimiza el orden de los vértices en el buffer para la caché de memoria y ordena los triángulos por proximidad para reducir el overdraw. Es una de esas optimizaciones que no cambia ni un byte del contenido ni un píxel de la imagen, cuesta un comando, y en geometría densa se nota de verdad.