override frente a const: qué puede hacer cada uno
La tabla de diferencias entre las dos constantes de WGSL, qué contextos admite cada una, y el patrón para tener tablas configurables cuando override solo admite escalares.
Las dos son constantes, las dos se escriben a nivel de módulo y las dos desaparecen del código generado. Y no son intercambiables: una admite cualquier tipo y sirve en cualquier contexto del lenguaje, la otra solo admite escalares y está prohibida en la mitad de los sitios. Saber cuál usar es casi siempre inmediato, y los casos donde no lo es tienen soluciones concretas.
- Enumerar las diferencias entre
constyoverrideen tipo, momento y contexto. - Elegir cuál usar para cada constante de un shader.
- Construir una tabla configurable a pesar de que
overridesolo admita escalares. - Promover una
constaoverridesin romper nada.
La tabla
const |
override |
|
|---|---|---|
| Momento en que se fija | al compilar el módulo | al crear el pipeline |
| Tipos admitidos | cualquiera constructible | solo escalares |
| Dónde se declara | módulo o función | solo módulo |
| Como tamaño de array | en cualquier espacio | solo en workgroup |
En atributos como @location |
sí | no |
En @workgroup_size |
sí | sí |
En un case de switch |
sí | no |
En const_assert |
sí | no |
Como inicializador de const |
sí | no |
| Coste al cambiar el valor | recompilar el módulo | recrear el pipeline |
Las dos filas que resumen todo: const es más flexible en tipos y contextos, y override es más flexible en el momento.
Lo que solo puede hacer const
Los tipos compuestos son territorio exclusivo de const, y eso incluye las tablas de datos que aparecen en cualquier shader no trivial:
// Pesos de un kernel gaussiano: no hay forma de hacer esto con override.
const PESOS = array<f32, 5>(0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);
// Un patron de muestreo.
const POISSON = array<vec2f, 4>(
vec2f(-0.94201624, -0.39906216),
vec2f( 0.94558609, -0.76890725),
vec2f(-0.09418410, -0.92938870),
vec2f( 0.34495938, 0.29387760),
);
// Un valor derivado de otras constantes.
const TAU : f32 = 6.283185307179586;
const MEDIO_TAU : f32 = TAU * 0.5;
También es la única que vale como tamaño de array fuera del espacio workgroup, y la única que se puede comprobar con const_assert:
const TAM_TESELA : u32 = 16u;
const_assert TAM_TESELA * TAM_TESELA <= 256u; // cabe en un grupo
Lo que solo puede hacer override
Cambiar sin recompilar el módulo. Es lo único, y es suficiente para justificar su existencia.
Una const en 4 y una override con valor por defecto 4 generan exactamente el mismo código si no especificas nada al crear el pipeline. La diferencia aparece cuando quieres un segundo pipeline con el valor en 8: con const hay que generar otro texto y crear otro módulo; con override, es una entrada en un diccionario.
De ahí sale la regla de decisión, que es casi mecánica:
¿El valor puede cambiar entre pipelines de la misma ejecución? Si no, const. Si sí, override.
Y su corolario práctico: empieza siempre con const. Es más flexible, no cuesta nada y no obliga a nada. Promuévela a override el día que de verdad necesites dos pipelines con valores distintos, que es un cambio de una palabra.
Tablas configurables
El conflicto aparece cuando quieres una tabla que dependa de una bandera de configuración: la tabla tiene que ser const y la bandera tiene que ser override. La solución es combinar las dos, poniendo todas las tablas posibles en const y eligiendo con la override:
override CALIDAD : u32 = 1u; // 0 baja, 1 media, 2 alta
fn numeroDeMuestras() -> u32 {
var tabla = array<u32, 3>(4u, 9u, 16u);
return tabla[CALIDAD];
}
@fragment
fn fs(e : EntradaFS) -> @location(0) vec4f {
let n = numeroDeMuestras();
var suma = vec3f(0.0);
for (var i = 0u; i < n; i = i + 1u) {
suma += muestrear(e.uv, i, n);
}
return vec4f(suma / f32(n), 1.0);
}
En el momento de crear el pipeline, CALIDAD es un número concreto, así que numeroDeMuestras se resuelve a una constante y el bucle vuelve a tener cota fija y desenrollable. El coste de tener las tres tablas escritas es cero: las dos que no se usan desaparecen.
El mismo patrón vale para elegir entre varias funciones, entre varios patrones de muestreo o entre varias fórmulas de mapeo de tono.
El coste de equivocarse en esta decisión no aparece el día que la tomas, sino meses después, y se manifiesta como rigidez.
El caso típico. Escribes el sistema de sombras con const NUM_CASCADAS = 4u; porque cuatro es el número correcto y no vas a cambiarlo. Pasa medio año, alguien quiere una opción de calidad baja con dos cascadas para portátiles, y resulta que ese número está horneado en el texto del shader. Cambiarlo obliga a generar dos textos, crear dos módulos, duplicar el código que los crea y decidir cuál usar en cada sitio. Lo que era una entrada en un diccionario se ha convertido en una refactorización.
El caso contrario también existe y es más silencioso. Declaras override cada número del shader por si acaso. No hay error, no hay aviso, y hay dos costes reales: el shader deja de poder usar esos valores como tamaños de array o en const_assert, y —peor— el diccionario de configuración crece hasta que nadie sabe qué combinaciones se usan de verdad. La flexibilidad que no se usa es deuda.
La heurística que ordena esto se apoya en una pregunta que se puede responder el día que escribes la constante: ¿este número aparecería en un menú de opciones gráficas? Número de cascadas, muestras de sombra, calidad de reflejos, número de luces: sí, y por tanto override. Constantes matemáticas, pesos de un kernel, umbrales de un algoritmo, tamaños de estructuras internas: no, y por tanto const.
Y hay un tercer grupo que la pregunta separa bien y que suele estar mal clasificado: los valores que el usuario no ve pero el hardware sí decide, como el tamaño de grupo de un kernel o el número de elementos por invocación. Esos no van en ningún menú y aun así tienen que ser override, porque quien los elige no eres tú al escribir el shader: es la máquina donde se ejecuta.