wandres.dev
WGSL I · El lenguaje de shading

La estructura de un módulo: directivas, declaraciones y el orden que no importa

Las tres directivas que van antes que todo, las siete cosas que se pueden declarar a nivel de módulo, y por qué en WGSL puedes usar una función antes de escribirla.

⏱ 16 min

Un módulo WGSL es la unidad de compilación completa: el texto que le pasas a createShaderModule y nada más. No hay enlazador, no hay unidades de traducción separadas y no hay forma de importar código de otro módulo. Esa restricción, que parece pobreza, determina cómo se organiza un renderer entero, y empieza por conocer exactamente qué puede aparecer en el texto y en qué orden.

🎯 Al terminar esta lección sabrás
  • Colocar correctamente las directivas enable, requires y diagnostic.
  • Enumerar las declaraciones válidas a nivel de módulo y las que no lo son.
  • Explicar por qué el orden de las declaraciones de módulo es irrelevante y qué sigue estando prohibido.
  • Usar const_assert y el identificador de descarte para escribir shaders que se rompen pronto.

Las directivas van antes que todo

Un módulo se divide en dos zonas: primero las directivas, después las declaraciones. Todas las directivas tienen que preceder a todas las declaraciones; una enable después de un struct es un error de compilación, no una advertencia.

Hay tres clases de directiva y cada una responde a una pregunta distinta.

enable f16;                                 // extension que requiere una feature del dispositivo
requires unrestricted_pointer_parameters;   // extension del lenguaje
diagnostic(off, derivative_uniformity);     // control de severidad

struct Material { /* ... */ }

enable activa una extensión que necesita una capacidad del hardware. Las que existen se corresponden una a una con features que hay que pedir al crear el dispositivo: f16 necesita shader-f16, clip_distances necesita clip-distances, dual_source_blending necesita dual-source-blending, subgroups necesita subgroups. Si el dispositivo no tiene la feature, el módulo no compila. Se pueden agrupar en una sola línea separadas por comas: enable f16, subgroups;.

requires declara una extensión de lenguaje, que es otra cosa: no depende del hardware sino de la versión del compilador del navegador. Las que define la especificación son readonly_and_readwrite_storage_textures, packed_4x8_integer_dot_product, unrestricted_pointer_parameters y pointer_composite_access. Las que soporta el navegador se consultan en navigator.gpu.wgslLanguageFeatures, que es un Set de cadenas:

if (navigator.gpu.wgslLanguageFeatures.has('pointer_composite_access')) {
  // puedes escribir p.x en vez de (*p).x
}

La diferencia práctica entre las dos directivas es que enable se comprueba contra el dispositivo y requires contra el navegador. Un requires de una extensión ya soportada es redundante en el sentido de que el código compilaría igual sin él, pero documenta la dependencia y produce un error claro donde toca en vez de un error de sintaxis desconcertante.

diagnostic ajusta la severidad de una regla del compilador. La única regla que la especificación define por ahora es derivative_uniformity, y las severidades son error, warning, info y off. Es la vía de escape para el análisis de uniformidad, y conviene usarla con más miedo del que se le suele tener.

Lo que se puede declarar a nivel de módulo

Siete cosas, ni una más:

Declaración Ejemplo Nota
alias alias Indice = u32; nombre alternativo para un tipo
const const PI2 = 6.28318; valor en tiempo de compilación
override override radio : f32 = 1.0; valor fijado al crear el pipeline
var var<private> contador : u32; variable con espacio de direcciones
struct struct Luz { pos : vec3f, } tipo compuesto
fn fn atenuar(d : f32) -> f32 { ... } función, con o sin atributo de etapa
const_assert const_assert TAM % 4 == 0; comprobación en compilación

La ausencia que sorprende es let: no existe a nivel de módulo. Un let es una constante de tiempo de ejecución dentro de una función, y fuera de una función no hay tiempo de ejecución. Lo que quieres a nivel de módulo es const si el valor lo conoces al escribir el shader, u override si lo quieres decidir al crear el pipeline.

La otra particularidad es que un var de módulo lleva casi siempre su espacio de direcciones entre ángulos. var<private>, var<workgroup>, var<uniform>, var<storage, read_write>. Las únicas variables de módulo que se declaran sin espacio son las de tipo textura y sampler, que viven en el espacio handle y no se puede escribir su nombre:

@group(0) @binding(0) var<uniform> camara : Camara;     // espacio explicito
@group(0) @binding(1) var muestreo : sampler;           // espacio handle, implicito
@group(0) @binding(2) var albedo : texture_2d<f32>;     // idem

Y una regla que se olvida: un var en el espacio function no puede aparecer a nivel de módulo, y un var de módulo no puede estar en el espacio function. Los dos mundos no se tocan.

El orden no importa

Esta es la diferencia más práctica frente a C, GLSL y HLSL: una declaración de nivel de módulo está en ámbito en todo el módulo, antes y después del punto donde aparece. Puedes llamar a una función que escribes cincuenta líneas más abajo, o usar una struct declarada al final.

