wandres.dev
WGSL V · La librería estándar

Bits y empaquetado: meter cuatro canales en un u32

Los operadores y funciones de bits de WGSL, la familia pack y unpack con su disposición exacta de bytes, y cómo se empaqueta un G-buffer sin perder lo que importa.

⏱ 18 min

Un shader que mueve mucha memoria gasta más tiempo esperando datos que calculando, y la forma más directa de acelerarlo es que los datos ocupen menos. WGSL trae para eso una familia de funciones de empaquetado que traducen entre vectores de flotantes y enteros de 32 bits en una sola instrucción de hardware, y un juego completo de operaciones de bits que casi nadie usa hasta que le hace falta.

🎯 Al terminar esta lección sabrás
  • Usar los operadores y las funciones de bits con los tipos correctos.
  • Empaquetar y desempaquetar valores normalizados con la familia pack y unpack.
  • Predecir qué componente cae en qué byte del entero resultante.
  • Elegir la codificación adecuada para cada dato de un G-buffer.

Los operadores de bits

Los seis de siempre: &, |, ^, ~, << y >>. Funcionan sobre i32 y u32 y sobre sus vectores, componente a componente.

La diferencia entre los dos tipos está en el desplazamiento a la derecha: sobre u32 es lógico e introduce ceros, y sobre i32 es aritmético y propaga el bit de signo. El desplazamiento a la izquierda es igual en los dos.

let banderas : u32 = 0b1011u;
let tiene = (banderas & 0b0010u) != 0u;    // comprobar un bit
let puesto = banderas | 0b0100u;           // poner un bit
let quitado = banderas & ~0b0001u;         // quitar un bit
let cambiado = banderas ^ 0b1000u;         // invertir un bit

El operando de desplazamiento tiene que ser u32, y desplazar por un número de bits mayor o igual que la anchura del tipo no es una operación con resultado útil; si el compilador puede ver que la constante se pasa de rango, lo rechaza directamente.

Un uso que aparece constantemente es empaquetar varias banderas y un índice en el mismo u32 para no gastar cuatro campos en una struct:

// Bits 0..15: indice de material. Bits 16..23: banderas.
fn material(empaquetado : u32) -> u32 { return empaquetado & 0xffffu; }
fn esTransparente(empaquetado : u32) -> bool { return (empaquetado & (1u << 16u)) != 0u; }

Las funciones de bits

Función Qué hace
countOneBits(e) número de bits a uno
countLeadingZeros(e) ceros a la izquierda antes del primer uno
countTrailingZeros(e) ceros a la derecha antes del primer uno
firstLeadingBit(e) índice del bit más significativo a uno
firstTrailingBit(e) índice del bit menos significativo a uno
reverseBits(e) invierte el orden de los 32 bits
extractBits(e, offset, count) extrae un campo de bits
insertBits(e, nuevos, offset, count) sustituye un campo de bits
bitcast(e) reinterpreta los bits como otro tipo

extractBits e insertBits sustituyen las máscaras y los desplazamientos escritos a mano, y se compilan a la instrucción de campo de bits que tienen casi todas las GPUs:

// Los mismos dos campos de antes, con las funciones.
let indice = extractBits(empaquetado, 0u, 16u);
let bandera = extractBits(empaquetado, 16u, 8u);

countOneBits es la operación con la que se cuentan elementos en máscaras de cobertura y en algoritmos de compactación. reverseBits aparece en la FFT y en el ordenado por bits de Morton. Y firstLeadingBit es el logaritmo entero en base dos, que es lo que se usa para calcular niveles de mip y profundidades de árbol.

Empaquetar valores normalizados

Aquí está lo verdaderamente útil. Estas funciones convierten entre vectores de flotantes y un u32, aplicando la cuantización de un formato normalizado, y en la mayoría del hardware son una sola instrucción.

Función Entrada Bits por componente Rango
pack4x8unorm vec4f 8 0 a 1
pack4x8snorm vec4f 8 -1 a 1
pack2x16unorm vec2f 16 0 a 1
pack2x16snorm vec2f 16 -1 a 1
pack2x16float vec2f 16 rango de medio flotante

Cada una tiene su unpack correspondiente que devuelve el vector original con el error de cuantización. La disposición es siempre la misma: el componente 0 va en los bits menos significativos.

