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

Arrays y structs: tamaño fijo, tamaño variable y valor cero

Cómo se declaran arrays fijos y en tiempo de ejecución, de dónde saca arrayLength su número, y qué puede y qué no puede contener una struct de WGSL.

⏱ 17 min

El único tipo de WGSL cuyo tamaño se decide fuera del shader es el array sin longitud, y es también el que hace posible todo el cómputo general en GPU: sin él no hay listas de partículas, ni buffers de luces, ni resultados de tamaño desconocido. Sus reglas son cortas, sus restricciones son tajantes y el número que devuelve arrayLength viene de un sitio que casi nadie tiene claro.

🎯 Al terminar esta lección sabrás
  • Declarar arrays de tamaño fijo y de tamaño en tiempo de ejecución en el sitio donde son legales.
  • Calcular a mano el valor que devolverá arrayLength para un binding dado.
  • Escribir structs con sus reglas de construcción, copia y anidamiento.
  • Enumerar los tipos que no pueden aparecer dentro de una struct y por qué.

Arrays de tamaño fijo

La forma es array<T, N>, donde N tiene que ser una expresión de tiempo de compilación positiva. Los arrays se anidan, se meten en structs y contienen structs:

const CASCADAS : u32 = 4u;

struct Sombra {
  matriz : mat4x4f,
  radio  : f32,
};

var<private> tabla : array<f32, 8>;
var<private> rejilla : array<array<f32, 16>, 16>;   // 16 por 16
var<private> cascadas : array<Sombra, CASCADAS>;

El acceso es con corchetes y el índice puede ser cualquier expresión entera calculada en ejecución. Un índice fuera de rango no lee memoria de otro recurso: la implementación lo acota o devuelve un valor del propio recurso, según lo que la especificación permite. Sigue siendo un bug —te dará el dato equivocado— pero es un bug contenido.

El único sitio donde la longitud puede no ser una constante literal es una variable var<workgroup>, donde se admite además una expresión que dependa de una override. Es lo que permite decidir el tamaño de la memoria compartida al crear el pipeline en vez de al escribir el shader.

El array de tamaño variable

array<T>, sin segundo parámetro, es un array cuya longitud se decide cuando se enlaza el recurso. Sus reglas de colocación son estrictas y hay exactamente dos sitios legales.

Como tipo completo de una variable de storage:

@group(0) @binding(0) var<storage, read> posiciones : array<vec4f>;

Como último miembro de una struct usada en storage:

struct Escena {
  numeroDeLuces : u32,
  ambiente      : vec3f,
  luces         : array<Luz>,     // tiene que ir la ultima
};

@group(0) @binding(1) var<storage, read> escena : Escena;

Fuera de ahí no vale: ni en uniform, ni en workgroup, ni en private, ni como miembro que no sea el último, ni dentro de una struct anidada, ni como elemento de otro array, ni como tipo de retorno o parámetro de una función. La razón es siempre la misma: el resto del lenguaje necesita conocer los desplazamientos de todo lo que hay después, y detrás de un array sin longitud no puede haber nada.

Su longitud se consulta con arrayLength, que toma un puntero al array y devuelve un u32:

let n = arrayLength(&escena.luces);
for (var i = 0u; i < n; i = i + 1u) {
  // ...
}

El ampersand no es decorativo y es un error frecuente olvidarlo: arrayLength(escena.luces) no compila.

Structs

Una struct se declara a nivel de módulo, con los miembros separados por comas y coma final permitida. No hay valores por defecto, no hay métodos, no hay herencia y no hay visibilidad.

struct Material {
  color     : vec4f,
  rugosidad : f32,
  metalico  : f32,
};

struct Modelo {
  transformada : mat4x4f,
  material     : Material,      // anidar structs normales es legal
};

El constructor lleva todos los miembros en orden de declaración, sin omitir ninguno:

let m = Material(vec4f(1.0, 0.0, 0.0, 1.0), 0.4, 0.0);
let vacio = Material();     // valor cero: todos los miembros a cero

Los miembros se leen y se escriben con punto, y la asignación de una struct copia: no hay referencias implícitas, no hay aliasing. Asignar una struct de 128 bytes copia 128 bytes, y el compilador se encarga de que eso normalmente signifique mover registros y no tocar memoria.

