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 dados gráficos utilizados (fundo cinza). 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.
Figura 4.7: Pipeline gráfico do OpenGL.
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 fazem parte do subconjunto do OpenGL ES utilizado pelo WebGL, mas são frequentemente utilizadas em aplicações para OpenGL desktop. Consulte a especificação do OpenGL 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, com tamanho 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;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.
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)\), mas isso não é obrigatório. Outros atributos comuns 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 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 que não variam de vértice para vértice, por isso o nome “uniforme”. Por exemplo, é possível armazenar uma matriz de transformação geométrica como uma variável uniforme. No vertex shader, todos os vértices podem ser transformados por essa matriz (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).
Por fim, a aplicação também pode enviar dados ao pipeline através de texturas no formato de imagens digitais. Os valores dessas imagens podem ser acessados pelo vertex shader e fragment shader, e podem ser interpretados como valores de cor RGBA ou como valores arbitrários definidos pelo usuário.
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 é um shader que processa cada vértice individualmente. O processamento de fluxo de vértices é paralelizado de forma massiva na GPU e não há como guardar o estado do processamento de um vértice para compartilhar com outro.
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 coordenadas17.
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 é sempre a diretiva de pré-processamento #version que identifica a versão da especificação GLSL utilizada (410 corresponde à especificação GLSL 4.10).
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 in18:
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 simplesmente 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 coordenadas \((x, y)\) em NDC são mapeadas para o sistema de coordenadas da janela (o espaço da janela, ou window space), em pixels. Esse mapeamento é configurado pelo comando glViewport. O valor \(z\) em NDC é mapeado de \([-1, 1]\) para \([0, 1]\) por padrão, mas pode ser alterado 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 representa 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 janela19, 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, e os demais podem ser descartados.
- 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 para vários buffers de cor. O fragment shader também pode alterar outras propriedades do fragmento através de variáveis embutidas. Por exemplo, a profundidade pode ser modificada através de gl_FragDepth.
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 formando 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).
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 no descarte do fragmento.
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çãoglScissor. Por exemplo, usando o código a seguir, o teste de tesoura será ativado e só serão modificados os fragmentos que estiverem no retângulo definido pelas coordenadas \((50,30)\) a \((250,130)\) no espaço da janela (a origem é o canto inferior esquerdo da janela).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,glStencilFuncestabelece 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.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 da tela” sejam exibidos. Por exemplo, no código a seguir,glDepthFuncfaz 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 mantido. Se não, é descartado.
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á com 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).
O tamanho do ponto pode ser definido através da função
glPointSizeou pela variável embutida (built-in)gl_PointSizeno vertex shader.↩︎Veremos mais detalhes sobre os diferentes sistemas de coordenadas do OpenGL em capítulos futuros.↩︎
Neste exemplo, o nome das variáveis de entrada também começa com o prefixo
in, mas isso é só uma convenção.↩︎A posição de cada fragmento também inclui o valor recíproco da coordenada \(w\) no espaço de recorte.↩︎