Los bloques vertex y fragment: buffers, targets y blending
El layout de vertex buffers con arrayStride, attributes y stepMode; los color targets con su formato, su writeMask y el estado de blending completo con sus factores y operaciones.
Los dos bloques programables del pipeline declaran mucho más que un módulo y un nombre de función: vertex describe la disposición exacta de los bytes que entran, y fragment describe qué sale y cómo se combina con lo que ya había. Entre los dos suman la mitad del descriptor, y son donde vive la configuración que más se toca cuando el renderer crece.
- Escribir un
vertex.bufferscompleto conarrayStride,attributesystepMode. - Elegir el formato de atributo correcto y calcular los offsets de un vértice entrelazado.
- Declarar varios color targets con formatos y máscaras de escritura distintas.
- Configurar el estado de blending con sus factores y operaciones separados para color y alfa.
El layout de vertex buffers
vertex.buffers es un array donde cada elemento describe un buffer de vértices, y su posición en el array es el índice de setVertexBuffer.
vertex: {
module: modulo,
entryPoint: 'vs',
buffers: [
{
// Buffer 0: geometría entrelazada, avanza por vértice.
arrayStride: 32,
stepMode: 'vertex', // por defecto
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x3' }, // posición
{ shaderLocation: 1, offset: 12, format: 'float32x3' }, // normal
{ shaderLocation: 2, offset: 24, format: 'float32x2' }, // uv
],
},
{
// Buffer 1: datos por instancia, avanza por instancia.
arrayStride: 16,
stepMode: 'instance',
attributes: [
{ shaderLocation: 3, offset: 0, format: 'float32x3' }, // desplazamiento
{ shaderLocation: 4, offset: 12, format: 'float32' }, // escala
],
},
],
}
arrayStride es la distancia en bytes entre dos elementos consecutivos del buffer. Tiene que ser múltiplo de 4 y no puede superar maxVertexBufferArrayStride, cuyo mínimo garantizado es 2048.
stepMode decide si el índice avanza por vértice ('vertex', por defecto) o por instancia ('instance'). Es todo el mecanismo de instancing por atributos: el mismo draw(cuenta, instancias) recorre el buffer 0 una vez por vértice y el buffer 1 una vez por instancia.
shaderLocation es el número del @location(n) en el WGSL. Los locations tienen que ser únicos entre todos los buffers, no solo dentro de uno, y están acotados por maxVertexAttributes, con mínimo garantizado de 16.
offset es la posición del atributo dentro de cada elemento. La suma de offset y el tamaño del formato no puede pasar de arrayStride.
Del lado del WGSL, la correspondencia es directa:
struct Entrada {
@location(0) posicion : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) uv : vec2<f32>,
@location(3) despl : vec3<f32>,
@location(4) escala : f32,
};
Poner posición, normal y UV en un buffer entrelazado es lo correcto cuando el vertex shader usa los tres, porque las tres lecturas caen en la misma línea de caché. Separarlos en buffers distintos gana cuando hay pases que solo usan la posición —sombras, pre-pass de profundidad—, porque entonces esos pases leen solo el buffer de posiciones y no arrastran normales que no usan. Un motor maduro suele tener las posiciones en su buffer y el resto entrelazado en otro.
Los formatos de atributo
GPUVertexFormat es un enum largo pero regular. Los nombres siguen el patrón tipo más bits más x más componentes:
| Familia | Ejemplos | Qué llega al shader |
|---|---|---|
uint8, uint16, uint32 |
uint8x2, uint16x4, uint32x3 |
u32 sin conversión |
sint8, sint16, sint32 |
sint8x4, sint16x2 |
i32 sin conversión |
unorm8, unorm16 |
unorm8x4, unorm16x2 |
f32 en [0, 1] |
snorm8, snorm16 |
snorm8x4, snorm16x4 |
f32 en [-1, 1] |
float16, float32 |
float16x2, float32x4 |
f32 |
| Especiales | unorm10-10-10-2, unorm8x4-bgra |
f32 en [0, 1] |
El tipo que declares en el WGSL tiene que ser compatible: un formato unorm8x4 produce un vec4<f32>, no un vec4<u32>. Los formatos uint y sint producen enteros y hay que declararlos como tales.
La utilidad práctica de esta tabla es la compresión de vértices. Una normal en float32x3 ocupa 12 bytes; la misma normal en snorm8x4 ocupa 4 y tiene precisión más que suficiente. Un color en float32x4 ocupa 16 bytes; en unorm8x4 ocupa 4. En una malla de un millón de vértices, pasar de 32 a 16 bytes por vértice ahorra 16 MiB y, más importante, duplica la eficiencia del caché de vértices.
Los color targets
fragment.targets es un array donde cada elemento corresponde a un attachment de color del render pass, en el mismo orden y con el mismo formato.
fragment: {
module: modulo,
entryPoint: 'fs',
targets: [
{ format: 'rgba16float' }, // @location(0)
{ format: 'rgba8unorm', writeMask: GPUColorWrite.ALL }, // @location(1)
{ format: 'rg16float',
writeMask: GPUColorWrite.RED | GPUColorWrite.GREEN }, // @location(2)
],
}
El número de targets está acotado por maxColorAttachments, con mínimo garantizado de 8. Cada uno se corresponde con una salida @location(n) del fragment shader.
writeMask es una máscara de bits con GPUColorWrite.RED, .GREEN, .BLUE, .ALPHA y .ALL, y por defecto es .ALL. Sirve para escribir solo algunos canales, que es lo que se usa para acumular en un canal sin tocar los otros, o para un pass que solo actualiza el alfa.
Un target puede ser null si el pipeline no escribe a ese attachment pero el pass sí lo tiene. Es la forma de tener un pipeline compatible con un pass de varios attachments que solo escribe a uno.
El estado de blending
blend es opcional en cada target, y si se omite, no hay mezcla: el color del fragmento sustituye al que había. Cuando se declara, tiene dos componentes independientes, color y alpha, cada uno con tres campos:
{
format: 'bgra8unorm',
blend: {
color: { operation: 'add', srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
alpha: { operation: 'add', srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
},
}
operation combina los dos términos ya multiplicados por sus factores, y admite 'add', 'subtract', 'reverse-subtract', 'min' y 'max'. Con 'min' y 'max' los factores se ignoran.
srcFactor multiplica el valor que sale del fragment shader; dstFactor multiplica el que ya está en el attachment. Los valores son 'zero', 'one', 'src', 'one-minus-src', 'src-alpha', 'one-minus-src-alpha', 'dst', 'one-minus-dst', 'dst-alpha', 'one-minus-dst-alpha', 'src-alpha-saturated', 'constant' y 'one-minus-constant'. Por defecto, srcFactor es 'one' y dstFactor es 'zero', que equivale a no mezclar.
Los factores 'constant' y 'one-minus-constant' usan un color que se fija en el pass con setBlendConstant(color), no en el pipeline. Es la única parte del estado de mezcla que no está congelada.
Hay cuatro factores adicionales —'src1', 'one-minus-src1', 'src1-alpha' y 'one-minus-src1-alpha'— que requieren la feature dual-source-blending y permiten que el fragment shader emita dos colores para el mismo target, lo que hace posible el antialiasing de texto subpíxel.
Las tres configuraciones que cubren el 95 % de los casos:
// Alfa clásico: el fragmento se mezcla según su propio alfa.
const alfaNormal = {
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
};
// Alfa premultiplicado: el color ya viene multiplicado por su alfa.
const premultiplicado = {
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
};
// Aditivo: para fuego, destellos y partículas luminosas. No necesita ordenación.
const aditivo = {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
};
No todos los formatos son mezclables. Los formatos enteros nunca lo son, y r32float, rg32float y rgba32float solo lo son si el dispositivo expone la feature float32-blendable. Declarar blend sobre un formato no mezclable es un error de validación al crear el pipeline. Y si algún factor usa el canal alfa de la fuente, la salida del shader para ese target tiene que ser un vec4, no un vec3.
La discusión entre alfa clásico y premultiplicado se plantea como cuestión de gustos y no lo es: el premultiplicado es matemáticamente correcto y el clásico no, en cuanto entra en juego el filtrado. La razón es que interpolar dos texels con alfa no premultiplicado interpola el color y el alfa por separado, y el resultado no es el color que corresponde a la mezcla. Un borde entre un píxel rojo opaco y uno transparente cuyo color RGB sea negro —lo habitual en un PNG— produce, al filtrar, un píxel rojo oscuro semitransparente en vez de un rojo semitransparente. Es el clásico halo oscuro alrededor de los sprites, y no se arregla con ningún ajuste de mezcla: se arregla premultiplicando antes de filtrar. Lo mismo ocurre al generar mipmaps, donde el problema se agrava en cada nivel. La cadena correcta es premultiplicar al cargar —premultipliedAlpha: true en copyExternalImageToTexture lo hace por ti—, generar mipmaps sobre los datos premultiplicados, y usar el estado de mezcla premultiplicado. A partir de ahí el filtrado, el mipmapping y la composición de varias capas son todos correctos, y desaparece una familia entera de artefactos que se suelen achacar a los assets.