wandres.dev
MULTISAMPLING · Antialiasing en hardware

Lo que el multimuestreo no arregla: textura, especular y siluetas

Por qué subir la frecuencia de muestreo de la visibilidad no toca el aliasing que nace dentro del sombreado, y cuál es la solución correcta para cada uno de los tres casos que quedan fuera.

⏱ 19 min

Activas el multimuestreo, los bordes de la geometría quedan limpios, y la imagen sigue centelleando. Las barandillas metálicas parpadean al mover la cámara, la textura de la carretera hierve en el horizonte, el borde de las hojas sigue escalonado. No es que el multimuestreo esté mal configurado: es que ninguno de esos tres artefactos es aliasing de geometría, y la técnica hace exactamente lo que promete y nada más. Cada uno tiene su causa y su solución, y ninguna de las tres soluciones consiste en añadir muestras.

🎯 Al terminar esta lección sabrás
  • Clasificar un artefacto de aliasing según dónde nace su alta frecuencia.
  • Resolver el aliasing de textura con la cadena de mipmaps y el filtrado correcto.
  • Reducir el aliasing especular ajustando la rugosidad a la varianza de las normales.
  • Explicar por qué el multimuestreo no encaja en un renderizador diferido.

La regla que clasifica todos los casos

El multimuestreo aumenta la frecuencia de muestreo de una sola señal: la cobertura geométrica. Cualquier alta frecuencia que aparezca dentro del cálculo del color de un fragmento sigue muestreándose una vez por píxel, exactamente igual que sin multimuestreo.

De ahí sale un criterio de diagnóstico que se aplica en dos segundos: si el artefacto desaparece al bajar la resolución de la textura, es aliasing de textura; si desaparece al hacer el material completamente mate, es aliasing especular; si desaparece al quitar el recorte por alfa, es aliasing de silueta recortada; y si desaparece con multimuestreo, era el único caso que el multimuestreo cubre. Los cuatro se ven parecidos en pantalla y tienen soluciones que no se parecen en nada.

Aliasing de textura

Una textura con detalle fino minificada —vista de lejos, o en una superficie inclinada— tiene mucha más información que píxeles disponibles. Muestrearla una vez por píxel es submuestrear, y submuestrear produce patrones de moiré y hervor.

La solución es de 1983 y se llama mipmapping: precalcular una pirámide de versiones filtradas y elegir en tiempo de ejecución el nivel cuya frecuencia encaja con la densidad de píxeles. No es una optimización de memoria, es una técnica de antialiasing, y esa confusión es sorprendentemente común.

En WebGPU eso son tres decisiones que van juntas. La textura tiene que tener mipLevelCount mayor que uno y sus niveles rellenos. El sampler tiene que llevar mipmapFilter: 'linear' además de minFilter y magFilter. Y para las superficies muy inclinadas hace falta maxAnisotropy, porque el mipmap elige un único nivel isótropo y una carretera vista en escorzo necesita mucha resolución en un eje y poca en el otro:

const muestreador = device.createSampler({
  label: 'material',
  magFilter: 'linear',
  minFilter: 'linear',
  mipmapFilter: 'linear',      // sin esto, saltos visibles entre niveles
  maxAnisotropy: 8,            // exige los tres filtros en 'linear'
});

Con eso, el aliasing de textura desaparece por completo, y desaparece sin multimuestreo, porque la solución no es tomar más muestras sino bajar la frecuencia de la señal antes de muestrearla. Es la idea que unifica esta lección entera.

Aliasing especular

Es el más difícil de los tres y el que más delata a un renderizador. Una superficie con un mapa de normales detallado y una rugosidad baja tiene un lóbulo especular estrecho: el brillo solo aparece donde la normal apunta casi exactamente en la dirección del reflejo. Dentro de un solo píxel puede haber cientos de orientaciones de normal distintas y el shader solo evalúa una. Cambia la cámara un milímetro y esa única normal salta a otra orientación, y el píxel pasa de negro a blanco. Eso es el centelleo de los objetos metálicos.

Que sea aliasing de sombreado y no de geometría se demuestra solo: subir a cuatro muestras no lo toca, porque las cuatro muestras del píxel comparten el mismo color calculado una vez.

La solución correcta también consiste en bajar la frecuencia de la señal, y aquí eso significa ensanchar el lóbulo especular en proporción a cuánto varían las normales dentro del píxel. La familia de técnicas se conoce desde el trabajo de Toksvig de 2005 y sus derivados, y todas comparten la misma idea: la longitud del promedio de un conjunto de normales unitarias mide su dispersión —si apuntan todas igual, la media tiene módulo uno; si están dispersas, el módulo baja— y esa dispersión se puede convertir en un incremento de rugosidad.

