wandres.dev
WGSL I · El lenguaje de shading

Puntos de entrada: las tres etapas y las reglas de su firma

Qué convierte a una función en punto de entrada, las reglas exactas de sus parámetros y su retorno, y cómo tiene que casar la salida de una etapa con la entrada de la siguiente.

⏱ 18 min

De todo el texto de un módulo, la API solo mira las funciones marcadas con un atributo de etapa. Ese atributo cambia radicalmente lo que la función puede declarar, qué puede llamar y qué tiene que devolver, hasta el punto de que la misma firma es válida en una etapa e ilegal en otra. Las reglas son pocas y están todas verificadas por el compilador, así que aprenderlas de golpe ahorra una colección entera de errores crípticos.

🎯 Al terminar esta lección sabrás
  • Declarar puntos de entrada de las tres etapas con sus atributos obligatorios.
  • Aplicar las reglas de anotación de parámetros y retorno, incluida la prohibición de anidar structs de entrada y salida.
  • Hacer casar la salida de un vertex shader con la entrada de un fragment shader sin depender de nombres.
  • Explicar qué es la interfaz de recursos de un punto de entrada y por qué es por punto de entrada y no por módulo.

Los tres atributos

@vertex, @fragment y @compute marcan una función como invocable desde la API. Una función sin ninguno de los tres es una función auxiliar: la puede llamar cualquier otra función del módulo, pero no aparece en ningún pipeline.

@vertex
fn vs(@location(0) posicion : vec3f) -> @builtin(position) vec4f {
  return vec4f(posicion, 1.0);
}

@fragment
fn fs() -> @location(0) vec4f {
  return vec4f(1.0, 0.5, 0.0, 1.0);
}

@compute @workgroup_size(64)
fn cs(@builtin(global_invocation_id) id : vec3u) {
  // ...
}

Tres reglas duras y sin excepciones:

Un punto de entrada no puede ser llamado por otra función del módulo. Es un error de compilación, no una advertencia. Si necesitas reutilizar la lógica de un vertex shader desde otro, extrae el cuerpo a una función normal y llama a esa desde los dos puntos de entrada.

@compute exige @workgroup_size, y ese atributo no vale en las otras dos etapas. Acepta uno, dos o tres argumentos; los que faltan valen 1. Sus valores tienen que ser expresiones de tiempo de compilación o expresiones override, nunca valores calculados en ejecución.

Cada etapa impone su propio retorno. Un @vertex tiene que devolver exactamente un @builtin(position), suelto o dentro de una struct. Un @compute no puede devolver nada. Un @fragment devuelve sus @location y opcionalmente @builtin(frag_depth) o @builtin(sample_mask), y puede no devolver nada si solo escribe en recursos de almacenamiento.

Además hay restricciones cruzadas que el compilador comprueba a través del grafo de llamadas: una variable var<workgroup> solo puede ser alcanzada estáticamente desde un punto de entrada @compute, y las derivadas y textureSample solo desde un @fragment. Como la comprobación es transitiva, una función auxiliar que llame a dpdx contamina a todo el que la llame, aunque el que la llame sea un vertex shader que nunca ejecute esa rama.

Las reglas de la firma

Todo lo que entra y sale de un punto de entrada tiene que estar completamente anotado. Cada parámetro y cada componente del retorno lleva un @location(n) o un @builtin(nombre), sin excepción. Un parámetro sin anotar es un error, porque no hay ningún sitio de donde sacar su valor.

Hay dos formas de escribirlo. Con parámetros sueltos:

@vertex
fn vs(
  @location(0) posicion : vec3f,
  @location(1) uv       : vec2f,
  @builtin(instance_index) instancia : u32,
) -> @builtin(position) vec4f {
  return vec4f(posicion + vec3f(f32(instancia), 0.0, 0.0), 1.0);
}

O agrupando en una struct, que es lo que se hace en cuanto pasas de tres:

struct EntradaVS {
  @location(0) posicion : vec3f,
  @location(1) uv       : vec2f,
  @builtin(instance_index) instancia : u32,
};

struct SalidaVS {
  @builtin(position) clip : vec4f,
  @location(0)       uv   : vec2f,
};

@vertex
fn vs(entrada : EntradaVS) -> SalidaVS {
  var salida : SalidaVS;
  salida.clip = vec4f(entrada.posicion, 1.0);
  salida.uv   = entrada.uv;
  return salida;
}

Las dos versiones son equivalentes para la API. Y se pueden mezclar: un parámetro struct y otro suelto en la misma firma es legal, siempre que no repitan localizaciones ni builtins.

Sobre estas structs pesan tres restricciones que no se aplican a las structs normales:

No se pueden anidar. Un miembro de una struct de entrada o salida no puede ser a su vez una struct. La razón es que la interfaz es una lista plana de ranuras numeradas y no hay forma de asignar localizaciones a una jerarquía sin inventar una convención.

Los tipos admitidos en @location son escalares y vectores numéricos. Nada de matrices, arrays, booleanos ni atómicos. Una matriz se pasa como cuatro @location consecutivas de vec4f, que es exactamente lo que hace el hardware por debajo.

