OpenGL – Framebuffers

Nos artigos anteriores de nossa série sobre OpenGL, vimos diferentes tipos de buffers que o OpenGL oferece: cor, depth e stencil. Esses buffers ocupam a memória de vídeo da mesma forma que qualquer outro objeto OpenGL, mas até agora temos pouco controle sobre eles além da especificação de formatos de pixel quando criamos o contexto OpenGL. Essa combinação de buffers é conhecida como framebuffer padrão e como vimos, um framebuffer é uma área da memória que pode ser renderizada. E como fazemos se quisermos renderizar um resultado e fazer algumas operações adicionais nele, como algum pós-processamento como visto em jogos modernos?

Neste capítulo veremos sobre os framebuffers objects, que são meios de criar framebuffers adicionais para renderização. A melhor coisa sobre framebuffers é que eles permitem que você renderize uma cena diretamente para uma textura, que pode ser usada em outras operações de renderização. Após discutirmos como framebuffers objects funcionam, veremos como usa-los para fazer o pós-processamento da cena do artigo anterior.

Criando um novo framebuffer

A primeira coisa que você precisa é de um framebuffer object para gerenciar seu novo framebuffer.

GLuint frameBuffer;
glGenFramebuffers(1, &frameBuffer);

Você ainda não pode usar esse framebuffer nesse momento, por que não está completo. Um framebuffer só está completo se:

  • Ao menos um buffer foi anexado a ele(ex. cor, depth, stencil)
  • Existe pelo menos uma cor anexa (OpenGL 4.1 e mais recentes)
  • Todos os anexos estão completos (Por exemplo, uma textura precisa ter um espaço de memória reservado)
  • Todos os anexos precisam ter o mesmo número de multisamples

Você pode verificar se um framebuffer está completo a qualquer tempo chamando a função glCheckFramebufferStatus e verificando se ela retorna GL_FRAMEBUFFER_COMPLETE. Veja a referência para outros valores de retorno. Você não precisa fazer essa checagem, mas é uma boa coisa a se fazer, assim como checar se os shaders compilaram com sucesso.
Agora, vamos conectar o framebuffer para trabalhar com ele.

glBindFramebuffer(GL_FRAMEBUFFER, frameBuffer);

O primeiro parâmetro especifica o alvo onde o framebuffer deve ser anexado. O OpenGL distingue entre GL_DRAW_FRAMEBUFFER de GL_READ_FRAMEBUFFER. O framebuffer conectado para leitura é usado em chamadas de glReadPixels, mas como essa distinção em aplicativos normais é muito rara, você pode suas ações aplicados à ambos usando GL_FRAMEBUFFER.

glDeleteFramebuffers(1, &frameBuffer);

Não se esqueça de limpar tudo no final.

Anexos

Seu framebuffer pode apenas ser usado como alvo de renderização de memória for alocada para armazenar os resultados. Isso é feito anexando imagens para cada buffer (cor, depth, stencil ou uma combinação desses dois últimos). Existem dois tipos de objetos que podem funcionar como imagens: texturas e renderbuffer objects. A vantagem do primeiro é que eles podem ser diretamente usados em shaders como visto nos artigos anteriores, mas renderbuffer objects podem ser mais otimizados especificamente como alvos de renderização dependendo de sua implementação.

Imagens de texturas

Gostariamos de sermos capazes de renderizar uma cena e então usar o resultado do buffer de cores em outra operação de renderização, então uma textura é o ideal nesse caso. Criar um textura para usar como uma imagem para o buffer de cores do novo framebuffer é tão simples quanto criar qualquer textura.

GLuint texColorBuffer;
glGenTextures(1, &texColorBuffer);
glBindTexture(GL_TEXTURE_2D, texColorBuffer);
glTexImage2D(
    GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL
);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

