Visualizando Vertex y Fragment Shaders en la Pipeline Gráfica

EN NL ES PT-BR


Los shaders son pequeños programas que se ejecutan en la GPU como parte del proceso de dibujar una escena. Algunos deciden dónde aparece la geometría en la pantalla, mientras que otros deciden el aspecto de esa geometría una vez dibujada. La primera distinción útil en ese proceso es entre vertex shaders y fragment shaders. Un vertex shader se ejecuta una vez por cada vértice de tu malla, mientras que un fragment shader se ejecuta para los fragmentos generados dentro de la cobertura del triángulo en la pantalla, aproximadamente uno por cada muestra de píxel cubierta. Estas dos etapas realizan trabajos diferentes y se ejecutan a frecuencias muy distintas, lo que explica gran parte del comportamiento, el costo y el resultado visual que ves en el renderizado en tiempo real.

Este artículo construye un modelo concreto de dónde se ejecuta cada etapa, qué datos lee y qué datos produce. Comienza con la división entre vértice y fragmento porque esa es la base del resto de la pipeline gráfica. Más adelante, una vez que esa base está clara, situamos los geometry shaders, tessellation shaders y compute shaders en contexto.

Etapas de Shader en la Pipeline Gráfica

La mayoría de los motores 3D en tiempo real siguen una pipeline de rasterización, lo que significa que parten de datos de triángulos y los convierten en la imagen 2D que ves en pantalla. En ese proceso, los vértices entran primero, luego se convierten en primitivas como triángulos, después la rasterización transforma esos triángulos en fragmentos, y finalmente los resultados que sobreviven se escriben en el framebuffer, que es el búfer de imagen que contiene el fotograma actual antes de mostrarse. Las dos etapas programables principales en este camino son el vertex shader antes de la rasterización y el fragment shader después de la rasterización. Etapas opcionales pueden añadir, refinar o calcular datos alrededor de ese camino, pero el traspaso de vértice a fragmento es la base para el renderizado ordinario de triángulos. Así que la pregunta más importante es: ¿qué recibe cada etapa como entrada?

  • Entrada del vertex shader: un vértice a la vez (posición, normal, UV, tangentes, atributos personalizados)
  • Salida del vertex shader: posición transformada en clip-space más valores por vértice que se interpolarán suavemente a través del triángulo, como color, normales o UVs
  • Entrada del fragment shader: esos valores interpolados para cada fragmento, más texturas, uniforms y parámetros de material
  • Salida del fragment shader: uno o más valores de color/profundidad escritos en los render targets

A medida que el triángulo se expande sobre más celdas del panel derecho, la diferencia entre el trabajo de vértice y el trabajo de fragmento se vuelve mucho más fácil de apreciar. El triángulo sigue teniendo solo tres vértices, pero la cobertura rasterizada toca muchas más celdas de la pantalla, por lo que el trabajo de fragmento aumenta mucho más rápido que el de vértice. Cambiar a Optional stages sitúa la teselación, la geometría y el cómputo en relación con ese camino principal de vértice a fragmento.

En la vista de etapas opcionales, la teselación y la geometría deben entenderse como procesamiento extra que ocurre antes de la rasterización, no entre la rasterización y el fragment shader. Los compute shaders son diferentes: son programas de GPU, pero no forman parte de este camino de triángulo a fragmento en absoluto.

Incluso cuando el triángulo se mantiene pequeño, alternar entre Vertex work y Fragment work muestra que el orden de las etapas no cambia con la carga de trabajo. Una vez que el triángulo llena más del panel en espacio de pantalla, el lado del fragmento crece en densidad visual mientras que la cantidad de vértices en las esquinas permanece igual. La vista Optional stages deja clara la distinción estructural: la teselación y la geometría siguen alimentando la rasterización, mientras que el cómputo permanece aparte en su propio camino de ejecución.

Una forma práctica de recordar esto es seguir dónde explota el recuento. Una malla puede tener decenas de miles de vértices, pero un dibujado a pantalla completa puede tocar millones de fragmentos. Dado que el recuento de fragmentos suele ser mucho mayor, las operaciones matemáticas costosas en los fragment shaders generalmente consumen más tiempo de fotograma que las mismas operaciones en los vertex shaders.

¿Qué es un Vertex Shader?

