Varios attachments de color y su correspondencia con los @location
Cómo se emparejan los colorAttachments del pass, los targets del pipeline y las salidas del fragment shader, y los dos límites del dispositivo que deciden cuántos caben.
Un fragment shader puede escribir en varios destinos a la vez, y esa capacidad es la que hace posible el renderizado diferido, la separación de canales para el post-proceso, los búferes de identificadores para selección y las técnicas de transparencia con acumulación. El mecanismo es una correspondencia posicional entre tres listas que viven en tres sitios distintos del API, y equivocarse en un índice produce el peor tipo de error: uno que compila y dibuja en el sitio que no es.
- Emparejar correctamente
colorAttachments,targetsy las salidas@locationdel shader. - Escribir la estructura de salida de un fragment shader con varios destinos.
- Calcular si un conjunto de attachments cabe en los límites del dispositivo.
- Usar attachments nulos y máscaras de escritura para reutilizar un pipeline en varias pasadas.
Tres listas indexadas por el mismo número
La correspondencia es puramente posicional y no hay nombres de por medio. El elemento i de colorAttachments en el descriptor del pass es el destino del elemento i de targets en el pipeline, que a su vez recibe lo que el fragment shader devuelve en @location(i).
// 1. El pipeline declara tres destinos, en orden.
const pipelineGeometria = device.createRenderPipeline({
label: 'g-buffer',
layout: layoutEscena,
vertex: { module, entryPoint: 'vs' },
fragment: {
module,
entryPoint: 'fs',
targets: [
{ format: 'rgba8unorm' }, // @location(0): albedo y rugosidad
{ format: 'rgba16float' }, // @location(1): normales
{ format: 'r32uint' }, // @location(2): identificador de objeto
],
},
depthStencil: { format: 'depth32float',
depthWriteEnabled: true, depthCompare: 'less' },
});
// 2. El pass ofrece tres attachments, en el mismo orden.
const pass = encoder.beginRenderPass({
label: 'geometria',
colorAttachments: [
{ view: albedoView, clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear', storeOp: 'store' },
{ view: normalView, clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear', storeOp: 'store' },
{ view: idView, clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear', storeOp: 'store' },
],
depthStencilAttachment: { view: depthView, depthClearValue: 1.0,
depthLoadOp: 'clear', depthStoreOp: 'store' },
});
Y el shader declara una estructura de salida cuyos campos llevan el atributo de localización:
struct SalidaG {
@location(0) albedo : vec4f,
@location(1) normal : vec4f,
@location(2) idObjeto : vec4u, // formato entero: la salida es u32
};
@fragment
fn fs(in: VsOut) -> SalidaG {
var out : SalidaG;
out.albedo = vec4f(muestrearAlbedo(in.uv), material.rugosidad);
out.normal = vec4f(normalize(in.normalMundo) * 0.5 + 0.5, 0.0);
out.idObjeto = vec4u(objeto.id, 0u, 0u, 0u);
return out;
}
Tres detalles de esa estructura que no son evidentes. El tipo de la salida tiene que ser compatible con el formato del target: un formato -uint exige un vector de u32, uno -sint un vector de i32, y el resto f32. Las localizaciones no tienen por qué estar completas ni ordenadas, pero cada una tiene que corresponder a un target declarado. Y si un target existe y el shader no escribe en su localización, el contenido de ese attachment para esos fragmentos queda indefinido, no cero: hay que limpiarlo o escribirlo, no dar por hecho nada.
Los dos límites
Dibujar a muchos destinos a la vez tiene dos techos, y el segundo llega mucho antes que el primero.
maxColorAttachments vale 8 por defecto. Es el número de attachments, sin más.
maxColorAttachmentBytesPerSample vale 32 por defecto, y es el que manda. Suma los bytes por muestra de todos los formatos que uses: rgba8unorm aporta 4, rgba16float aporta 8, rgba32float aporta 16, r8unorm aporta 1. Con 32 bytes caben cuatro attachments rgba16float, u ocho rgba8unorm, o dos rgba32float. El cálculo recorre los attachments en el orden en que los declaras y alinea cada uno antes de sumarlo, de modo que en configuraciones mixtas el orden de declaración puede decidir si el conjunto cabe o no. Si te sale justo, prueba a reordenar de mayor a menor antes de rendirte.
Ese límite no es un capricho de la especificación: es la memoria del tile en el hardware de renderizado por tiles. Cada píxel del tile tiene que guardar todos sus attachments simultáneamente en la memoria interna del chip, y esa memoria es del orden de decenas de kilobytes. Treinta y dos bytes por muestra es lo que la especificación garantiza en cualquier dispositivo; algunos ofrecen más y puedes pedirlo al crear el dispositivo, pero pedirlo te excluye del hardware que no lo tiene.
El coste de un render target adicional se estima siempre en ancho de banda: una textura más que escribir, tantos megabytes por fotograma. En el hardware de escritorio esa cuenta es correcta. En una GPU de tiles —todo lo móvil y buena parte de las integradas— hay un segundo efecto, mayor y completamente invisible desde el API. El renderizador de tiles divide la pantalla en bloques y procesa uno cada vez manteniendo todos sus attachments en memoria interna del chip. El tamaño del bloque no es fijo: el driver lo elige para que quepa en esa memoria, y por tanto es inversamente proporcional al número de bytes por píxel de tus attachments. Con un solo target de 4 bytes el tile puede ser de 32 por 32 píxeles; con un G-buffer de 16 bytes por píxel más profundidad, el mismo hardware baja a 16 por 16 o a 8 por 8. Y aquí está el golpe: antes de sombrear, un tiler tiene que repartir la geometría entre los tiles que toca, y un triángulo que cubre una región de pantalla se procesa una vez por cada tile que interseca. Al dividir por cuatro el área del tile, el número de tiles se multiplica por cuatro y el coste de esa fase de reparto crece en la misma proporción. El resultado medido es característico y desconcierta a quien no lo espera: añades un target de 8 bytes a un pase que ya iba bien, el ancho de banda sube un 30% como esperabas, y el tiempo de fotograma sube un 80% porque la fase de geometría se ha encarecido sin que hayas tocado ni un vértice. Las dos consecuencias operativas son claras. Empaqueta agresivamente: una normal cabe en dos canales de 16 bits con codificación octaédrica y no necesita tres, y la rugosidad y el metálico caben en el alfa de otro target; cada byte por píxel que ahorras compra tamaño de tile. Y si tienes que elegir entre un G-buffer gordo y dos pasadas de geometría más ligeras, en móvil la respuesta no es obvia y hay que medirla, mientras que en escritorio casi siempre gana el G-buffer.
Attachments nulos y máscaras
Dos mecanismos permiten que un mismo pipeline sirva para pasadas que escriben en distintos subconjuntos de destinos.
El primero es que tanto targets como colorAttachments admiten valores nulos en posiciones concretas. Un null en targets significa que esa localización existe en el shader pero no se escribe en ninguna parte; un null en colorAttachments significa que ese hueco del pass no tiene destino. Los índices de los demás no se desplazan: el hueco se mantiene. Sirve para reutilizar un shader que produce cuatro salidas en una pasada que solo necesita dos.
El segundo es el writeMask de cada target, que es independiente por attachment. Un pase de solo profundidad y de solo identificador se puede montar con el mismo pipeline poniendo el writeMask de los targets de color a cero, aunque conviene recordar que enmascarar la escritura no ahorra el sombreado: el fragment shader se ejecuta igual y calcula igual. Si lo que quieres es ahorrar trabajo y no solo escritura, lo correcto es otro pipeline con otro shader, o directamente un pipeline sin bloque fragment.
Y cada target lleva también su propio bloque blend, con configuraciones distintas entre attachments del mismo pass. Es exactamente lo que necesita weighted blended OIT, que acumula con suma en un destino y multiplica en el otro simultáneamente; la configuración completa está en las técnicas independientes del orden.
Añade a tu escena un attachment r32uint con el identificador del objeto y una copia de una región de un píxel a un búfer de lectura. Con eso tienes selección por ratón exacta, sin trazado de rayos en la CPU y sin volúmenes envolventes, con la silueta real de la geometría incluidos los recortes por alfa. Es una de las funcionalidades con mejor relación entre líneas de código y utilidad que puedes añadir a un visor.