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

Alfa premultiplicado y el halo oscuro que no debería estar ahí

Qué guarda cada una de las dos convenciones de alfa, por qué el filtrado bilineal produce bordes sucios con alfa recto, y dónde se premultiplica en un pipeline de WebGPU.

⏱ 18 min

Hay dos formas de guardar un píxel translúcido y solo una de ellas sobrevive a que la interpolen. La diferencia entre ambas es una multiplicación, parece un detalle de formato y no lo es: es la razón por la que los sprites recortados tienen un borde oscuro, por la que los mipmaps de una textura con transparencia salen sucios, y por la que el compositor del navegador espera exactamente una de las dos y no la otra. Entender por qué el alfa premultiplicado es la representación correcta y no solo una convención alternativa cambia cómo montas el pipeline de assets.

🎯 Al terminar esta lección sabrás
  • Distinguir alfa recto de alfa premultiplicado en términos de qué número guarda cada canal.
  • Explicar por qué la interpolación bilineal produce halos con alfa recto y no con premultiplicado.
  • Premultiplicar en el punto correcto del pipeline: carga, subida a textura o shader.
  • Configurar alphaMode del canvas de forma coherente con lo que escribe el fragment shader.

Dos convenciones para el mismo píxel

Con alfa recto —el que produce un editor de imágenes y el que guarda un PNG— los tres canales de color son el color del objeto, tal cual, y el alfa es una cobertura almacenada aparte. Un rojo puro al 50% de opacidad es (1, 0, 0, 0.5).

Con alfa premultiplicado, los canales de color ya vienen multiplicados por el alfa. El mismo rojo al 50% es (0.5, 0, 0, 0.5).

La operación de composición es la misma en ambos casos, resultado = C_s * a_s + C_d * (1 - a_s), pero en el caso premultiplicado la primera multiplicación ya está hecha, y por eso el factor de la fuente pasa de src-alpha a one:

// Alfa recto
{ color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
  alpha: { srcFactor: 'one',       dstFactor: 'one-minus-src-alpha' } }

