4.3 Pipeline do OpenGL

A figura 4.7 mostra um diagrama dos estágios de processamento do pipeline gráfico do OpenGL (fundo amarelo) e de como os dados gráficos (fundo cinza) interagem com cada estágio. As etapas programáveis são mostradas com fundo preto. No lado esquerdo há uma ilustração do resultado de cada etapa para a renderização de um triângulo colorido.

Pipeline gráfico do OpenGL.

Figura 4.7: Pipeline gráfico do OpenGL.

Observação

Para simplificar, algumas etapas do pipeline foram omitidas, tais como:

  • O geometry shader, utilizado para o processamento de geometria após a montagem de primitivas;
  • Os shaders de tesselação (tessellation control shader e tessellation evaluation shader), utilizados para subdivisão de primitivas;
  • O compute shader, utilizado para processamento de propósito geral (GPGPU).

Essas etapas não serão utilizadas nas atividades da disciplina pois, no momento, não fazem parte do subconjunto do OpenGL ES utilizado pelo WebGL. Entretanto, são etapas frequentemente utilizadas em aplicações para OpenGL desktop. Consulte a especificação do OpenGL 4.6 para ter acesso ao pipeline completo.

Aplicação

Antes de iniciar o processamento, a aplicação deve especificar o formato dos dados gráficos e enviar esses dados à memória que será acessada durante a renderização. A aplicação também deve configurar as etapas programáveis do pipeline, compostas pelo vertex shader e fragment shader. Os shaders devem ser compilados, ligados e ativados previamente.

A geometria a ser processada é especificada através de um arranjo ordenado de vértices. O tipo de primitiva que será formada a partir desses vértices é determinado no comando de renderização. As primitivas suportadas pelo OpenGL são descritas a seguir e mostradas na figura 4.8:

  • GL_POINTS: cada vértice forma um ponto que será desenhado na tela como um pixel ou como um quadrilátero centralizado no vértice. O tamanho do ponto/quadrilátero pode ser definido pelo usuário16;
  • GL_LINES: cada grupo de dois vértices forma um segmento de reta;
  • GL_LINE_STRIP: os vértices são conectados em ordem para formar uma polilinha;
  • GL_LINE_LOOP: os vértices são conectados em ordem para formar uma polilinha, e o último vértice forma um segmento com o primeiro vértice, formando um laço;
  • GL_TRIANGLES: cada grupo de três vértices forma um triângulo;
  • GL_TRIANGLE_STRIP: os vértices formam uma faixa de triângulos com arestas compartilhadas;
  • GL_TRIANGLE_FAN: os vértices formam um leque de triângulos de modo que todos os triângulos compartilham o primeiro vértice.
Primitivas do OpenGL.

Figura 4.8: Primitivas do OpenGL.

Cada vértice do arranjo de vértices de entrada é composto por um conjunto de atributos definidos pela aplicação. Cada atributo pode ser um único valor ou um conjunto de valores. A forma como esses valores são interpretados depende exclusivamente do que é definido no vertex shader. Geralmente, considera-se que cada vértice tem pelo menos uma posição 2D \((x,y)\) ou 3D \((x,y,z)\). Outros atributos comuns para cada vértice são o vetor normal, cor e coordenadas de textura.

Para ser utilizado pelo pipeline, o arranjo de vértices deve ser armazenado na memória como um recurso chamado Vertex Buffer Object (VBO). Cada atributo de vértice pode ser armazenado como um VBO separado, mas também é possível deixar todos os atributos em um único VBO (interleaved data). Cabe à aplicação especificar o formato dos dados de cada VBO e como eles serão lidos pelo vertex shader. Isso deve ser feito sempre antes da chamada do comando de renderização, para todos os VBOs. Alternativamente, essa configuração pode ser feita apenas uma vez e armazenada em um Vertex Array Object (VAO), bastando então ativar o VAO antes de cada renderização.

