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

OIT: weighted blended, depth peeling y listas por píxel

Las tres familias de transparencia independiente del orden, con la configuración exacta de sus attachments y el coste que pagas por cada una en memoria, pasadas y precisión.

⏱ 22 min

Si el problema es que el operador de composición no conmuta, hay dos salidas: cambiar el operador por uno que sí conmute, o guardar todos los fragmentos y ordenarlos por píxel en lugar de por objeto. La primera vía da una aproximación barata y la segunda una solución exacta y cara, y entre ambas hay una familia de compromisos que la industria lleva veinte años explorando. Ninguna es gratis, ninguna es universalmente mejor, y saber cuánto cuesta cada una es más útil que saber implementarlas.

🎯 Al terminar esta lección sabrás
  • Montar weighted blended OIT con sus dos attachments y sus dos configuraciones de mezcla.
  • Explicar qué aproxima esa técnica y en qué escenas la aproximación se rompe.
  • Describir depth peeling y calcular su coste en función del número de capas.
  • Justificar por qué las listas enlazadas por píxel son exactas y por qué casi nadie las usa.

Weighted blended OIT

La idea de McGuire y Bavoil, publicada en 2013, es sustituir la composición ordenada por una media ponderada conmutativa más un término de revelado. En lugar de aplicar over en el orden correcto, acumulas todos los fragmentos con una suma —que conmuta— y guardas aparte cuánta luz del fondo sobrevive.

Se necesitan dos attachments, con mezclas distintas:

const passOIT = encoder.beginRenderPass({
  colorAttachments: [
    { // 0: acumulacion. Suma pura, empieza en cero.
      view: acumView,
      clearValue: { r: 0, g: 0, b: 0, a: 0 },
      loadOp: 'clear', storeOp: 'store',
    },
    { // 1: revelado. Producto de los (1 - a), empieza en uno.
      view: revelView,
      clearValue: { r: 1, g: 1, b: 1, a: 1 },
      loadOp: 'clear', storeOp: 'store',
    },
  ],
  depthStencilAttachment: { view: depthView, depthReadOnly: true },
});

Y el pipeline declara dos targets con mezclas independientes. Esta es la parte que hay que copiar con cuidado:

targets: [
  { format: 'rgba16float',
    blend: { color: { srcFactor: 'one', dstFactor: 'one' },
             alpha: { srcFactor: 'one', dstFactor: 'one' } } },
  { format: 'r8unorm',
    blend: { color: { srcFactor: 'zero', dstFactor: 'one-minus-src' },
             alpha: { srcFactor: 'zero', dstFactor: 'one-minus-src' } } },
]

El segundo target multiplica el destino por 1 - src y descarta la fuente: es exactamente el producto acumulado de los factores de transmisión. El primero suma. Las dos operaciones son conmutativas, así que el orden de dibujado deja de importar.

El shader escribe las dos salidas y aplica el peso:

struct SalidaOIT {
  @location(0) acumulacion : vec4f,
  @location(1) revelado    : vec4f,
};

@fragment
fn fs(in: VsOut) -> SalidaOIT {
  let color = material(in);          // rgb sin premultiplicar, a = opacidad
  let z = in.pos.z;                  // profundidad del fragmento

  // Peso: mas cerca pesa mas. Los coeficientes son de la familia que propone
  // el articulo original y hay que ajustarlos a la escala de la escena.
  let w = clamp(pow(min(1.0, color.a * 10.0) + 0.01, 3.0)
                * 1e8 * pow(1.0 - z * 0.9, 3.0), 1e-2, 3e3);

  var out : SalidaOIT;
  out.acumulacion = vec4f(color.rgb * color.a, color.a) * w;
  out.revelado    = vec4f(color.a, 0.0, 0.0, 0.0);
  return out;
}

La resolución es una pasada de pantalla completa que reconstruye el color medio dividiendo por el peso total y lo compone sobre el fondo con el factor de revelado:

@fragment
fn resolver(@builtin(position) p: vec4f) -> @location(0) vec4f {
  let acum = textureLoad(acumulacion, vec2i(p.xy), 0);
  let rev  = textureLoad(revelado,    vec2i(p.xy), 0).r;
  // El max evita dividir por cero donde no hubo ningun fragmento.
  let medio = acum.rgb / max(acum.a, 1e-5);
  return vec4f(medio, rev);
}

Esa pasada se dibuja sobre la escena opaca con srcFactor: 'one-minus-src-alpha' y dstFactor: 'src-alpha': donde el revelado vale uno el fondo pasa entero, donde vale cero manda la media acumulada.

Lo que aproxima es fácil de enunciar: sustituye la composición ordenada por una media ponderada por profundidad. Con dos o tres capas de opacidad moderada el resultado es casi indistinguible del correcto. Se rompe cuando hay muchas capas muy opacas —la media las mezcla todas y el cristal pierde la sensación de superficie— y cuando la escena abarca un rango de profundidades enorme, porque entonces ninguna función de peso separa bien lo cercano de lo lejano.

Depth peeling

La técnica de Cass Everitt, de 2001, es la fuerza bruta honesta: si el problema es que hay varios fragmentos por píxel, extráelos de uno en uno, en orden.

