wandres.dev
BLENDING Y TRANSPARENCIA · El orden que siempre duele

La ecuación de mezcla: cinco operaciones y diecisiete factores

El bloque blend del descriptor de pipeline campo a campo, la lista completa de GPUBlendFactor y GPUBlendOperation con sus nombres exactos, y las reglas de validación que rechazan combinaciones que parecen razonables.

⏱ 19 min

El mezclado es la última etapa de función fija del pipeline gráfico y la única que lee lo que ya había en el framebuffer. Su configuración cabe en seis valores por cada attachment de color, pero esos seis valores se eligen de dos enumeraciones cuyos nombres son largos, parecidos entre sí y muy fáciles de confundir. Equivocarse no produce un error: produce una imagen distinta de la que esperabas, y averiguar cuál de los diecisiete factores has escrito mal mirando el resultado es de las tareas más ingratas que hay.

🎯 Al terminar esta lección sabrás
  • Escribir la ecuación de mezcla que ejecuta el hardware y localizar cada término en el descriptor.
  • Enumerar los valores exactos de GPUBlendFactor y GPUBlendOperation y la fórmula de cada uno.
  • Configurar por separado el componente de color y el de alfa sin copiar el uno en el otro.
  • Reconocer las reglas de validación que rechazan combinaciones aparentemente válidas.

La ecuación

Cada target de color de un render pipeline puede llevar un objeto blend. Si no lo lleva, el fragmento sobrescribe lo que hubiera. Si lo lleva, el hardware calcula, por separado para el color y para el alfa:

resultado_rgb = operacion_color( src.rgb * srcFactor , dst.rgb * dstFactor )
resultado_a   = operacion_alfa ( src.a   * srcFactor , dst.a   * dstFactor )

Donde src es lo que devuelve el fragment shader y dst es lo que ya había en el attachment. La estructura del descriptor refleja esa separación literalmente:

const pipeline = device.createRenderPipeline({
  layout: 'auto',
  vertex: { module, entryPoint: 'vs' },
  fragment: {
    module,
    entryPoint: 'fs',
    targets: [{
      format: navigator.gpu.getPreferredCanvasFormat(),
      blend: {
        color: { operation: 'add', srcFactor: 'src-alpha',
                 dstFactor: 'one-minus-src-alpha' },
        alpha: { operation: 'add', srcFactor: 'one',
                 dstFactor: 'one-minus-src-alpha' },
      },
      writeMask: GPUColorWrite.ALL,
    }],
  },
  primitive: { topology: 'triangle-list' },
});

blend.color y blend.alpha son ambos obligatorios si escribes blend. No hay herencia: omitir alpha no lo hace igual a color, hace que el descriptor sea inválido. Dentro de cada uno, los tres campos tienen valor por defecto: operation vale 'add', srcFactor vale 'one' y dstFactor vale 'zero'.

Esos valores por defecto son el operador identidad. Un blend: { color: {}, alpha: {} } calcula src * 1 + dst * 0, que es exactamente sobrescribir. Sirve como referencia mental: cualquier configuración de mezcla es una desviación de one y zero.

Y conviene decirlo pronto: no es lo mismo poner el bloque neutro que no poner blend. Omitirlo le dice al backend que la unidad de mezcla está apagada, y algunas implementaciones pueden entonces saltarse la lectura del destino. El bloque neutro obliga a leer el destino para multiplicarlo por cero.

Los diecisiete factores

Esta es la lista completa de GPUBlendFactor, en el orden en que la declara la especificación. S es el color de la fuente, D el del destino, C la constante de mezcla y S1 la segunda fuente del sombreado de doble origen.

Valor Factor para RGB Factor para alfa
zero 0 0
one 1 1
src S.rgb S.a
one-minus-src 1 - S.rgb 1 - S.a
src-alpha S.a S.a
one-minus-src-alpha 1 - S.a 1 - S.a
dst D.rgb D.a
one-minus-dst 1 - D.rgb 1 - D.a
dst-alpha D.a D.a
one-minus-dst-alpha 1 - D.a 1 - D.a
src-alpha-saturated min(S.a, 1 - D.a) 1
constant C.rgb C.a
one-minus-constant 1 - C.rgb 1 - C.a
src1 S1.rgb S1.a
one-minus-src1 1 - S1.rgb 1 - S1.a
src1-alpha S1.a S1.a
one-minus-src1-alpha 1 - S1.a 1 - S1.a

Fíjate en la única fila asimétrica: src-alpha-saturated vale 1 en el componente de alfa, no min(S.a, 1 - D.a). Es una herencia directa de OpenGL y de Vulkan, existe para que el operador de acumulación de polígonos ordenados de delante a atrás dé un alfa correcto, y es el detalle que hace que ese factor funcione cuando lo copias de un ejemplo antiguo y no entiendas por qué.

Los cuatro factores que empiezan por src1 leen una segunda salida del fragment shader. Requieren la feature dual-source-blending, que hay que pedir al crear el dispositivo, obligan a que haya exactamente un target de color y a que el shader declare las dos salidas con el atributo @blend_src de WGSL. Sirven para el mezclado subpíxel de texto y para máscaras de color por canal. Sin la feature, usarlos es un error de validación al crear el pipeline.

Los dos factores de constante leen un color que no está en el pipeline sino en el pass, fijado con pass.setBlendConstant(color). Acepta un array de cuatro números o un objeto con r, g, b y a. Su valor inicial es (0, 0, 0, 0) en cada render pass, así que si lo pones una vez y abres un pass nuevo, vuelve a cero. Es el mismo diseño que el valor de referencia del estarcido, y tiene la misma consecuencia: setBlendConstant no se puede grabar en un render bundle.

