Dithering estocástico y el árbol de decisión de la transparencia
Cómo convertir la opacidad en una decisión binaria por píxel para que la transparencia viaje por el camino opaco, qué ruido usar, y el criterio completo para elegir técnica.
Todas las técnicas del nivel comparten un supuesto: que un fragmento translúcido tiene que mezclarse con lo que hay debajo. Hay una familia entera que rechaza ese supuesto y decide, píxel a píxel, que el fragmento es completamente opaco o completamente inexistente. A cambio de ruido obtiene algo que ninguna otra consigue: transparencia que funciona con el búfer de profundidad tal cual, sin ordenar, sin attachments extra y sin pasadas de resolución. Es la opción más barata y la que mejor se integra, y por eso está en casi todos los motores modernos aunque casi ningún tutorial la mencione.
- Implementar descarte estocástico con un hash estable y explicar qué aproxima.
- Elegir entre ruido azul, gradiente entrelazado y hash en espacio de objeto según el caso.
- Configurar
alphaToCoverageEnabledy calcular su resolución real de opacidad. - Recorrer el árbol de decisión completo de la transparencia y justificar cada rama.
Convertir la opacidad en una decisión
Un objeto con opacidad 0,3 se puede representar de dos formas: como un fragmento que deja pasar el 70% de la luz del fondo, o como un conjunto de píxeles del que el 30% son totalmente opacos y el resto no existen. A distancia, con suficientes píxeles, las dos se ven igual. La segunda es un problema resuelto: son píxeles opacos, y los píxeles opacos los ordena el búfer de profundidad.
El shader es de dos líneas:
@fragment
fn fs(in: VsOut) -> @location(0) vec4f {
let material = evaluarMaterial(in);
if (material.a < ruido(in.pos.xy)) { discard; }
return vec4f(material.rgb, 1.0);
}
Con eso, el objeto se dibuja en la lista de opacos, escribe profundidad, se ocluye correctamente contra cualquier cosa, aparece en el mapa de sombras con la misma silueta calada, funciona en un renderizador diferido, y no exige ninguna ordenación. Todos los problemas de las lecciones anteriores desaparecen a la vez.
El precio son dos cosas. La primera es el ruido, y de eso va la sección siguiente. La segunda es que discard desactiva la escritura temprana de profundidad y rompe el rechazo por bloques, con las consecuencias que ya conoces de early-Z y lo que lo desactiva: el objeto con descarte se dibuja después del bloque opaco sólido, nunca antes.
El ruido no es un detalle de implementación
Elegir mal la función de ruido convierte una técnica invisible en un artefacto evidente. Hay tres opciones y cada una resuelve un problema distinto.
Ruido blanco por píxel y por fotograma. Es lo que sale de un hash de las coordenadas y el número de fotograma. Se integra perfectamente con acumulación temporal, porque cada fotograma toma una muestra distinta y el promediado converge al valor correcto. Sin acumulación temporal es inaceptable: la superficie hierve.
Gradiente entrelazado. La función de Jorge Jiménez, que produce un patrón fijo en pantalla con muy buena distribución espacial:
// Interleaved gradient noise. Estable en pantalla, sin repeticion visible.
fn ign(p: vec2f) -> f32 {
return fract(52.9829189 * fract(0.06711056 * p.x + 0.00583715 * p.y));
}
Al ser fijo en espacio de pantalla, no parpadea, pero el patrón se desliza sobre la superficie cuando la cámara se mueve, y en superficies grandes y planas se percibe como una textura que flota. Es la mejor opción para interfaces y para objetos que se mueven poco.
Hash en espacio de objeto. La aportación de Wyman y McGuire en 2017, y la que resuelve el problema de verdad para geometría: en lugar de indexar el ruido por la posición en pantalla, se indexa por las coordenadas del objeto, escaladas al tamaño de píxel actual. Así el patrón queda pegado a la superficie, no se desliza al mover la cámara, y como la escala se ajusta con la derivada de la coordenada, tampoco cambia de frecuencia al acercarse. La sutileza que hace que funcione es interpolar entre dos escalas consecutivas de potencias de dos, igual que hace un mipmap, para que la transición sea continua en lugar de saltar.
En los tres casos, la calidad final depende de si hay acumulación temporal detrás. El dithering estocástico y el antialiasing temporal son técnicas hermanas: la primera produce una imagen correcta en promedio y la segunda es la que hace el promedio. Sin la segunda, la primera solo sirve con contenido de silueta dura, tipo follaje, donde el ojo tolera el ruido porque espera detalle de alta frecuencia.
Alpha to coverage, el primo de función fija
Existe una versión de esto implementada en hardware que no usa discard y por tanto no rompe la prueba temprana de profundidad. Se activa con un booleano del bloque de multimuestreo:
multisample: {
count: 4,
mask: 0xFFFFFFFF,
alphaToCoverageEnabled: true,
}
Con eso, el alfa que devuelve el fragment shader se convierte en una máscara de cobertura: de las cuatro muestras del píxel, se escriben tantas como corresponda al alfa. El sombreado sigue ejecutándose una sola vez por píxel, así que el coste añadido es prácticamente nulo.
Su límite es aritmético y conviene tenerlo claro antes de adoptarlo: con cuatro muestras hay exactamente cinco niveles de opacidad representables, cero, un cuarto, la mitad, tres cuartos y uno. Para el borde calado de una hoja, donde el alfa solo toma valores cercanos a cero o a uno y la cuantización afecta a una franja de dos píxeles, es perfecto. Para un cristal con opacidad 0,3 uniforme en toda su superficie, es un patrón de rejilla visible y constante. La regla se enuncia sola: alpha to coverage para siluetas, dithering para superficies.
La comparación habitual entre técnicas de transparencia es una tabla de coste y calidad, y esa tabla deja fuera el criterio que más pesa en un motor real: cuántas otras partes del sistema tienes que modificar para que la técnica encaje. Ponlo en concreto. Si adoptas weighted blended OIT, tus objetos transparentes ya no están en el búfer de profundidad, así que el reflejo en espacio de pantalla no los ve, la oclusión ambiental no los ve, el desenfoque de profundidad de campo no sabe a qué distancia están, el antialiasing temporal no tiene vectores de movimiento para ellos y el mapa de sombras necesita un camino aparte. Cada uno de esos cinco sistemas necesita una excepción, y las excepciones son donde viven los bugs de los tres años siguientes. El objeto dibujado con descarte estocástico, en cambio, es un objeto opaco para todo lo demás: escribe profundidad, aparece en el prepass, se ocluye, proyecta sombra con su silueta real, tiene vectores de movimiento y entra en cualquier efecto de post-proceso sin que ese efecto sepa que era translúcido. El coste de integración es cero porque no hay integración. Por eso los motores que renderizan vegetación densa, pelo y tejido —tres casos donde la transparencia es masiva— usan descarte estocástico y no OIT, aunque el ruido sea teóricamente peor que una media ponderada. Y por eso el consejo operativo es contraintuitivo: empieza siempre por dithering y sube de técnica solo cuando el material concreto lo exija, en lugar de empezar por la solución general y descubrir tarde que arrastra media docena de excepciones. La transparencia correcta de un cristal de escaparate es un problema; la de doscientos mil polígonos de hierba es un presupuesto.
El árbol completo
flowchart TB
A[Tienes contenido con transparencia] --> B{El modo de mezcla conmuta}
B -->|Aditivo multiplicativo min o max| C[No ordenes nada y dibuja sin escribir profundidad]
B -->|Es el operador over| D{Cuantas capas se solapan por pixel}
D -->|Casi siempre una sola| E[Ordena por objeto de atras a delante]
D -->|Varias capas| F{La silueta es recortada como follaje pelo o rejilla}
F -->|Si| G[Dithering estocastico o alpha to coverage]
F -->|No es cristal humo o liquido continuo| H{Necesitas exactitud o vale una aproximacion}
H -->|Aproximacion suficiente| I[Weighted blended OIT con dos attachments y una resolucion]
H -->|Exactitud con pocas capas| J[Depth peeling con una pasada de geometria por capa]
H -->|Exactitud total y sin objetivo movil| K[Listas enlazadas por pixel con atomicos]
E --> L[Asume el fallo en intersecciones y ciclos]
G --> M[Todo sigue en el camino opaco pero necesitas acumulacion temporal]
style A fill:#89b4fa,color:#11111b
style B fill:#f9e2af,color:#11111b
style D fill:#f9e2af,color:#11111b
style F fill:#f9e2af,color:#11111b
style H fill:#f9e2af,color:#11111b
style C fill:#a6e3a1,color:#11111b
style E fill:#a6e3a1,color:#11111b
style G fill:#a6e3a1,color:#11111b
style I fill:#cba6f7,color:#11111b
style J fill:#fab387,color:#11111b
style K fill:#f38ba8,color:#11111b
style L fill:#f9e2af,color:#11111b
style M fill:#94e2d5,color:#11111bDos ramas merecen un comentario que el diagrama no cabe.
La primera bifurcación, la de la conmutatividad, es la que más gente se salta y la que más devuelve. Está desarrollada en por qué ordenar no resuelve la transparencia, y su corolario es que una parte grande de lo que llamas transparencia en tu escena probablemente no necesita nada de esta lección.
Y la rama de la exactitud tiene una trampa de encuadre: la pregunta no es si quieres exactitud, que evidentemente sí, sino si el usuario va a poder distinguirla. Un cristal de coche visto desde fuera tiene dos capas y cualquier técnica funciona. Un acuario lleno de agua turbia con veinte peces translúcidos tiene veinte, y ahí ninguna aproximación se sostiene. Cuenta capas antes de elegir; el número de capas por píxel es el único dato que decide, y se mide dibujando la escena transparente con mezcla aditiva y un color constante para ver el histograma de sobredibujado.
Coge un objeto con opacidad 0,5 y dibújalo tres veces: con mezcla over ordenada, con descarte estocástico usando gradiente entrelazado, y con alphaToCoverageEnabled y cuatro muestras. Ponlos uno al lado del otro y aléjate de la pantalla. A partir de cierta distancia los tres son indistinguibles, y esa distancia es exactamente el criterio con el que se decide qué materiales de tu escena pueden bajar de técnica sin que nadie lo note.