@fragment
fn fs() -> @location(0) vec4f {
  return vec4f(tono(0.3), 1.0);   // 'tono' se declara despues: es legal
}

fn tono(x : f32) -> vec3f {
  return vec3f(x, x * 0.5, 1.0 - x);
}

Se acabaron las declaraciones adelantadas y los prototipos. Lo que sigue prohibido es la circularidad: dos funciones que se llaman entre sí, un alias que se refiere a sí mismo, una struct que se contiene. El compilador construye el grafo de dependencias y exige que sea acíclico, que es la misma regla que prohíbe la recursión vista desde otro ángulo.

Dentro de una función, en cambio, el ámbito vuelve a ser el de siempre: un nombre está disponible desde su declaración hasta el final del bloque, y una variable local puede ocultar a una de módulo con el mismo nombre. Ocultar el nombre de una variable de módulo es legal y es una fuente de confusión gratuita; la convención razonable es prefijar las de módulo o no repetir nombres.

const_assert merece un párrafo propio porque casi nadie lo usa. Acepta cualquier expresión booleana de tiempo de compilación y falla la compilación del módulo si es falsa, con lo que convierte una suposición implícita en un error temprano:

const TAM_GRUPO : u32 = 64u;
const_assert TAM_GRUPO % 32u == 0u;   // el shader asume multiplo del ancho de onda tipico

@compute @workgroup_size(TAM_GRUPO)
fn cs() { /* ... */ }

También vale dentro de una función, donde se evalúa igualmente en compilación. Es la única forma que tiene el lenguaje de documentar una invariante y hacerla verificar.

💡
Los nombres que no puedes usar

Un identificador no puede empezar por doble guion bajo, y _ a solas no es un identificador sino el destino de descarte. La asignación _ = expresion; evalúa la expresión y tira el resultado, y su uso real es forzar que un recurso cuente como «usado estáticamente» por el shader: si declaras un binding que el módulo no lee, algunas configuraciones de layout se quejan, y un _ = algo; lo silencia sin generar código.

El módulo como unidad, y lo que eso obliga

Sin enlazador y sin #include, compartir código entre shaders solo se puede hacer de una manera: concatenando texto en JavaScript antes de llamar a createShaderModule. No es un apaño, es el modelo previsto.

const comun = /* wgsl */ `
  fn srgbALineal(c : vec3f) -> vec3f {
    return pow((c + 0.055) / 1.055, vec3f(2.4));
  }
`;

const modulo = device.createShaderModule({
  label: 'material pbr',
  code: comun + fuentePbr,
});

El comentario /* wgsl */ delante de la plantilla no es decorativo: es la marca que reconocen las extensiones de editor y las herramientas de formateo para resaltar la cadena como WGSL.

La decisión de diseño que sigue a esto es cuántos módulos crear, y la respuesta que da mejor resultado es pocos y grandes. Un módulo puede contener tantos puntos de entrada como quieras, de las tres etapas mezcladas, y el vertex y el fragment de un pipeline pueden salir del mismo módulo o de dos distintos indistintamente. Compartir un módulo entre varios pipelines evita recompilar el mismo texto una y otra vez y permite a la implementación reutilizar el análisis.

La ausencia de preprocesador te obliga a decidir dónde vive la variabilidad, y es una decisión de arquitectura

En GLSL, la respuesta a «este shader necesita dos variantes» era #ifdef y no se pensaba más. Esa facilidad produjo la patología conocida como explosión combinatoria de permutaciones: motores con veinte flags independientes que generan un millón de variantes teóricas, un caché de shaders de gigabytes y tiempos de carga de minutos.

WGSL no te da esa herramienta, y el resultado es que tienes que elegir conscientemente entre tres mecanismos con costes distintos. Uno: generar texto desde JavaScript. Es lo más parecido al #ifdef y tiene su mismo problema: cada combinación es un módulo nuevo, una compilación nueva y un pipeline nuevo. Dos: override constants. El texto es uno solo, la compilación del módulo es una sola, y la especialización ocurre al crear el pipeline; el compilador elimina las ramas muertas igual que lo haría el preprocesador, pero sin duplicar fuente. Tres: una uniform. Cero pipelines extra, cero compilaciones, pero la rama se evalúa en ejecución y el coste es real aunque sea uniforme para todas las invocaciones.

El criterio que funciona es la frecuencia de cambio frente al coste de la rama. Si el valor cambia por fotograma, uniform, sin discusión. Si no cambia nunca durante la vida de la aplicación y afecta a la estructura del shader —número de luces, presencia de un mapa de normales, calidad de las sombras—, override. Y la generación de texto se reserva para lo que ninguno de los otros dos puede hacer: cambiar la firma de una función, declarar un binding distinto o cambiar el tipo de una variable.

Que WGSL te obligue a pensarlo antes de escribirlo es, a largo plazo, mucho más barato que un preprocesador que te deja aplazar la decisión hasta que ya tienes cuatrocientas variantes.

Con la anatomía del módulo clara, toca lo único que la API mira dentro de él: los puntos de entrada y sus atributos de etapa.