El catálogo de atributos de WGSL
Los quince atributos del lenguaje, dónde se aplica cada uno, cuáles admiten expresiones override, y para qué sirven de verdad @align, @size, @invariant y @must_use.
Los atributos son la parte de WGSL que no se parece a ningún lenguaje de propósito general: son la interfaz declarativa entre el shader y todo lo que hay fuera de él. Unos conectan con la API, otros colocan bytes en memoria, y unos pocos hablan directamente con el compilador para pedirle que se contenga. Conocer los quince de golpe cuesta una lectura y evita buscar la sintaxis quince veces.
- Colocar cada atributo en el elemento sintáctico donde es legal.
- Distinguir los atributos cuyos argumentos admiten
overridede los que exigen constantes. - Forzar el layout de una struct con
@aligny@size. - Aplicar
@invariant,@must_usey@diagnosticen los casos donde resuelven un problema real.
La gramática y el mapa
Un atributo es una arroba, un nombre y, si lo lleva, una lista de argumentos entre paréntesis. Se colocan delante del elemento que decoran y se pueden encadenar varios en la misma línea o en líneas distintas.
| Atributo | Se aplica a | Argumentos |
|---|---|---|
@vertex, @fragment, @compute |
funciones | ninguno |
@workgroup_size(x, y, z) |
funciones @compute |
de uno a tres, admite override |
@location(n) |
parámetros, retorno, miembros de struct de interfaz | constante |
@builtin(nombre) |
los mismos sitios que @location |
un nombre del catálogo |
@interpolate(tipo, muestreo) |
miembros con @location entre etapas |
uno o dos nombres |
@invariant |
el @builtin(position) de salida de vértice |
ninguno |
@blend_src(n) |
salidas de fragmento con dual-source-blending |
0 o 1 |
@group(n), @binding(n) |
variables de módulo de recurso | constante |
@align(n), @size(n) |
miembros de struct | constante |
@id(n) |
declaraciones override |
constante |
@must_use |
funciones con retorno | ninguno |
@diagnostic(sev, regla) |
funciones y algunos bloques | severidad y nombre de regla |
La regla general sobre los argumentos: casi todos exigen una expresión de tiempo de compilación, es decir, literales, const y operaciones entre ellos. La excepción es @workgroup_size, que además admite expresiones override, y esa excepción es la que permite decidir el tamaño de grupo al crear el pipeline.
override TAM : u32 = 64u;
@compute @workgroup_size(TAM) // legal: override
fn cs() { }
// @group(TAM) ... // ERROR: @group exige constante
Los que conectan con la API —@location, @builtin, @group, @binding— tienen lección propia. Los otros cuatro que merecen desarrollo son los que siguen.
@align y @size: colocar bytes a mano
Las reglas de disposición del lenguaje producen desplazamientos automáticos. Estos dos atributos permiten anularlos hacia arriba, nunca hacia abajo.
@align(n) fuerza que ese miembro empiece en un múltiplo de n. El valor tiene que ser potencia de dos y no menor que la alineación natural del tipo: puedes alinear un f32 a 16, pero no puedes alinear un vec4f a 4.
struct Bloque {
a : f32, // offset 0
@align(16) b : f32, // offset 16 en lugar de 4
}; // AlignOf 16, SizeOf 32
@size(n) fuerza el espacio que ese miembro ocupa dentro de la struct, que tiene que ser mayor o igual que su tamaño natural. No cambia el tipo ni lo que el shader lee: solo empuja al miembro siguiente.
struct Bloque2 {
@size(16) v : vec3f, // ocupa 16 aunque un vec3f mida 12
f : f32, // offset 16, no 12
}; // SizeOf 32
La diferencia entre los dos es la dirección en la que empujan: @align mueve el principio de este miembro, @size mueve el principio del siguiente. Los dos afectan al tamaño y a la alineación de la struct que los contiene, con las mismas reglas de siempre.
Su uso real no es artístico: es hacer que una struct de WGSL coincida byte a byte con una estructura que ya existe en otro sitio, típicamente un formato de fichero, un layout impuesto por una librería o una struct que también consume código de CPU.
@interpolate e @invariant
@interpolate decide cómo viaja un valor de la etapa de vértices a la de fragmentos. Acepta un tipo —perspective, linear o flat— y opcionalmente un muestreo —center, centroid o sample, y para flat los valores first y either.
struct SalidaVS {
@builtin(position) clip : vec4f,
@location(0) uv : vec2f, // perspective, center
@location(1) @interpolate(flat) idMat : u32,
@location(2) @interpolate(linear) lineal : f32,
@location(3) @interpolate(perspective, sample) porMuestra : vec3f,
};
Sin atributo, el comportamiento es perspective con muestreo center, que es lo que quieres el 95 % de las veces. Los tipos enteros obligan a flat. Y el atributo tiene que ser idéntico en el lado del vertex y en el del fragment, o el pipeline no se crea.
@invariant es otra cosa y solo se aplica al @builtin(position) de salida de un vertex shader. Le pide al compilador que el cálculo de esa posición produzca exactamente los mismos bits que produciría cualquier otro shader que la calcule con la misma expresión y los mismos datos.
@vertex
fn vsProfundidad(@location(0) p : vec3f) -> @invariant @builtin(position) vec4f {
return camara.viewProj * modelo.matriz * vec4f(p, 1.0);
}
Pedir invarianza le prohíbe al compilador reasociar el producto de matrices, fusionar multiplicación y suma en una instrucción combinada, o precalcular parte de la expresión. Las tres son optimizaciones que cambian el último bit del resultado, y son de las que más rendimiento dan en un vertex shader. Ponlo solo en los shaders donde hace falta, y no en todos por costumbre.
Los que hablan con el compilador
@must_use marca una función cuyo resultado no se puede ignorar. Llamarla como si fuera un procedimiento es un error de compilación:
@must_use
fn normalizarSeguro(v : vec3f) -> vec3f {
let l = length(v);
return select(vec3f(0.0, 0.0, 1.0), v / l, l > 1e-6);
}
Sirve para funciones puras y caras cuyo valor es lo único que producen. Si de verdad quieres descartar el resultado, la asignación de descarte lo permite: _ = normalizarSeguro(v);.
@diagnostic ajusta la severidad de una regla del compilador en un ámbito concreto, y hoy la única regla estándar es derivative_uniformity. A nivel de módulo se escribe como directiva; sobre una función o un bloque, como atributo:
@fragment
@diagnostic(warning, derivative_uniformity)
fn fs(entrada : Entrada) -> @location(0) vec4f {
// aqui el analisis de uniformidad avisa en lugar de fallar
return vec4f(1.0);
}
Bajar la severidad no cambia el comportamiento del hardware: cambia si el compilador te deja pasar. Si el análisis tenía razón, el resultado sigue siendo indeterminado; lo único que has hecho es asumir tú la responsabilidad.
@id(n) da un identificador numérico a una override para poder especificarla por número en vez de por nombre al crear el pipeline. Los números tienen que ser únicos en el módulo.
Este atributo parece esotérico hasta que te encuentras con el bug, y entonces se convierte en la cosa más útil del catálogo.
El escenario es un depth prepass. En la primera pasada dibujas la geometría solo a profundidad, con un vertex shader mínimo. En la segunda dibujas el color con depthCompare: 'equal', confiando en que la profundidad calculada coincida exactamente con la de la primera pasada. Es una técnica estándar y elimina todo el sobredibujado del shader de iluminación.
Y falla. Aparecen píxeles con costuras, motas que parpadean, superficies que desaparecen a trozos según el ángulo. La causa es que los dos vertex shaders calculan la misma expresión pero el compilador la optimiza de forma distinta en cada uno: en el shader corto le sale a cuenta fusionar la multiplicación y la suma en una instrucción combinada, en el largo se queda sin registros y reasocia el producto de matrices en otro orden. Los dos resultados son correctos en aritmética real y difieren en el último bit en coma flotante. Con depthCompare: 'equal', un bit de diferencia es un píxel que no pasa el test.
Las tres salidas, en orden de preferencia. Marcar la posición con @invariant en los dos shaders, que es exactamente para lo que existe el atributo. Usar depthCompare: 'less-equal' en vez de 'equal', que tolera la diferencia pero no arregla el problema si el error va en la otra dirección. O compartir literalmente la misma función para calcular la posición en los dos módulos, lo cual ayuda pero no garantiza nada, porque el compilador especializa por punto de entrada y puede tomar decisiones distintas en cada uno.
El mismo mecanismo explica el z-fighting entre una geometría y su propia sombra proyectada, y las costuras entre pasadas de un renderer deferred que recalcula la posición. Siempre que dos shaders distintos tengan que estar de acuerdo al bit, @invariant es la única herramienta que lo garantiza.