Saltar al contenido

Por qué el dibujado está partido en etapas y no en una función

Las dos restricciones del hardware que obligan a que el pipeline gráfico tenga exactamente esta forma, y qué etapa nace de cada una.

Las dos restricciones que lo explican todo

Restricción uno: la aritmética es casi gratis y el acceso a memoria es carísimo. En una GPU moderna, una multiplicación en coma flotante cuesta un ciclo y hay miles de unidades haciéndolo a la vez. Una lectura de memoria externa cuesta cientos de ciclos. La proporción entre ambas cosas lleva décadas empeorando: cada generación añade más potencia aritmética que ancho de banda. De ahí sale la estrategia central del hardware gráfico, que es esconder la latencia con paralelismo: cuando un grupo de ejecución se bloquea esperando una textura, el planificador mete otro grupo en las mismas unidades. Para que eso funcione hacen falta miles de tareas independientes listas a la vez. Todo el pipeline está diseñado para producir esas tareas.

Restricción dos: el número de elementos de trabajo cambia brutalmente a mitad del proceso. Un triángulo tiene tres vértices y puede cubrir un píxel o dos millones. Ese factor de amplificación no se conoce hasta que los vértices están transformados y proyectados. Es decir: hay un punto del proceso en el que un elemento de entrada se convierte en un número imprevisible de elementos de salida.

Esas dos restricciones bastan para deducir la arquitectura. Como la amplificación es imprevisible, no puedes escribir una sola función que vaya del vértice al píxel: tendría que emitir un número variable de resultados, y eso destruye el paralelismo regular que la restricción uno exige. La solución es partir el trabajo justo en el punto de amplificación e insertar ahí una etapa que sepa repartir: el rasterizador. Antes del corte, una tarea por vértice. Después del corte, una tarea por fragmento. Ambas son masivamente paralelas y regulares; el corte es lo que las hace posibles.

En naranja las dos etapas donde se ejecuta código tuyo. En azul las de función fija, implementadas en silicio dedicado. En rojo las etapas opcionales que existen en el hardware de escritorio pero que ninguna API web expone, y que tienen su propia lección.

Qué se paraleliza, qué no, y por qué

La pregunta que ordena todo el diagrama es: ¿puede esta etapa procesar sus elementos sin mirar a los demás?

El vertex shader sí. Cada vértice se transforma sin saber nada de sus vecinos. No puede crear vértices ni destruirlos, no puede leer el resultado de otra invocación, no puede escribir en una posición arbitraria de memoria. Esas prohibiciones son exactamente lo que permite lanzar cien mil invocaciones a la vez sin ninguna sincronización. La API te limita para poder ir rápido, y ese patrón se repite en todo el pipeline.

El fragment shader casi. Cada fragmento se colorea independientemente, con una excepción importante: las derivadas. Para saber cuánto varía una coordenada de textura entre píxeles vecinos —y con ello elegir el nivel de mipmap— el hardware ejecuta los fragmentos en bloques de dos por dos y compara valores entre las cuatro invocaciones del bloque. Eso convierte al fragment shader en casi independiente, con una dependencia local mínima. Tiene consecuencias importantes que se ven en la lección de rasterización.

El rasterizador no. Su trabajo es determinar qué píxeles cubre un triángulo, y ese trabajo tiene una estructura espacial que no se reparte bien en un modelo de una tarea por elemento. Además necesita coherencia: los fragmentos vecinos deben acabar en la misma unidad para aprovechar cachés y para formar los bloques de dos por dos. Es un algoritmo idéntico siempre, ejecutado billones de veces, con una estructura de datos muy específica. Ese es el perfil exacto de lo que conviene poner en silicio dedicado en lugar de en un shader.

La mezcla tampoco, y por un motivo distinto. Dos triángulos que caen en el mismo píxel tienen que combinarse en el orden en que los enviaste, porque la mezcla no es conmutativa. Eso significa que el final del pipeline tiene un punto de serialización obligatorio por píxel. La GPU ejecuta miles de fragmentos en desorden, pero el resultado tiene que ser como si se hubieran procesado en orden de envío. Mantener esa ilusión es trabajo de unidades dedicadas, y es la razón profunda de que la transparencia sea cara.

El ciclo de vida de un triángulo

Con las restricciones sobre la mesa, la secuencia completa se lee sola.

Ensamblado de entrada. El hardware lee los búferes de vértices y, si existe, el búfer de índices, y compone para cada vértice el conjunto de atributos que el shader espera. Existe porque los datos están en memoria en un formato compacto que hay que descodificar.

Vertex shader. Una invocación por vértice. Su única salida obligatoria es una posición en espacio de recorte. Existe porque las transformaciones son distintas en cada aplicación y no se pueden fijar en silicio.

Ensamblado de primitivas y recorte. Los vértices se agrupan de tres en tres para formar triángulos y se recortan contra el volumen de visión. El recorte existe por una razón puramente aritmética: la proyección en perspectiva divide por una coordenada que puede ser cero o negativa detrás de la cámara, y dividir ahí produce basura. Hay que cortar antes de dividir.

División perspectiva y viewport. Se divide por esa coordenada y se pasa a un cubo normalizado; después se escalan las coordenadas al tamaño real en píxeles.

Rasterización. Se determina qué muestras del framebuffer caen dentro del triángulo y se generan fragmentos con los atributos interpolados.

Fragment shader. Una invocación por fragmento. Calcula el color. Es donde vive casi todo el coste de una escena moderna.

Pruebas por fragmento. Profundidad, stencil, tijera. Deciden si el fragmento sobrevive.

Mezcla y escritura. El color superviviente se combina con lo que ya había y se escribe.

Lo que este modelo predice antes de medir

Un modelo mental sirve si genera predicciones. Este genera al menos cuatro, y todas se comprueban.

Predice que subdividir una malla es barato hasta que deja de serlo. Duplicar los triángulos duplica el trabajo por vértice, pero no cambia el número de píxeles cubiertos. Si tu escena está limitada por fragmentos, una malla con el doble de detalle puede costar prácticamente lo mismo.

Predice que el tamaño de la ventana afecta a una mitad del coste y no a la otra. Es la prueba de diagnóstico que acabas de leer.

Predice que un shader de fragmento complejo cuesta mucho más que un shader de vértice complejo, porque se ejecuta órdenes de magnitud más veces. Mover un cálculo del fragment shader al vertex shader, cuando el resultado se puede interpolar sin error visible, es la optimización más rentable que existe en gráficos.

Predice que dibujar de delante a atrás ahorra trabajo en objetos opacos, porque los fragmentos tapados pueden descartarse antes de ejecutar el shader. Three.js lo hace por defecto y por eso no lo notas, pero explica por qué el orden de dibujo importa incluso cuando el resultado visual es idéntico.

Las cuatro predicciones se refinan en las lecciones siguientes, empezando por qué es exactamente un vértice, que resulta no ser lo que casi todo el mundo cree.