A diferença entre essa textura e as texturas que você viu nos artigos anteriores é o valor NULL para o parâmetro data. Isso faz sentido pois data irá ser criado dinâmicamente dessa vez com as operações de renderização. Como essa é a imagem para o buffer de cores, os parâmetros formatinternalformat são um pouco mais restritivos. O parâmetro format normalmente estará limitado aos valores GL_RBGGL_RGBA e o parâmetro internalformat aos formatos de cor.
Aqui, escolhemos o formato interno RGB padrão, mas você pode experimentat com formatos mais exóticos como o GL_RGB10 se quiser uma precisão de cor de 10 bits. O aplicativo mostrado aqui possui uma resolução de 800×600 pixels,  então fizemos o novo buffer de cores coincidir com isso. A resolução não precisa coincidir com o framebuffer padrão, mas não esqueça de chamar glViewport caso decida variar.
A única coisa que falta agora é anexar a imagem ao framebuffer.

glFramebufferTexture2D(
    GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texColorBuffer, 0
);

O segundo parâmetro implica que você terá múltiplos anexos de cor. Um fragment shader pode ter como saída dados diferentes para qualquer uma dessas cores apenas conectando variáveis out com os anexos usando a função glBindFragDataLocation usada anteriormente. Ficaremos no uso de apenas uma saída nesse momento. Mipmaping não tem uso aqui, já que a imagem do buffer de cores será renderizada com seu tamanho original quando usada para pós-processamento.

Imagens de Renderbuffer Object

Como estamos usando um buffer de stencil e depth para renderizar um cubo giratório, teremos que cria-los também. O OpenGL permite que você combine esses dois buffers em uma imagem, assim precisaremos criar apenas um buffer antes de podermos usa-lo no framebuffer. Apesar de podermos fazer isso através da criação de uma outra textura, é mais eficiente armazenar esses buffers em um renderbuffer object, por que estamos interessados apenas e ler o buffer de cores em um shader.

GLuint rboDepthStencil;
glGenRenderbuffers(1, &rboDepthStencil);
glBindRenderbuffer(GL_RENDERBUFFER, rboDepthStencil);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);

A criação de um renderbuffer object é muito similar à criação de uma textura, sendo que a diferença é que esse objeto é projetado para ser usado como uma imagem ao invés de um buffer de dados de propósito geral como uma textura. Escolhemos usar o formato interno GL_DEPTH24_STENCIL8 aqui, que é adequado para armazenar tanto o buffer de depth quando o de stencil com 24 r 8 bits de precisão, respectivamente.

glFramebufferRenderbuffer(
  GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rboDepthStencil
);

Anexar também é bastante fácil. Você pode remover esse object como qualquer outro depois de anexado com uma chamada para glDeleteRenderBuffers.

Usando um framebuffer

Selecionar um framebuffer como alvo de renderização é bastante fácil; de fato, isso pode ser feito com uma chamada simples:

glBindFramebuffer(GL_FRAMEBUFFER, frameBuffer);

Após essa chamada, todas as operações de renderização armazenarão seus resultados nos anexos da framebuffer recém criado. Para alternar para o framebuffer visível padrão de sua tela, simplesmente passe 0 como parâmetro.

glBindFramebuffer(GL_FRAMEBUFFER, 0);

Note that although only the default framebuffer will be visible on your screen, Note que apesar de apenas o framebuffer padrão estar visível na tela, vocÊ pode ler qualquer framebuffer que estiver correntemente conectado com uma chamada à glReadPixels desde que não tenha sido conectado com GL_DRAW_FRAMEBUFFER.

Pós-processamento

Nos jogos de hoje, os efeitos de pós-processamento parecem quase tão importantes quanto as cenas que são renderizadas na tela, e de fato, alguns resultados espetaculares podem ser conseguidos com técnicas diferentes. Os efeitos de pós-processamento em gráficos em tempo real são implementados normalmente no fragment shader com a cena sendo renderizada usada como entrada na forma de uma textura. Objetos de framebuffer permitem que usemos uma textura como buffer de cores, de forma que possamos preparar a entrada para um efeito de pós-processamento.
Usar shaders para criar um efeito de pós-processamento para uma cena anteriormente renderizada em uma textura, é renderizado normalmente como um retângulo 2D que preenche a tela. Dessa forma, a cena original com o efeito aplicado preenche a tela em seu tamanho original como se fosse renderizada para o framebuffer padrão em primeiro lugar.
Naturalmente, você pode ser bastante criativo com framebuffers e usa-los para fazer qualquer cosia desde portais até câmeras no mundo do jogo, apenas renderizando uma cena múltiplas vezes de ângulos diferentes e e exibir em monitores ou outros objetos na imagem final. Esses usos são mais específicos, então ficará como exercício para você.

