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.
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 cuadroconsole.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 fragmentovarying 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:
// Vertexvarying vec3 vMundo;void main() { vec4 mundo = modelMatrix * vec4( position, 1.0 ); vMundo = mundo.xyz; gl_Position = projectionMatrix * viewMatrix * mundo;}// Fragmentvarying 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: malvIluminacion = max( dot( normalize( normalMatrix * normal ), uLuz ), 0.0 );// Fragment: bienvec3 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.