wandres.dev
SHADERS I · GLSL y el modelo mental

Vertex frente a fragment: dos etapas, dos escalas

Cuántas veces se ejecuta cada etapa, qué obligación tiene cada una, y la regla para decidir en cuál de las dos poner cada cálculo.

⏱ 18 min

Las dos etapas programables ejecutan el mismo lenguaje pero viven en escalas que difieren en cinco órdenes de magnitud. Un cuadrado a pantalla completa dispara seis invocaciones de vértice y dos millones de invocaciones de fragmento. Esa asimetría es la primera herramienta de optimización que tienes, y también la trampa en la que casi todo el mundo cae al mover un cálculo al sitio equivocado.

🎯 Al terminar esta lección sabrás
  • Contar cuántas veces se ejecuta cada etapa para una geometría concreta.
  • Escribir la obligación mínima de cada etapa y qué ocurre si no se cumple.
  • Aplicar la regla de subir cálculos al vertex shader y reconocer cuándo no vale.
  • Explicar por qué los triángulos diminutos son desproporcionadamente caros.

Cuántas veces corre cada una

El vertex shader corre una vez por vértice procesado, que no es lo mismo que por vértice de la geometría. Con geometría indexada, la GPU mantiene una caché pequeña de vértices ya transformados, así que un vértice compartido por seis triángulos puede procesarse una sola vez. Sin índice, se procesa seis veces. Ése es el argumento real a favor de indexar, más que el ahorro de memoria.

El fragment shader corre una vez por fragmento candidato. Un fragmento es un píxel potencial: la GPU lo genera para cada píxel que un triángulo cubre, lo shadea, y después decide si sobrevive al test de profundidad. Con dos triángulos superpuestos, los píxeles del solapamiento se shadean dos veces aunque solo se vea uno. Eso es el overdraw, y es la razón por la que una escena con mucha transparencia se arrastra.

// Los numeros reales de tu escena, cuadro a cuadro
console.log( renderer.info.render );
// { frame, calls, triangles, points, lines }

Three.js cuenta triángulos y draw calls pero no fragmentos, porque nadie los cuenta: dependen de la cobertura en pantalla. La estimación de servilleta es el área que ocupa el objeto en píxeles multiplicada por las capas que se superponen.

Una escena típica a 1920 por 1080: dos millones de píxeles. Con overdraw de 1.5, tres millones de invocaciones de fragmento por cuadro por pasada. A sesenta cuadros, ciento ochenta millones por segundo. Frente a eso, cincuenta mil vértices son ruido estadístico.

La obligación de cada etapa

El vertex shader tiene que escribir gl_Position, un vec4 en clip space. Es su único deber. Si no lo hace, el enlace del programa falla o el resultado es indefinido.

void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}

El fragment shader tiene que escribir el color. En el dialecto que Three.js expone eso es gl_FragColor, que en realidad es una macro sobre una salida declarada por el prefijo. Opcionalmente puede escribir gl_FragDepth para sobrescribir la profundidad, con un coste alto: desactiva el descarte temprano por profundidad para todo el dibujado.

void main() {
  gl_FragColor = vec4( 1.0, 0.4, 0.6, 1.0 );
}

También puede descartar el fragmento con discard, que no escribe nada. Y ahí conviene ser claro: discard no ahorra tiempo de shader, porque para llegar a la instrucción ya has ejecutado todo lo anterior. Lo que ahorra es el escrito en el búfer, y lo que cuesta es que la mera presencia de discard en el código obliga al hardware a posponer el test de profundidad hasta después del shading para todo el material.

La regla y sus excepciones

La regla es: si un cálculo da el mismo resultado para todos los fragmentos de un triángulo, o si su resultado varía de forma lineal sobre la superficie, hazlo en el vertex shader. La interpolación es gratis; el cálculo repetido dos millones de veces no.

Un ejemplo concreto. Este fragment shader calcula la posición mundial por fragmento:

// Caro: una multiplicacion de matriz por fragmento
varying vec3 vPosicionLocal;
uniform mat4 uModelo;

void main() {
  vec3 mundo = ( uModelo * vec4( vPosicionLocal, 1.0 ) ).xyz;
  gl_FragColor = vec4( fract( mundo ), 1.0 );
}