Lo que no puede contener una struct:

  • Texturas y samplers. Viven en el espacio handle y solo pueden ser variables de módulo sueltas.
  • Punteros. El lenguaje no permite almacenar punteros en ningún tipo compuesto.
  • Un array sin longitud que no sea el último miembro, y solo en storage.
  • Tipos atómicos, salvo cuando la struct vive en el espacio workgroup o storage.
  • A sí misma, ni directa ni indirectamente. La recursión de tipos está prohibida igual que la de funciones.

Y una regla que se olvida: las structs de entrada y salida de un punto de entrada no se pueden anidar, aunque las structs normales sí. Un miembro con @location no puede ser una struct.

ℹ️
Las variables sin inicializador valen cero, con una excepción

En los espacios function y private, una variable declarada sin inicializador se inicializa al valor cero de su tipo: ceros numéricos, false, y recursivamente para vectores, matrices, arrays y structs. No hay basura del fotograma anterior y no hay lectura de memoria sin inicializar, lo cual elimina una clase entera de bugs no deterministas.

La excepción a tener en cuenta es la memoria de grupo de trabajo. Una var<workgroup> no admite inicializador, y el patrón correcto es siempre escribirla antes de leerla, con la barrera que corresponda. Depender de que valga cero al arrancar el grupo es apoyarse en una garantía que no conviene dar por supuesta.

De dónde sale el número de arrayLength

Aquí está la parte que casi nadie tiene clara y que produce bucles que procesan menos elementos de los que deberían. arrayLength no lee ningún contador escrito en el buffer. Lo calcula la implementación a partir del tamaño del binding, con esta fórmula:

longitud = piso( (tamaño_enlazado - desplazamiento_del_array) / stride_del_elemento )

Donde tamaño_enlazado es el tamaño del rango que enlazaste en el bind group —el size de la entrada, o el tamaño del buffer menos el offset si no lo especificaste—, desplazamiento_del_array es la posición del array dentro de la struct, y stride_del_elemento es el tamaño del elemento redondeado a su alineación.

Tres consecuencias directas. La primera: si enlazas un buffer de 1000 bytes con elementos de 48 bytes, arrayLength devuelve 20, no 20,83; los 40 bytes de cola se pierden y no hay aviso. La segunda: si aplicas un dynamic offset, la longitud cambia con él, porque el desplazamiento reduce el rango visible. La tercera, y la útil: puedes cambiar la longitud sin tocar el shader ni el buffer, simplemente creando un bind group que enlace un rango distinto.

// Mismo buffer, dos vistas de longitudes distintas.
const grupoCompleto = device.createBindGroup({
  layout, entries: [{ binding: 0, resource: { buffer: particulas } }],
});
const grupoMitad = device.createBindGroup({
  layout, entries: [{ binding: 0, resource: { buffer: particulas, offset: 0, size: 48 * 500 } }],
});
El contador que necesitas casi nunca es arrayLength

arrayLength te dice cuántos elementos caben en el binding, no cuántos hay vivos. En un sistema donde el número real de elementos cambia por fotograma —partículas que nacen y mueren, objetos que pasan el culling, colisiones detectadas— esa diferencia lo es todo, y el patrón correcto es un contador explícito.

Ahora bien, dónde poner ese contador es una decisión con consecuencias. La opción cómoda es meterlo en la misma struct, delante del array. Funciona, y tiene dos costes que no se ven. El primero es de alineación: el array queda desplazado y su primer elemento ya no está en el origen del buffer, lo que complica cualquier copia y obliga a que el código de JavaScript conozca el desplazamiento. El segundo es de contención: si el contador es un atómico que todas las invocaciones incrementan, y está en la misma línea de caché que los primeros elementos del array, las escrituras al contador y las escrituras a los datos se pelean por la misma línea.

La alternativa que usan los motores es un buffer de contadores aparte, pequeño, con un atomic<u32> por lista y padding generoso entre ellos. Cuesta un binding más y un setBindGroup que ya estabas haciendo, y a cambio el buffer de datos empieza en el elemento cero, se copia entero sin cuentas, se puede usar como vertex buffer sin desplazamientos y no comparte línea de caché con nada.

Y hay una tercera pieza que se olvida: si el contador va a alimentar un drawIndirect, tiene que vivir en un buffer con el flag INDIRECT y con la disposición exacta que espera el comando. En ese caso el contador es el buffer indirecto, se escribe desde el compute shader en la posición del instanceCount, y no hace falta leerlo nunca desde la CPU. Ese es el patrón que separa un renderer dirigido por GPU de uno que solo usa la GPU para pintar.

Sabiendo qué tipos existen, toca saber cómo se colocan en memoria: las reglas de disposición.