Dejar de calcular offsets a mano
Un generador de layouts en cincuenta líneas que aplica las reglas de WGSL, cómo verificarlo contra la GPU con un buffer de ida y vuelta, y qué ofrecen las librerías de reflexión.
Las reglas de alineación de WGSL son cuatro y caben en una tarde. Aplicarlas a mano en una struct de veinte campos que cambia cada semana es, en cambio, una garantía estadística de bug. La salida no es memorizar mejor: es escribir una vez las cuatro reglas en código y no volver a pensar en ellas, y después montar un test que compare tu cálculo con el que hace la GPU de verdad.
- Implementar un calculador de layout que aplique las cuatro reglas de WGSL a una descripción de struct.
- Generar desde ese cálculo las funciones que escriben cada campo en un
ArrayBuffer. - Verificar el layout con un viaje de ida y vuelta a la GPU que detecte cualquier desfase.
- Saber qué aportan las librerías de reflexión de WGSL y qué siguen sin resolver.
Las cuatro reglas en cincuenta líneas
Una tabla de tipos y una función de acumulación bastan. La tabla contiene AlignOf y SizeOf de cada tipo primitivo; la función recorre los miembros aplicando la regla del redondeo.
const TIPOS = {
f32: { align: 4, size: 4 }, i32: { align: 4, size: 4 },
u32: { align: 4, size: 4 },
vec2f: { align: 8, size: 8 },
vec3f: { align: 16, size: 12 },
vec4f: { align: 16, size: 16 },
mat2x2f: { align: 8, size: 16 }, mat3x2f: { align: 8, size: 24 },
mat4x2f: { align: 8, size: 32 }, mat2x3f: { align: 16, size: 32 },
mat3x3f: { align: 16, size: 48 }, mat4x3f: { align: 16, size: 64 },
mat2x4f: { align: 16, size: 32 }, mat3x4f: { align: 16, size: 48 },
mat4x4f: { align: 16, size: 64 },
};
const roundUp = (k, n) => Math.ceil(n / k) * k;
// espacio: 'uniform' | 'storage'
function medir(tipo, espacio) {
if (typeof tipo === 'string') return TIPOS[tipo];
if (tipo.array) {
const e = medir(tipo.array, espacio);
let stride = roundUp(e.align, e.size);
if (espacio === 'uniform') stride = roundUp(16, stride);
return { align: espacio === 'uniform' ? roundUp(16, e.align) : e.align,
size: stride * tipo.n, stride };
}
// struct: array de [nombre, tipo]
let offset = 0, align = 1;
const campos = {};
for (const [nombre, t] of tipo.struct) {
const m = medir(t, espacio);
const a = espacio === 'uniform' && (t.struct || t.array)
? roundUp(16, m.align) : m.align;
offset = roundUp(a, offset);
campos[nombre] = { offset, ...m };
offset += m.size;
align = Math.max(align, a);
}
return { align, size: roundUp(align, offset), campos };
}
Con eso, el layout de la struct de cámara se calcula solo:
const Camara = medir({ struct: [
['vista', 'mat4x4f'],
['proyeccion', 'mat4x4f'],
['viewProj', 'mat4x4f'],
['posicion', 'vec3f'],
['tiempo', 'f32'],
]}, 'uniform');
console.log(Camara.size); // 208
console.log(Camara.campos.posicion.offset); // 192
Y el caso que más muerde, el array en espacio uniform, sale correcto sin pensarlo:
const Pesos = medir({ struct: [['valores', { array: 'f32', n: 64 }]] }, 'uniform');
console.log(Pesos.size); // 1024, no 256
Faltan f16, los atributos @align y @size, los atomic, y los arrays de tamaño en tiempo de ejecución. Añadir los dos atributos son cuatro líneas —un override del align y del size en el bucle— y los demás casos solo aparecen en shaders concretos. La versión de arriba resuelve el 95 % de las structs de un renderer.
Generar el escritor
Con el layout calculado, escribir el buffer deja de ser aritmética manual. Una función que reciba el layout y devuelva un objeto con vistas por campo elimina toda la clase de bugs:
function vista(layout, buffer = new ArrayBuffer(layout.size), base = 0) {
const f32 = new Float32Array(buffer);
const u32 = new Uint32Array(buffer);
const i32 = new Int32Array(buffer);
const api = { buffer, base };
for (const [nombre, c] of Object.entries(layout.campos)) {
const idx = (base + c.offset) / 4;
const n = c.size / 4;
const arr = c.size === 4 && nombre.startsWith('n') ? u32 : f32;
api[nombre] = {
set: (v) => (typeof v === 'number' ? (arr[idx] = v) : arr.set(v, idx)),
get idx() { return idx; },
};
}
api.u32 = u32; api.i32 = i32; api.f32 = f32;
return api;
}
const camara = vista(Camara);
camara.vista.set(matrizVista);
camara.proyeccion.set(matrizProyeccion);
camara.posicion.set([0, 2, 5]);
camara.tiempo.set(performance.now() / 1000);
device.queue.writeBuffer(bufferCamara, 0, camara.buffer);
Nadie escribe un número de offset en ninguna parte. Si mañana añades un campo a la struct de WGSL, lo añades también a la descripción de JavaScript y todos los offsets se recalculan solos.
El test que garantiza que el cálculo es correcto
Un calculador propio puede estar mal. La forma de saberlo con certeza es preguntárselo a la GPU: escribes un patrón conocido en un buffer, un compute shader lo lee campo a campo y escribe los valores leídos en un buffer de salida, y comparas.
const wgsl = `
struct Camara {
vista : mat4x4<f32>,
proyeccion : mat4x4<f32>,
viewProj : mat4x4<f32>,
posicion : vec3<f32>,
tiempo : f32,
};
@group(0) @binding(0) var<uniform> entrada : Camara;
@group(0) @binding(1) var<storage, read_write> salida : array<f32>;
@compute @workgroup_size(1)
fn main() {
salida[0] = entrada.vista[0][0];
salida[1] = entrada.proyeccion[0][0];
salida[2] = entrada.viewProj[0][0];
salida[3] = entrada.posicion.x;
salida[4] = entrada.tiempo;
}`;
Del lado de JavaScript se rellena con valores marcadores distintos y se comprueban:
const c = vista(Camara);
c.vista.set([1001, ...new Array(15).fill(0)]);
c.proyeccion.set([1002, ...new Array(15).fill(0)]);
c.viewProj.set([1003, ...new Array(15).fill(0)]);
c.posicion.set([1004, 0, 0]);
c.tiempo.set(1005);
// ... crear pipeline, despachar, leer 'salida' con mapAsync ...
const leido = new Float32Array(bufferLectura.getMappedRange());
console.assert(leido[0] === 1001, 'vista desplazada');
console.assert(leido[3] === 1004, 'posicion desplazada');
console.assert(leido[4] === 1005, 'tiempo desplazado');
Ese test tarda unos milisegundos, se ejecuta en el arranque en modo desarrollo, y detecta al instante cualquier desfase: entre tu cálculo y el de la implementación, y también entre tu descripción de JavaScript y la struct de WGSL cuando alguien cambia una sin la otra. Es, con diferencia, el mejor retorno de veinte líneas de test que hay en un proyecto de WebGPU.
Rellenar con ceros y comprobar que salen ceros no detecta nada, porque la memoria no inicializada de un buffer de WebGPU es cero por especificación. Cada campo tiene que llevar un valor único y reconocible; los números grandes y consecutivos funcionan bien porque un desplazamiento de un slot salta a la vista.
Las librerías de reflexión
Existen herramientas que parsean el WGSL y extraen los layouts directamente del shader, eliminando la duplicación entre la descripción de JavaScript y la struct. La más usada del ecosistema es webgpu-utils, con makeShaderDataDefinitions() para extraer las definiciones y makeStructuredView() para obtener un objeto con un campo por miembro; wgsl_reflect cubre el mismo terreno a más bajo nivel y es la base sobre la que se construyen varias de ellas.
La ventaja es evidente: una sola fuente de verdad, el propio shader. Si cambias la struct de WGSL, el objeto de JavaScript cambia solo.
Lo que estas librerías no resuelven es más interesante. No te dicen si tu layout es eficiente: te dan el layout que has escrito, con su padding y su desperdicio. No detectan que un array<f32, 64> en espacio uniform ocupa 1024 bytes: te lo calculan correctamente y tú sigues sin enterarte. Y añaden un parser de WGSL a tu bundle, que si generas shaders dinámicamente hay que ejecutar en tiempo de ejecución.
La combinación que mejor funciona en proyectos serios es usar reflexión para los layouts y mantener el test de ida y vuelta de arriba. La reflexión elimina la duplicación; el test verifica que el shader hace lo que crees.
Hay una clase de fallo que sobrevive a todas las herramientas y a todos los tests de layout: cuando la struct está bien y lo que está mal es el orden de las matrices. WGSL usa matrices en orden de columnas, igual que GLSL, y mat4x4<f32> es un array de cuatro vec4<f32> que son las columnas. Si tu librería de matemáticas de JavaScript produce matrices en orden de filas —algunas lo hacen, y algunas lo hacen solo en ciertas funciones—, los 64 bytes están perfectamente alineados y el resultado es una transformación transpuesta. El síntoma es una escena que rota al revés, o que se deforma al escalar de forma no uniforme, y el layout no tiene nada que ver. El test que lo atrapa no comprueba offsets: comprueba que multiplicar la matriz por un vector conocido en la GPU da el mismo resultado que multiplicarlo en la CPU. Cuando lleves horas revisando alineaciones y todo cuadre, esa es la hipótesis que te falta.
Extiende el calculador de arriba con soporte para @align(n) y @size(n): añade a la descripción de cada campo dos propiedades opcionales y aplícalas en el bucle, respetando que @align no puede ser menor que la alineación natural y @size no puede ser menor que el tamaño natural. Después comprueba con el test de ida y vuelta que tu implementación coincide con la de la GPU en un caso con ambos atributos.