// Alfa premultiplicado: los dos bloques identicos, y ahi esta la pista
{ color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
  alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' } }

Que los dos bloques coincidan no es una comodidad estética. Significa que con alfa premultiplicado el color y el alfa se componen con la misma operación lineal, y una operación lineal conmuta con cualquier promedio ponderado. Esa es la propiedad entera de la que sale todo lo demás.

De dónde sale el halo

El hardware de texturas no lee un téxel, lee cuatro y los promedia, con pesos que dependen de la posición subpíxel. El filtrado bilineal, los mipmaps y el filtrado anisotrópico son todos promedios ponderados de los valores almacenados.

Considera el borde de un sprite. Un téxel dentro vale (1, 0, 0, 1) en alfa recto: rojo opaco. El téxel de fuera es transparente, y como es transparente, la herramienta que generó el PNG puso su color a negro, porque el color de un píxel invisible no importa: (0, 0, 0, 0).

Ahora promedia los dos. El color medio es (0.5, 0, 0), un rojo a media intensidad, y el alfa medio es 0.5. Al componer con alfa recto obtienes 0.5 * 0.5 = 0.25 de rojo sobre el fondo. Lo correcto sería 0.5: medio téxel de rojo puro cubriendo medio píxel. Has perdido la mitad de la energía y el borde se ve oscuro. Ese es el halo, y aparece exactamente en la frontera del alfa, que es donde más se mira.

Con premultiplicado, el téxel de dentro vale (1, 0, 0, 1) y el de fuera (0, 0, 0, 0). El promedio es (0.5, 0, 0, 0.5), que ya es el resultado premultiplicado correcto y se compone con factor one: 0.5 de rojo. Exacto.

La formulación general del argumento merece enunciarse porque explica de golpe todos los casos: componer es lineal en los valores premultiplicados y no lo es en los valores rectos. Cualquier promedio —filtrado bilineal, generación de mipmaps, resolución de multimuestreo, downsampling de un target— conmuta con la composición si los datos están premultiplicados y la corrompe si no lo están. El halo no es un defecto del filtrado: es la consecuencia de filtrar en un espacio donde la operación que viene después no es lineal.

Con alfa premultiplicado, aditivo y over dejan de ser dos modos y pasan a ser dos valores del mismo dato

Aquí está la consecuencia que casi nunca se cuenta y que cambia el diseño de un sistema de partículas. Con alfa premultiplicado, un fragmento con a = 0 y rgb distinto de cero es perfectamente válido: significa «añade esta energía sin tapar nada», que es exactamente lo que hace la mezcla aditiva. Y un fragmento con rgb = a * color y a grande es la composición over clásica. Los dos casos los calcula la misma ecuación, src + dst * (1 - a_s), sin cambiar de pipeline: si a_s vale cero, el término del destino se conserva íntegro y estás sumando; si vale uno, el destino desaparece y estás sustituyendo; y todos los valores intermedios son una interpolación continua entre ambos comportamientos. El efecto práctico es enorme. Un sistema de partículas normal necesita dos pipelines —uno aditivo para el fuego y las chispas, otro alfa para el humo— y con ellos dos lotes de dibujo, dos ordenaciones y una decisión por partícula sobre a cuál pertenece. Con premultiplicado es un solo pipeline y un solo lote, y el artista controla el carácter de cada partícula moviendo un número en la textura o en el color por instancia. Humo que se enciende y pasa a ser fuego sin cambiar de material. Y hay un remate: como el aditivo puro es independiente del orden y el over no lo es, tener las dos cosas en el mismo lote significa que la sensibilidad al orden de tu sistema de partículas se vuelve proporcional a cuánto alfa opaco tenga, y bajar ese alfa es una decisión artística barata que compra corrección. Los motores que hacen esto llaman a la técnica premultiplied blending o blend mode unificado, y quien no la conoce acaba escribiendo el doble de código para la mitad del resultado.

Dónde se premultiplica

Hay tres sitios, y elegir mal cuesta o rendimiento o corrección.

Al decodificar la imagen. createImageBitmap() acepta la opción premultiplyAlpha, con valores 'none', 'premultiply' y 'default'. Pedir 'premultiply' hace el trabajo en el decodificador del navegador, que es código nativo, y llega ya listo a la GPU.

Al subir la textura. El destino de copyExternalImageToTexture() acepta la bandera premultipliedAlpha. Puesta a true, la copia premultiplica durante la subida.

const bitmap = await createImageBitmap(blob, { premultiplyAlpha: 'premultiply' });

const textura = device.createTexture({
  label: 'sprite',
  size: [bitmap.width, bitmap.height],
  format: 'rgba8unorm-srgb',
  usage: GPUTextureUsage.TEXTURE_BINDING
       | GPUTextureUsage.COPY_DST
       | GPUTextureUsage.RENDER_ATTACHMENT,
});

device.queue.copyExternalImageToTexture(
  { source: bitmap, flipY: false },
  { texture: textura, premultipliedAlpha: true },
  [bitmap.width, bitmap.height],
);

En el fragment shader. Multiplicar en el shader es la opción de emergencia, y es incorrecta para el problema que nos ocupa: cuando el shader recibe el valor, el filtrado bilineal ya ha ocurrido sobre datos rectos y el halo ya está dentro. Multiplicar después no lo quita. Sirve solo cuando el color no viene de una textura filtrada, por ejemplo si es un color plano por instancia.

@fragment
fn fs(in: VsOut) -> @location(0) vec4f {
  // Correcto: la textura ya esta premultiplicada, el color por instancia no.
  let tex = textureSample(sprite, muestreador, in.uv);
  let tinte = in.color.rgb * in.color.a;   // premultiplicamos el tinte
  return vec4f(tex.rgb * tinte, tex.a * in.color.a);
}

Queda un matiz que separa lo correcto de lo aproximadamente correcto: la premultiplicación y la codificación sRGB no conmutan. Multiplicar por el alfa es una operación lineal, y los valores de una textura rgba8unorm-srgb están guardados con una curva no lineal. Lo riguroso sería linealizar, multiplicar y volver a codificar; lo que hacen el navegador y prácticamente todo el mundo es multiplicar sobre los valores codificados. El error es pequeño y visualmente invisible en la mayoría de los casos, pero existe, es sistemático, y si alguna vez comparas tu composición contra una hecha en un compositor de vídeo y ves una diferencia de medio nivel en los bordes, esta es la razón.

El canvas y la página

La configuración del contexto tiene un campo alphaMode con exactamente dos valores, y su valor por defecto es 'opaque'.

Con 'opaque', el canal alfa de la textura del canvas se ignora: el navegador trata el canvas como opaco y lo que hay detrás en la página no se ve nunca. Es lo que quieres para una escena a pantalla completa y tiene la ventaja de que el compositor puede saltarse la mezcla.

Con 'premultiplied', el canvas se compone sobre la página, y el navegador exige que los valores estén premultiplicados. Un valor con rgb mayor que a está fuera del dominio válido y el resultado no está definido: en la práctica verás recortes y colores lavados en los bordes. Es el fallo típico de quien activa la transparencia del canvas para superponer WebGPU sobre HTML y arrastra un pipeline con alfa recto.

context.configure({
  device,
  format: navigator.gpu.getPreferredCanvasFormat(),
  alphaMode: 'premultiplied',   // el canvas se compone con la pagina
});

Y la comprobación mental de dos segundos: si alphaMode es 'premultiplied', el último pipeline que escribe al canvas tiene que producir valores premultiplicados. Si dibujas un rojo a media opacidad, el fragment shader debe devolver vec4f(0.5, 0.0, 0.0, 0.5), no vec4f(1.0, 0.0, 0.0, 0.5).