La tabla de formatos de vértice con sus tamaños
Los cuarenta formatos de atributo de WebGPU con sus bytes y su tipo en WGSL, qué familias convierten y cuáles no, y cómo se comprime un vértice de 48 bytes a 24.
La lista de formatos de atributo parece un enum largo y aburrido, y es en realidad la palanca más directa que tienes sobre el ancho de banda de la etapa de geometría. Un vértice de una malla típica ocupa 48 bytes si lo escribes sin pensar y 24 si eliges los formatos con criterio, con una pérdida de calidad que en la mayoría de los casos no se puede ver.
- Localizar el tamaño en bytes de cualquier formato de atributo.
- Elegir el tipo WGSL correcto para cada familia de formato.
- Reconocer qué combinaciones de componentes existen y cuáles no.
- Comprimir el vértice de una malla real a la mitad sin artefactos visibles.
La tabla
| Formato | Bytes | Tipo en WGSL |
|---|---|---|
uint8 |
1 | u32 |
uint8x2 |
2 | vec2u |
uint8x4 |
4 | vec4u |
sint8 |
1 | i32 |
sint8x2 |
2 | vec2i |
sint8x4 |
4 | vec4i |
unorm8 |
1 | f32 |
unorm8x2 |
2 | vec2f |
unorm8x4 |
4 | vec4f |
snorm8 |
1 | f32 |
snorm8x2 |
2 | vec2f |
snorm8x4 |
4 | vec4f |
uint16 |
2 | u32 |
uint16x2 |
4 | vec2u |
uint16x4 |
8 | vec4u |
sint16 |
2 | i32 |
sint16x2 |
4 | vec2i |
sint16x4 |
8 | vec4i |
unorm16 |
2 | f32 |
unorm16x2 |
4 | vec2f |
unorm16x4 |
8 | vec4f |
snorm16 |
2 | f32 |
snorm16x2 |
4 | vec2f |
snorm16x4 |
8 | vec4f |
float16 |
2 | f32 |
float16x2 |
4 | vec2f |
float16x4 |
8 | vec4f |
float32 |
4 | f32 |
float32x2 |
8 | vec2f |
float32x3 |
12 | vec3f |
float32x4 |
16 | vec4f |
uint32 |
4 | u32 |
uint32x2 |
8 | vec2u |
uint32x3 |
12 | vec3u |
uint32x4 |
16 | vec4u |
sint32 |
4 | i32 |
sint32x2 |
8 | vec2i |
sint32x3 |
12 | vec3i |
sint32x4 |
16 | vec4i |
unorm10-10-10-2 |
4 | vec4f |
unorm8x4-bgra |
4 | vec4f |
Los formatos de un solo componente de 8 y 16 bits son una incorporación relativamente reciente a la especificación; los de 32 bits y los de varios componentes están desde el principio.
Las cuatro reglas de lectura
El sufijo dice cuántos componentes. Sin sufijo, uno; x2, dos; x4, cuatro.
No existen versiones de tres componentes salvo en 32 bits. Hay float32x3, uint32x3 y sint32x3, y no hay float16x3 ni unorm8x3 ni nada parecido. La razón es la alineación del hardware: un atributo de 6 bytes o de 3 bytes obligaría a lecturas no alineadas. Cuando necesites tres componentes de 16 bits, usas la versión de cuatro y desperdicias el último, o replanteas la codificación.
El prefijo dice cómo se convierte:
uintysintllegan al shader como enteros, sin conversión de valor. Un byte con valor 200 llega como200u.unormdivide entre el máximo del tipo y produce un flotante entre 0 y 1. Un byte con valor 255 llega como1.0.snormproduce un flotante entre -1 y 1. Un byte con valor 127 llega como1.0.floatconvierte de medio flotante o entrega el flotante tal cual.
Los dos especiales. unorm10-10-10-2 empaqueta cuatro valores normalizados con 10, 10, 10 y 2 bits en 4 bytes, y es la mejor relación calidad-tamaño para normales y para colores de baja precisión con un canal extra. unorm8x4-bgra es un unorm8x4 con los componentes en orden azul, verde, rojo, alfa, y existe porque es el orden en el que muchos formatos de imagen y muchos motores antiguos guardan el color.
Comprimir un vértice real
Este es el vértice que sale de escribir sin pensar, tal como lo entrega un importador ingenuo:
| Atributo | Formato | Bytes |
|---|---|---|
| posición | float32x3 |
12 |
| normal | float32x3 |
12 |
| tangente | float32x4 |
16 |
| uv | float32x2 |
8 |
| total | 48 |
Y este es el mismo vértice eligiendo formatos con criterio:
| Atributo | Formato | Bytes | Qué se pierde |
|---|---|---|---|
| posición | float32x3 |
12 | nada |
| normal octaédrica | snorm16x2 |
4 | error angular por debajo de la centésima de grado |
| tangente más signo de bitangente | unorm10-10-10-2 |
4 | 10 bits por eje y 2 para el signo |
| uv | unorm16x2 |
4 | resolución de 1 entre 65535 del rango de la UV |
| total | 24 |
La mitad de bytes por vértice. En una malla de doscientos mil vértices eso son casi cinco megabytes menos de memoria y, sobre todo, la mitad de tráfico en cada pasada que dibuje esa malla.
El descriptor correspondiente:
{
arrayStride: 24,
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x3' }, // posicion
{ shaderLocation: 1, offset: 12, format: 'snorm16x2' }, // normal octaedrica
{ shaderLocation: 2, offset: 16, format: 'unorm10-10-10-2' }, // tangente y signo
{ shaderLocation: 3, offset: 20, format: 'unorm16x2' }, // uv
],
}
Y el vertex shader que lo descomprime:
fn decodificarOctaedrica(e : vec2f) -> vec3f {
var v = vec3f(e.xy, 1.0 - abs(e.x) - abs(e.y));
let t = max(-v.z, 0.0);
v = vec3f(
v.x + select(t, -t, v.x >= 0.0),
v.y + select(t, -t, v.y >= 0.0),
v.z,
);
return normalize(v);
}
@vertex
fn vs(entrada : EntradaVS) -> SalidaVS {
var s : SalidaVS;
s.clip = camara.viewProj * modelo.matriz * vec4f(entrada.posicion, 1.0);
s.normal = decodificarOctaedrica(entrada.normalCodificada);
s.uv = entrada.uv;
return s;
}
De todos los atributos, la posición es el que peor tolera la cuantización, porque su error se ve como geometría que tiembla. Sí se puede comprimir, pero no con snorm16 a secas: hay que normalizar contra la caja envolvente de la malla y guardar el escalado y el origen en la uniform del objeto. Con ese cambio, unorm16x4 da precisión de una parte entre 65535 del tamaño del objeto, que para un personaje de dos metros son treinta micras y es de sobra. Sin ese cambio, cuantizar posiciones en coordenadas de mundo destruye las mallas lejanas.
La cuenta de bytes ahorrados es la parte fácil de justificar y no es la que más rinde. Lo que de verdad cambia al reducir el tamaño del vértice es el comportamiento del caché de vértices posprocesados.
Toda GPU guarda los resultados del vertex shader de los últimos vértices procesados, para que un vértice compartido por varios triángulos no se procese varias veces. Ese caché es pequeño —del orden de decenas de entradas— y su tamaño se mide en entradas, pero lo que ocupa cada entrada depende de cuántas salidas tenga tu vertex shader y, en las arquitecturas por tiles, también de la memoria que hay que escribir por vértice.
Con una malla bien optimizada, cada vértice se comparte entre unos seis triángulos. Si el caché acierta, ese vértice se procesa una vez; si falla, se procesa hasta seis veces. La diferencia entre las dos situaciones es un factor de seis en el coste de la etapa de geometría, y depende de dos cosas que están en tus manos: el orden de los índices y el tamaño de lo que produce el vertex shader.
De ahí salen dos acciones concretas, y la segunda es la que casi nadie hace. La primera es reordenar los índices con un algoritmo de optimización de caché al importar la malla; hay librerías que lo hacen en milisegundos y la ganancia es del orden del veinte o el treinta por ciento en escenas limitadas por geometría. La segunda es reducir las salidas del vertex shader, no solo sus entradas: comprimir los atributos de entrada ahorra ancho de banda de lectura, pero recortar las variables entre etapas ahorra caché, memoria de tiles y trabajo de interpolación.
El caso extremo que ilustra el punto es el pase de profundidad. Un vertex shader que solo devuelve @builtin(position) y nada más es varias veces más barato que el mismo shader devolviendo seis varyings, aunque la aritmética sea idéntica. Por eso los motores tienen un shader de profundidad separado y minúsculo en vez de reutilizar el de color, y por eso ese pase cuesta mucho menos de lo que su nombre sugiere.