El render pass: adjuntos, loadOp y storeOp
El descriptor completo del pase de render, qué significan las operaciones de carga y almacenamiento, por qué existen y qué cuesta elegir mal en una GPU móvil.
Un render pass es un ámbito con adjuntos fijos. Todo lo que se dibuja dentro escribe en las mismas texturas, con el mismo tamaño y el mismo número de muestras, y el pase empieza y termina con operaciones explícitas sobre esas texturas. Esas operaciones, loadOp y storeOp, parecen burocracia y son la abstracción que hace que WebGPU funcione bien en móvil.
- Escribir un descriptor de render pass con adjuntos de color y de profundidad.
- Elegir
loadOpystoreOpcon criterio y saber qué cuesta cada opción. - Explicar por qué esas operaciones existen, en términos de arquitectura de GPU.
- Enumerar el resto de campos del descriptor y para qué sirven.
El descriptor
const pase = encoder.beginRenderPass({
label: 'pase principal',
colorAttachments: [{
view: context.getCurrentTexture().createView(),
resolveTarget: undefined,
depthSlice: undefined,
clearValue: { r: 0.04, g: 0.04, b: 0.06, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}],
depthStencilAttachment: {
view: texturaProfundidad.createView(),
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
occlusionQuerySet: undefined,
timestampWrites: undefined,
maxDrawCount: undefined,
});
colorAttachments es obligatorio: un array con un elemento por objetivo de color, hasta maxColorAttachments, que por defecto es 8. Un elemento puede ser null para dejar un hueco en la numeración de @location.
Cada adjunto de color tiene seis campos. view es la textura de destino; la especificación admite tanto un GPUTextureView como directamente un GPUTexture. resolveTarget es el destino de la resolución de multimuestreo. depthSlice selecciona una capa cuando la vista es de una textura 3D. clearValue es el color de limpieza, con valor por defecto negro transparente. loadOp y storeOp son obligatorios.
depthStencilAttachment es opcional y tiene sus propias operaciones separadas para profundidad y estencil: depthLoadOp, depthStoreOp, depthClearValue, stencilLoadOp, stencilStoreOp, stencilClearValue, y un depthReadOnly y stencilReadOnly para pases que solo prueban sin escribir.
occlusionQuerySet habilita las consultas de oclusión dentro del pase. timestampWrites permite escribir marcas de tiempo al principio y al final, y requiere la feature timestamp-query. maxDrawCount es una pista sobre cuántos dibujos va a haber, con un valor por defecto muy alto.
loadOp y storeOp
Son los dos campos que hay que entender de verdad.
loadOp dice qué hacer con el contenido de la textura al empezar el pase:
'clear': rellenarla conclearValue.'load': conservar lo que hubiera.
storeOp dice qué hacer al terminar:
'store': guardar el resultado en la textura.'discard': descartarlo; el contenido queda indefinido.
Las cuatro combinaciones tienen sentido y se usan.
'clear' más 'store' es el caso normal: limpio, dibujo, guardo. Es lo que hace el pase principal de cada fotograma.
'load' más 'store' acumula sobre lo que ya había. Se usa para dibujar en varios pases sobre el mismo objetivo, por ejemplo la interfaz encima de la escena.
'clear' más 'discard' parece absurdo y no lo es: es lo correcto para un búfer de profundidad que solo se usa dentro del pase. Se limpia al entrar, se usa para ordenar la geometría, y su contenido no interesa después.
'load' más 'discard' es raro y aparece en pases que solo leen la profundidad existente.
Por qué existen estas operaciones
La respuesta corta es: por las GPUs móviles. La larga vale la pena porque explica una diferencia arquitectónica grande.
Las GPUs de escritorio son de renderizado inmediato: cada triángulo se rasteriza y se escribe a la memoria del framebuffer según llega. Las GPUs móviles son de renderizado por tiles: dividen la pantalla en bloques pequeños, de 16 por 16 o 32 por 32 píxeles, y procesan un bloque entero en memoria dentro del chip antes de escribirlo a la memoria principal. Ese diseño existe porque el ancho de banda de memoria es el recurso más escaso de un móvil y el que más batería consume.
Para que ese esquema funcione, el hardware necesita saber dos cosas al empezar cada tile: si tiene que traer el contenido previo de la memoria principal a la memoria del tile, y si al terminar tiene que escribirlo de vuelta.
loadOp: 'clear' significa «no traigas nada, rellena en chip». Es gratis. loadOp: 'load' significa «tráete el tile de memoria principal». Eso es una lectura completa de la pantalla.
storeOp: 'store' significa «escribe el tile a memoria principal». storeOp: 'discard' significa «no lo escribas». Eso ahorra una escritura completa de la pantalla.
Los números importan. Un búfer de profundidad de 1920 por 1080 en depth24plus son unos 8 MB. Con storeOp: 'store' innecesario, son 8 MB escritos por fotograma que nadie va a leer: casi 500 MB por segundo de ancho de banda tirados a la basura en el dispositivo donde el ancho de banda más escasea. Es la optimización de una sola línea con mejor relación entre esfuerzo y resultado de toda la API.
Usa 'clear' en lugar de 'load' siempre que vayas a cubrir toda la superficie de todas formas. Y usa 'discard' en el búfer de profundidad y en cualquier objetivo intermedio cuyo contenido no necesites después del pase. Las dos son gratis de escribir y las dos ahorran una pasada completa de memoria.
Lo que se puede hacer dentro
El objeto que devuelve beginRenderPass es un GPURenderPassEncoder y tiene un conjunto acotado de métodos.
Estado: setPipeline, setBindGroup, setVertexBuffer, setIndexBuffer, setViewport, setScissorRect, setBlendConstant, setStencilReference.
Dibujo: draw, drawIndexed, drawIndirect, drawIndexedIndirect.
Otros: executeBundles para reproducir render bundles pregrabados, beginOcclusionQuery y endOcclusionQuery, y los métodos de depuración pushDebugGroup, popDebugGroup e insertDebugMarker.
Y end(), que cierra el ámbito. Cualquier llamada después de end() es un error, y olvidar end() antes de finish() también.
Lo que no se puede hacer dentro de un render pass: copias entre búferes o texturas, ni un compute pass. Esas operaciones van en el encoder, fuera de cualquier pase. Es una restricción con sentido: una copia en mitad de un pase rompería la localidad de los tiles.
En una GPU de escritorio, la intuición razonable es que el coste está en los dibujos. En una GPU de tiles esa intuición falla, y falla en la dirección cara: el coste dominante es empezar y terminar pases, porque cada transición implica potencialmente traer y escribir la pantalla entera.
Piénsalo con números. Un pase con loadOp: 'load' y storeOp: 'store' sobre un objetivo de 1080p en rgba8unorm mueve 8 MB de entrada y 8 MB de salida: 16 MB. Cinco pases así en un fotograma son 80 MB, y a 60 fotogramas son 4,8 GB por segundo, que en muchos móviles es la mayor parte del ancho de banda disponible. Los dibujos que hay dentro pueden ser irrelevantes en comparación.
De ahí tres reglas que en móvil son la diferencia entre 60 y 25 fotogramas por segundo. Una: fusiona pases. Si dos pases dibujan a los mismos adjuntos, hazlos uno solo. Cada pase que eliminas ahorra un ciclo completo de carga y descarga. Dos: nunca uses 'load' si vas a cubrir la superficie. El caso típico es un pase de post-proceso que escribe cada píxel: 'clear' es estrictamente mejor aunque el color de limpieza no se vea nunca. Tres: 'discard' en todo lo que no sobreviva al pase, empezando por el búfer de profundidad, que casi nadie descarta y que casi nunca hace falta guardar.
Y la consecuencia de arquitectura, que es la que separa un motor que funciona en móvil de uno que no: una canalización de post-proceso con seis pases encadenados es un diseño de escritorio. En móvil, cada eslabón cuesta dos pasadas completas por la pantalla, y la respuesta correcta no es optimizar los shaders sino fusionar etapas en menos pases, aunque el shader resultante sea más largo y más feo. La aritmética extra es gratis; el ancho de banda no.
Queda la parte que de verdad dibuja: draw y submit.