Los pases como ámbitos: estado que no sale de sus llaves
Qué significa que un pase sea un ámbito, qué estado vive dentro, por qué no se pueden anidar, y cómo se organiza un fotograma en pases sin desperdiciar ancho de banda.
Un pase es la unidad de trabajo homogéneo de WebGPU: un conjunto de comandos que escriben a los mismos destinos con la misma configuración de adjuntos. La palabra clave es ámbito: el estado que pones dentro no existe fuera, y no se hereda de un pase al siguiente. Esa propiedad es la que elimina de raíz la familia de bugs de estado global de WebGL, y también la que impone una disciplina de organización que hay que aprender.
- Enumerar qué estado vive dentro del ámbito de un pase y qué no.
- Explicar por qué los pases no se anidan ni se solapan.
- Distinguir un render pass de un compute pass en capacidades y restricciones.
- Organizar un fotograma en pases minimizando su número.
Lo que hay dentro del ámbito
Un GPURenderPassEncoder empieza completamente vacío. No hay pipeline puesto, no hay bind groups, no hay búferes de vértices ni de índices, el viewport cubre el adjunto entero y el rectángulo de recorte también. Nada se hereda del pase anterior ni de ningún otro código.
Lo que se establece dentro del ámbito y muere con él:
El pipeline activo, con setPipeline. Los bind groups, por índice, con setBindGroup. Los búferes de vértices, por ranura, con setVertexBuffer. El búfer de índices, con setIndexBuffer. El viewport y el recorte, con setViewport y setScissorRect. La constante de mezcla, con setBlendConstant. La referencia de estencil, con setStencilReference.
Lo que no está en el ámbito porque está en el descriptor y es fijo para todo el pase: los adjuntos de color, el adjunto de profundidad, las operaciones de carga y almacenamiento, el número de muestras y el conjunto de consultas de oclusión. Cambiar cualquiera de esas cosas significa terminar el pase y empezar otro.
Esa división es la que decide la organización de un fotograma. Todo lo que dibuje a los mismos destinos con la misma configuración cabe en un pase, por distinto que sea. Cien materiales, cien pipelines, cien bind groups: un solo pase, porque los destinos son los mismos.
Nada de anidamiento
Un pase tiene que cerrarse con end() antes de abrir otro. No se pueden anidar, no se pueden solapar, no se puede tener dos abiertos a la vez sobre el mismo encoder. Mientras hay un pase abierto, el encoder está bloqueado y no admite sus propias operaciones.
const encoder = device.createCommandEncoder();
const a = encoder.beginRenderPass(descA);
// encoder.copyBufferToBuffer(...); <- error: el encoder esta bloqueado
// const b = encoder.beginRenderPass(descB); <- error: ya hay un pase abierto
a.end();
encoder.copyBufferToBuffer(x, y, tam); // ahora si
const b = encoder.beginComputePass();
b.end();
device.queue.submit([encoder.finish()]);
Los dos errores más frecuentes de esta familia son olvidar end(), que hace que finish() produzca un command buffer inválido, y llamar a un método del pase después de end(), que es un error de validación inmediato.
Una consecuencia útil de la prohibición de anidar: el orden de los pases dentro de un encoder es el orden de ejecución, y la implementación usa ese orden para deducir dependencias. Si el pase A escribe una textura y el B la lee, grabarlos en ese orden es todo lo que hace falta para que las barreras se coloquen bien.
Render y cómputo
Los dos tipos de pase tienen la misma forma —abrir, grabar, cerrar— y capacidades distintas.
Un render pass tiene adjuntos obligatorios, usa el rasterizador, y su unidad de trabajo es draw con sus variantes. Puede reproducir render bundles pregrabados con executeBundles. Su descriptor es grande.
Un compute pass no tiene adjuntos: su descriptor solo admite label y timestampWrites. No usa el rasterizador. Su unidad de trabajo es dispatchWorkgroups(x, y, z) y su variante indirecta dispatchWorkgroupsIndirect(buffer, offset). Solo admite setPipeline y setBindGroup como estado.
const computo = encoder.beginComputePass({ label: 'simulacion' });
computo.setPipeline(pipelineSimulacion);
computo.setBindGroup(0, recursos);
computo.dispatchWorkgroups(Math.ceil(N / 64));
computo.end();
La simetría entre los dos es deliberada y es lo que hace que aprender cómputo después de render cueste tan poco: el modelo de recursos, el de comandos y el lenguaje son los mismos.
Cuántos pases y cuáles
La regla es corta: el mínimo que la estructura permita. Cada pase cuesta, y en una GPU de tiles cuesta bastante, porque implica potencialmente cargar y descargar los adjuntos enteros.
Un fotograma típico de una aplicación con post-proceso tiene esta forma:
const encoder = device.createCommandEncoder({ label: 'frame' });
// 1. Computo: simulacion, culling, animacion de esqueletos.
const sim = encoder.beginComputePass({ label: 'simulacion' });
sim.setPipeline(pipelineSim);
sim.setBindGroup(0, recursosSim);
sim.dispatchWorkgroups(gruposSim);
sim.end();
// 2. Render de la escena a una textura intermedia.
const escena = encoder.beginRenderPass({
label: 'escena',
colorAttachments: [{
view: intermedia.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}],
depthStencilAttachment: {
view: profundidad.createView(),
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'discard', // no hace falta despues del pase
},
});
dibujarOpacos(escena);
dibujarTransparentes(escena); // el mismo pase: mismos destinos
escena.end();
// 3. Post-proceso al canvas.
const post = encoder.beginRenderPass({
label: 'post',
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: 'clear', // se cubre entera: clear es mejor que load
clearValue: { r: 0, g: 0, b: 0, a: 1 },
storeOp: 'store',
}],
});
post.setPipeline(pipelinePost);
post.setBindGroup(0, grupoPost);
post.draw(3); // triangulo a pantalla completa
post.end();
device.queue.submit([encoder.finish()]);
Tres pases. Fíjate en dos decisiones. Los opacos y los transparentes van en el mismo pase, porque escriben a los mismos adjuntos: separarlos en dos pases no aportaría nada y costaría una carga y descarga completa. Y el depthStoreOp es 'discard', porque la profundidad solo se usa dentro del pase.
Un pase nuevo hace falta cuando cambia algo del descriptor: otros adjuntos, otro tamaño, otro número de muestras, o cuando necesitas leer como textura algo que escribiste como adjunto. Ese último caso es el que fuerza la mayoría de las separaciones: no se puede leer una textura que es adjunto del pase activo, así que cualquier efecto que consuma el resultado del render necesita su propio pase.
Hay un ejercicio que hacen los equipos que rinden bien y casi nadie más: dibujar el grafo de pases de un fotograma antes de implementarlo, con sus entradas, sus salidas y sus operaciones de carga y almacenamiento. Cuesta media hora y ordena decisiones que de otro modo se toman por acumulación.
Lo que ese grafo hace evidente y que el código esconde. Los pases que se pueden fusionar, que casi siempre son más de los que parecen: dos pases que escriben a los mismos adjuntos son uno. Los objetivos intermedios que no hacen falta, porque su consumidor podría leer directamente de la fuente. Las cargas y descargas innecesarias, que en el grafo aparecen como una flecha que sale de un pase y no entra en ninguno, y que deberían ser 'discard'. Y el camino crítico: qué pase depende de cuál, y por tanto qué se puede reordenar para que la GPU tenga trabajo mientras espera.
Hay una forma concreta de leer el grafo que detecta el problema más caro. Busca ciclos que pasen por la CPU. Una flecha que sale de un pase, vuelve a JavaScript y entra en otro pase del mismo fotograma es un error de arquitectura, no de implementación: introduce una espera de la longitud entera de la cola y no hay optimización que lo arregle. La solución siempre pasa por cerrar el ciclo dentro de la GPU, con dibujado indirecto, con un búfer que se lee y se escribe en pases sucesivos, o aceptando datos de hace dos fotogramas.
El grafo tiene además una virtud práctica que no se anticipa: es la mejor documentación posible del renderizador, y es lo primero que pide cualquiera que se incorpore al proyecto. Diez cajas y quince flechas explican en un minuto lo que el código explica en dos días.
Cerrado el pase, queda cerrar el encoder: finish y el command buffer.