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

El render pipeline: todo el estado gráfico en un objeto

Los seis bloques de GPURenderPipelineDescriptor, qué controla cada uno, qué significa layout auto y cuándo conviene la versión asíncrona.

⏱ 18 min

El descriptor del pipeline de render es el objeto más grande de WebGPU y el que concentra todo lo que WebGL repartía en treinta llamadas de estado. Tiene seis bloques, de los que dos son obligatorios, y cada campo que dejas por defecto es una decisión que sigue tomándose: no hay estado heredado, hay valores por defecto explícitos que conviene conocer.

🎯 Al terminar esta lección sabrás
  • Enumerar los seis bloques del descriptor y qué controla cada uno.
  • Escribir el pipeline mínimo de un triángulo y justificar cada campo.
  • Explicar qué hace layout: 'auto' y cuáles son sus límites.
  • Elegir entre la creación síncrona y la asíncrona.

Los seis bloques

const pipeline = device.createRenderPipeline({
  label: 'triangulo',
  layout: 'auto',
  vertex: {
    module: modulo,
    entryPoint: 'vs',
    buffers: [],
  },
  primitive: {
    topology: 'triangle-list',
    frontFace: 'ccw',
    cullMode: 'none',
  },
  depthStencil: undefined,
  multisample: {
    count: 1,
    mask: 0xFFFFFFFF,
    alphaToCoverageEnabled: false,
  },
  fragment: {
    module: modulo,
    entryPoint: 'fs',
    targets: [{ format: FORMATO_CANVAS }],
  },
});

layout es obligatorio y describe qué recursos ve el pipeline. Puede ser un GPUPipelineLayout construido a mano o la cadena 'auto'.

vertex es obligatorio. Contiene module, entryPoint opcional, constants para las constantes de especialización y buffers, la lista de disposiciones de búferes de vértices. Aquí está vacía porque el shader genera las posiciones a partir de vertex_index.

primitive describe cómo se ensamblan y descartan las primitivas. topology admite 'point-list', 'line-list', 'line-strip', 'triangle-list' —el valor por defecto— y 'triangle-strip'. stripIndexFormat solo aplica a las topologías de tira. frontFace es 'ccw' o 'cw' y define qué orientación se considera frontal. cullMode es 'none' —por defecto—, 'front' o 'back'. Y unclippedDepth desactiva el recorte por profundidad, pero requiere la feature depth-clip-control.

depthStencil es opcional y su ausencia significa que no hay prueba de profundidad ni de estencil. Si está, format es obligatorio y tiene que coincidir con el del adjunto del pase.

multisample controla el antialiasing por hardware. count es 1 por defecto y el valor típico para MSAA es 4. Tiene que coincidir con el número de muestras de las texturas adjuntas.

fragment es opcional. Sin él, el pipeline no produce color, lo cual tiene sentido en un pase que solo rellena el búfer de profundidad. Cuando está, targets es obligatorio: un elemento por objetivo de color, cada uno con format obligatorio y opcionalmente blend y writeMask.

⚠️
Los formatos tienen que coincidir en tres sitios

El format de targets[0] tiene que ser exactamente el de la vista que se adjunta en colorAttachments[0] del pase. El format de depthStencil tiene que ser el de la vista de depthStencilAttachment. Y multisample.count tiene que coincidir con sampleCount de las texturas adjuntas. Tres coincidencias que la validación comprueba y que conviene derivar de constantes únicas en lugar de escribir a mano.

layout auto y sus límites

layout: 'auto' pide a la implementación que deduzca la disposición de recursos a partir de las declaraciones @group y @binding del propio WGSL. Es cómodo y para empezar es lo correcto.

Los layouts generados se recuperan con pipeline.getBindGroupLayout(indice):

const grupo = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [{ binding: 0, resource: { buffer: uniformes } }],
});

Tiene dos límites que hacen que se abandone en cuanto el proyecto crece.

Los layouts generados no son compartibles. Cada pipeline con 'auto' produce sus propios objetos de layout, y un bind group creado con el layout del pipeline A no se puede usar con el pipeline B, aunque los dos declaren exactamente los mismos recursos. Eso obliga a crear un bind group por pipeline para datos que son idénticos, como las matrices de cámara.

No permite recursos declarados y no usados. Si el WGSL declara un @binding que ningún código del shader lee, el compilador puede eliminarlo y el layout generado no lo incluirá, con lo que tu bind group deja de encajar. Es una fuente de sorpresas al comentar líneas de un shader para depurar.

La alternativa es un GPUPipelineLayout explícito, construido a partir de GPUBindGroupLayouts que tú creas y compartes entre pipelines:

const layoutEscena = device.createBindGroupLayout({
  label: 'escena',
  entries: [{
    binding: 0,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
    buffer: { type: 'uniform' },
  }],
});

const layoutPipeline = device.createPipelineLayout({
  bindGroupLayouts: [layoutEscena],
});

const pipeline = device.createRenderPipeline({
  layout: layoutPipeline,
  // ...
});

Con eso, un solo bind group de cámara sirve para todos los pipelines que compartan layoutEscena. Es más código y es la arquitectura correcta a partir del segundo pipeline.

Síncrono o asíncrono

Hay dos formas de crear un pipeline y la diferencia importa más de lo que parece.

createRenderPipeline(descriptor) retorna inmediatamente un objeto usable. Pero la compilación real al código máquina de la GPU puede seguir ocurriendo en segundo plano, y si usas el pipeline antes de que termine, el pase espera. Ese es el origen del parón que aparece la primera vez que un objeto entra en cámara.

createRenderPipelineAsync(descriptor) devuelve una promesa que no resuelve hasta que el pipeline está listo para usarse sin bloquear. Si el descriptor es inválido, rechaza con un GPUPipelineError, lo cual además convierte un error silencioso en una excepción que puedes capturar.

const pipelines = await Promise.all([
  device.createRenderPipelineAsync(descOpaco),
  device.createRenderPipelineAsync(descTransparente),
  device.createRenderPipelineAsync(descSombras),
]);

Esa es la forma correcta en una pantalla de carga: se crean todos, se espera a todos, y el primer fotograma no tiene sorpresas. La creación síncrona es aceptable para prototipos y para el pipeline único de una demo.

El pipeline mínimo de verdad

Quitando todo lo que es valor por defecto, el pipeline del triángulo se queda en esto:

const pipeline = device.createRenderPipeline({
  label: 'triangulo',
  layout: 'auto',
  vertex: { module: modulo, entryPoint: 'vs' },
  fragment: { module: modulo, entryPoint: 'fs', targets: [{ format: FORMATO_CANVAS }] },
});

Cuatro líneas. Los valores por defecto que estás aceptando son: topología de lista de triángulos, sin descarte de caras, sin prueba de profundidad, una sola muestra, sin mezcla, y escritura de los cuatro canales de color. Para un triángulo son exactamente los correctos.

Los dos que hay que recordar porque muerden después: cullMode por defecto es 'none', así que las caras traseras se dibujan y una malla cerrada gasta el doble de fragmentos de los necesarios. Y sin depthStencil no hay prueba de profundidad, así que en una escena 3D los objetos se dibujan en el orden en que los mandas y los de atrás tapan a los de delante.

El número de pipelines es un presupuesto que hay que fijar antes de escribir el sistema de materiales

Todo lo que hay en el descriptor es inmutable, así que cada combinación distinta es un objeto distinto. Eso suena administrativo hasta que se hacen las cuentas de una arquitectura de materiales normal: tres tipos de material, con y sin mezcla, con y sin sombras, con y sin skinning, contra dos formatos de objetivo, son 48 pipelines. Añade una variante y son 96.

El coste no es la memoria: es el tiempo de compilación, que va de unos pocos milisegundos a decenas por pipeline según la complejidad del shader y la plataforma. Cien pipelines pueden ser varios segundos de arranque. Y no se puede diferir sin más, porque compilar en el primer uso es el parón que estropea el momento en que el usuario empieza a interactuar.

Las tres decisiones que hay que tomar pronto, no tarde. Una: enumerar las variantes en lugar de generarlas por combinación. Un motor que crea pipelines a demanda acaba con variantes que se usan una vez; uno que declara explícitamente las diez combinaciones que su arte necesita mantiene el número acotado. Dos: usar constantes de especialización —el campo constants de vertex y fragment, que rellena las override de WGSL— para las variantes que solo cambian un valor numérico, en lugar de generar código fuente distinto. Sigue siendo un pipeline por variante, y el módulo se compila una vez. Tres: medir el tiempo total de compilación desde el primer día y ponerle un techo, porque es una cifra que crece de forma monótona y que nadie mira hasta que el arranque tarda ocho segundos y ya hay cuarenta materiales en producción.

La regla de decisión que resume las tres: si dudas entre una rama dentro del shader y dos pipelines, cuenta cuántos píxeles ejecutan la rama. Muchos píxeles y ramas caras, dos pipelines. Pocos píxeles o ramas triviales, una rama. Lo que no funciona es no decidirlo y dejar que la arquitectura lo decida por acumulación.

Con el pipeline listo, falta el ámbito donde se usa: el render pass.