Alterando o código

Infelizmente, é um pouco mais difícil cobrir as alterações no código passo a passo aqui, especialmente se você se desviou do código fonte exemplo. Agora que você sabe como um framebuffer é criado e conectado e como pondo algum cuidado nisso, você deve ser capaz de fazer isso. Vamos falar globalmente sobre cada passo aqui:

  • Em primeiro lugar, crie o framebuffer e verifique se ele está completo. Tente conecta-lo como um alvo de renderização e você verá que sua tela fica preta porque sua cena não é mais renderizada com o framebuffer padrão. Tente alterar a cor da cena e ler novamente o framebuffer usando glReadPixels para verificar se a cena é renderizada de forma adequada com o novo framebuffer.
  • Em seguida, crie novos programa de shadervertex array object e vertex buffer object para renderizar coisa em 2D ao contrário de ser em 3D. Isso é útil para alternar para do framebuffer padrão para esse e ver facilmente os resultos. Seu shader 2D não precisa de nenhuma matriz de transformação. Tente renderizar um retângulo na frente da cena do cubo giratório dessa forma.
  • Finalmente, tente renderizar a cena 3D  no framebuffer criado por você e o retângulo no framebuffer padrão. Agora tente usar a textura do framebuffer no retângulo para renderizar a cena.

Escolhemos ter apenas 2 coordenadas de posição e 2 coordenadas de textura em nossa renderização 2D. O shader 2D deve ficar assim:

#version 150
in vec2 position;
in vec2 texcoord;
out vec2 Texcoord;
void main() {
    Texcoord = texcoord;
    gl_Position = vec4(position, 0.0, 1.0);
}

 

#version 150
in vec2 Texcoord;
out vec4 outColor;
uniform sampler2D texFramebuffer;
void main() {
    outColor = texture(texFramebuffer, Texcoord);
}

Com esse shader, a saída do programa deve ser a mesma do que antes de você saber sobre framebuffers. Renderizar um frame deve ficar aproximadamente dessa forma:

// Bind our framebuffer and draw 3D scene (spinning cube)
glBindFramebuffer(GL_FRAMEBUFFER, frameBuffer);
glBindVertexArray(vaoCube);
glEnable(GL_DEPTH_TEST);
glUseProgram(sceneShaderProgram);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texKitten);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texPuppy);
// Draw cube scene here
// Bind default framebuffer and draw contents of our framebuffer
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glBindVertexArray(vaoQuad);
glDisable(GL_DEPTH_TEST);
glUseProgram(screenShaderProgram);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texColorBuffer);
glDrawArrays(GL_TRIANGLES, 0, 6);

Ambas operações de desenho 2D e 3D tem seus próprios vertex array (cubo versus quadrado), shader program (pós-processamento 2D e 3D) e texturas. Você pode perceber que conectar o buffer de cores da textura é tão fácil quando texturas normais. Tenha em mente que chamadas como glBindTexture que alteram o estado do OpenGL são relativamente custosas, então tente usa-las o minimo possível.
Eu acho que não importa o quão bem for a explicação sobre a estrutura geral do programa aqui, alguns de vocês preferem dar um olhada no novo exemplo de código e talvez executar um comando diff entre este código e o código do artigo anterior.

Efeitos de pós-processamento

Discutiremos agora vários efeitos interessantes de pós-processamento, como eles funcionam e como eles são exibidos.

Manipulação de cores

Inverter cores é uma opção encontrada normalmente em programas de manipulação de imagens, mas você pode fazer isso usando shaders.