La primera pasada dibuja normalmente con prueba de profundidad y obtiene la capa más cercana. La segunda repite el dibujado entero, pero descarta todo fragmento cuya profundidad sea menor o igual que la que guardó la pasada anterior: obtiene así la segunda capa más cercana. Repetir N veces da las N capas frontales, en orden, y componerlas es trivial porque el orden es exacto.

El coste es igual de fácil de enunciar: N pasadas de geometría completa. Todo: transformación de vértices, rasterización, sombreado. Cuatro capas cuestan cuatro veces el renderizado de la geometría transparente, más dos texturas de profundidad para alternar entre ellas, más el descarte por píxel que desactiva la prueba temprana de profundidad —el mecanismo está en early-Z y lo que lo desactiva—.

La variante de Bavoil y Myers de 2008, dual depth peeling, extrae simultáneamente la capa más cercana y la más lejana usando un target rg32float con la operación de mezcla max, aprovechando que guardar el máximo de la profundidad y el máximo de su negativo equivale a mantener un intervalo. Con eso, el número de pasadas se reduce a la mitad. Es la razón por la que la operación max existe en la enumeración de mezcla y una de las pocas veces que se usa fuera de un efecto de post-proceso.

Depth peeling es exacto para las capas que extrae, y esa es su virtud: es la referencia contra la que se comparan las demás. Cuando dudes de si un artefacto de OIT es de la técnica o de tu código, renderiza la misma escena con seis capas de peeling y compara.

Listas enlazadas por píxel

La solución completa guarda todos los fragmentos y los ordena por píxel. Necesita tres recursos: una textura de enteros con el índice del primer nodo de cada píxel, un búfer grande de nodos con color, alfa, profundidad y el índice del siguiente, y un contador atómico que reparte posiciones en ese búfer.

El fragment shader, en lugar de mezclar, escribe: reserva un nodo con atomicAdd, guarda sus datos y engancha el nodo a la cabeza de la lista de su píxel con un intercambio atómico. Una pasada de resolución recorre después cada lista, la ordena en registros y aplica over en el orden correcto.

En WebGPU esto es implementable: los storage buffers con acceso de lectura y escritura están permitidos en la etapa de fragmento —no en la de vértice— y las operaciones atómicas sobre u32 están en el núcleo del lenguaje. Pero el precio es alto y conviene enumerarlo entero. La memoria no está acotada por la resolución sino por la complejidad de profundidad, así que hay que dimensionar el búfer para el peor caso y decidir qué hacer cuando se desborda. El shader escribe en memoria, lo que anula cualquier optimización de visibilidad temprana. La ordenación por píxel se hace en registros y su coste crece con el cuadrado del número de capas si usas inserción. Y en hardware de renderizado por tiles, que es todo el móvil, el patrón de escrituras dispersas desde el fragment shader es lo peor que se le puede pedir a la arquitectura.

La función de peso de weighted blended OIT es una constante mágica dependiente de la escala de tu escena, y por eso la técnica se ve preciosa en las demos y regular en tu aplicación

Weighted blended OIT se presenta siempre como «una función de peso y ya está», y esa frase esconde el trabajo entero. El peso depende de la profundidad del fragmento en unidades absolutas, y el artículo original propone varias funciones con coeficientes calibrados para escenas de entre 0,1 y 500 unidades de mundo. Si tu escena es un producto de veinte centímetros o una ciudad de dos kilómetros, esos coeficientes están fuera de rango y el resultado es característico: o todos los fragmentos pesan casi igual y la transparencia se ve lavada y sin profundidad, o el peso satura y la capa más cercana domina hasta hacer desaparecer todo lo demás. La técnica no es un componente que se enchufa, es un componente que se calibra, y hay que recalibrarlo si cambia el rango de la cámara. Peor: la calibración correcta depende también de la opacidad típica del contenido, porque el peso multiplica por el alfa. Dos consecuencias prácticas. La primera es que si adoptas la técnica, expón los coeficientes como parámetros de escena y no como constantes del shader; te ahorrará descubrirlos otra vez dentro de seis meses. La segunda es más útil todavía: normaliza la profundidad antes de pesar. Si en lugar de la profundidad absoluta usas una profundidad relativa al rango de la escena visible, o al intervalo entre el fragmento transparente más cercano y el más lejano del fotograma anterior, la función de peso se vuelve independiente de la escala y deja de necesitar calibración. Es una idea de diez líneas que casi ninguna implementación pública incorpora y que convierte una técnica frágil en una robusta.

Qué cuesta cada una

Técnica Memoria extra Pasadas de geometría Exactitud Móvil
Ordenar por objeto ninguna 1 falla con intersecciones y ciclos bien
Weighted blended 2 targets, rgba16float más r8unorm 1 más una resolución aproximada, calibrable aceptable
Depth peeling, N capas 2 texturas de profundidad N exacta hasta N capas caro
Dual depth peeling 1 target rg32float más los de color N partido por 2 exacta hasta N capas caro
Listas por píxel búfer sin cota fija más cabecera 1 más una resolución exacta muy malo

El árbol de decisión completo, con la opción barata que falta —el dithering estocástico—, cierra el nivel en la lección siguiente.