El diccionario constants: dar valores al crear el pipeline
Dónde va el diccionario de constantes, cómo se forman sus claves, cómo se convierten los números de JavaScript a los tipos de WGSL, y por qué es un diccionario por etapa y no por pipeline.
Los valores de las override se entregan en un diccionario dentro del descriptor de cada etapa programable. Es una estructura minúscula, con tres reglas de conversión y una particularidad que sorprende: no hay un diccionario por pipeline, hay uno por etapa, y eso significa que el mismo módulo puede compilarse con valores distintos en el vertex y en el fragment del mismo pipeline.
- Colocar el diccionario
constantsen el sitio correcto del descriptor. - Formar las claves con nombre o con identificador numérico.
- Predecir cómo se convierte un número de JavaScript a cada tipo de WGSL.
- Explicar las consecuencias de que el diccionario sea por etapa.
Dónde va
constants es un campo de cada etapa programable: vertex, fragment y compute.
const pipeline = device.createRenderPipeline({
label: 'pbr con 8 luces y niebla',
layout: disposicion,
vertex: {
module: modulo,
entryPoint: 'vs',
buffers: [layoutVertices],
constants: { ESCALA_MUNDO: 100.0 },
},
fragment: {
module: modulo,
entryPoint: 'fs',
targets: [{ format }],
constants: {
NUM_LUCES: 8,
USA_NIEBLA: 1,
EXPOSICION: 1.2,
},
},
primitive: { topology: 'triangle-list', cullMode: 'back' },
});
Y en un pipeline de cómputo:
const pipelineComputo = device.createComputePipeline({
layout: disposicion,
compute: {
module: modulo,
entryPoint: 'reducir',
constants: { TAM_GRUPO: 256 },
},
});
Las claves
La clave es el @id si la override lo tiene, y el nombre de la variable si no lo tiene. Las claves del diccionario son cadenas, así que un identificador numérico se escribe como número y JavaScript lo convierte:
@id(0) override NUM_LUCES : u32 = 4u;
override EXPOSICION : f32 = 1.0;
constants: {
0: 8, // por @id
EXPOSICION: 1.2, // por nombre
}
Dos errores de validación que produce esto:
Una clave que no corresponde a ninguna override hace fallar la creación del pipeline. No se ignora silenciosamente, lo cual es una buena noticia: un nombre mal escrito se detecta.
Una override sin valor por defecto y sin entrada en el diccionario también hace fallar la creación.
La conversión de valores
Todos los valores del diccionario son números de JavaScript, es decir, flotantes de doble precisión, y se convierten al tipo declarado en WGSL:
| Tipo WGSL | Conversión |
|---|---|
bool |
cero es falso, cualquier otro valor es verdadero |
i32, u32 |
tiene que ser un valor entero representable en el tipo |
f32 |
tiene que ser representable como flotante simple |
f16 |
tiene que ser representable como medio flotante |
De ahí salen dos consecuencias prácticas. La primera es que los booleanos se pasan como 0 y 1, y true y false de JavaScript funcionan porque se convierten a esos números. La segunda es que un valor fuera de rango es un error de validación, no un truncamiento: pasar 70000 a un override de tipo f16 falla, igual que pasar 3.5 a uno de tipo u32.
constants: {
USA_NIEBLA: true, // funciona: se convierte a 1
NUM_LUCES: 8, // bien
// NUM_LUCES: 8.5, // ERROR: no es un entero
}
Un diccionario por etapa
Esta es la particularidad que hay que entender bien. Si el vertex shader y el fragment shader salen del mismo módulo y los dos usan la misma override, cada etapa recibe su propio valor.
vertex: { module: m, entryPoint: 'vs', constants: { CALIDAD: 2 } },
fragment: { module: m, entryPoint: 'fs', constants: { CALIDAD: 0 } },
Eso es legal y compila dos especializaciones distintas del mismo texto. Casi siempre es un bug: alguien cambió la calidad en una etapa y olvidó la otra, y el resultado es un shader donde la geometría se desplaza con una teselación que el sombreado no espera.
La defensa es no escribir el diccionario dos veces:
const cfg = { CALIDAD: 2, USA_NIEBLA: 1, NUM_LUCES: 8 };
const pipeline = device.createRenderPipeline({
layout: disposicion,
vertex: { module: m, entryPoint: 'vs', buffers, constants: cfg },
fragment: { module: m, entryPoint: 'fs', targets, constants: cfg },
primitive: { topology: 'triangle-list' },
});
Pasar el mismo objeto a las dos etapas garantiza que no se desincronicen, y no cuesta nada porque las override que una etapa no usa se ignoran sin error.
Cada combinación de constantes es una compilación del driver, y son independientes entre sí. Lanzarlas todas a la vez con la variante asíncrona y esperar con Promise.all aprovecha los hilos de compilación que la implementación tenga, en vez de serializarlas.
const pipelines = await Promise.all(
variantes.map((cfg) => device.createRenderPipelineAsync({ ...descriptor, fragment: { ...frag, constants: cfg } }))
);Merece la pena entender el reparto de trabajo entre createShaderModule y createRenderPipeline, porque de él depende que crear veinte variantes sea razonable o sea inaceptable.
Al crear el módulo, la implementación analiza el WGSL: lo parsea, comprueba tipos, resuelve nombres, hace el análisis de uniformidad y lo convierte a su representación intermedia. Ese trabajo depende solo del texto, y por eso se hace una vez aunque después crees cincuenta pipelines con él.
Al crear el pipeline, se sustituyen las override por sus valores, se propagan las constantes, se elimina el código muerto, se resuelve el layout de recursos y se traduce al lenguaje del backend, que a su vez lo compila a código máquina. Eso sí se repite por variante, y es la parte cara.
La proporción entre las dos partes es lo que decide tu estrategia. Con un shader pequeño, el análisis del texto domina y crear muchas variantes sale barato. Con un shader PBR completo de mil líneas, el análisis del texto es una fracción y lo que domina es el compilador del driver, con lo que cada variante cuesta casi lo mismo que la primera.
Esto tiene una implicación de diseño que se aprovecha poco: compartir el módulo entre todas las variantes es gratis y hay que hacerlo siempre. Y tiene otra que se aprovecha aún menos: como el análisis del texto ya está hecho, generar variantes con override es estrictamente más barato que generarlas concatenando cadenas distintas, porque en el segundo caso cada variante es un módulo nuevo que hay que analizar desde cero además de compilar.
Dicho de otro modo: si vienes del mundo de las macros de GLSL y estás tentado de seguir generando texto, el argumento a favor de override no es solo de limpieza. Es que la implementación puede reutilizar la mitad del trabajo, y con cincuenta variantes esa mitad es medio segundo de carga.