Como valores de cores são valores de ponto flutuante que variam de 0.0 a 1.0, inverter um canal é simplesmente calcular 1.0 – canal. Se você fizer isso para todos os canais (vermelho, verde, azul), obterá uma cor inversa. No fragment shader, isso pode ser feito dessa forma:

outColor = vec4(1.0, 1.0, 1.0, 1.0) - texture(texFramebuffer, Texcoord);

Isso afetará o canal alpha também, mas isso não importa pois o blending do canal alpha está inativo por padrão.


Tornar as cores num tom de cinza pode ser facilmente feito pelo cálculo da médio da intensidade de cada canal.

outColor = texture(texFramebuffer, Texcoord);
float avg = (outColor.r + outColor.g + outColor.b) / 3.0;
outColor = vec4(avg, avg, avg, 1.0);

Isso funciona bem, mas humanos são mais sensíveis ao verde e menos ao azul, então uma melhor conversão seria com pesos nos canais.

outColor = texture(texFramebuffer, Texcoord);
float avg = 0.2126 * outColor.r + 0.7152 * outColor.g + 0.0722 * outColor.b;
outColor = vec4(avg, avg, avg, 1.0);

Blur

Existem duas técnicas bem conhecidas para borrar uma imagem: box blurgaussian blur. Os resultados do último tem mais qualidade, mas o primeiro é mais fácil de implementar e aproxima o gaussian blut relativamente bem.

O borramento é feito obtendo uma amostragem de pixels em torno de um pixel e calculando a cor média deles.

const float blurSizeH = 1.0 / 300.0;
const float blurSizeV = 1.0 / 200.0;
void main() {
    vec4 sum = vec4(0.0);
    for (int x = -4; x <= 4; x++)
        for (int y = -4; y <= 4; y++)
            sum += texture(
                texFramebuffer,
                vec2(Texcoord.x + x * blurSizeH, Texcoord.y + y * blurSizeV)
            ) / 81.0;
    outColor = sum;
}

Você pode perceber que um total de 81 amostras é usado. Você pode alterar a quantidade de amostras nos eixos X e Y para controlar a quantidade do borrão. As variáveis blurSize são usadas para determinar a distância entre cada amostra. Uma contagem de amostrar alta e uma distância entre amostras baixa resulta em uma aproximação melhor, mas diminui rapidamente a performance, então tente achar um bom balanceamento.

Sobel

A operação Sobel é usada em algoritmos de detecção de cantos; vamos dar uma olhada em como ele fica:

fragment shader deve ficar dessa forma:

vec4 s1 = texture(texFramebuffer, Texcoord - 1.0 / 300.0 - 1.0 / 200.0);
vec4 s2 = texture(texFramebuffer, Texcoord + 1.0 / 300.0 - 1.0 / 200.0);
vec4 s3 = texture(texFramebuffer, Texcoord - 1.0 / 300.0 + 1.0 / 200.0);
vec4 s4 = texture(texFramebuffer, Texcoord + 1.0 / 300.0 + 1.0 / 200.0);
vec4 sx = 4.0 * ((s4 + s3) - (s2 + s1));
vec4 sy = 4.0 * ((s2 + s4) - (s1 + s3));
vec4 sobel = sqrt(sx * sx + sy * sy);
outColor = sobel;

Assim como no caso do borrão, umas poucas amostras são lidas e combinadas de uma forma interessante. Você pode ler mais sobre os detalhes técnicos aqui.

Conclusão

O legal sobre os shaders é que você pode manipular imagens pixel por pixel em tempo real por causa da imensa capacidade de processamento em paralelo de sua placa de vídeo. Não é surpresa que versão mais recentes de softwares como o Photoshop usem placas de vídeo para acelerar as operações de manipulação de imagens. Existem muitos efeitos complexos como HDR, motion blur e SSAO (screen space ambiente occlusion), mas esses envolvem um pouco mais de trabalho do que o uso de um simples shader, estando assim fora do escopo desse artigo.
Fonte: open.gl/framebuffers