let color = vec4f(1.0, 0.5, 0.25, 1.0);
let empaquetado = pack4x8unorm(color);      // 0xff4080ff aproximadamente
let recuperado = unpack4x8unorm(empaquetado);

Las conversiones son las de siempre: unorm acota a cero y uno y multiplica por 255; snorm acota a menos uno y uno y multiplica por 127. La pérdida es real y hay que dimensionarla antes de usarla: 8 bits dan 256 niveles, que es suficiente para un color final y claramente insuficiente para una normal o para una posición.

pack2x16float es distinto de los otros cuatro: no cuantiza a un rango normalizado, sino que convierte dos f32 a dos medios flotantes con su rango y su precisión. Es el que quieres cuando el valor no está acotado.

Con la extensión packed_4x8_integer_dot_product aparecen además pack4xI8, pack4xU8, sus variantes con acotado, sus unpack, y las dos operaciones que dan nombre a la extensión, dot4I8Packed y dot4U8Packed, que calculan un producto escalar de cuatro enteros de 8 bits empaquetados en un solo u32. Es la instrucción que usan las redes neuronales cuantizadas.

Los casos reales

Un G-buffer compacto. En vez de tres targets de rgba16float, uno de rgba32uint con todo empaquetado:

struct Gbuffer {
  @location(0) datos : vec4u,
};

@fragment
fn fs(entrada : Entrada) -> Gbuffer {
  var s : Gbuffer;
  s.datos.x = pack4x8unorm(vec4f(albedo, 0.0));       // color base
  s.datos.y = pack2x16snorm(normalOctaedrica);        // normal en 32 bits
  s.datos.z = pack4x8unorm(vec4f(rugosidad, metalico, oclusion, 0.0));
  s.datos.w = bitcast<u32>(profundidadLineal);
  return s;
}

Vértices comprimidos leídos desde un storage buffer. Una normal por vértice en snorm8x4 ocupa 4 bytes en vez de 12, y se desempaqueta con una instrucción en el vertex shader.

Estado de simulación. Un contador y tres banderas en el mismo u32 que se manipula con operaciones atómicas, en vez de cuatro campos que necesitarían cuatro atómicas distintas.

Ocho bits para una normal parecen suficientes hasta que enciendes un especular

El cálculo que casi nadie hace antes de elegir un formato es el del error angular, y es el que decide si una normal comprimida sirve o no.

Con tres componentes en snorm8, cada eje tiene 256 niveles repartidos entre menos uno y uno. Las direcciones representables no están repartidas uniformemente por la esfera —se apelotonan hacia las diagonales y se separan hacia los ejes—, y el error angular en el caso peor ronda el grado. Un grado de error en la normal es invisible en el término difuso, que varía como el coseno y es una función muy suave. Y es perfectamente visible en el especular de una superficie pulida, donde el exponente amplifica cualquier desviación: aparece un moteado o un contorneado en las zonas de brillo suave, sobre todo en superficies curvas y grandes como la carrocería de un coche.

La solución estándar tiene dos partes. La primera es no guardar tres componentes: una normal unitaria tiene dos grados de libertad, así que el tercero es información redundante que además se puede recuperar con una raíz. La segunda es usar una proyección que reparta bien, y la que ha ganado es la codificación octaédrica: se proyecta la esfera sobre un octaedro, se despliega en un cuadrado, y se guardan dos números. El reparto es casi uniforme y no hay singularidades.

Con dos componentes de 16 bits, es decir, un solo u32 con pack2x16snorm, el error angular baja del orden de la centésima de grado, que es indistinguible de una normal completa en f32 para cualquier material. Mismo número de bytes que tres componentes de 8 bits más su relleno, y dos órdenes de magnitud menos de error.

La moraleja generaliza más allá de las normales: antes de elegir cuántos bits, decide cuántos grados de libertad tiene de verdad el dato y cuál es la métrica de error que se va a notar. Un color se mide en diferencia perceptual y 8 bits por canal bastan si están en el espacio adecuado. Una normal se mide en ángulo y necesita 16 por eje. Una profundidad se mide en error relativo y necesita 24 o una distribución no lineal. Empaquetar bien no es apretar bits: es escoger la representación.