Além da criação dos VBOs, a aplicação pode criar variáveis globais, chamadas de variáveis uniformes (uniform variables), que podem ser lidas pelo vertex shader e fragment shader. Essa é uma outra forma de enviar dados ao pipeline. As variáveis uniformes contêm dados apenas de leitura e que não variam de vértice para vértice, por isso o nome “uniforme.” Por exemplo, uma matriz de transformação geométrica pode ser armazenada como uma variável uniforme pois todos os vértices serão transformados por essa matriz durante o processamento no vertex shader (isto é, a matriz de transformação é a mesma para todos os vértices). Também é possível criar buffers de dados uniformes (Uniform Buffer Objects, ou UBOs) para enviar arranjos de dados. A especificação do OpenGL garante ser possível enviar pelo menos 16KB de dados no formato de UBOs, mas é comum os drivers oferecerem suporte a até 64KB.

A aplicação também pode enviar dados ao pipeline usando buffers de texturas (buffer textures). Os valores dos texels dessas texturas podem ser lidos no vertex shader e no fragment shader como se fossem valores de arranjos unidimensionais. Esses valores podem ser interpretados como cores RGBA normalizadas entre 0 e 1 ou como valores arbitrários em ponto flutuante de até 32 bits.

Uma forma mais recente e flexível de enviar dados uniformes é através dos Shader Storage Buffer Objects (SSBOs). O tamanho de um SSBO pode ser de até 128MB segundo a especificação, mas na maioria das implementações pode ser tão grande quanto a memória de vídeo disponível. Além disso, esse recurso pode ser utilizado tanto para leitura quanto escrita.

Há muitas formas de enviar e receber dados da GPU. Entretanto, para deixarmos as coisas mais simples, usaremos neste curso apenas os recursos mais básicos, como VBOs, VAOs e variáveis uniformes.

Vertex shader

Os shaders do OpenGL são programas escritos na linguagem OpenGL Shading Language (GLSL). GLSL é similar à linguagem C, mas utiliza novas palavras-chave, novos tipos de dados, qualificadores e operações.

O vertex shader processa cada vértice individualmente. Entretanto, esse processamento é paralelizado de forma massiva na GPU. Cada execução de um vertex shader acessa apenas os atributos do vértice que está sendo processado. Não há como compartilhar o estado do processamento de um vértice com os demais vértices.

A entrada do vertex shader é um conjunto de atributos de vértice definidos pelo usuário. Esses atributos são alimentados pelo pipeline de acordo com os VBOs atualmente ativos.

A saída do vertex shader é também um conjunto de atributos de vértice definidos pelo usuário. Esses atributos podem ser diferentes dos atributos de entrada. Além de escrever o resultado nos atributos de saída, é esperado (mas não obrigatório) que o vertex shader preencha uma variável embutida gl_Position com a posição final do vértice em um sistema de coordenadas homogêneas 4D \((x, y, z, w)\) chamado de espaço de recorte (clip space). Nos próximos estágios, a geometria das primitivas será determinada com bases nessas coordenadas. Veremos mais detalhes sobre os diferentes sistemas de coordenadas do OpenGL em capítulos futuros.

Exemplo

A seguir é exibido o código-fonte de um vertex shader:

#version 410

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec4 inColor;

out vec4 fragColor;

void main() {
  gl_Position = vec4(inPosition.x, inPosition.y * 1.5, 0, 1);
  fragColor = inColor / 2;
}

A primeira linha do vertex shader é a diretiva de pré-processamento #version que identifica a versão da especificação GLSL utilizada. Neste exemplo, 410 corresponde à especificação GLSL 4.10. Podemos substituir por #version 300 es para restringir os comandos do GLSL à especificação GLSL 3.0 ES compatível com o WebGL 2.0. Na verdade isso é necessário se quisermos rodar a aplicação em WebAssembly. Felizmente não precisamos nos preocupar com isso pois a ABCg faz esse ajuste automaticamente para nós. Em todos os exemplos que veremos nesta disciplina, usaremos funções do OpenGL/GLSL 4.1 que também são compatíveis com OpenGL ES 3.0.