Un vertex shader suele ser responsable de la colocación geométrica. Su tarea más común es multiplicar cada posición de vértice por las matrices de modelo, vista y proyección. También puede preparar valores para etapas posteriores, como normales en espacio mundial, coordenadas de textura o datos específicos de efectos como un valor de máscara, un factor de mezcla o un vector de dirección que el fragment shader usará más adelante. En términos directos, un vertex shader es un programa de GPU que se ejecuta una vez por cada vértice de entrada en una llamada de dibujo. No puede sombrear cada píxel cubierto porque la rasterización aún no ha ocurrido. Su salida principal es una posición en clip-space, más cualquier valor por vértice que deba interpolarse a través de la primitiva para el trabajo posterior del fragmento.

Matemáticamente, la transformación canónica suele verse así:

clipPos=PVM[x,y,z,1]T\text{clipPos} = P\,V\,M\,[x,y,z,1]^T

Aquí, [x,y,z,1]^T es la posición original del vértice expresada en coordenadas homogéneas. M es la matriz de modelo, que sitúa el objeto en el mundo. V es la matriz de vista, que expresa la escena desde el punto de vista de la cámara. P es la matriz de proyección, que mapea esa posición en espacio de cámara a clip-space para que la GPU pueda continuar hacia el renderizado en espacio de pantalla.

El detalle importante no es la fórmula en sí, sino la tasa de ejecución. Si tu modelo tiene 20.000 vértices, este shader se ejecuta aproximadamente 20.000 veces para esa llamada de dibujo. No se ejecuta para cada píxel en pantalla.

En el área de juego interactiva que aparece a continuación, el triángulo gris son los datos de vértice de entrada y el triángulo azul es la salida transformada después de una cadena de transformación simplificada. Arrastrar las esquinas grises redefine la forma de la malla original, mientras que arrastrar el resultado azul, su manejador de rotación o su manejador de escala cambia las posiciones transformadas que la etapa de vértice entregaría a la rasterización. Lo importante es notar que todo ese movimiento sigue ocurriendo moviendo solo las esquinas del triángulo en el espacio de dispositivo normalizado, sin crear ningún detalle interior nuevo.

Cuando el triángulo fuente se redefine a la izquierda, la salida a la derecha mantiene la misma transformación por vértice, lo que facilita separar la estructura de la malla del trabajo de colocación posterior. Rotar o trasladar el triángulo azul muestra cómo los vértices transformados se reposicionan como grupo, y aumentar la escala hace que el triángulo ocupe más pantalla sin crear esquinas geométricas adicionales. Esa huella de pantalla más grande también insinúa por qué el costo de los fragmentos puede aumentar más adelante, incluso cuando esta etapa aún procesa solo tres vértices.

Una consecuencia práctica de esta división de etapas es que los vertex shaders son buenos para dar forma y preparar geometría, mientras que los fragment shaders son mejores para el detalle visual fino dentro de cada triángulo. Si introduces trabajo de apariencia a nivel de píxel en la etapa de vértice, el resultado suele verse bloqueado o inestable porque las salidas de los vértices solo se conocen en las esquinas del triángulo y luego se interpolan a través de la superficie. La interpolación es útil, pero no equivale a un cómputo verdadero por fragmento.

¿Qué es un Fragment Shader?

Después de rasterizar las primitivas, la GPU genera fragmentos. Cada fragmento tiene valores interpolados (varyings) provenientes de la etapa de vértice. Ahora el fragment shader decide la apariencia visible de la superficie: color base, detalle de textura, respuesta de iluminación, lógica de transparencia y, a veces, si un fragmento debe descartarse. En términos directos, un fragment shader es un programa de GPU que se ejecuta para cada fragmento generado, aproximadamente por cada muestra de píxel cubierta antes de que las escrituras de profundidad, stencil, blending y render target finalicen el fotograma. Recibe datos interpolados en lugar de vértices de malla en bruto. Eso lo convierte en el lugar adecuado para las decisiones de material a nivel de píxel.

Esta etapa es donde los materiales se convierten en detalle de imagen. Si muestreas una textura, combinas mapas de normales, calculas términos BRDF, aplicas niebla y mezclas capas, ese trabajo suele ocurrir aquí.

