let, var, const y override: cuatro palabras, cuatro momentos
Qué distingue a las cuatro formas de declarar un nombre en WGSL, en qué instante se fija cada valor, y por qué la diferencia entre un valor y una vista de memoria importa más que la mutabilidad.
WGSL tiene cuatro formas de dar nombre a algo y la diferencia entre ellas no es de estilo. Cada una fija su valor en un instante distinto —al escribir el shader, al crear el pipeline, al ejecutar la invocación— y dos de ellas nombran valores mientras que la otra nombra una posición de memoria. Elegir mal no suele ser un error de compilación: es un shader que hace más trabajo del necesario.
- Asignar cada declaración al momento en que se fija su valor.
- Distinguir un valor de una vista de memoria y saber qué se puede hacer con cada uno.
- Elegir la declaración correcta para cada dato de un shader.
- Enumerar las restricciones de cada una: dónde se puede declarar y qué tipos admite.
Cuatro momentos
| Palabra | Se fija | Dónde se declara | Tipos |
|---|---|---|---|
const |
al compilar el módulo | módulo o función | cualquier tipo constructible |
override |
al crear el pipeline | solo módulo | solo escalares |
let |
al ejecutar, y no cambia | solo función | cualquier tipo constructible |
var |
al ejecutar, y puede cambiar | módulo o función | según el espacio de direcciones |
const GRAVEDAD : f32 = 9.81; // literal en el codigo generado
override PASOS : u32 = 4u; // decidido al crear el pipeline
@compute @workgroup_size(64)
fn cs(@builtin(global_invocation_id) id : vec3u) {
let i = id.x; // inmutable, calculado en ejecucion
var acumulado = vec3f(0.0); // mutable, tiene direccion
for (var k = 0u; k < PASOS; k = k + 1u) {
acumulado = acumulado + vec3f(0.0, -GRAVEDAD, 0.0);
}
velocidades[i] = acumulado;
}
const es el más fuerte de los cuatro: su valor se conoce al compilar el texto, se puede usar como tamaño de array, como argumento de un atributo y en cualquier expresión constante. Puede ser de cualquier tipo constructible, incluidas structs y arrays enteros.
override es una constante que se decide más tarde, al crear el pipeline. Solo admite tipos escalares y solo se declara a nivel de módulo. Tiene lección propia porque su modelo de uso es un tema en sí mismo.
let da nombre a un valor calculado en ejecución que no va a cambiar. No tiene dirección, no se le puede aplicar el operador &, y no admite reasignación.
var declara una variable: una posición de memoria en algún espacio de direcciones, con un valor inicial —el que le des o el valor cero— y con la capacidad de cambiar.
Valor frente a vista de memoria
Esta distinción es la que de verdad separa let de var, más que la mutabilidad.
Un let es un valor. Un var nombra una posición de memoria, y usar su nombre en una expresión provoca una carga implícita del contenido. En la terminología de la especificación, un var produce una referencia y el lenguaje inserta la conversión a valor donde hace falta.
Las consecuencias prácticas son tres:
Solo un var admite el operador de dirección.
var v = vec3f(1.0);
let p = &v; // ptr<function, vec3f>
let w = vec3f(2.0);
// let q = &w; // ERROR: un let no tiene direccion
Solo a un var se le puede asignar. Y la asignación a un componente también:
var color = vec4f(0.0);
color.a = 1.0; // legal
color = color * 2.0; // legal
Un let de tipo compuesto se copia al declararlo. let copia = miStruct; produce un valor independiente; modificar el original después no cambia la copia.
Existe la idea de que let es un registro y var es memoria, y de ahí sale la costumbre de declararlo todo con let por rendimiento. No es así: el compilador promociona a registros cualquier var local cuya dirección no se tome y cuyos accesos pueda seguir. Un var escalar dentro de un bucle no cuesta un byte de memoria.
Lo que sí cuesta es un var cuya dirección se pasa a otro sitio, o un array local indexado con un índice que el compilador no puede resolver: eso último es lo que fuerza el array a memoria de verdad. El criterio de elección es la claridad —usa let cuando el valor no cambie, porque documenta la intención—, no el rendimiento.
Cuál usar
La regla es corta y cubre casi todos los casos:
Si el valor lo conoces al escribir el shader, const. Constantes matemáticas, tamaños de tabla, umbrales fijos. El compilador las sustituye y desaparecen.
Si el valor lo conoces al crear el pipeline pero no antes, override. Número de luces, número de muestras de una sombra, banderas de calidad. Permite tener un solo texto y varias variantes compiladas.
Si el valor lo calculas dentro de la invocación y no vuelve a cambiar, let. Es la declaración por defecto para resultados intermedios, y debería ser la más frecuente de un shader.
Si necesitas mutar, acumular o tomar la dirección, var. Acumuladores de bucles, contadores, estructuras que se rellenan por partes, arrays de trabajo.
Si el valor viene de la CPU, ninguna de las cuatro sirve por sí sola: es un var<uniform> o un var<storage> con sus coordenadas de recurso, y su valor cambia entre dibujados sin recompilar nada.
Ese último punto es la frontera importante. La decisión de si un número es una const, una override o una uniform es una decisión de arquitectura, no de sintaxis: la primera se hornea en el código, la segunda multiplica pipelines, y la tercera cuesta un acceso a memoria por invocación pero no cuesta ninguna compilación.
Las restricciones que se olvidan
let no existe a nivel de módulo. Fuera de una función no hay tiempo de ejecución, así que lo que quieres es const o override.
var a nivel de módulo necesita espacio de direcciones, y solo puede ser private, workgroup, uniform, storage o handle. Las de recurso necesitan además @group y @binding.
const no admite tipos no constructibles. Nada de texturas, punteros ni arrays sin longitud.
override solo admite escalares. No hay override de tipo vec3f; si necesitas un vector configurable, o son tres override escalares o es una uniform.
El sombreado de nombres es legal. Una variable local puede tener el mismo nombre que una de módulo y la oculta dentro de su ámbito. Es legal y es una mala idea.
var<private> contador : u32;
fn f() {
var contador = 0u; // oculta la de modulo; legal y confuso
contador = contador + 1u;
}
Aquí está la diferencia de rendimiento que justifica toda la distinción entre los cuatro momentos, y es una diferencia que no se ve leyendo el código.
Considera un bucle de iluminación. Con la cota en una uniform:
for (var i = 0u; i < ajustes.numLuces; i = i + 1u) { ... }
El compilador no sabe cuántas iteraciones hay. Tiene que generar un bucle de verdad: comprobación de condición, salto hacia atrás, contador en un registro. No puede desenrollar, no puede precalcular nada que dependa de i, y no puede reordenar los accesos a memoria de varias iteraciones para solaparlos.
Con la cota en una override:
for (var i = 0u; i < NUM_LUCES; i = i + 1u) { ... }
En el momento de crear el pipeline, NUM_LUCES es un número concreto. El compilador desenrolla el bucle si le compensa, funde las cargas de memoria de varias iteraciones en accesos anchos, y elimina la comprobación. En un bucle de iluminación con cuatro luces, la diferencia medida suele estar entre el veinte y el cincuenta por ciento del coste del fragment shader.
El precio es que cada valor distinto de NUM_LUCES es un pipeline distinto que hay que compilar. Y ahí está la decisión real: una override cambia coste de ejecución por coste de compilación y por número de objetos de pipeline. Con dos o tres variantes, es un intercambio buenísimo. Con una override por cada parámetro configurable de tu motor, has reinventado la explosión de permutaciones de GLSL con otra sintaxis.
El criterio que funciona en la práctica: usa override para el puñado de parámetros que cambian la estructura del shader —cuántas iteraciones, si existe una rama entera, qué tamaño tiene un array de grupo— y uniforms para todo lo que sea un número que multiplica o suma. Los primeros son pocos y valen mucho; los segundos son muchos y no ganan nada compilándose.