wandres.dev
FRAGMENT SHADERS · Interpolación y salida

Los tres modos de interpolación: perspective, linear y flat

Qué hace cada modo, cuál es el vértice que provee el valor en flat, cuándo tiene sentido linear, y cómo se pasan datos por triángulo en una API sin geometry shaders.

⏱ 16 min

Entre el vertex shader y el fragment shader hay una etapa de hardware fijo que no se programa y que decide qué valor exacto recibe cada fragmento. @interpolate es el único control que tienes sobre ella, tiene tres modos y el que no elijas nunca es el más útil de los tres para pasar información que no es geométrica.

🎯 Al terminar esta lección sabrás
  • Elegir el modo de interpolación adecuado para cada tipo de dato.
  • Explicar qué vértice provee el valor con flat y qué implica cada opción de muestreo.
  • Identificar los casos en los que linear es correcto y perspective no.
  • Pasar datos por triángulo sin geometry shaders.

Los tres modos

struct SalidaVS {
  @builtin(position)                        clip   : vec4f,
  @location(0)                              uv     : vec2f,   // perspective por defecto
  @location(1) @interpolate(linear)         pant   : vec2f,
  @location(2) @interpolate(flat)           idMat  : u32,
};

perspective es el modo por defecto y el correcto para todo lo que sea una magnitud geométrica: coordenadas de textura, posiciones del mundo, normales, colores por vértice. Interpola corrigiendo por la perspectiva, con la mecánica que describe la lección sobre el paso de clip space al viewport.

linear interpola linealmente en el espacio de pantalla, sin corregir. Es lo que en GLSL y HLSL se llama noperspective. No es una versión rápida de la anterior: es una versión distinta que solo es correcta para valores que ya viven en coordenadas de pantalla.

flat no interpola: todos los fragmentos de la primitiva reciben el mismo valor, el de uno de sus vértices. Es obligatorio para tipos enteros, y es la elección correcta para cualquier dato que sea constante sobre el triángulo.

Los tres tienen que declararse igual en la salida del vertex shader y en la entrada del fragment shader. Un desajuste es un error al crear el pipeline.

flat y el vértice que provee el valor

Como no hay interpolación, hay que decidir de cuál de los tres vértices sale el valor. El atributo admite un segundo argumento para eso:

@location(2) @interpolate(flat, first)  idMat : u32,     // el primer vertice
@location(3) @interpolate(flat, either) idObj : u32,     // el que decida la implementacion

first toma el valor del primer vértice de la primitiva, y es el comportamiento por defecto si escribes @interpolate(flat) a secas. Es determinista y es lo que quieres cuando el valor de los tres vértices es distinto y te importa cuál gana.

either le dice a la implementación que puede tomar el de cualquiera. Es más permisivo y en algunas arquitecturas evita trabajo, y solo es seguro cuando los tres vértices llevan el mismo valor, que es el caso habitual: un identificador de material que viene de la instancia es idéntico en los tres.

La regla práctica: si el valor procede de la instancia o de una uniform, either. Si procede del vértice y los tres difieren, first y ten claro cuál es el primero.

Cuándo linear

Los casos en los que linear es lo correcto son pocos y concretos, pero cuando aparecen, perspective da un resultado mal.

El caso canónico es un valor que ya está en coordenadas de pantalla. Si el vertex shader calcula la posición en píxeles de algo y la pasa al fragment shader, esa magnitud es lineal en pantalla por construcción, y corregirla por perspectiva la deforma.

// El grosor de un contorno medido en pixeles: es una magnitud de pantalla.
@location(1) @interpolate(linear) grosorPixeles : f32,

El otro caso es el de los efectos de pantalla completa, donde el triángulo se dibuja con w igual a 1 y los dos modos coinciden; ahí da igual cuál uses y perspective está bien.

Lo que no es un caso válido: usar linear para ahorrar. La corrección de perspectiva la hace hardware dedicado y no cuesta medible; elegir linear por rendimiento es cambiar corrección por nada.

Datos por triángulo sin geometry shaders

WebGPU no tiene geometry shaders y no los va a tener. La forma de pasar un dato constante por triángulo es flat más un poco de organización de la geometría, y hay tres patrones según de dónde venga el dato.

Si el dato viene de la instancia, no hay nada que hacer: se lee de un storage buffer con instance_index y se pasa flat, o directamente se lee en el fragment shader.

Si el dato viene del triángulo y la malla no comparte vértices entre triángulos con datos distintos, se guarda como atributo de vértice y se pasa con @interpolate(flat, first). Cada triángulo lee el de su primer vértice.

Si el dato viene del triángulo y los vértices se comparten, hay que duplicar los vértices en las fronteras o cambiar el enfoque. La alternativa que se usa es calcular el índice del triángulo en el fragment shader, aprovechando que en una lista de triángulos sin índices el número de triángulo es el índice del vértice dividido entre tres:

struct SalidaVS {
  @builtin(position) clip : vec4f,
  @location(0) @interpolate(flat, either) triangulo : u32,
};

@vertex
fn vs(@builtin(vertex_index) vi : u32) -> SalidaVS {
  var s : SalidaVS;
  s.triangulo = vi / 3u;
  // ...
  return s;
}

Con ese índice, el fragment shader puede leer una tabla de datos por triángulo desde un storage buffer, que es exactamente lo que haría un geometry shader pero sin su coste.

💡
flat es más barato que perspective, aunque no por lo que parece

Un valor flat no consume interpolador, no necesita la división de la corrección de perspectiva y, en las arquitecturas por tiles, ocupa menos memoria intermedia porque se puede almacenar una vez por primitiva en lugar de por vértice. En un shader con muchas variables entre etapas, marcar como flat las que de verdad son constantes por triángulo reduce el gasto sin cambiar ni un píxel del resultado.

Interpolar la magnitud equivocada es un bug que produce imágenes casi correctas

La interpolación es lineal sobre la superficie del triángulo, y ese «lineal» hay que tomárselo literalmente: solo produce el resultado correcto para funciones que de verdad son lineales. Cuando lo que interpolas es el resultado de aplicar una función no lineal en el vertex shader, el fragmento del centro del triángulo recibe un valor que no corresponde a nada.

Los tres casos que aparecen a diario. Normalizar en el vértice: la interpolación de dos vectores unitarios no es unitaria, y el error se ve como un relieve tenue siguiendo la topología. Aplicar una curva de gamma o de tono en el vértice: la media de dos colores convertidos no es la conversión de la media, y aparecen bandas oscuras en los degradados. Calcular una atenuación o una niebla exponencial en el vértice: la exponencial de la media no es la media de las exponenciales, y la niebla se separa de las siluetas de la geometría en los polígonos grandes.

Los tres tienen la misma solución y el mismo coste: interpola la magnitud cruda y aplica la función en el fragment shader. Interpola la normal sin normalizar y normaliza en el fragmento. Interpola el color lineal y conviértelo al final. Interpola la distancia y calcula la niebla al final.

Y tienen también la misma tentación en contra, que es que hacerlo en el vértice es más barato. Lo es, y en geometría densa donde cada triángulo cubre pocos píxeles puede compensar. Pero el criterio no es el coste: es si la función es lo bastante lineal en la escala de un triángulo. Con triángulos pequeños, casi cualquier función lo es y el atajo funciona. Con un suelo formado por dos triángulos enormes, ninguna lo es y el atajo se ve. Ese es el motivo de que el mismo truco funcione perfectamente en un personaje y produzca artefactos evidentes en el terreno.