En la práctica se implementa en dos sitios. Al generar los mipmaps del mapa de normales, se guarda el módulo del promedio y se usa para precalcular una rugosidad efectiva por nivel, que se almacena en un canal libre. Y en el shader, se acota la rugosidad hacia abajo en función de la derivada de la normal en pantalla:

// Cota inferior de rugosidad a partir de la variacion de la normal por pixel.
// Evita que un lobulo especular sea mas estrecho que un pixel.
fn rugosidadFiltrada(n: vec3f, rugosidadBase: f32) -> f32 {
  let dx = dpdx(n);
  let dy = dpdy(n);
  let variacion = max(dot(dx, dx), dot(dy, dy));
  let cota = sqrt(saturate(0.5 * variacion));
  return max(rugosidadBase, cota);
}

No es exacto —la derivación rigurosa pasa por el filtrado de la distribución de normales— pero elimina la mayor parte del centelleo por unas pocas instrucciones, y es lo que hacen la mayoría de los motores en su camino directo.

Todo el antialiasing es lo mismo: o subes la frecuencia de muestreo o bajas la de la señal, y casi siempre es más barato lo segundo

Cuando se ponen los tres casos de esta lección uno al lado del otro aparece la estructura común, y esa estructura es lo que convierte un catálogo de trucos en una forma de pensar. El aliasing ocurre cuando una señal tiene frecuencias por encima de la mitad de la frecuencia de muestreo. Hay exactamente dos remedios: subir la frecuencia de muestreo o filtrar la señal antes de muestrear. El multimuestreo es el primer remedio aplicado a la cobertura geométrica, y es caro porque multiplicar muestras siempre lo es. Los mipmaps son el segundo aplicado a las texturas, y son casi gratis porque el filtrado se precalcula una vez y se consulta con una interpolación. El ensanchado de rugosidad es el segundo aplicado al lóbulo especular, y también es casi gratis por la misma razón. Alpha to coverage es un caso híbrido curioso: convierte una señal binaria en una cuantizada usando las muestras que ya existen. Y el antialiasing temporal, que veremos a continuación, es el primero disfrazado: sube la frecuencia de muestreo repartiéndola en el tiempo en lugar de en el espacio. Con ese marco delante, el orden en que hay que atacar el aliasing en un renderizador se ordena solo, y no es el orden en que lo ataca casi nadie. Primero, filtra todo lo que se pueda precalcular: mipmaps bien generados en espacio lineal, normales prefiltradas, rugosidad acotada, texturas de detalle con la resolución que su uso pide y no la que venía del artista. Eso cuesta tiempo de compilación de assets y cero tiempo de ejecución. Segundo, usa las muestras que el hardware te da barato: cuatro muestras de cobertura, con la operación de resolución dentro del tile. Y solo entonces, tercero, plantéate gastar en técnicas de pantalla completa. Un renderizador que hace los pasos uno y dos rara vez necesita el tres, y un renderizador que se salta el uno no se arregla con ninguna cantidad del tres: el antialiasing temporal aplicado sobre un especular sin filtrar produce un centelleo emborronado, que es peor que el centelleo limpio porque además de parpadear pierde detalle.

Siluetas recortadas y arquitecturas que no encajan

Quedan dos casos que no son de frecuencia sino estructurales.

El recorte por alfa produce una silueta que no es geométrica: el borde de una hoja está definido por un discard dentro del fragment shader, y el rasterizador ni se entera. Las cuatro muestras del píxel comparten el mismo resultado del shader, así que o se descartan todas o ninguna. La solución es alphaToCoverageEnabled, que convierte el alfa en cobertura y distribuye las muestras; está desarrollada en el dithering estocástico, y es la razón por la que la vegetación recortada y el multimuestreo aparecen siempre juntos en los motores.

El sombreado diferido es incompatible en un sentido económico más que técnico. Un G-buffer multimuestreado es posible —son texturas con cuatro muestras— pero la pasada de iluminación lee ese G-buffer, y como cada muestra puede pertenecer a una superficie distinta, hay que iluminar por muestra. Eso es exactamente supersampling de la parte cara del renderizador, con lo que la propiedad que hacía asequible el multimuestreo desaparece. Las mitigaciones clásicas consisten en detectar los píxeles de borde y iluminar solo esos por muestra, lo que exige una máscara de estarcido y dos pipelines, y complica bastante para ganar poco. Por eso la regla práctica es la que ya conoces: los renderizadores directos usan multimuestreo y los diferidos usan antialiasing temporal, y la elección de arquitectura decide la de antialiasing y no al revés.