wandres.dev
EL PRIMER TRIÁNGULO · Pipeline completo mínimo

El módulo de shader: de una cadena de WGSL a código de GPU

createShaderModule, el WGSL mínimo de un triángulo explicado línea a línea, cómo leer los errores de compilación y por qué un módulo puede contener varias etapas.

⏱ 17 min

Un GPUShaderModule es una unidad de compilación: una cadena de WGSL que la implementación traduce al lenguaje intermedio de la plataforma y, finalmente, al código máquina de la GPU concreta. Crear uno son dos líneas, y en esas dos líneas se decide si los errores de tu shader van a aparecer con número de línea o si van a aparecer como un pipeline que no funciona sin explicar por qué.

🎯 Al terminar esta lección sabrás
  • Crear un módulo de shader y consultar sus mensajes de compilación.
  • Leer el WGSL mínimo de un triángulo y explicar cada atributo.
  • Explicar por qué un módulo puede contener varias funciones de entrada.
  • Diagnosticar errores de compilación con la información que da la API.

createShaderModule

const modulo = device.createShaderModule({
  label: 'triangulo',
  code: `
    @vertex
    fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
      let p = array(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));
      return vec4f(p[i], 0.0, 1.0);
    }

    @fragment
    fn fs() -> @location(0) vec4f {
      return vec4f(1.0, 0.4, 0.6, 1.0);
    }
  `,
});

El descriptor tiene tres miembros: code, que es obligatorio y contiene el WGSL; label; y compilationHints, una lista opcional de pistas sobre con qué disposición de recursos se va a usar el módulo, que algunas implementaciones aprovechan para adelantar trabajo.

Un detalle que sorprende: la llamada retorna un módulo aunque el WGSL tenga errores. WebGPU no lanza excepciones por eso. Si hay errores, el módulo es inválido, se emite un GPUValidationError, y el fallo se manifiesta al crear el pipeline. Para verlo en el sitio correcto hay que preguntarlo.

Leer los errores

getCompilationInfo() devuelve una promesa con los mensajes del compilador, cada uno con posición exacta:

async function compilar(device, code, label) {
  const modulo = device.createShaderModule({ label, code });
  const info = await modulo.getCompilationInfo();
  for (const m of info.messages) {
    const linea = code.split('\n')[m.lineNum - 1] ?? '';
    console[m.type === 'error' ? 'error' : 'warn'](
      `[${label}] ${m.type} ${m.lineNum}:${m.linePos} ${m.message}\n  ${linea.trim()}`
    );
  }
  if (info.messages.some((m) => m.type === 'error')) {
    throw new Error(`El shader ${label} no compila`);
  }
  return modulo;
}

Cada GPUCompilationMessage trae message, type —que vale 'error', 'warning' o 'info'—, lineNum, linePos, offset y length. Con lineNum y el código fuente original se puede imprimir la línea culpable, que es lo que convierte un mensaje del compilador en algo accionable.

Esta función, o una equivalente, debería ser la única forma de crear módulos en tu proyecto. La diferencia entre usarla y no usarla es la diferencia entre «error de sintaxis en la línea 34, columna 12: se esperaba ;» y «el pipeline es inválido».

💡
En desarrollo, siempre; en producción, opcional

getCompilationInfo() es asíncrono y tiene un coste. Un patrón razonable es llamarlo siempre en desarrollo y solo cuando el pipeline falla en producción, para no añadir una espera al arranque de cada usuario.

El WGSL, línea a línea

El shader de arriba es lo mínimo que produce un triángulo, y cada elemento tiene un porqué.

@vertex marca la función como punto de entrada de la etapa de vértices. Se ejecuta una vez por vértice.

@builtin(vertex_index) i: u32 es un parámetro que la etapa recibe del hardware: el índice del vértice que le toca procesar, empezando en cero. Con draw(3) toma los valores 0, 1 y 2. Es lo que permite generar geometría sin ningún búfer de vértices, que es exactamente lo que hace este ejemplo.

-> @builtin(position) vec4f declara la salida obligatoria de un vertex shader: la posición en clip space, en coordenadas homogéneas de cuatro componentes. Es el único valor que la etapa está obligada a producir.

array(vec2f(...), ...) construye un array cuyo tipo se infiere. Es un valor constante que el compilador resuelve en tiempo de compilación.