Lo primero que hay que concretar es que un fragment shader no recibe tres valores de esquina separados y luego elige uno. Recibe valores que se han combinado a través del triángulo durante la rasterización. A medida que la sonda se mueve por la visualización a continuación, el color del fragmento inspeccionado cambia continuamente porque la GPU está mezclando las tres salidas de los vértices mediante ponderación baricéntrica antes de que comience el sombreado del fragmento.

Observa lo que cambia suavemente a través del triángulo: no son los valores originales de los vértices directamente, sino valores interpolados. Ese paso de interpolación es una de las razones principales por las que las etapas de vértice y fragmento están emparejadas. La etapa de vértice prepara los puntos extremos de los datos, y la etapa de fragmento utiliza valores continuos entre esos puntos extremos para calcular la apariencia final.

Las normales siguen la misma regla. A menudo se escriben una vez por vértice, luego se combinan a través de la primitiva, y solo entonces se normalizan y se usan para la iluminación. El siguiente explorador hace visible esa transferencia: cambiar las direcciones de las normales de los vértices redefine el campo de normales a través del triángulo, mientras que mover la sonda revela la diferencia entre la normal combinada en bruto y la dirección normalizada que realmente es útil para el sombreado.

Este detalle es importante porque muchos modelos de iluminación dependen de esa normal interpolada en lugar de una dirección plana para todo el triángulo. Si la normal por fragmento cambia suavemente, la respuesta difusa y especular también puede cambiar suavemente. Si las entradas son demasiado gruesas o la iluminación se evalúa demasiado pronto, el resultado comienza a perder detalle que debería existir entre los vértices.

Esa compensación se vuelve más clara en la comparación de iluminación a continuación. Con una malla gruesa, la versión por vértice solo puede muestrear el destello en unas pocas esquinas e interpolar el resultado a través del interior, por lo que la región brillante puede verse borrosa o desplazada. A medida que la luz se mueve, la versión por fragmento se mantiene más fiel porque la iluminación se evalúa en cada fragmento cubierto en lugar de aproximarse a partir de unas pocas muestras de vértice. Aumentar la densidad de la malla reduce la brecha, que es por qué la iluminación por vértice puede ser aceptable para algunas superficies y claramente insuficiente para otras.

Mesh density

Vertex Shader vs Fragment Shader

La forma más rápida de comparar estas etapas es hacerse las mismas cuatro preguntas para ambas.

  1. ¿Con qué frecuencia se ejecuta? Vertex shader: una vez por vértice. Fragment shader: una vez por fragmento.

  2. ¿Cuál es el propósito principal? Vertex shader: transformación geométrica y configuración de varyings. Fragment shader: sombreado final y salida de color/profundidad.

  3. ¿Qué datos dominan su entrada? Vertex shader: atributos de malla más uniforms de transformación. Fragment shader: varyings interpolados, texturas, luces, uniforms de material.

  4. ¿Qué patrón de rendimiento es típico? Vertex shader: escala con la complejidad geométrica. Fragment shader: escala con la cobertura en pantalla y el overdraw.

Estas diferencias implican reglas prácticas de optimización. Si un efecto se puede aproximar con matemática por vértice e interpolación, puede ser más barato. Si la precisión debe ser exacta a nivel de píxel, como en el caso de la respuesta especular, el mapeo de normales o el detalle procedural fino, pertenece a la etapa de fragmento incluso si el costo aumenta.

Errores Comunes en la Selección de Etapas

Un error frecuente es meter demasiada lógica en los fragmentos sin considerar la cobertura. Un post-efecto a pantalla completa en 4K puede ejecutar muchos millones de invocaciones de shader por fotograma. Otro error es empujar la lógica de apariencia demasiado pronto hacia los vértices y luego preguntarse por qué el detalle se colapsa en triángulos grandes.

Un proceso de decisión simple ayuda:

  1. ¿Este cómputo define la colocación del objeto? Ponlo en el vertex.
  2. ¿Define la apariencia a nivel de píxel? Ponlo en el fragment.
  3. ¿Necesita información de píxeles vecinos a partir de datos ya renderizados? A menudo esto significa un pase de post-procesamiento posterior, posiblemente con compute.

Ese proceso no es perfecto, pero evita la mayoría de los errores arquitectónicos en el código de renderizado en tiempo real, especialmente si lo comparas con técnicas como el ray marching con campos de distancia con signo, que se sitúan completamente fuera del camino estándar de rasterización de triángulos.