Cada localización y cada builtin aparece una sola vez en la interfaz de ese punto de entrada, contando parámetros sueltos y miembros de struct juntos.

⚠️
Un tipo entero en @location tiene que ser flat

Si declaras @location(3) id : u32 como salida de vértice y entrada de fragmento, el compilador exige @interpolate(flat). No hay forma sensata de interpolar un entero, y en vez de inventar una el lenguaje te obliga a decirlo. El error que produce olvidarlo menciona la interpolación pero no el tipo, y cuesta un rato ver que el problema es que ese u32 no puede viajar interpolado.

La interfaz entre etapas casa por número, no por nombre

Cuando un vertex shader y un fragment shader forman un pipeline, la correspondencia entre la salida de uno y la entrada del otro se establece exclusivamente por el número de @location. Los nombres de los miembros son irrelevantes, el orden de declaración es irrelevante, y hasta la struct puede ser distinta en los dos lados:

struct SalidaVS {
  @builtin(position) clip   : vec4f,
  @location(0)       uv     : vec2f,
  @location(1)       normal : vec3f,
};

// El fragment declara solo lo que usa, en el orden que quiere.
struct EntradaFS {
  @location(1) n : vec3f,
  @location(0) coordenadas : vec2f,
};

@fragment
fn fs(entrada : EntradaFS) -> @location(0) vec4f {
  return vec4f(normalize(entrada.n) * 0.5 + 0.5, 1.0);
}

Las reglas de compatibilidad son que el fragment no puede pedir una localización que el vertex no produce, que los tipos de las localizaciones compartidas tienen que coincidir exactamente, y que los atributos @interpolate tienen que coincidir también. Al revés sí vale: el vertex puede producir salidas que el fragment ignora, y lo normal es que las produzca porque el mismo vertex shader alimenta a varios fragment shaders.

Producir de más no es gratis. El número de variables entre etapas está acotado por maxInterStageShaderVariables, cuyo mínimo garantizado es 16, y cada una ocupa espacio de interpolación y ancho de banda entre la etapa de vértices y la de fragmentos. @builtin(position) no cuenta contra ese límite; las @location sí.

Varios puntos de entrada y la interfaz de recursos

Un módulo puede contener tantos puntos de entrada como quieras, de etapas distintas, y la elección se hace en el descriptor del pipeline:

const modulo = device.createShaderModule({ label: 'escena', code: fuente });

const pipeline = device.createRenderPipeline({
  layout: 'auto',
  vertex:   { module: modulo, entryPoint: 'vs' },
  fragment: { module: modulo, entryPoint: 'fsOpaco', targets: [{ format }] },
  primitive: { topology: 'triangle-list' },
});

entryPoint se puede omitir si el módulo tiene exactamente un punto de entrada de esa etapa. Con dos @fragment en el mismo módulo, omitirlo es un error de validación. Escribirlo siempre cuesta doce caracteres y evita que añadir un segundo punto de entrada rompa un pipeline que estaba escrito hace seis meses.

Lo importante de que haya varios puntos de entrada en un módulo es que la interfaz de recursos es por punto de entrada, no por módulo. Los recursos que forman parte del contrato de un pipeline son solo los que ese punto de entrada usa estáticamente, siguiendo el grafo de llamadas desde él. Dos pipelines creados del mismo módulo pueden tener layouts completamente distintos porque sus puntos de entrada alcanzan bindings distintos.

El módulo se valida entero, pero se especializa por punto de entrada, y esa asimetría explica dos errores muy distintos

Hay dos momentos en la vida de un shader y confundirlos cuesta tardes enteras.

En createShaderModule se valida el texto completo. Todo: los puntos de entrada que vas a usar y los que no, las funciones auxiliares que nadie llama, las structs huérfanas. Un error de sintaxis o de tipos en un punto de entrada que jamás vas a instanciar invalida el módulo entero y con él todos los pipelines que dependían de él. Por eso conviene borrar el código muerto de un módulo en vez de dejarlo comentado a medias: no te está costando rendimiento, pero sí te está costando superficie de fallo.

En createRenderPipeline se especializa un punto de entrada. Ahí es donde se resuelven las override, donde se comprueba que el layout declarado cubre los bindings que ese punto de entrada usa estáticamente, donde se comprueba que la salida del vertex casa con la entrada del fragment y donde se genera el código máquina real. Un mismo módulo válido puede producir un pipeline que funciona y otro que falla, y el mensaje de error viene del pipeline, no del módulo.

La consecuencia operativa es la parte que casi nadie aplica: si un binding no lo usa nadie estáticamente, deja de existir para efectos de layout. Comentas una línea del fragment shader para depurar, la textura deja de estar en uso, el layout: 'auto' genera un bind group layout con una entrada menos, y el bind group que llevabas usando deja de ser compatible con un error que habla de layouts y no de la línea que comentaste. El truco de emergencia es un _ = textureLoad(...) o un _ = algo; que mantenga vivo el uso estático mientras depuras; el truco de fondo es no usar layout: 'auto' en un renderer que vaya a durar.

Con las etapas declaradas, queda la mecánica de entregárselas al navegador: el módulo de shader en la API.