El coste real del fragment shader
Cómo se cuenta el número de invocaciones de fragmento de un fotograma, por qué los triángulos pequeños se sombrean cuatro veces, y la jerarquía de frecuencias que decide dónde va cada cálculo.
El fragment shader es la etapa que más veces se ejecuta de todo el pipeline, por un margen que no se parece a nada: entre cien y diez mil veces más que el vertex shader en una escena típica. Ese factor es el que convierte una instrucción de más en un milisegundo, y es también el que hace que la optimización más rentable casi nunca sea optimizar el shader, sino ejecutarlo menos veces.
- Calcular el número de invocaciones de fragmento de un fotograma con sus tres multiplicadores.
- Explicar el sobrecoste de cuadrado que producen los triángulos pequeños.
- Ordenar los cálculos de un renderer por frecuencia de ejecución.
- Elegir la técnica de reducción de coste adecuada a cada cuello.
La cuenta
El número de invocaciones de fragmento de un fotograma es el producto de cuatro factores:
invocaciones = pixeles * sobredibujado * factor_de_cuadrado * muestras_sombreadas
Píxeles. A 1920 por 1080 son 2,07 millones. A 2560 por 1440, 3,7 millones. A 3840 por 2160, 8,3 millones. La relación entre 1080p y 4K es exactamente 4.
Sobredibujado. Cuántas veces se sombrea el mismo píxel. En una escena opaca bien ordenada con prueba de profundidad temprana efectiva, se acerca a 1. Sin ordenar, ronda 2 o 3. Con vegetación, humo y partículas, sube a 10 o más en las zonas afectadas.
Factor de cuadrado. El rasterizador sombrea en bloques de 2 por 2, siempre. Un triángulo que cubre un solo píxel ejecuta cuatro invocaciones y aprovecha una. Con triángulos grandes el factor es casi 1; con triángulos del tamaño de unos pocos píxeles se acerca a 4.
Muestras sombreadas. 1 en el caso normal, y el número de muestras si el shader fuerza sombreado por muestra.
Con números concretos: una escena a 1440p, con sobredibujado 2, geometría razonable y sin sombreado por muestra, son unos 7,4 millones de invocaciones por fotograma. A 60 por segundo, 445 millones de invocaciones por segundo. Un shader de 200 operaciones aritméticas son 89 mil millones de operaciones por segundo, que una GPU de escritorio absorbe sin despeinarse y una integrada no.
El sobrecoste de los triángulos pequeños
Este es el factor que más sorprende porque no depende del shader ni de la resolución: depende de la densidad de la malla.
Cuando un triángulo cubre menos de cuatro píxeles, la mayoría de los cuadrados que toca están parcialmente cubiertos, y las invocaciones no cubiertas se ejecutan igualmente como auxiliares para que las derivadas funcionen. En el límite, un triángulo de un píxel cuesta cuatro invocaciones.
Eso convierte el nivel de detalle de las mallas en un problema de fragmentos, no de vértices, que es lo contrario de la intuición. Un personaje de cien mil triángulos visto a lo lejos ocupando doscientos píxeles de pantalla tiene triángulos submicroscópicos: cada píxel puede estar cubierto por varios triángulos distintos, cada uno pagando su cuadrado completo. El coste de sombreado de ese personaje diminuto puede superar al del mismo personaje en primer plano.
La respuesta es la de siempre y sigue siendo obligatoria: niveles de detalle. Y la métrica correcta para decidir cuándo cambiar de nivel no es la distancia sino el área en píxeles por triángulo: el nivel de detalle tiene que mantener los triángulos por encima de unos pocos píxeles.
La jerarquía de frecuencias
Todo cálculo de un renderer se ejecuta a una de estas frecuencias, ordenadas de menos a más veces por fotograma:
| Frecuencia | Veces por fotograma | Dónde vive |
|---|---|---|
| Por aplicación | una vez en total | constantes, const, override |
| Por fotograma | 1 | uniform buffer de cámara |
| Por objeto | decenas o miles | storage buffer por instancia |
| Por vértice | decenas de miles | vertex shader |
| Por fragmento | millones | fragment shader |
| Por muestra | millones por N | fragment shader con sample |
La optimización más rentable es siempre mover un cálculo hacia arriba en esta tabla. Y la lista de cosas que están una fila más abajo de donde deberían es sorprendentemente larga en casi cualquier código real: normalizaciones de vectores constantes, conversiones de espacio de color de valores uniformes, inversiones de matrices, cálculos de la posición de la luz en espacio de vista, divisiones por el tamaño de la pantalla.
Ahora bien, hay un límite que se olvida: mover de fragmento a vértice solo gana si hay menos vértices que fragmentos. En una malla densa vista de cerca puede haber más vértices que píxeles cubiertos, y entonces el traslado empeora las cosas además de introducir el error de interpolación. La cuenta hay que hacerla con los números de tu escena, no por costumbre.
Qué hacer cuando el fragment shader es el cuello
En orden de rentabilidad, que casi nunca coincide con el orden en que se intenta:
Reducir la resolución de lo caro. No todo tiene que renderizarse a resolución completa. Los reflejos, el desenfoque, la oclusión ambiental y el humo se ven bien a la mitad o a un cuarto, y el ahorro es cuadrático. Es la palanca más grande y la que menos se toca.
Reducir el sobredibujado. Ordenar los opacos de cerca a lejos, un depth prepass cuando los shaders son caros, y limitar el tamaño en pantalla de las partículas, que son la causa número uno de sobredibujado descontrolado.
Reducir el factor de cuadrado. Niveles de detalle agresivos, y evitar geometría densa a distancia.
Mover trabajo a una frecuencia menor. Precalcular en compute lo que no dependa del fragmento, guardar en texturas lo que sea función de pocas variables, subir a uniform lo que sea constante.
Y por último, optimizar el shader. Menos texturas, menos ramas divergentes, menos precisión donde no importe. Es lo último de la lista porque suele dar el diez o el veinte por ciento, mientras que las cuatro anteriores dan factores.
Cuando un fotograma no cabe en el presupuesto, la reacción estándar es buscar qué optimizar. La respuesta que casi siempre gana, y que casi nadie considera primero, es renderizar menos píxeles.
La aritmética es contundente. Bajar de 4K a 1440p reduce los píxeles en un 55 %. Bajar a 1080p, en un 75 %. Y esa reducción no afecta a una parte del fotograma: multiplica todo lo que ocurre por píxel, que en un renderer moderno es la iluminación, las sombras, el post-proceso, los reflejos y la composición. Ninguna optimización de shader se acerca a ese factor.
Lo que hace que la idea suene mal es la suposición de que menos resolución significa una imagen peor, y eso dejó de ser cierto. La combinación de renderizar a resolución interna reducida y reconstruir con información temporal —acumulando muestras de fotogramas anteriores con vectores de movimiento— produce imágenes que, en movimiento, son difíciles de distinguir de la resolución nativa. Es lo que hacen todos los motores comerciales desde hace años, y por eso las consolas actuales renderizan internamente muy por debajo de lo que anuncian.
Hay una versión mucho más simple que se puede montar en una tarde y que ya da la mayor parte del beneficio: resolución dinámica. Se mide el tiempo del fotograma, y si se pasa del presupuesto, se reduce la escala de renderizado un escalón; si sobra, se sube. El render se hace a una textura de resolución variable y se escala al canvas al final. El resultado es un fotograma estable a costa de nitidez variable, que es un intercambio que el ojo tolera mucho mejor que los tirones.
Y aún hay una versión más simple y a menudo suficiente en la web: no renderizar a la resolución del dispositivo por defecto. Un móvil con devicePixelRatio de 3 pide nueve veces más píxeles que uno de ratio 1. Acotar ese ratio a 2, o a 1,5 en dispositivos modestos, es una línea de código y suele ser la diferencia entre 30 y 60 fotogramas por segundo. Es lo primero que hay que probar cuando algo va lento en móvil, antes de mirar un solo shader.