wandres.dev
FRAGMENT SHADERS · Interpolación y salida

center, centroid y sample: dónde se evalúa la interpolación

Los tres puntos de muestreo del interpolador, la extrapolación fuera del triángulo que produce center, lo que centroid arregla y lo que rompe, y el coste de sombrear por muestra.

⏱ 16 min

El segundo argumento de @interpolate decide en qué punto exacto del píxel se evalúan las variables interpoladas, y solo importa cuando hay multimuestreo. Los tres valores producen resultados distintos en los bordes de los triángulos, y los dos que no son el de por defecto arreglan un artefacto a cambio de introducir otro. Elegir bien exige saber cuál de los dos problemas te afecta.

🎯 Al terminar esta lección sabrás
  • Explicar dónde evalúa el interpolador con cada uno de los tres muestreos.
  • Reconocer el artefacto que produce la extrapolación de center en los bordes.
  • Decidir para qué variables merece la pena centroid y qué empeora al usarlo.
  • Cuantificar el coste de sample y compararlo con las alternativas.

Los tres puntos

@location(0) @interpolate(perspective, center)   a : vec2f,   // por defecto
@location(1) @interpolate(perspective, centroid) b : vec2f,
@location(2) @interpolate(perspective, sample)   c : vec2f,

center evalúa en el centro geométrico del píxel, esté cubierto o no por el triángulo.

centroid evalúa en un punto que está garantizadamente dentro del área cubierta por la primitiva dentro de ese píxel. Cuál sea exactamente ese punto lo decide la implementación.

sample evalúa en la posición de la muestra concreta que se está sombreando, lo cual implica que el fragment shader se ejecuta una vez por muestra en lugar de una vez por píxel.

Sin multimuestreo, los tres coinciden y la elección da igual. Con multimuestreo, la diferencia se concentra en los píxeles del borde de cada triángulo, que son pocos en área y muy visibles.

center y la extrapolación

Con multimuestreo, un píxel del borde puede tener algunas muestras dentro del triángulo y otras fuera. El fragment shader se ejecuta una vez para ese píxel, y con center las variables se evalúan en el centro del píxel, que puede estar fuera del triángulo.

Evaluar la interpolación fuera del triángulo no es interpolar: es extrapolar. Y una extrapolación se sale del rango que tenían los valores en los vértices.

Las consecuencias concretas:

  • Una coordenada de textura que iba de 0 a 1 puede salir en 1.03, y muestrear fuera de la región prevista. En un atlas, eso es el texel del vecino: la línea de color equivocado en el borde de cada sprite.
  • Un peso de mezcla que iba de 0 a 1 puede salir negativo y producir un color negativo, que después del tone mapping aparece como un punto brillante o negro parpadeante.
  • Un valor que se pasa a sqrt o a pow puede salir negativo y devolver algo indefinido.

El artefacto característico es un borde de píxeles con color equivocado justo en las siluetas, que aparece solo con multimuestreo activado, lo cual despista mucho porque el multimuestreo se supone que arregla los bordes, no que los estropee.

centroid y lo que rompe

centroid elimina la extrapolación por construcción: el punto de evaluación está dentro del área cubierta, así que el valor está dentro del rango de los vértices.

Lo que introduce a cambio es que el punto de evaluación deja de estar en una rejilla regular. En un píxel del borde puede estar desplazado hacia una esquina, y en el píxel de al lado hacia otra. Como las derivadas se calculan restando valores de píxeles vecinos, esa irregularidad las estropea justo en los bordes:

  • La selección de nivel de mip da saltos en las siluetas, produciendo un borde de un nivel de mip distinto.
  • Los cálculos basados en fwidth para antialiasing procedural dan anchos erráticos en los bordes.
  • Cualquier normal reconstruida con dpdx y dpdy se rompe en el contorno.

De ahí sale la regla que hay que aplicar: centroid solo en las variables cuyo rango importa, y nunca en las que se derivan. En la práctica eso significa marcar con centroid las coordenadas de un atlas, los pesos de mezcla de un terreno y las máscaras normalizadas, y dejar en center las coordenadas de textura de las que se calcula el mip.

