Las override constants: una constante que se decide al crear el pipeline
Cómo se declara un override, qué tipos admite, para qué sirve el atributo @id, y en qué contextos del lenguaje se puede usar una expresión override y en cuáles no.
Entre lo que se conoce al escribir el shader y lo que cambia cada fotograma hay un tercer momento que ningún lenguaje de shading tenía bien resuelto: lo que se decide al arrancar la aplicación y ya no cambia. WGSL le da un nombre y una sintaxis, y con ellos elimina la razón de ser del preprocesador sin traer sus problemas.
- Declarar
overridecon y sin valor por defecto y saber qué implica cada forma. - Enumerar los tipos que admite y por qué no admite más.
- Usar
@idy explicar qué desacopla. - Identificar los contextos del lenguaje donde una expresión
overridees válida.
La declaración
override NUM_LUCES : u32 = 4u; // con valor por defecto
override CALIDAD_SOMBRA : u32; // sin el, hay que darlo al crear el pipeline
override USA_NIEBLA : bool = false;
override EXPOSICION : f32 = 1.0;
@id(100) override ESCALA : f32 = 1.0; // con identificador numerico
Solo se declaran a nivel de módulo, nunca dentro de una función. Y solo admiten tipos escalares: bool, i32, u32, f32 y f16 si la extensión está activa. No hay override de tipo vector, ni de struct, ni de array.
La restricción a escalares no es arbitraria: el valor viaja desde JavaScript como un número suelto en un diccionario, y admitir tipos compuestos obligaría a definir un formato de serialización y sus reglas de alineación. Si necesitas un vector configurable, o son tres override escalares o es una uniform.
Un override sin valor por defecto es obligatorio: si no lo especificas al crear el pipeline, la creación falla con un error de validación. Uno con valor por defecto es opcional. La forma sin defecto es útil precisamente porque convierte un olvido en un error temprano en vez de en un comportamiento silencioso.
El valor por defecto puede ser una expresión que use const y otras override:
override TAM_TESELA : u32 = 16u;
override INVOCACIONES : u32 = TAM_TESELA * TAM_TESELA;
@id y lo que desacopla
Sin @id, la clave con la que se especifica el valor desde JavaScript es el nombre de la variable. Con @id(n), la clave es ese número.
@id(0) override NUM_LUCES : u32 = 4u;
@id(1) override USA_NIEBLA : bool = false;
constants: { 0: 8, 1: 1 }, // por identificador numerico
Los identificadores tienen que ser únicos en el módulo. La ventaja de usarlos es que renombrar la variable en el WGSL deja de romper el código de JavaScript, lo cual importa cuando el shader y el código que lo configura los mantiene gente distinta o cuando el WGSL se genera.
La desventaja es de legibilidad: un diccionario con claves numéricas no dice nada al leerlo. El punto medio que funciona es declarar los identificadores en una constante de JavaScript con nombres:
const OVR = { NUM_LUCES: 0, USA_NIEBLA: 1 };
const constants = { [OVR.NUM_LUCES]: 8, [OVR.USA_NIEBLA]: 1 };
Dónde vale una expresión override
Aquí está la parte que hay que saber de memoria, porque el compilador la aplica sin piedad. Una expresión override no es una expresión de tiempo de compilación, y por tanto no vale en los sitios que exigen una.
Sí vale:
- En cualquier expresión de tiempo de ejecución del cuerpo de una función.
- En
@workgroup_size, en las tres dimensiones. - Como número de elementos de un array en el espacio de direcciones
workgroup. - Como valor por defecto de otra
override.
No vale:
- Como tamaño de un array en cualquier otro espacio de direcciones.
- En
@location,@binding,@group,@align,@sizeni@id. - Como valor de un
casede unswitch. - En el inicializador de una
const. - En un
const_assert.
override TAM : u32 = 64u;
var<workgroup> parciales : array<f32, TAM>; // legal: espacio workgroup
// var<private> tabla : array<f32, TAM>; // ERROR: espacio private
@compute @workgroup_size(TAM) // legal
fn cs(@builtin(local_invocation_index) li : u32) {
if li < TAM { parciales[li] = 0.0; } // legal: expresion de ejecucion
}
La combinación de las dos cosas legales —el tamaño de grupo y el array de grupo— es exactamente lo que se necesita para escribir un kernel de reducción cuyo tamaño de bloque se decida al arrancar según lo que el dispositivo prefiera.
Si escribes if USA_SOMBRAS { let s = textureSample(mapaSombra, cmp, uv); } y creas el pipeline con USA_SOMBRAS en falso, el compilador elimina la rama y el código no se ejecuta. Lo que no cambia es la interfaz de recursos: el uso estático de mapaSombra es una propiedad del módulo, no del pipeline especializado, así que ese binding sigue siendo obligatorio y el bind group tiene que proporcionarlo. Si quieres que un pipeline no necesite un recurso, el recurso no puede aparecer en el texto del punto de entrada, y eso solo se consigue generando dos textos distintos.
El error de modelo mental que hay que evitar desde el principio es pensar en override como en una uniform que se pone una sola vez. No lo es, y la diferencia se ve en el código generado.
Una uniform es una lectura de memoria. El compilador no sabe su valor, así que genera un acceso, y toda la lógica que dependa de ella se genera completa: las dos ramas de cada if, el bucle con su comprobación de condición, la multiplicación por un número que podría ser uno.
Una override es un valor conocido en el momento en que el compilador del driver genera instrucciones. Con USA_NIEBLA en falso, la rama de la niebla no existe en el binario: no hay salto, no hay registros reservados para sus variables, no hay lecturas de sus texturas, y el resto del shader dispone de más registros, lo que puede aumentar la ocupación. Con NUM_LUCES en 4, el bucle se desenrolla y las cuatro iteraciones se entrelazan para tapar la latencia de las lecturas.
De ahí sale la consecuencia práctica que decide cuándo usarlo: el beneficio de una override es proporcional a cuánto cambia la estructura del código, no a cuánto se usa el valor. Un override que multiplica un color en una línea no gana nada frente a una uniform, porque una multiplicación por un valor de memoria cuesta lo mismo que por un literal. Un override que decide si existen quince líneas con tres muestreos de textura gana muchísimo.
Y de ahí sale también el coste que hay que vigilar: cada combinación distinta de valores es un pipeline distinto y una compilación distinta del driver. Ese es el precio, y es el mismo precio que tenía el preprocesador de GLSL. La diferencia es que aquí el precio es visible —cada variante es un objeto que creas explícitamente— en lugar de estar escondido en un sistema de macros que genera cadenas. Ver el coste es lo que hace que no se te vaya de las manos.
El siguiente paso es dárselos al pipeline: especificar constants al crear el pipeline.