Nas linhas 3 e 4 são definidas as variáveis que receberão os atributos de entrada. Essas variáveis são identificadas com o qualificador in17:

  • inPosition é uma tupla de dois elementos (vec2) que recebe uma posição 2D (a posição do vértice).
  • inColor é uma tupla de quatro elementos (vec4) que recebe componentes de cor RGBA (a cor do vértice).

O vertex shader tem apenas um atributo de saída, definido na linha 6 através da variável fragColor com o qualificador out.

A função main é chamada para cada vértice processado. Para cada chamada, inPosition e inColor recebem os atributos do vértice.

Na linha 9, a variável embutida gl_Position é preenchida com \((x, \frac{3}{2}y,0,1)\), onde \(x\) e \(y\) são as coordenadas da posição 2D de entrada. Isso significa que a geometria sofrerá uma escala não uniforme: será “esticada” verticalmente.

Na linha 10, a variável de saída recebe a cor de entrada com a intensidade de cada componente RGBA dividida por dois. Isso significa que a cor de saída terá a metade da intensidade da cor de entrada.

Montagem de primitivas

A montagem de primitivas recebe os atributos de vértices processados pelo vertex shader e monta as primitivas de acordo com o que é informado na chamada do comando de renderização.

As primitivas geradas são formadas por pontos, segmentos ou triângulos. As primitivas da figura 4.8 são sempre decompostas em uma dessas três primitivas básicas. Por exemplo, se a primitiva informada pela aplicação é GL_LINE_STRIP, a polilinha será desmembrada em uma sequência de segmentos individuais.

Recorte

Na etapa de recorte, as primitivas que estão fora do volume de visão (fora do viewport) são descartadas ou recortadas. Por exemplo, se a ponta de um triângulo estiver fora do volume de visão, o triângulo será recortado e formará um quadrilátero, que é então decomposto em dois triângulos. Os atributos dos vértices a mais gerados no recorte são obtidos através da interpolação linear dos atributos dos vértices originais. O recorte também pode operar sobre planos de recorte definidos pelo usuário no vertex shader.

Após o recorte, ocorre a divisão perspectiva, que consiste na conversão das coordenadas homogêneas 4D \((x, y, z, w)\) em coordenadas cartesianas 3D \((x, y, z)\). Isso é feito dividindo \(x\), \(y\) e \(z\) por \(w\). O sistema de coordenadas resultante é chamado de coordenadas normalizadas do dispositivo (normalized device coordinates, ou NDC). Em NDC, todas as primitivas após o recorte estão situadas dentro de um volume de visão canônico: um cubo de \((-1, -1, -1)\) a \((1, 1, 1)\).

Ainda nesta etapa, as componentes \(x\) e \(y\) das coordenadas em NDC são mapeadas para o sistema de coordenadas da janela (chamado de espaço da janela, ou window space), em pixels. Esse mapeamento é configurado pelo comando glViewport. O valor \(z\) é mapeado de \([-1, 1]\) para \([0, 1]\) por padrão, mas isso pode ser configurado com glDepthRange.

Rasterização

Todas as primitivas contidas no volume de visão canônico passam por uma conversão matricial, na ordem em que foram processadas nas etapas anteriores. O resultado da rasterização de cada primitiva é um conjunto de fragmentos que representam amostras da primitiva no espaço da tela. Um fragmento pode ser interpretado como um pixel em potencial. A cor final de cada pixel no framebuffer poderá ser determinada por um fragmento ou pela combinação de vários fragmentos.

Cada fragmento é descrito por dados como:

  • Posição \((x, y, z)\) em coordenadas da janela18, sendo que \(z\) é a profundidade do fragmento (por padrão, um valor no intervalo \([0, 1]\)). Como cada fragmento tem uma profundidade, é possível determinar qual fragmento está “mais na frente” quando vários fragmentos são mapeados para a mesma posição \((x, y)\) da janela. Assim, a cor do pixel pode ser determinada apenas pelo fragmento mais próximo. Os demais podem ser descartados pois estão sendo escondidos pelo fragmento mais próximo.
  • Atributos interpolados a partir dos vértices da primitiva. Isso inclui todos os atributos definidos na saída do vertex shader. Por exemplo, se a saída do vertex shader devolve um atributo de cor RGB para cada vértice (uma tupla de três valores), então cada fragmento terá também uma cor RGB, com valores obtidos através da interpolação (geralmente linear) dos atributos definidos nos vértices.