Y esta versión hace exactamente lo mismo, con la multiplicación en el vertex shader:

// Vertex
varying vec3 vMundo;
void main() {
  vec4 mundo = modelMatrix * vec4( position, 1.0 );
  vMundo = mundo.xyz;
  gl_Position = projectionMatrix * viewMatrix * mundo;
}
// Fragment
varying vec3 vMundo;
void main() {
  gl_FragColor = vec4( fract( vMundo ), 1.0 );
}

Es correcto porque una transformación afín es lineal, y la interpolación lineal de las esquinas transformadas coincide con la transformación del punto interpolado. Cuatro vértices en vez de dos millones de fragmentos.

Ahora la excepción, que es donde se rompe. Esto no es equivalente:

// Vertex: mal
vIluminacion = max( dot( normalize( normalMatrix * normal ), uLuz ), 0.0 );
// Fragment: bien
vec3 N = normalize( vNormal );
float iluminacion = max( dot( N, uLuz ), 0.0 );

La diferencia es que normalize y max no son lineales. Interpolar el resultado de una función no lineal no es lo mismo que aplicar la función al valor interpolado. La primera versión es el sombreado de Gouraud, y su síntoma es que un brillo especular puede desaparecer entero si cae en el centro de un triángulo grande, porque ninguno de los tres vértices lo vio. La segunda es Phong, y cuesta más pero es correcta.

La regla completa, entonces: sube al vertex shader lo que sea lineal en el espacio de la superficie. Deja en el fragment shader las normalizaciones, las potencias, los umbrales, las funciones de ruido y cualquier cosa cuyo resultado tenga más frecuencia espacial que la malla.

Por qué los triángulos diminutos arruinan el rendimiento

El hardware no shadea fragmentos de uno en uno: los shadea en bloques de dos por dos, siempre. La razón es que las derivadas —dFdx, dFdy, fwidth, y la selección automática de nivel de mipmap— se calculan restando valores entre fragmentos vecinos del mismo bloque, y para eso los cuatro tienen que ejecutarse a la vez.

La consecuencia es directa: un triángulo que cubre un solo píxel cuesta cuatro invocaciones de fragmento. Tres de las cuatro se descartan al final, pero se han ejecutado enteras. Un triángulo que cubre dos píxeles también cuesta cuatro. La eficiencia de un triángulo pequeño es del 25 %.

Ese es el motivo real por el que un modelo de medio millón de triángulos visto de lejos puede ser más lento que el mismo modelo decimado a veinte mil, aunque el número de fragmentos visibles sea idéntico. No es el coste de transformar los vértices: es que a esa distancia cada triángulo mide menos de un píxel y todos pagan la penalización del bloque de dos por dos. Y es el argumento técnico que sostiene todo el sistema de niveles de detalle.

La interpolacion es lineal en la varying, no en lo que la varying representa

La trampa que sobrevive a la explicación de Gouraud contra Phong y que sigue mordiendo a gente con años de experiencia: interpolar un color en el espacio equivocado. Si escribes un color sRGB en una varying y lo interpolas entre dos vértices, el rasterizador hace una media aritmética de valores codificados, y la media aritmética de dos códigos sRGB no corresponde al color intermedio en luminancia. Un degradado de negro a blanco interpolado en sRGB se ve claramente más brillante en el centro que el mismo degradado interpolado en lineal. Como Three.js trabaja en espacio lineal desde r152 y convierte a la salida, si te limitas a usar THREE.Color y a dejar que el motor haga la conversión final estás en el espacio correcto sin saberlo. El problema aparece cuando alguien decide “optimizar” pasando un color ya codificado en una varying, o cuando se mezcla un valor leído de una textura marcada como SRGBColorSpace con uno construido a mano. La misma lógica se aplica a cualquier cosa no lineal que quieras interpolar: una profundidad interpolada en espacio de vista no coincide con la profundidad real —por eso existe la corrección de perspectiva—, y un ángulo interpolado entre 350 y 10 grados pasa por 180 en vez de por 0. Si lo que quieres interpolar no vive en un espacio afín, interpola otra cosa: para colores, valores lineales; para direcciones, el vector y no el ángulo; para rotaciones, nada, porque una varying no sabe hacer slerp.