vec4f(p[i], 0.0, 1.0) compone un vec4f a partir de un vec2f y dos escalares. WGSL permite esa composición y es idiomática. La z a cero pone el triángulo en el plano medio y la w a uno indica que no hay división perspectiva.

@fragment marca la etapa de fragmentos, que se ejecuta una vez por fragmento cubierto por el triángulo.

-> @location(0) vec4f declara que la salida va al objetivo de color número 0, que se corresponde con el primer elemento de colorAttachments del render pass y con el primer elemento de targets del pipeline. Los tres números tienen que coincidir.

Las coordenadas de clip space de WebGPU son las que hay que tener claras desde el principio: X e Y van de -1 a 1, con -1 a la izquierda y abajo; Z va de 0 a 1, con 0 en el plano cercano. Eso último es distinto de OpenGL y de WebGL, donde Z iba de -1 a 1, y coincide con Direct3D, Metal y Vulkan.

Un módulo, varias etapas

En el ejemplo, vs y fs están en el mismo módulo. Es lo habitual y tiene ventajas concretas.

El compilador ve las dos etapas a la vez y puede verificar que las salidas de una encajan con las entradas de la otra, que es una de las cosas que se valida al crear el pipeline.

Las declaraciones se comparten. Los struct, las constantes, las funciones auxiliares y las variables de módulo con @group y @binding se escriben una vez y las usan las dos etapas.

Es una sola compilación en lugar de dos.

Nada obliga a ello: puedes tener un módulo por etapa y combinarlos en el pipeline. Y un módulo puede contener tantas funciones de entrada como quieras, de las tres clases: @vertex, @fragment y @compute. Un módulo único con todos los shaders de un efecto es un patrón perfectamente razonable.

El descriptor del pipeline elige cuál usar con entryPoint. Y desde una revisión reciente de la especificación, entryPoint es opcional: si el módulo tiene exactamente una función de entrada de esa etapa, se infiere. Si tiene dos o más y no lo indicas, el pipeline es inválido. Escribirlo explícitamente sigue siendo la opción defendible, porque hace el código independiente de cuántas entradas acabe teniendo el módulo.

// Con entryPoint explicito: robusto ante cambios en el modulo.
vertex: { module: modulo, entryPoint: 'vs' },
fragment: { module: modulo, entryPoint: 'fs', targets: [{ format }] },
Generar el triángulo desde vertex_index no es un truco de tutorial: es una técnica de producción

Casi todo el material presenta el triángulo sin búfer de vértices como una simplificación pedagógica, algo que se abandona en cuanto se aprende setVertexBuffer. Es al revés: es la técnica correcta para toda una familia de casos, y se usa constantemente en motores de verdad.

El caso canónico es el cuadrilátero a pantalla completa, que hace falta en cada pase de post-proceso, cada desenfoque, cada corrección de color, cada composición. Nadie reserva un búfer de vértices para eso. Se dibuja con draw(3) —un solo triángulo sobrredimensionado que cubre toda la pantalla, más eficiente que dos triángulos porque evita la costura diagonal donde los cuadrados de fragmentos se procesan dos veces— y las coordenadas salen de vertex_index:

@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
  let x = f32((i << 1u) & 2u) * 2.0 - 1.0;
  let y = f32(i & 2u) * 2.0 - 1.0;
  return vec4f(x, y, 0.0, 1.0);
}

Ese fragmento genera un triángulo con vértices en (-1,-1), (3,-1) y (-1,3), que cubre por completo el cuadrado visible. Aparece en la práctica totalidad de los motores modernos.

La idea general es más amplia y vale la pena tenerla presente: vertex_index e instance_index son entradas gratuitas que permiten generar geometría procedimentalmente. Partículas como cuadriláteros orientados a cámara, líneas expandidas a bandas, rejillas, cintas, impostores: todo eso se puede generar en el vertex shader a partir de índices y de datos leídos de un búfer de almacenamiento, sin ningún búfer de vértices y sin ensamblador de vértices. Ahorra ancho de banda, ahorra memoria, y es más flexible. La pregunta que conviene hacerse ante cada malla no es qué formato de vértices usar, sino si hacen falta vértices.

Con el programa compilado, falta el objeto que describe cómo se ejecuta: el render pipeline.