Fragment shader

O fragment shader é um programa que processa cada fragmento individualmente após a rasterização. A entrada do fragment shader é o mesmo conjunto de atributos definidos pelo usuário na saída do vertex shader. É possível acessar também outros atributos pré-definidos que compõem o conjunto de dados de cada fragmento. Por exemplo, a posição do fragmento pode ser acessada através de uma variável embutida chamada gl_FragCoord.

A saída do fragment shader geralmente é uma cor em formato RGBA (uma tupla de quatro valores), mas é possível produzir também mais de uma cor caso o pipeline tenha sido configurado para renderizar simultaneamente em vários buffers de cor. O fragment shader também pode alterar as propriedades do fragmento através de variáveis embutidas. Por exemplo, a profundidade pode ser modificada através de gl_FragDepth.

Exemplo

A seguir é exibido o código-fonte do fragment shader que acompanha o vertex shader mostrado no exemplo anterior:

#version 410

in vec4 fragColor;

out vec4 outColor;

void main() { 
    outColor = vec4(fragColor.r, fragColor.r, fragColor.r, 1);
}

Esse fragment shader só tem um atributo de entrada, definido na linha 3 pela variável fragColor. O atributo de entrada é a cor RGBA correspondente ao atributo de saída do vertex shader. A saída do fragment shader também é uma cor RGBA, definida pela variável outColor.

A função main é chamada para cada fragmento processado. Para cada chamada, fragColor recebe o atributo do fragmento, que é o atributo de saída do vertex shader, mas interpolado entre os vértices da primitiva. Por exemplo, se a primitiva é um segmento formado por um vértice de cor RGB branca \((1,1,1)\) e outro vértice de cor preta \((0,0,0)\), o fragmento produzido no ponto médio do segmento terá a cor cinza \((0.5, 0.5, 0.5)\).

Na linha 8, outColor recebe uma cor RGBA na qual as componentes RGB são uma replicação da componente R da cor de entrada. Isso significa que a cor resultante é um tom de cinza que corresponde à intensidade de vermelho da cor original.

Se esse fragment shader e o vertex shader do exemplo anterior fossem utilizados no projeto “Hello, World!” da ABCg (seção 1.5), o triângulo resultante seria igual ao mostrado à direita na figura 4.9. Observe o efeito da mudança de escala da geometria (feita no vertex shader) e modificação das cores (intensidade reduzida pela metade no vertex shader, e conversão para tons de cinza no fragment shader).

Renderização do triângulo do projeto "Hello, World!" com os shaders originais (esquerda) e shaders utilizados nos exemplos (direita).

Figura 4.9: Renderização do triângulo do projeto “Hello, World!” com os shaders originais (esquerda) e shaders utilizados nos exemplos (direita).

Operações de fragmentos

