Graphics pipeline en OpenGL

Francisco ZavalaFrancisco Zavala
12 min read

En gráficos por computadora 3D al proceso de transformar los datos de una escena tridimensional en una representación 2D que se pueda visualizar en pantalla se le conoce como graphics pipeline o pipeline gráfico, y está compuesto por una serie de etapas que transforman y procesan la información de la escena hasta producir los píxeles finales que serán renderizados.

Podemos imaginar al pipeline gráfico como una máquina de procesamiento en la que introducimos nuestros datos más básicos —como los vértices— y, a medida que avanzan por cada etapa, estos datos se transforman según los comandos e instrucciones que hemos definido en nuestro programa. Al final del recorrido, obtenemos una imagen 2D lista para mostrarse en pantalla.

Graphics pipeline

Para comprender mejor estos conceptos, exploraremos cómo funciona cada etapa del proceso, acompañándola con el código necesario en C++, OpenGL y GLSL.

vertex array

Aunque no es técnicamente parte del pipeline de renderizado, el proceso de obtención de los vértices que definen un objeto 3D es muy importante. Estos vértices suelen obtenerse mediante modelado 3D, donde se manipulan nubes de puntos para crear mallas poligonales (típicamente trianguladas).

En ejemplos posteriores trabajaremos con modelos 3D complejos, pero para esta demostración usaremos una geometría simple: un cuadrado construido con dos triángulos

    // Array de vértices para un cuadrado (dos triángulos)
    float vertexPositions[] = {
        // Primer triángulo
        -0.5f, -0.5f, 0.0f, // esquina inferior izquierda
        0.5f, -0.5f, 0.0f, // esquina inferior derecha
        0.5f,  0.5f, 0.0f, // esquina superior derecha

        // Segundo triángulo
        -0.5f, -0.5f, 0.0f, // esquina inferior izquierda
        0.5f,  0.5f, 0.0f, // esquina superior derecha
        -0.5f,  0.5f, 0.0f  // esquina superior izquierda
    };

Vertex Shader

El vertex shader es la primera etapa programable del pipeline gráfico de OpenGL y también la única obligatoria: Su función principal es transformar las posiciones de los vértices (de coordenadas del mundo a coordenadas de pantalla) y generar información que será utilizada por las siguientes etapas del pipeline.

Vertex Pulling

Antes de que se ejecute el vertex shader, OpenGL realiza automáticamente una etapa fija conocida como vertex pulling. Esta etapa no es programable por el usuario, y su tarea es extraer los datos de los vértices desde la memoria (almacenados en buffers) y proporcionarlos al shader como entradas.

Estos datos suelen residir en objetos llamados VBOs (Vertex Buffer Objects), que contienen atributos como posiciones, normales o coordenadas de textura y la forma en que estos atributos se organizan y enlazan se encapsula en un VAO (Vertex Array Object).

Configuración en C++

A continuación se muestra cómo se configura configura el VBO y VAO

glGenVertexArrays(numVAOs, vao);                 // Genera un VAO (almacena la configuración de atributos)
glBindVertexArray(vao[0]);                       // Activa el VAO

glGenBuffers(numVBOs, vbo);                      // Genera un VBO (almacena datos de vértices)
glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);           // Lo enlaza como buffer de tipo GL_ARRAY_BUFFER
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexPositions), vertexPositions, GL_STATIC_DRAW);  
// Carga los datos de los vértices al buffer

glEnableVertexAttribArray(0);                    // Habilita el atributo de vértice en la ubicación 0
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0); 
// Define cómo deben interpretarse los datos del VBO: 
// - índice 0 (layout location)
// - 3 componentes por vértice (x, y, z)
// - tipo GL_FLOAT
// - sin normalizar
// - sin separación entre atributos (stride = 0)
// - sin desplazamiento inicial (offset = 0)

Correspondencia en el Shader

