Varyings: el puente interpolado entre las dos etapas
Cómo viaja un valor del vertex shader al fragment shader, qué le hace el rasterizador por el camino, y las tres consecuencias que hay que tener siempre presentes.
Una varying es el único canal entre las dos etapas programables, y no es un canal transparente: entre que el vertex shader la escribe y el fragment shader la lee, el rasterizador la interpola sobre la superficie del triángulo con corrección de perspectiva. Esa interpolación es gratis, es lineal, y es la fuente de tres errores clásicos que solo desaparecen cuando entiendes exactamente qué se está promediando.
- Trazar el recorrido completo de un dato desde un atributo hasta el color de un fragmento.
- Explicar qué significa que la interpolación tenga corrección de perspectiva.
- Justificar por qué una normal hay que renormalizarla en el fragment shader.
- Usar el cualificador
flaty saber cuándo es obligatorio.
El recorrido completo
flowchart TB A[atributos un valor por vertice] --> B[vertex shader] U[uniforms iguales para todo el dibujado] --> B B --> C[gl_Position en clip space] B --> D[varyings escritas una vez por vertice] C --> E[recorte division por w y viewport] E --> F[rasterizacion en fragmentos] D --> G[interpolacion con correccion de perspectiva] F --> G G --> H[fragment shader lee las varyings ya interpoladas] U --> H T[texturas memoria de solo lectura] --> H T --> B H --> I[color del fragmento] style A fill:#89b4fa,color:#11111b style U fill:#cba6f7,color:#11111b style T fill:#94e2d5,color:#11111b style B fill:#fab387,color:#11111b style C fill:#89b4fa,color:#11111b style D fill:#89b4fa,color:#11111b style E fill:#f9e2af,color:#11111b style F fill:#f9e2af,color:#11111b style G fill:#f9e2af,color:#11111b style H fill:#fab387,color:#11111b style I fill:#a6e3a1,color:#11111b
Las cajas naranjas son tuyas. Las amarillas son hardware de función fija que no puedes modificar. El punto que hay que retener es que una varying se escribe tres veces por triángulo y se lee una vez por fragmento, y que entre esos dos momentos hay una operación matemática concreta.
Declarar una varying es escribir el mismo nombre y el mismo tipo en las dos etapas. Si no coinciden, el enlace falla.
// Vertex
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vPosicionVista;
void main() {
vUv = uv;
vNormal = normalMatrix * normal; // ojo: sin normalizar aqui
vec4 posicionVista = modelViewMatrix * vec4( position, 1.0 );
vPosicionVista = posicionVista.xyz;
gl_Position = projectionMatrix * posicionVista;
}
// Fragment
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vPosicionVista;
void main() {
vec3 N = normalize( vNormal ); // imprescindible
vec3 V = normalize( -vPosicionVista ); // la camara esta en el origen del espacio de vista
float fresnel = pow( 1.0 - max( dot( N, V ), 0.0 ), 3.0 );
gl_FragColor = vec4( vec3( fresnel ), 1.0 );
}
En el dialecto que Three.js compila, varying es en realidad una macro: se expande a out en el vertex shader y a in en el fragment shader. Puedes escribir in y out directamente si prefieres; el resultado es idéntico.
Qué hace la corrección de perspectiva
La interpolación ingenua tomaría las coordenadas baricéntricas del fragmento dentro del triángulo en pantalla y las usaría como pesos. Eso está mal en cuanto hay perspectiva, porque la proyección no conserva las razones de distancia: el punto medio de una arista en pantalla no corresponde al punto medio de la arista en el espacio.
El rasterizador lo corrige dividiendo cada varying por la componente w del clip space en los vértices, interpolando eso linealmente, e invirtiendo la división al final. El resultado es que la varying interpolada corresponde al valor real en el punto de la superficie, no en el punto de la pantalla.
Ese detalle es la razón por la que una textura sobre un suelo en perspectiva se ve correcta. Si desactivaras la corrección, obtendrías la deformación característica de los juegos de la primera PlayStation, donde las texturas se doblaban al girar la cámara.
Lo que no corrige es la no linealidad de la propia magnitud. La interpolación sigue siendo lineal en el valor de la varying, y ahí están las tres consecuencias.
Las tres consecuencias
Una dirección interpolada deja de ser unitaria. Tres normales unitarias en los vértices producen, en el centro del triángulo, la media de las tres, que tiene módulo menor que uno salvo que las tres coincidan. Cuanto más se abre el ángulo entre ellas, más se acorta. Por eso normalize( vNormal ) en el fragment shader no es un adorno defensivo: sin él, la intensidad difusa cae en el centro de cada triángulo y aparece un facetado suave que parece un problema de la malla.
Y por el mismo motivo, normalizar en el vertex shader no sirve: lo que hay que normalizar es el resultado de la interpolación, no las entradas.
Interpolar el resultado de una función no lineal es distinto de aplicar la función al valor interpolado. Ya lo vimos con la iluminación de Gouraud. Se aplica igual a un color en espacio no lineal, a un ángulo que cruza el origen, o a cualquier valor que hayas comprimido con una raíz o una potencia. La regla es: interpola la magnitud cruda y aplica la transformación después.
Cada varying cuesta. Ocupa un slot de interpolación y consume ancho de banda entre las dos etapas por cada fragmento. La especificación de WebGL 2 garantiza al menos sesenta componentes de varying, es decir quince vec4, y ése es un presupuesto que se agota antes de lo que parece: un shader con UV, normal, tangente, posición mundial, posición de vista y dos conjuntos de coordenadas de sombra ya va por doce. Cuando te falte sitio, empaqueta: dos vec2 caben en un vec4, y una normal se puede comprimir a dos componentes con codificación octaédrica.
El cualificador flat
flat desactiva la interpolación: todos los fragmentos del triángulo reciben el valor del vértice provocador, que es el último de los tres por convención en WebGL 2.
// Vertex
flat varying int vIndiceDeGrupo;
// Fragment
flat varying int vIndiceDeGrupo;
Como varying se expande a out y a in, la construcción flat varying produce flat out y flat in, que es exactamente la sintaxis correcta.
Hay dos motivos para usarlo. El primero es que para los tipos enteros es obligatorio: int, uint, ivec y uvec no se pueden interpolar y el compilador lo rechaza sin flat. El segundo es que a veces quieres el valor de una cara, no una mezcla: un índice de material, un identificador de objeto, un color plano por triángulo.
El coste es menor que el de una varying interpolada, porque no hay que calcular nada por fragmento. Y hay un tercer uso menos conocido: flat sobre un float da sombreado facetado exacto sin necesidad de convertir la geometría a no indexada, siempre que el atributo de origen ya sea constante por cara.
Hay una clase de bug que se manifiesta como un desplazamiento de medio téxel y que nace de tratar una varying como si fuera una dirección de memoria. Si guardas en una textura una tabla de datos —el estado de una simulación, un índice, una paleta— y la lees con texture2D( uTabla, vUv ), estás pidiendo un muestreo con filtrado y con coordenadas normalizadas, y el téxel que obtienes depende de dónde caiga exactamente el centro del fragmento. Con LinearFilter te devuelve una mezcla de dos entradas de la tabla, que no significa nada. Con NearestFilter te devuelve una entrada concreta, pero cuál depende de un redondeo, y basta con que la resolución del objetivo no coincida exactamente con la de la tabla para que la correspondencia se desplace. El síntoma es que la simulación funciona a 256 por 256 y falla a 250 por 250, o que la paleta devuelve el color de al lado en las últimas filas. La solución no es afinar la coordenada con medio téxel de offset —eso es tapar el agujero— sino dejar de usar coordenadas normalizadas: texelFetch( uTabla, ivec2( indice % ancho, indice / ancho ), 0 ) lee el téxel número tal, sin filtrado, sin normalización y sin ambigüedad. Está disponible en cualquier ShaderMaterial de r184 y es la forma correcta de usar una textura como memoria. Reserva texture2D para lo que es: muestrear una imagen.