Las cinco operaciones

GPUBlendOperation tiene exactamente cinco valores:

Valor Resultado
add src * srcFactor + dst * dstFactor
subtract src * srcFactor - dst * dstFactor
reverse-subtract dst * dstFactor - src * srcFactor
min min(src, dst)
max max(src, dst)

Y aquí está la regla de validación que más gente descubre por las malas: si operation es min o max, tanto srcFactor como dstFactor tienen que valer one. El hardware ignora los factores en esas dos operaciones, así que la especificación exige que declares explícitamente que no los usas. Un min con src-alpha no es una mezcla exótica, es un pipeline inválido.

Con eso, las combinaciones que aparecen en cualquier renderizador se escriben así:

// "Over" con alfa recto: lo que produce un fragment shader normal.
const over = { color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
               alpha: { srcFactor: 'one',       dstFactor: 'one-minus-src-alpha' } };

// "Over" con alfa premultiplicado. El de la leccion siguiente.
const overPremultiplicado =
              { color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
                alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' } };

// Aditivo: fuego, chispas, luz. Independiente del orden.
const aditivo = { color: { srcFactor: 'one', dstFactor: 'one' },
                  alpha: { srcFactor: 'one', dstFactor: 'one' } };

// Multiplicativo: sombras de contacto, tintado. Tambien independiente del orden.
const multiplicativo = { color: { srcFactor: 'dst', dstFactor: 'zero' },
                         alpha: { srcFactor: 'dst', dstFactor: 'zero' } };

// Maximo: acumular la envolvente de varias contribuciones sin saturar.
const maximo = { color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
                 alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' } };
El componente de alfa casi nunca lleva los mismos factores que el color, y el bug se esconde hasta que renderizas a textura

La configuración que circula por todas partes para transparencia es src-alpha y one-minus-src-alpha, y muchísima gente la copia en los dos bloques, color y alfa. Con el alfa recto, el resultado correcto de componer una fuente sobre un destino es a_out = a_s + a_d * (1 - a_s), es decir, factor one para la fuente. Si pones src-alpha en el bloque de alfa, lo que calculas es a_s² + a_d * (1 - a_s): un alfa sistemáticamente demasiado bajo, y cuadráticamente peor cuanto más translúcido sea el objeto. Nadie lo nota, y hay una razón muy concreta para ello: el canvas configurado con alphaMode: 'opaque' tira el canal alfa a la basura. Estás escribiendo basura en un canal que nadie lee. El día que rediriges ese mismo pipeline a una textura intermedia —un pase de transparencias que luego se compone, un target de post-proceso, una capa que se mezcla con la página con alphaMode: 'premultiplied'— la basura entra en la ecuación y aparece un halo translúcido alrededor de todo lo que tenga alfa parcial. El síntoma clásico es «se ve bien en pantalla y mal cuando añado bloom». La regla que hay que grabarse: el bloque alpha de una composición over lleva one y one-minus-src-alpha, siempre, tanto con alfa recto como con premultiplicado. Los dos bloques solo coinciden en el caso premultiplicado, y eso no es casualidad: es la propiedad que hace que el alfa premultiplicado sea la representación buena.

Lo que la validación no te deja hacer

Cuatro reglas más, todas comprobadas al crear el pipeline, y todas con una razón de hardware detrás.

No se puede mezclar en formatos enteros. Un target r32uint o rgba8sint rechaza el bloque blend. La unidad de mezcla opera en coma flotante normalizada; los formatos enteros existen precisamente para saltársela y escribir identificadores, índices o contadores sin que nadie los interpole ni los combine.

Los formatos de 32 bits por canal en coma flotante necesitan una feature. r32float, rg32float y rgba32float solo admiten mezcla si el dispositivo tiene float32-blendable. Es una de las pocas features que conviene comprobar antes de diseñar un acumulador: si tu técnica de OIT quiere acumular en rgba32float con mezcla aditiva y el adaptador no la trae, tienes que bajar a rgba16float.

Si algún factor lee el alfa de la fuente, la salida del fragment shader tiene que tener cuatro componentes. Un shader que devuelve vec3f no puede usar src-alpha ni one-minus-src-alpha. Suena obvio dicho así, pero el error aparece con la forma «el pipeline no compila» cuando lo que has cambiado es el descriptor, no el shader.

El writeMask es independiente de la mezcla y se aplica después. Vale GPUColorWrite.ALL por defecto, que es 0xF, y las constantes son GPUColorWrite.RED, .GREEN, .BLUE, .ALPHA y .ALL, combinables con la barra vertical. Un writeMask: GPUColorWrite.ALPHA deja pasar solo el canal alfa, y esa es la forma canónica de escribir una máscara de cobertura sin tocar el color, o de hacer un pase que solo actualiza el alfa acumulado. Ojo con una consecuencia poco intuitiva: la mezcla se calcula igualmente para los canales enmascarados, así que el writeMask ahorra escritura pero no cálculo.

⚔️ Reto práctico

Monta un pipeline con operation: 'reverse-subtract', srcFactor: 'one' y dstFactor: 'one', y dibuja un quad blanco sobre un fondo gris. Obtendrás el negativo de la zona cubierta, que es la forma más barata de un efecto de inversión sin leer la textura de destino como recurso. Después intenta cambiar la operación a min sin tocar los factores y comprueba que el pipeline se rechaza: es la regla de validación de esta lección con nombre y apellidos.