wandres.dev
VERTEX SHADERS · De atributos a clip space

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.

⏱ 17 min

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.

🎯 Al terminar esta lección sabrás
  • 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:

  • uint y sint llegan al shader como enteros, sin conversión de valor. Un byte con valor 200 llega como 200u.
  • unorm divide entre el máximo del tipo y produce un flotante entre 0 y 1. Un byte con valor 255 llega como 1.0.
  • snorm produce un flotante entre -1 y 1. Un byte con valor 127 llega como 1.0.
  • float convierte 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;
}
💡
La posición casi nunca se debe comprimir así

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.

El coste de un vértice no es lo que ocupa: es cuántos vértices caben en el caché de reutilización

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.