En el vertex shader, los atributos de vértice se declaran con el calificador in, que indica que los datos llegarán desde fuera del shader (desde la etapa de vertex pulling)

    const char *vshaderSource =
        "#version 450\n"
        "layout(location=0) in vec3 position;\n"
        "void main(void) {\n"
        "    gl_Position = vec4(position, 1.0);\n"
        "}\n";
  • in se usa para recibir atributos de entrada. En el vertex shader, suele usarse para atributos como posición, color o normales.

  • out se usa para enviar información a la siguiente etapa, como un fragment shader o geometry shader.

  • layout(location=0) indica que este atributo corresponde al índice 0, el mismo que configuramos desde C++ con glVertexAttribPointer.

  • in vec3 position; declara la variable que recibirá la posición de cada vértice.

  • En el cuerpo de main(), esa posición se convierte en un vec4 y se asigna a gl_Position, la variable especial que representa la posición final del vértice.

Esta rutina de entrada y salida es la forma principal de transmitir información personalizada entre las distintas etapas del pipeline gráfico en OpenGL. A través de los calificadores in y out, no solo es posible llevar posiciones de vértices desde la aplicación al vertex shader, sino también pasar datos adicionales desde fuera como colores, normales, coordenadas de textura o cualquier otro atributo necesario para el procesamiento gráfico.

Primitivas y ensamblaje

Una vez que el vertex shader ha transformado las posiciones de los vértices y ha enviado sus datos a las etapas posteriores, el pipeline gráfico necesita agrupar esos vértices en unidades geométricas llamadas primitivas. Estas primitivas son la base sobre la cual OpenGL genera la geometría visible en pantalla. Las más comunes incluyen puntos, líneas y triángulos, siendo estos últimos la forma más utilizada en el renderizado 3D moderno.

En nuestro ejemplo, hemos definido un arreglo de vértices que representa dos triángulos, los cuales juntos forman un cuadrado. Estos vértices se almacenan en un VBO y son interpretados por OpenGL según el modo de dibujo especificado en la función glDrawArrays:

glDrawArrays(GL_TRIANGLES, 0, 6);

El primer parámetro (GL_TRIANGLES) le indica a OpenGL que cada grupo de tres vértices consecutivos debe interpretarse como un triángulo independiente. Esto implica que los vértices serán consumidos de la siguiente manera:

  • Vértices 0, 1 y 2 → primer triángulo

  • Vértices 3, 4 y 5 → segundo triángulo

Rasterización

Una vez que las primitivas han sido correctamente ensambladas a partir de los vértices procesados por el vertex shader, el pipeline gráfico de OpenGL continúa con una serie de etapas cruciales que determinan qué partes de esas primitivas realmente se dibujarán en pantalla. Este conjunto de pasos intermedios asegura que solo los fragmentos visibles y válidos de la geometría lleguen al fragment shader.

Clipping

La primera de estas etapas es el clipping, cuyo objetivo es eliminar las partes de las primitivas que quedan fuera del espacio visible de la escena. Este espacio visible es definido por el sistema de coordenadas de recorte (clip space), que abarca desde -1 hasta 1 en cada eje después de la transformación por la matriz de proyección.

Por ejemplo, si una primitiva cruza los límites de este volumen, OpenGL la recorta automáticamente y conserva solo la porción que permanece dentro del espacio visible. Este proceso es automático y no requiere intervención del programador.

Diagram showing a red triangular shape partially inside and outside a rectangle on the left. An arrow labeled "Clip" points to a second rectangle on the right, where the triangle is clipped to fit within the boundaries.

🛈 El clipping puede modificar las primitivas originales, generando nuevos vértices en los bordes del volumen de recorte. Exploraremos el proceso con más detalle en futuros programas.

Transformación al viewport

Luego del clipping, los vértices que han sobrevivido son transformados desde coordenadas normalizadas (NDC, Normalized Device Coordinates) al sistema de coordenadas de pantalla mediante la transformación de viewport. Esta etapa adapta la escena para que se dibuje en una región específica de la ventana definida por el usuario (el viewport), usualmente con glViewport().

Diagram illustrating world and device coordinates. On the left, a larger triangle and rectangle show the "Window" in world coordinates with labeled axes and min/max points. On the right, a smaller version in "ViewPort" shows the device coordinates. Axes and limits are also labeled for both.

Este paso toma en cuenta el tamaño real de la ventana y convierte las coordenadas flotantes en coordenadas absolutas de píxeles, permitiendo que las primitivas se alineen correctamente en la pantalla.