Após o processamento no fragment shader, cada fragmento passa por uma sequência de testes que podem resultar em seu descarte. Se o fragmento falhar em algum desses testes, ele será ignorado e não contribuirá para a cor do pixel final.

  • O teste de propriedade de pixel (pixel ownership test) verifica se o fragmento corresponde a um pixel do framebuffer que está de fato visível no sistema de janelas. Por exemplo, se uma outra janela estiver sobrepondo a janela do OpenGL, os fragmentos mapeados para a área sobreposta serão descartados.

  • O teste de tesoura (scissor test), quando ativado com glEnable, descarta fragmentos que estão fora de um retângulo definido no espaço da janela pela função glScissor. Por exemplo, usando o código a seguir, o teste de tesoura será ativado e serão descartados todos os fragmentos que estiverem fora do retângulo definido pelas coordenadas \((50,30)\) a \((250,130)\) pixels no espaço da janela (o pixel de coordenada \((0,0)\) corresponde ao canto inferior esquerdo da janela):

    glEnable(GL_SCISSOR_TEST);    
    glScissor(50, 30, 200, 100);
  • O teste de estêncil (stencil test), quando ativado com glEnable, descarta fragmentos que não passam em um teste de comparação entre um valor de estêncil do fragmento (um número inteiro, geralmente de 8 bits) e o valor de estêncil do buffer de estêncil (stencil buffer), que é um dos buffers do framebuffer. Por exemplo, no código a seguir, glStencilFunc estabelece que o teste deve comparar se o valor de estêncil do fragmento é maior que 5. Se sim, o fragmento é mantido. Se não, é descartado.

    glEnable(GL_STENCIL_TEST);  
    glStencilFunc(GL_GREATER, 5, 0xFF)
  • O teste de profundidade (depth test), quando ativado com glEnable, descarta fragmentos que não passam em um teste de comparação do valor de profundidade do fragmento (valor \(z\) no espaço da janela) com o valor de profundidade armazenado atualmente no buffer de profundidade (depth buffer). Com o teste de profundidade é possível fazer com que só os fragmentos mais próximos sejam exibidos. Por exemplo, no código a seguir, glDepthFunc faz com que o teste de profundidade compare se o valor de profundidade do fragmento é menor que o valor do buffer de profundidade (GL_LESS é a comparação padrão). Se sim, o fragmento é mantido. Se não, é descartado.

    glEnable(GL_DEPTH_TEST);  
    glDepthFunc(GL_LESS);

Muitos desses testes podem ser realizados antes do fragment shader, em uma otimização chamada de early per-fragment test, suportada pela maioria das GPUs atuais. Por exemplo, se o fragment shader não modificar gl_FragDepth, é possível fazer o teste de profundidade logo após a rasterização, evitando o processamento de um fragmento que já se sabe que não contribuirá para a formação da imagem.

Se o fragmento passou por todos os testes e não foi descartado, sua cor será utilizada para modificar o pixel correspondente no(s) buffer(s) de cor. Mesmo que o fragmento não tenha passado por todos os testes, é possível que o buffer de estêncil e buffer de profundidade sejam modificados. Esse comportamento pode ser determinado pela aplicação. Também é possível usar operações de mascaramento para permitir, por exemplo, que somente as componentes RG da cor RGB sejam escritas no buffer de cor.

Antes do buffer de cor ser modificado, também é possível fazer com que a cor do fragmento seja misturada com a cor atual do buffer de cor, em uma operação de mistura de cor (color blending). Por exemplo, considere o código a seguir:

glEnable(GL_BLEND);
glBlendEquation(GL_FUNC_ADD);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); 

glEnable(GL_BLEND) habilita o modo de mistura de cor. As funções glBlendEquation e glBlendFunc configuram a mistura de cor para que cada componente de cor RGBA do buffer de cor seja calculada como \(C=C_sF_s + C_dF_d\), onde:

  • \(C_s\) é a componente de cor do fragmento (origem);
  • \(C_d\) é a componente de cor do buffer de cor (destino);
  • \(F_s\) é um fator de mistura de cor, que nesse caso é a componente A da cor do fragmento (GL_SRC_ALPHA);
  • \(F_d\) é um fator de mistura de cor, que nesse caso é 1 menos a componente A da cor do fragmento (GL_ONE_MINUS_SRC_ALPHA).

O resultado é a combinação da cor do fragmento com a cor atual do buffer de cor, usando a componente A do fragmento como fator de opacidade (1=totalmente opaco, 0=totalmente transparente).


  1. O tamanho do ponto pode ser definido através da função glPointSize ou pela variável embutida (built-in) gl_PointSize no vertex shader.↩︎

  2. Neste exemplo, o nome das variáveis de entrada também começa com o prefixo in, mas isso é só uma convenção.↩︎

  3. A posição de cada fragmento também inclui o valor recíproco da coordenada \(w\) no espaço de recorte.↩︎