Las reglas de alineación de WGSL, con la tabla completa
La tabla de alineación y tamaño de todos los tipos host-shareable de WGSL, el cálculo del offset de cada miembro de una struct paso a paso, y por qué vec3 es el tipo que rompe todos los layouts.
Este es el punto donde WebGPU produce sus bugs más desagradables: los que no dan error. Escribes doce floats desde JavaScript, el shader lee doce floats, y los valores están desplazados porque WGSL insertó un hueco que tú no contaste. No hay excepción, no hay mensaje de validación, no hay nada: solo una escena que se ve mal por una razón que no aparece en ningún sitio. Las reglas que producen ese hueco son cuatro y caben en una tabla.
- Recitar la alineación y el tamaño de cada tipo escalar, vector y matriz de WGSL.
- Aplicar la regla de offset de miembro para calcular la posición de cada campo de una struct.
- Explicar por qué
vec3<f32>tiene tamaño 12 y alineación 16, y qué consecuencias tiene. - Calcular el tamaño total de una struct anidada con arrays y verificarlo.
Las cuatro reglas
WGSL define dos funciones sobre cada tipo host-shareable: AlignOf, la alineación en bytes, y SizeOf, el tamaño en bytes. A partir de ellas, todo el layout sale de cuatro reglas.
Regla 1: el offset de un miembro se redondea hacia arriba hasta su alineación. El primer miembro de una struct va en el offset 0. Cada miembro siguiente empieza en el primer múltiplo de su AlignOf que sea mayor o igual que el final del miembro anterior. Formalmente, offset(n) = roundUp(AlignOf(tipo(n)), offset(n-1) + SizeOf(tipo(n-1))).
Regla 2: la alineación de una struct es la mayor de las de sus miembros.
Regla 3: el tamaño de una struct se redondea hacia arriba hasta su propia alineación. Es decir, SizeOf(S) = roundUp(AlignOf(S), offset(último) + SizeOf(último)). Este redondeo final es el que produce el padding de cola que casi nadie cuenta.
Regla 4: un array tiene el AlignOf de su elemento, y su SizeOf es N × stride, donde stride = roundUp(AlignOf(E), SizeOf(E)). El stride es lo que separa dos elementos consecutivos, y puede ser mayor que el tamaño del elemento.
Con roundUp(k, n) definido como Math.ceil(n / k) * k. Cuatro reglas, ni una más.
La tabla
Estos son los valores para los tipos de 32 bits, que son los que usarás el 95 % del tiempo:
| Tipo | AlignOf |
SizeOf |
|---|---|---|
i32, u32, f32 |
4 | 4 |
atomic<i32>, atomic<u32> |
4 | 4 |
vec2<f32> |
8 | 8 |
vec3<f32> |
16 | 12 |
vec4<f32> |
16 | 16 |
mat2x2<f32> |
8 | 16 |
mat3x2<f32> |
8 | 24 |
mat4x2<f32> |
8 | 32 |
mat2x3<f32> |
16 | 32 |
mat3x3<f32> |
16 | 48 |
mat4x3<f32> |
16 | 64 |
mat2x4<f32> |
16 | 32 |
mat3x4<f32> |
16 | 48 |
mat4x4<f32> |
16 | 64 |
La notación matCxR es C columnas por R filas, al contrario de lo que sugiere la costumbre matemática. mat3x2<f32> son tres columnas de dos componentes: tres vec2<f32>, 24 bytes.
Con f16 habilitado —requiere la feature shader-f16 en el dispositivo y enable f16; en el módulo— los valores se reducen a la mitad con la misma estructura:
| Tipo | AlignOf |
SizeOf |
|---|---|---|
f16 |
2 | 2 |
vec2<f16> |
4 | 4 |
vec3<f16> |
8 | 6 |
vec4<f16> |
8 | 8 |
mat2x2<f16> |
4 | 8 |
mat3x3<f16> |
8 | 24 |
mat4x4<f16> |
8 | 32 |
El caso que rompe todo: vec3
vec3<f32> ocupa 12 bytes y se alinea a 16. Esa asimetría es la fuente de la mayoría de los bugs de layout de la web, y merece entenderse en lugar de memorizarse.
El motivo es de hardware. Las unidades de carga de una GPU leen memoria en bloques de 16 bytes; un vector de tres componentes que cruzara ese límite necesitaría dos accesos. Alinear a 16 garantiza que cabe en un bloque. Pero el tamaño no se redondea a 16, porque si el siguiente miembro es un f32 cabe dentro de ese mismo bloque, en los cuatro bytes sobrantes, y desperdiciarlos sería absurdo.
Eso produce dos comportamientos radicalmente distintos según lo que venga después:
struct A {
v : vec3<f32>, // offset 0, ocupa 0..12
f : f32, // offset 12: cabe en el hueco. SizeOf(A) = 16
};
struct B {
v : vec3<f32>, // offset 0, ocupa 0..12
w : vec3<f32>, // offset 16: se alinea a 16, hueco de 4 bytes en 12..16
}; // SizeOf(B) = roundUp(16, 16 + 12) = 32
En A no hay padding y el struct mide 16. En B hay cuatro bytes de padding en medio y cuatro de cola, y mide 32. Si escribes seis floats consecutivos desde JavaScript esperando llenar B, los tres últimos aterrizan en los offsets 12, 16 y 20, y el shader lee w como los valores de los offsets 16, 20 y 24: uno correcto, dos basura.
Y ahora el caso realmente traicionero, el array:
// stride = roundUp(AlignOf(vec3<f32>), SizeOf(vec3<f32>)) = roundUp(16, 12) = 16
var<storage, read> puntos : array<vec3<f32>>;
Cada elemento ocupa 16 bytes aunque el tipo mida 12. Un Float32Array de posiciones densamente empaquetado —x, y, z, x, y, z…— no es un array<vec3<f32>> válido: hay que insertar un float de relleno cada tres. Es el bug número uno de quien porta un buffer de vértices a un storage buffer.
Dentro de un shader, vec3<f32> es cómodo y correcto. En una struct que se escribe desde JavaScript, o en un array, la recomendación de todo el que ha sufrido esto es la misma: usa vec4<f32> y aprovecha la cuarta componente para algo, o declara explícitamente el relleno. El coste de memoria es idéntico —el padding lo pagas igual— y el layout deja de tener sorpresas.
Dos cálculos completos, paso a paso
La struct de cámara
Toma esta struct, que es la que aparece en el 90 % de los renderers:
struct Camara {
vista : mat4x4<f32>,
proyeccion : mat4x4<f32>,
viewProj : mat4x4<f32>,
posicion : vec3<f32>,
tiempo : f32,
};
El cálculo, miembro a miembro, aplicando la regla 1:
| Miembro | AlignOf |
SizeOf |
Fin del anterior | Offset | Ocupa |
|---|---|---|---|---|---|
vista |
16 | 64 | — | 0 | 0..64 |
proyeccion |
16 | 64 | 64 | roundUp(16, 64) = 64 |
64..128 |
viewProj |
16 | 64 | 128 | roundUp(16, 128) = 128 |
128..192 |
posicion |
16 | 12 | 192 | roundUp(16, 192) = 192 |
192..204 |
tiempo |
4 | 4 | 204 | roundUp(4, 204) = 204 |
204..208 |
AlignOf(Camara) es el máximo de las alineaciones: 16. SizeOf(Camara) = roundUp(16, 204 + 4) = roundUp(16, 208) = 208.
208 bytes, 52 floats, sin ningún hueco. Es un layout afortunado: posicion y tiempo encajan exactamente en un bloque de 16. Ese es el motivo por el que la struct de cámara se escribe así en todas partes y no con tiempo antes de posicion, que produciría 224 bytes con doce de padding.
Y la escritura desde JavaScript, con los offsets en unidades de Float32Array, es decir, divididos por 4:
const datos = new Float32Array(52);
datos.set(vista, 0); // byte 0
datos.set(proyeccion, 16); // byte 64
datos.set(viewProj, 32); // byte 128
datos.set(posicion, 48); // byte 192
datos[51] = tiempo; // byte 204
device.queue.writeBuffer(bufferCamara, 0, datos);
La struct con array anidado
struct Luz {
posicion : vec3<f32>,
intensidad : f32,
color : vec3<f32>,
radio : f32,
};
struct Escena {
ambiente : vec4<f32>,
numLuces : u32,
luces : array<Luz, 8>,
};
Luz primero. posicion en 0 (12 bytes), intensidad en 12 —cabe en el hueco—, color en roundUp(16, 16) = 16, radio en 28. AlignOf(Luz) = 16, SizeOf(Luz) = roundUp(16, 32) = 32. Sin padding interno: los dos vec3 con su f32 detrás encajan perfectamente. Es el motivo por el que las structs de luz se declaran siempre en ese orden.
Escena después. ambiente en 0 (16 bytes). numLuces en 16 (4 bytes). luces es un array cuyo AlignOf es el de Luz, o sea 16, así que su offset es roundUp(16, 20) = 32. Hay doce bytes de padding entre numLuces y luces, y ahí es donde se pierde todo el mundo.
El stride del array es roundUp(16, 32) = 32, así que SizeOf(array<Luz, 8>) = 256. SizeOf(Escena) = roundUp(16, 32 + 256) = 288.
const escena = new ArrayBuffer(288);
const f = new Float32Array(escena);
const u = new Uint32Array(escena);
f.set(ambiente, 0); // byte 0
u[4] = luces.length; // byte 16
// byte 20..32: padding, no lo toques
for (let i = 0; i < luces.length; i++) {
const base = 8 + i * 8; // byte 32 + i*32, en floats
f.set(luces[i].posicion, base);
f[base + 3] = luces[i].intensidad;
f.set(luces[i].color, base + 4);
f[base + 7] = luces[i].radio;
}
device.queue.writeBuffer(bufferEscena, 0, escena);
Fíjate en el ArrayBuffer compartido entre un Float32Array y un Uint32Array: es la forma limpia de escribir campos de tipos distintos sin DataView ni conversiones. Los dos arrays son vistas sobre los mismos bytes.
Cuando un layout está mal, el síntoma no es aleatorio: es sistemático y tiene forma. Si todo se ve bien salvo un campo que aparece siempre a cero, casi seguro que ese campo cayó en un hueco de padding. Si un campo tiene el valor del campo anterior, hay un desplazamiento de un slot. Si las luces impares funcionan y las pares no, el stride del array está mal por un factor de dos. Y si todo funciona en tu máquina y falla en otra, no es el layout: es que estabas leyendo memoria no inicializada que en tu GPU salía a cero. Aprender a leer esas firmas ahorra horas, pero la lección de fondo es otra: si el layout se calcula a mano, tarde o temprano se calcula mal. La lección sobre las herramientas enseña a que lo calcule una máquina, que es lo único que escala cuando la struct tiene veinte campos y cambia cada semana.
Calcula a mano el tamaño y los offsets de esta struct, y después verifica tu respuesta escribiendo un patrón conocido desde JavaScript y leyéndolo de vuelta con un storage buffer y mapAsync: struct T { a: f32, b: vec3<f32>, c: mat3x3<f32>, d: vec2<f32>, e: array<f32, 3> }. La respuesta tiene dos trampas, una en b y otra en e.