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, à esquerda) e de como os dados gráficos (fundo cinza, à direita) interagem com cada estágio. As etapas programáveis são mostradas com fundo preto (vertex shader e fragment shader). No lado esquerdo há uma ilustração do resultado de cada etapa para a renderização de um triângulo colorido.
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 (OpenGL for Embedded Systems) utilizado pelo WebGL 2.0. 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 da versão mais recente para desktop.
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ário17;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.
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 comando de desenho.
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.
A versão mais recente da GLSL é a versão 4.6. Entretanto, usaremos a especificação GLSL ES 3.0 para garantir a compatibilidade com WebGL 2.0. Os documentos de especificação dessas duas versões podem ser acessados pelos links a seguir:
- OpenGL ES Shading Language 3.0: versão compatível com WebGL 2.0.
- OpenGL Shading Language 4.6: versão mais recente com suporte a geometry shaders, tessellation shaders, compute shaders, mas sem compatibilidade com WebGL 2.0.
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.
A seguir é exibido o código-fonte de um vertex shader:
#version 300 es
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 contém a diretiva de pré-processamento #version
que identifica a versão da especificação GLSL utilizada. A diretiva deve ser escrita obrigatoriamente na primeira linha do shader, sem espaços anteriores ou linhas em branco. Neste exemplo, #version 300 es
corresponde à especificação GLSL ES 3.0. Se quisermos escrever um shader que use funcionalidades mais recentes do OpenGL – ainda que quebrando a compatibilidade com WebGL 2.0 – devemos mudar essa diretiva para a versão correspondente. Por exemplo, a especificação GLSL 4.0, introduzida com o OpenGL 4.0, oferece suporte a shaders de tesselação. Um shader com a diretiva #version 400
é um shader compatível com essa especificação. A especificação mais recente é a 4.6 (#version 460
).
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 in
18:
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 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. 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
.
A seguir é exibido o código-fonte do fragment shader que acompanha o vertex shader mostrado no exemplo anterior:
#version 300 es
precision mediump float;
in vec4 fragColor;
out vec4 outColor;
void main() {
outColor = vec4(fragColor.r, fragColor.r, fragColor.r, 1);
}
Na primeira linha temos a identificação da versão do shader, que é GLSL ES 3.0.
A instrução na linha 3 especifica qual é a precisão numérica padrão para o tipo float
neste shader. A precisão pode ser lowp
(baixa precisão: 8 bits ou mais), mediump
(média precisão: 10 bits ou mais) ou highp
(alta precisão: 16 bits ou mais). No vertex shader não precisamos especificar uma precisão pois o padrão já é highp
. Essas indicações de precisão são apenas dicas ao driver, e geralmente só produzem alguma diferença quando a plataforma é um dispositivo móvel. Para desktop, e mesmo no navegador rodando em um desktop, a precisão geralmente é highp
em todos os casos.
Esse fragment shader só tem um atributo de entrada, definido na linha 5 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 10, 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).
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çãoglScissor
. 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):(GL_SCISSOR_TEST); glEnable(50, 30, 200, 100); glScissor
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.(GL_STENCIL_TEST); glEnable(GL_GREATER, 5, 0xFF) glStencilFunc
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.(GL_DEPTH_TEST); glEnable(GL_LESS); glDepthFunc
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.
Color blending
Antes do buffer de cor ser modificado, é possível fazer com que a cor do fragmento que está sendo renderizado (chamada de cor de origem) seja misturada com a cor atual do buffer de cor (chamada de cor de destino), em uma operação de mistura de cor (color blending). Por exemplo, considere o código a seguir:
(GL_BLEND);
glEnable(GL_FUNC_ADD);
glBlendEquation(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glBlendFunc
glEnable(GL_BLEND)
habilita o modo de mistura de cor.
As funções glBlendEquation
e glBlendFunc
configuram a mistura de cor para que cada nova componente de cor (R, G, B, e A) do buffer de cor seja calculada como \(C=C_sF_s + C_dF_d\), onde:
- \(C_s\) é a cor de origem (source color): \(C_s=[R_s, G_s, B_s, A_s]\);
- \(C_d\) é a cor de destino (destination color): \(C_d=[R_d, G_d, B_d, A_d]\);
- \(F_s\) (primeiro parâmetro de
glBlendFunc
) é o fator de mistura da cor de origem: paraGL_SRC_ALPHA
, \(F_s=A_s\); - \(F_d\) (segundo parâmetro de
glBlendFunc
) é o fator de mistura da cor de destino: paraGL_ONE_MINUS_SRC_ALPHA
, \(F_d=1-A_s\);
O resultado para glBlendEquation(GL_FUNC_ADD)
e glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
é \(C=C_sA_s + C_d(1-A_s)\), isto é, uma interpolação linear entre a cor de origem (\(C_s\)) e cor de destino (\(C_d\)), usando a componente A de origem (\(A_s\)) como parâmetro de interpolação. Se \(A_s=0\), mantém-se a cor atual do buffer de cor. Se \(A_s=1\), o buffer de cor é substituído pela cor de origem. Se \(A_s=0.5\), a nova cor é uma média entre a cor de destino e a cor de origem.
Outras equações de combinação de cores podem ser obtidas usando os seguintes argumentos com glBlendEquation
:
Argumento | Equação de combinação |
---|---|
GL_FUNC_ADD |
\(C=C_sF_s + C_dF_d\) |
GL_FUNC_SUBTRACT |
\(C=C_sF_s - C_dF_d\) |
GL_FUNC_REVERSE_SUBTRACT |
\(C=C_dF_d - C_sF_s\) |
GL_MIN |
\(C=\min(C_sF_s, C_dF_d)\) |
GL_MAX |
\(C=\max(C_sF_s, C_dF_d)\) |
Em glBlendFunc
podem ser usados os seguintes argumentos, tanto para \(F_s\) quanto \(F_d\):
Argumento | \(F_s\) ou \(F_d\) |
---|---|
GL_ZERO |
\((0,0,0,0)\) |
GL_ONE |
\((1,1,1,1)\) |
GL_SRC_COLOR |
\((R_s, G_s, B_s, A_s)\) |
GL_ONE_MINUS_SRC_COLOR |
\((1,1,1,1) - (R_s, G_s, B_s, A_s)\) |
GL_DST_COLOR |
\((R_d, G_d, B_d, A_d)\) |
GL_ONE_MINUS_DST_COLOR |
\((1,1,1,1) - (R_d, G_d, B_d, A_d)\) |
GL_SRC_ALPHA |
\((A_s, A_s, A_s, A_s)\) |
GL_ONE_MINUS_SRC_ALPHA |
\((1,1,1,1) - (A_s, A_s, A_s, A_s)\) |
GL_DST_ALPHA |
\((A_d, A_d, A_d, A_d)\) |
GL_ONE_MINUS_DST_ALPHA |
\((1,1,1,1) - (A_d, A_d, A_d, A_d)\) |
GL_CONSTANT_COLOR |
\((R_c, G_c, B_c, A_c)\) |
GL_ONE_MINUS_CONSTANT_COLOR |
\((1,1,1,1) - (R_c, G_c, B_c, A_c)\) |
GL_CONSTANT_ALPHA |
\((A_c, A_c, A_c, A_c)\) |
GL_ONE_MINUS_CONSTANT_ALPHA |
\((1,1,1,1) - (A_c, A_c, A_c, A_c)\) |
GL_SRC_ALPHA_SATURATE |
\((i,i,i,1)\), onde \(i=\min(A_s, 1-A_d)\) |
Nessa tabela, \((R_c, G_c, B_c, A_c)\) é uma cor que pode ser especificada com glBlendColor
(o padrão é \((0,0,0,0)\)).
O tamanho do ponto pode ser definido através da função
glPointSize
(suportada apenas no OpenGL desktop) ou pela variável built-ingl_PointSize
no vertex shader (forma recomendada, compatível com OpenGL, OpenGL ES e WebGL).↩︎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.↩︎