Y también significa que si un mismo vec2f se usa para las dos cosas, hay que pasarlo dos veces con muestreos distintos, o —mejor— repensar la codificación para que el valor no se pueda salir de rango aunque se extrapole.

sample y el coste multiplicado

sample cambia la frecuencia de ejecución del shader. Con 4x, un fragment shader que se ejecutaba una vez por píxel pasa a ejecutarse cuatro veces. El coste no es aproximadamente cuatro veces: es cuatro veces.

Lo mismo ocurre si el shader lee @builtin(sample_index), que también fuerza el sombreado por muestra aunque no marques ninguna variable con sample.

A cambio se obtiene antialiasing completo, no solo de bordes geométricos sino también del contenido del shader: los brillos especulares pequeños, los patrones de alta frecuencia, los bordes de una textura de alpha test. El multimuestreo normal no antialiasea nada de eso, porque todas las muestras de un píxel comparten el mismo resultado del shader.

Cuando de verdad hace falta antialiasing del contenido, casi siempre hay opciones mejores que pagar cuatro veces el fragment shader entero:

  • Prefiltrar en la fuente: mipmaps bien generados, normales que se prefiltran hacia rugosidad, texturas de alto contraste suavizadas.
  • Antialiasing analítico con fwidth para bordes procedurales.
  • Antialiasing temporal, que reparte las muestras entre fotogramas.
  • Renderizar a más resolución y bajar, que cuesta lo mismo que sombrear por muestra y además antialiasea la geometría igual de bien.
⚠️
El muestreo tiene que coincidir entre las dos etapas

Igual que el tipo de interpolación, el muestreo declarado en la salida del vertex shader y en la entrada del fragment shader tienen que ser idénticos. Y @interpolate(flat) no admite center, centroid ni sample: sus únicos muestreos válidos son first y either, porque no hay nada que evaluar en ningún punto.

La extrapolación de center es la causa de las costuras de los atlas, y explica por qué el margen de un atlas nunca es suficiente

Todo el mundo que ha hecho un atlas de texturas conoce el problema de las costuras: en el borde de cada región aparece una línea con el color de la región vecina. La solución que se aplica siempre es dejar un margen de píxeles alrededor de cada región, y la pregunta que nadie sabe responder es cuánto margen hace falta.

La respuesta depende de cuál de las tres causas tengas, y son tres causas distintas que producen el mismo artefacto.

La primera es el filtrado bilineal. Al muestrear cerca del borde, el filtro mezcla el texel de fuera. Un margen de un texel lo resuelve, y es la causa que todo el mundo conoce.

La segunda son los mipmaps. En el nivel de mip N, un texel abarca dos elevado a N texels del nivel cero, así que el margen necesario se duplica en cada nivel. Con cuatro niveles hacen falta dieciséis texels de margen, y por eso los atlas con mipmaps o llevan márgenes generosos o se limitan a unos pocos niveles.

Y la tercera, la que casi nadie tiene en cuenta, es la extrapolación de center con multimuestreo. En los píxeles de la silueta, la coordenada de textura se evalúa fuera del triángulo y puede salirse del rango de la región del atlas por una cantidad que no depende del número de texels de margen sino del tamaño en pantalla del triángulo. Un triángulo grande visto en escorzo puede extrapolar varias unidades de coordenada.

Por eso el margen a veces no arregla nada y por eso el bug aparece solo cuando el multimuestreo está activo. Las dos soluciones reales para esta tercera causa son marcar la coordenada del atlas con centroid, o —la robusta— acotar la coordenada dentro del shader al rectángulo de la región antes de muestrear:

let uvSegura = clamp(uv, region.min, region.max);

Ese acotado cuesta dos instrucciones, no depende del margen, no depende del nivel de mip y no depende del multimuestreo. Es lo que hacen los motores que usan atlas en serio, y es la razón de que sus atlas guarden el rectángulo de cada región y no solo el desplazamiento.