El descriptor del render pipeline: el mapa de los seis bloques
Los seis campos de createRenderPipeline, qué controla cada bloque, qué es obligatorio y qué no, y por qué un pipeline sin fragment sigue siendo útil.
Un render pipeline de WebGPU es un objeto inmutable que congela todo el estado gráfico: los shaders, el formato de los vértices, cómo se ensamblan los triángulos, cómo se testea la profundidad, cómo se mezclan los colores. En WebGL ese estado estaba repartido en cuarenta llamadas y se validaba en cada draw; aquí se declara una vez, se valida una vez, y a partir de ahí setPipeline es un cambio de estado sin coste de validación. El precio es que hay que declararlo todo.
- Enumerar los seis campos del descriptor y decir cuáles son obligatorios.
- Escribir un pipeline completo y saber qué valor por defecto toma cada campo omitido.
- Explicar para qué sirve un pipeline sin bloque
fragment. - Relacionar cada bloque con la etapa del pipeline gráfico que controla.
Los seis campos
const pipeline = device.createRenderPipeline({
label: 'opaco-pbr',
layout: pipelineLayout, // obligatorio: GPUPipelineLayout o 'auto'
vertex: { // obligatorio
module: modulo,
entryPoint: 'vs', // opcional si hay un solo @vertex
constants: {}, // override constants
buffers: [layoutVertices], // opcional
},
fragment: { // opcional
module: modulo,
entryPoint: 'fs',
targets: [{ format: 'bgra8unorm' }],
},
primitive: { // opcional, todo tiene defecto
topology: 'triangle-list',
frontFace: 'ccw',
cullMode: 'back',
},
depthStencil: { // opcional
format: 'depth24plus',
depthWriteEnabled: true,
depthCompare: 'less',
},
multisample: { // opcional, todo tiene defecto
count: 1,
mask: 0xFFFFFFFF,
alphaToCoverageEnabled: false,
},
});
Solo dos campos son obligatorios: layout y vertex. Todo lo demás tiene valor por defecto o se puede omitir por completo.
El reparto de responsabilidades sigue el orden del pipeline gráfico. vertex controla la entrada de geometría y su transformación. primitive controla el ensamblado y el descarte por orientación. depthStencil controla las pruebas por fragmento antes y después del shader. multisample controla cuántas muestras hay por píxel. fragment controla el sombreado y la escritura a los attachments. Y layout cruza todos los bloques declarando qué recursos ve el conjunto.
Lo obligatorio y lo omitible
layout no tiene valor por defecto. O pasas un GPUPipelineLayout explícito, con las ventajas de compartición que vimos en el nivel 18, o pasas la cadena 'auto' con las limitaciones que ya conoces.
vertex.module es obligatorio. vertex.entryPoint no lo es desde que la especificación añadió la deducción automática: si el módulo contiene exactamente una función con @vertex, se usa esa. Si contiene varias y omites el nombre, es un error de validación. Lo mismo aplica a fragment.entryPoint.
vertex.buffers se puede omitir o dejar vacío, y entonces el vertex shader no recibe atributos: todo lo que necesite lo saca de @builtin(vertex_index), de @builtin(instance_index) y de los recursos atados. Es exactamente lo que hace el triángulo de pantalla completa del generador de mipmaps.
primitive y multisample pueden omitirse enteros y toman todos sus valores por defecto: topología de lista de triángulos, sentido antihorario como frontal, sin culling, una muestra por píxel.
depthStencil se omite si el render pass no tiene attachment de profundidad. Si el pass sí lo tiene y el pipeline no declara depthStencil, el draw falla.
El pipeline sin fragment
fragment es opcional, y omitirlo produce un pipeline que ejecuta el vertex shader, rasteriza y escribe profundidad, pero no produce ningún color. Suena inútil y es la base de dos técnicas muy usadas.
La primera es el pass de sombras. Un mapa de sombras solo necesita profundidad; el color no se usa para nada. Sin bloque fragment, el hardware puede usar un camino rápido que en muchas arquitecturas dobla el rendimiento de rasterizado, porque no hay que reservar ni escribir attachments de color.
const pipelineSombra = device.createRenderPipeline({
label: 'sombras',
layout: layoutSombra,
vertex: { module: moduloSombra, buffers: [soloPosiciones] },
// sin fragment
primitive: { topology: 'triangle-list', cullMode: 'front' },
depthStencil: {
format: 'depth32float',
depthWriteEnabled: true,
depthCompare: 'less',
},
});
La segunda es el pre-pass de profundidad. Se dibuja la escena entera sin color para llenar el depth buffer, y después se dibuja de nuevo con depthCompare: 'equal' y depthWriteEnabled: false. El segundo pase solo ejecuta el fragment shader en los fragmentos realmente visibles, eliminando todo el overdraw. En una escena con shaders pesados y mucho solapamiento, el coste del pase extra de geometría se recupera con creces.
Un pipeline sin fragment sigue respetando cullMode, sigue escribiendo profundidad y sigue ejecutando las operaciones de stencil. Lo único que no hace es producir color. Es un pipeline completo al que le falta la última etapa.
Las override constants
Los bloques vertex y fragment aceptan un campo constants, que es un mapa de identificadores a valores para las constantes declaradas con override en el WGSL:
override MUESTRAS_SOMBRA : u32 = 9u;
override USAR_NORMAL_MAP : bool = true;
@id(7) override EXPOSICION : f32 = 1.0;
fragment: {
module: modulo,
entryPoint: 'fs',
constants: {
MUESTRAS_SOMBRA: 25, // por nombre
USAR_NORMAL_MAP: false,
7: 1.8, // por @id numérico
},
targets: [{ format }],
}
El valor se fija en el momento de crear el pipeline, así que el compilador puede propagarlo: un bucle de MUESTRAS_SOMBRA iteraciones se desenrolla, y una rama con USAR_NORMAL_MAP en falso desaparece del código generado. Es la forma correcta de tener variantes de shader sin duplicar el código fuente ni pagar ramas en tiempo de ejecución.
Cada combinación de constantes produce un pipeline distinto con su propia compilación, así que el número de variantes se multiplica rápido y hay que vigilarlo. El coste de esa compilación es el tema de la última lección de este nivel.
La propiedad más útil del render pipeline no es que sea rápido de usar sino que es determinista: el mismo descriptor produce siempre un pipeline equivalente, sin ningún estado oculto. Eso permite montar una caché por contenido del descriptor, y es lo que hacen todos los motores serios. Serializas el descriptor a una clave —el código del shader, los formatos, los flags— y consultas un Map antes de crear nada. En un motor con materiales configurables, esa caché convierte cientos de creaciones potenciales en las decenas que realmente son distintas, y elimina la duplicación que se produce cuando dos materiales resultan tener exactamente la misma configuración de estado gráfico. El detalle que casi todo el mundo olvida al implementarla es incluir el GPUPipelineLayout en la clave por identidad de objeto y no por contenido, porque dos layouts estructuralmente idénticos producen pipelines incompatibles a efectos de bind groups. Una caché que los confunda es peor que no tener caché.