🛈 Aunque aquí se menciona brevemente, el viewport y su rol en el pipeline serán explorados con mayor profundidad más adelante.


Culling (descartado de caras)

Antes de que las primitivas lleguen a la rasterización, OpenGL puede realizar una etapa llamada culling para descartar aquellas caras que no deberían ser visibles desde la posición actual de la cámara. Esto se basa en la orientación de los vértices (conocido como winding order): si los vértices se ordenan en sentido horario o antihorario.

Diagram illustrating a view frustum with concepts of view-frustum culling, back-face culling, and occlusion culling. A visible object is shown inside the frustum, while other objects are outside, representing different culling techniques.

Esta técnica es útil para mejorar el rendimiento, ya que evita renderizar superficies ocultas, como la parte trasera de un objeto sólido.

🛈 El culling también se tratará de forma detallada en un artículo posterior, incluyendo su configuración con glEnable(GL_CULL_FACE).


Rasterización de primitivas

Finalmente, las primitivas visibles pasan por la etapa de rasterización, donde se convierten en fragmentos. Aquí, OpenGL determina qué píxeles de la pantalla están cubiertos por la primitiva y genera un fragmento por cada uno de ellos. Estos fragmentos contienen información interpolada de los vértices originales, como color, coordenadas de textura o normales.

Cada fragmento generado será enviado al fragment shader, que se encargará de calcular su color final (y otras propiedades) antes de ser escrito al framebuffer.

La rasterización marca la transición del espacio continuo de coordenadas al mundo discreto de los píxeles, y es una de las etapas más intensivas del pipeline.

Fragment Shader

Una vez que la rasterización ha generado todos los fragmentos (uno por cada píxel cubierto por una primitiva), la siguiente etapa programable del pipeline gráfico es el fragment shader. Su tarea principal es calcular el color de cada fragmento individual, y potencialmente otras propiedades como la profundidad (depth), transparencia o coordenadas para mapas de texturas.

A diferencia del vertex shader, que opera por cada vértice, el fragment shader se ejecuta una vez por fragmento, lo que puede traducirse en millones de ejecuciones por cuadro en una escena compleja. Es aquí donde se definen los detalles visuales más importantes como: iluminación, texturas, reflejos, sombreado, etc.

este fragment shader asigna a cada fragmento un color rojo puro:

const char *fshaderSource =
    "#version 450\n"
    "out vec4 color;\n"
    "void main(void) {\n"
    "    color = vec4(1.0, 0.0, 0.0, 1.0);\n" // Rojo
    "}\n";
  • out vec4 color;: Declara una variable de salida llamada color. Esta variable será el valor final que se escriba en el framebuffer para cada fragmento.
  • vec4(1.0, 0.0, 0.0, 1.0): se asigna el color rojo en a cada fragmento.

En este caso, como no hay atributos adicionales, el fragment shader simplemente actúa como un generador de color constante.

Etapas finales: pruebas y mezcla de fragmentos

OpenGL realiza una serie de pruebas conocidas como etapas finales. Estas pruebas permiten controlar si el fragmento será realmente escrito en el framebuffer y cómo debe combinarse con los valores ya presentes en él. Estas etapas, aunque son parte del pipeline, pueden habilitarse o configurarse según el comportamiento deseado.

Pruebas de fragmentos (Final Fragment Tests)

Las pruebas más comunes son:

  • Prueba de profundidad (Depth Test)
    Determina si un fragmento debe escribirse o no, comparando su valor de profundidad (gl_FragDepth) con el que ya está almacenado en el depth buffer. Si la prueba falla, el fragmento es descartado.
    Se activa con:

      glEnable(GL_DEPTH_TEST);
    
  • Prueba de stencil (Stencil Test)
    Permite crear máscaras complejas en pantalla, útiles para efectos como espejos o reflejos. Se activa con:

      glEnable(GL_STENCIL_TEST);
    
  • Prueba de scissor (Scissor Test)
    Limita el área de la ventana donde se puede dibujar. Es útil para renderizar solo una región específica de la pantalla.

      glEnable(GL_SCISSOR_TEST);
      glScissor(x, y, width, height);
    