Otros Tipos de Shader en Contexto

Geometry Shaders

Los geometry shaders se ejecutan por primitiva después de la etapa de vértice. Pueden emitir nuevas primitivas, por lo que son útiles para efectos específicos como salidas de mapas de sombras en capas o expansión de líneas. Sin embargo, a menudo se evitan en rutas críticas de rendimiento porque pueden convertirse en un cuello de botella de rendimiento. Muchos motores modernos prefieren alternativas como instancing, mesh shaders en APIs compatibles o generación impulsada por compute, y el resumen de la pipeline de renderizado de Khronos es una referencia formal útil si deseas el ordenamiento de etapas a nivel de API detrás de ese resumen.

Tessellation Shaders

La teselación se divide en etapas de control y evaluación. Subdivide primitivas de parche para añadir densidad geométrica en la GPU. Esto puede mejorar superficies curvas y displacement mapping cuando el detalle en espacio de pantalla lo requiere. Para la intuición de curvas de bajo nivel detrás de las rutas espaciales controladas por manejadores, visualizar curvas Bezier 3D y la construcción De Casteljau es un complemento útil antes de pasar de curvas a parches. La desventaja es el aumento de complejidad y las restricciones de hardware/API, por lo que muchos equipos lo usan de forma selectiva.

Compute Shaders

Los compute shaders no están vinculados a la rasterización. Ejecutan kernels de GPU de propósito general sobre grupos de hilos y se usan ampliamente para simulación, culling, actualizaciones de partículas, preparación de iluminación por clústeres, eliminación de ruido y post-procesamiento. En los renderizadores modernos, el compute a menudo coopera con los pases gráficos tradicionales en lugar de reemplazarlos, y esos pases a menudo consumen entradas procedurales construidas a partir de funciones de ruido como value noise, Perlin noise y ruido fractal. Para una explicación orientada a principiantes sobre las entradas, salidas y ejecución local de etapa de los shaders, la introducción a shaders de LearnOpenGL sigue siendo un recurso útil.

Un mapa mental útil es:

  • Vertex + Fragment: ruta principal de gráficos rasterizados
  • Geometry + Tessellation: etapas opcionales de amplificación/refinamiento geométrico
  • Compute: ruta de procesamiento paralelo general que puede alimentar o consumir datos de renderizado

Desarrollando Intuición para Proyectos Reales

Al depurar problemas de renderizado, identifica el límite de etapa donde aparecen los datos incorrectos por primera vez. Si las posiciones transformadas son incorrectas antes de la rasterización, inspecciona la lógica del vertex. Si la geometría se ve bien pero el color/iluminación es incorrecto, inspecciona la lógica del fragment. Si la topología o subdivisión es incorrecta, inspecciona las etapas de geometry/teselación. Si los búferes de preprocesamiento son incorrectos, inspecciona los kernels de compute.

También puedes perfilar por intención de etapa. Un alto costo de vértices a menudo se debe a mallas densas o skinning pesado. Un alto costo de fragmentos a menudo se debe a una gran cobertura de pantalla, matemática de material costosa o overdraw de capas transparentes. Esa división da dirección inmediata para experimentos de optimización, y si deseas una explicación más profunda del paso de interpolación entre las etapas programables, la lección de Scratchapixel sobre rasterización es el recurso más relevante.

Resumen

Los vertex y fragment shaders son diferentes porque se ejecutan sobre unidades de trabajo distintas. Los vertex shaders procesan puntos de la malla y preparan datos interpolados. Los fragment shaders procesan fragmentos rasterizados y calculan la apariencia final.

Si mantienes ese modelo de ejecución en mente, la mayoría de las decisiones de pipeline se vuelven más claras:

  • Matemática de posicionamiento y preparación de varyings en vertex shaders.
  • Lógica de material e iluminación precisa a nivel de píxel en fragment shaders.
  • Usa geometry y tessellation solo cuando sus capacidades específicas sean necesarias.
  • Usa compute para tareas generales de GPU fuera del flujo estricto de rasterización.

Ese modelo escala desde demos simples hasta renderizadores de producción y hace que el código de shaders sea más fácil de razonar, optimizar y depurar.