wandres.dev
WGSL II · Tipos y espacios de dirección

Las reglas de disposición del lenguaje: alineación, tamaño y desplazamiento

La tabla completa de AlignOf y SizeOf de WGSL, el algoritmo exacto que decide el desplazamiento de cada miembro de una struct, y cómo escribir el mismo dato desde JavaScript sin equivocarse.

⏱ 20 min

Una struct de WGSL no ocupa la suma de sus miembros. Ocupa lo que dictan tres reglas de alineación que el lenguaje define al bit, y que producen huecos que nadie escribió y que la CPU tiene que respetar exactamente al rellenar el buffer. Es la fuente número uno de bugs silenciosos en WebGPU: no falla nada, no hay error de validación, simplemente los valores llegan desplazados.

🎯 Al terminar esta lección sabrás
  • Distinguir los tipos host-shareable de los que solo existen dentro del shader.
  • Aplicar la tabla de AlignOf y SizeOf a cualquier tipo del lenguaje.
  • Calcular a mano el desplazamiento de cada miembro de una struct y su tamaño total.
  • Escribir el buffer equivalente desde JavaScript sin repetir los números a mano.

Host-shareable, o el subconjunto que cruza

Solo un subconjunto de los tipos de WGSL puede aparecer en un uniform buffer o en un storage buffer: los host-shareable. Son los escalares numéricos —i32, u32, f32 y f16, nunca bool—, los vectores y matrices de esos escalares, los arrays de tipos host-shareable, y las structs cuyos miembros lo son todos.

Cada tipo host-shareable tiene dos números definidos por la especificación: AlignOf, la alineación, que es la potencia de dos a la que tiene que empezar; y SizeOf, el tamaño que ocupa. Los dos números son independientes: un vec3f tiene alineación 16 y tamaño 12, y esa discrepancia es la causa de la mitad de los huecos que vas a encontrar.

Todo el sistema se apoya en una sola operación auxiliar, redondear hacia arriba al múltiplo siguiente:

roundUp(k, n) = ceil(n / k) * k

La tabla

Tipo AlignOf SizeOf
i32, u32, f32 4 4
f16 2 2
vec2f, vec2i, vec2u 8 8
vec3f, vec3i, vec3u 16 12
vec4f, vec4i, vec4u 16 16
vec2h 4 4
vec3h 8 6
vec4h 8 8
mat2x2f 8 16
mat3x2f 8 24
mat4x2f 8 32
mat2x3f 16 32
mat3x3f 16 48
mat4x3f 16 64
mat2x4f 16 32
mat3x4f 16 48
mat4x4f 16 64

Las matrices no son un caso especial: una matCxR se comporta exactamente como un array<vecR, C>. Su alineación es la del vector columna y su tamaño es C veces el stride de columna, que es SizeOf(vecR) redondeado a AlignOf(vecR). Por eso una mat3x3f mide 48 y no 36: cada columna es un vec3f de 12 bytes que ocupa 16.

Para los arrays valen dos fórmulas:

stride  = roundUp(AlignOf(T), SizeOf(T))
AlignOf(array<T, N>) = AlignOf(T)
SizeOf(array<T, N>)  = N * stride

Un array<f32, 4> en storage tiene stride 4 y ocupa 16 bytes. Un array<vec3f, 4> tiene stride 16 —no 12— y ocupa 64. El espacio de direcciones uniform añade restricciones adicionales sobre los strides que no se aplican en storage, y conviene no dar por hecho que una struct mide lo mismo en los dos.

El algoritmo de la struct

Tres reglas, aplicadas en orden, y no hay más:

Regla 1, la alineación de la struct es el máximo de las alineaciones de sus miembros.

Regla 2, el desplazamiento de cada miembro es el final del miembro anterior redondeado a la alineación de este:

offset(M0) = 0
offset(Mi) = roundUp(AlignOf(Mi), offset(Mi-1) + SizeOf(Mi-1))

Regla 3, el tamaño de la struct es el final del último miembro redondeado a la alineación de la struct:

SizeOf(S) = roundUp(AlignOf(S), offset(Mn) + SizeOf(Mn))

Aplicado a una struct bien ordenada, no sobra ni un byte:

struct Foco {
  posicion   : vec3f,   // align 16, size 12  -> offset 0
  radio      : f32,     // align  4, size  4  -> offset 12
  color      : vec3f,   // align 16, size 12  -> offset 16
  intensidad : f32,     // align  4, size  4  -> offset 28
  direccion  : vec3f,   // align 16, size 12  -> offset 32
  angulo     : f32,     // align  4, size  4  -> offset 44
};                      // AlignOf = 16, SizeOf = roundUp(16, 48) = 48

El patrón de emparejar cada vec3f con un f32 detrás es lo que hace que no haya huecos: el escalar cae justo en los cuatro bytes que el vector desperdicia.

Y aplicado a la misma información mal ordenada, el resultado cambia:

struct Mal {
  activo : f32,       // offset 0,  size  4
  matriz : mat4x4f,   // align 16 -> offset 16   (12 bytes de relleno)
  escala : vec3f,     // align 16 -> offset 80
  tinte  : vec4f,     // align 16 -> offset 96   (4 bytes de relleno)
};                    // SizeOf = roundUp(16, 112) = 112

Ciento doce bytes para noventa y seis de datos. Moviendo activo al final, detrás del vec3f, la misma struct mide 96 exactos. La diferencia no es la memoria: es que el caso bueno cabe en menos líneas de caché y se lee de una vez.

⚠️
Los huecos no se leen, pero se copian

El relleno que produce la alineación no es memoria muerta desde el punto de vista del bus: cuando la GPU trae una struct a caché, trae también los huecos. Un array de mil structs con un 15 % de relleno mueve un 15 % más de bytes por el bus de memoria en cada pasada. En un shader limitado por ancho de banda, que es la mayoría de los shaders que recorren arrays grandes, ese porcentaje se traduce casi uno a uno en tiempo.

El mismo dato desde JavaScript

El error clásico no es calcular mal el layout en WGSL: es calcularlo bien y luego escribir el buffer en JavaScript con desplazamientos hechos a ojo. La forma robusta es declarar el layout una vez, en constantes, y derivar de ahí todo lo demás.

// Desplazamientos en BYTES, calculados con las tres reglas.
const FOCO = {
  posicion:   0,
  radio:      12,
  color:      16,
  intensidad: 28,
  direccion:  32,
  angulo:     44,
  tamano:     48,
};

function escribirFoco(vistaBytes, indice, foco) {
  const base = indice * FOCO.tamano;
  const f = new Float32Array(vistaBytes.buffer, vistaBytes.byteOffset + base, FOCO.tamano / 4);
  f[0] = foco.posicion[0];  f[1] = foco.posicion[1];  f[2] = foco.posicion[2];
  f[3] = foco.radio;
  f[4] = foco.color[0];     f[5] = foco.color[1];     f[6] = foco.color[2];
  f[7] = foco.intensidad;
  f[8] = foco.direccion[0]; f[9] = foco.direccion[1]; f[10] = foco.direccion[2];
  f[11] = foco.angulo;
}

Fíjate en que FOCO.tamano es 48 y no 6 * 4 + 3 * 4: el tamaño sale de la regla 3, no de contar campos. Y fíjate en que los índices del Float32Array son desplazamientos de bytes divididos entre cuatro, lo cual solo funciona porque todos los miembros son de cuatro bytes; en cuanto haya un u32 mezclado hace falta un DataView o dos vistas superpuestas sobre el mismo ArrayBuffer.

Para structs que cambian a menudo, escribir estas constantes a mano es una fuente de errores garantizada, y merece la pena generar la tabla desde una descripción única del layout que sirva a la vez para emitir el WGSL y los desplazamientos. Hay librerías del ecosistema que lo hacen leyendo el propio WGSL.

La mat3x3f es el bug de alineación que todo el mundo comete una vez

Tienes una matriz normal 3x3 en la CPU. Son nueve flotantes. La declaras en el shader como mat3x3f, escribes nueve flotantes en el buffer, y las normales salen retorcidas de una forma que parece un problema de signo o de convención de mano.

No lo es. mat3x3f ocupa 48 bytes y espera doce flotantes, no nueve. Sus tres columnas son vec3f alineados a 16, así que la disposición real es tres, hueco, tres, hueco, tres, hueco. Al escribir nueve flotantes seguidos, la segunda columna del shader lee los componentes tercero, cuarto y quinto de tu array, y la tercera lee basura. El resultado es una matriz que no es ninguna transformación reconocible y que aun así produce imágenes casi plausibles, que es lo peor que puede pasar.

Hay tres salidas y conviene conocer las tres. La primera es escribir doce flotantes con relleno explícito, que es lo correcto si de verdad quieres una mat3x3f. La segunda, y la que usan casi todos los motores, es declararla como mat4x4f y quedarse con la parte de arriba a la izquierda: gastas 16 bytes más pero eliminas la clase entera de bug, y la matriz de modelo ya era 4x4 de todos modos. La tercera es no mandar la matriz normal: si la transformada no tiene escalado no uniforme, la parte 3x3 de la matriz de modelo ya sirve para normales, y te ahorras el dato entero.

El mismo mecanismo se aplica a array<vec3f, N>, que tiene stride 16 y no 12, y a cualquier vec3 que sea el último miembro de una struct. Si tuvieras que memorizar una sola frase de todo el nivel, que sea esta: en la frontera con el host, un vec3 ocupa el sitio de un vec4. Con esa frase interiorizada, la mayoría de los layouts salen bien a la primera.

Queda la otra mitad del sistema de tipos, la que decide quién ve qué memoria: los espacios de dirección.