Estas pruebas se ejecutan en orden, y si alguna de ellas falla, el fragmento se descarta, ahorrando cómputo y evitando escribir resultados innecesarios.


Blending: combinando colores en pantalla

Si un fragmento pasa todas las pruebas, entonces OpenGL puede combinar su color con el color que ya se encuentra en el framebuffer. Este proceso se llama blending (mezcla), y es esencial para representar transparencia, efectos de iluminación suaves, partículas, humo, etc.

🛈 Todas esta pruebas y configuraciones sera tratará de forma detallada en programas posteriores.


Resultados

Finalmente, si el fragmento pasa las pruebas y el blending ha sido aplicado (si está activo), el resultado final se escribe en el framebuffer. Este es el paso que realmente altera los píxeles de la ventana visible, concluyendo así el procesamiento de la imagen para ese cuadro.

Ejemplo 1

A bright red rectangle centered against a black background.

Ejemplo 2

puedes usar otras primitivas como las lineas para dibujar la geometría.

    glDrawArrays(GL_LINE_LOOP, 0, 6); // dibujar el cuadrado como un loop de lineas

Ejemplo 3

Con una pequeña modificación en el vertex shader, es posible asignar un color específico a cada vértice y transmitir esa información al fragment shader utilizando las variables out y in, que permiten compartir datos entre etapas del pipeline.

Gradient with smooth transitions between various colors including blue, green, red, and yellow, framed by a black background.

    const char *vshaderSource =
        "#version 450\n"
        "layout(location=0) in vec3 position;\n"
        "out vec4 color_;\n"
        "void main(void) {\n"
        "    gl_Position = vec4(position, 1.0);\n"
        "    if(gl_VertexID == 0 || gl_VertexID == 3) {\n"
        "        color_ = vec4(1.0, 0.0, 0.0, 1.0); // Rojo para los vértices 0 y 3\n"
        "    } else if(gl_VertexID == 1 || gl_VertexID == 5) {\n"
        "        color_ = vec4(0.0, 0.0, 1.0, 1.0); // Verde para los vértices 1 y 4\n"
        "    } else {\n"
        "        color_ = vec4(0.0, 1.0, 0.0, 1.0); // Azul para los vértices 2 y 5\n"
        "    }\n"
        "}\n";

¿Qué hace este shader?

  • Primero, se posiciona cada vértice en el espacio de clip (gl_Position) usando los datos recibidos desde el VBO (vec3 position).

  • Luego, se utiliza la variable incorporada gl_VertexID para asignar un color específico a ciertos vértices. Esta variable representa el índice del vértice que se está procesando en ese momento.

  • Dependiendo del valor de gl_VertexID, se asigna un color diferente a color_, que es una variable out. Esta variable será enviada a la siguiente etapa del pipeline: el fragment shader.

Esto permite definir colores por vértice sin necesidad de pasar un segundo atributo en el VBO, utilizando solo la lógica dentro del shader.


    const char *fshaderSource =
        "#version 450\n"
        "out vec4 color;\n"
        "in vec4 color_;\n"
        "void main(void) {\n"
        "    color =  color_;\n" 
        "}\n";
  • Aquí, el fragment shader recibe el color generado por el vertex shader a través de la variable in color_.

  • El valor recibido no es exactamente el que se escribió en el vertex shader, sino una versión interpolada que OpenGL calcula automáticamente en la etapa de rasterización, dependiendo de la posición del fragmento dentro del triángulo.

  • Finalmente, este color se asigna a la salida final del fragment shader, la variable out color, que se convierte en el color visible del píxel.

Cada etapa del pipeline gráfico cumple una función específica y secuencial en la transformación de los datos de la escena, preparando y refinando la información hasta convertirla en fragmentos listos para mostrarse en pantalla. En esta sección hemos realizado un recorrido general por cada una de estas fases, entendiendo su papel dentro del flujo de renderizado. Más adelante, exploraremos cómo estas etapas pueden ser controladas y modificadas para construir escenas más complejas, dinámicas e interesantes.

Puedes encontrar el código fuente en el siguiente repositorio

0
Subscribe to my newsletter

Read articles from Francisco Zavala directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Francisco Zavala
Francisco Zavala