
Bloom
Devido à gama limitada de brilho disponível para monitores convencionais, a tarefa de exibir de forma convincente fontes de luz brilhante e superfícies iluminadas é difícil por definição. Um dos métodos comuns para destacar áreas claras no monitor é uma técnica que adiciona um halo de brilho ao redor de objetos brilhantes, dando a impressão de "espalhar" a luz para fora da fonte de luz. Como resultado, o observador dá a impressão de um alto brilho dessas áreas iluminadas ou fontes de luz.
O efeito descrito de um halo e a saída da luz além da fonte são alcançados por uma técnica de pós-processamento chamada
bloom . A aplicação do efeito adiciona um halo característico de brilho a todas as áreas brilhantes da cena exibida, que podem ser vistas no exemplo abaixo:
Bloom adiciona uma pista visual distinta à imagem sobre o brilho significativo dos objetos cobertos pelo halo do efeito aplicado. Sendo aplicado de maneira seletiva e precisa (com a qual muitos jogos, infelizmente, não conseguem lidar), o efeito pode melhorar significativamente a expressividade visual da iluminação usada na cena, além de adicionar drama em determinadas situações.
Essa técnica funciona em conjunto com a renderização
HDR quase como uma adição evidente. Aparentemente, por causa disso, muitas pessoas misturam equivocadamente esses dois termos com a total permutabilidade. No entanto, essas técnicas são completamente independentes e são usadas para diferentes fins. É possível implementar bloom usando o buffer de quadro padrão com profundidade de cor de 8 bits, assim como aplicar a renderização HDR sem recorrer ao uso de bloom. A única coisa é que a renderização HDR permite implementar o efeito de uma maneira mais eficiente (veremos isso mais adiante).
Para implementar a floração, a cena iluminada é renderizada primeiro da maneira usual. Em seguida, um buffer de cores HDR e um buffer de cores contendo apenas partes brilhantes da cena são extraídos. Essa imagem de parte brilhante extraída é então borrada e sobreposta à imagem HDR original da cena.
Para tornar mais claro, analisaremos o processo passo a passo. Renderize uma cena contendo 4 fontes de luz brilhante exibidas como cubos coloridos. Todos eles têm um valor de brilho na faixa de 1,5 a 15,0. Se o buffer de cores for enviado para o HDR, o resultado será o seguinte:
A partir desse buffer de cores HDR, extraímos todos os fragmentos cujo brilho excede um limite predeterminado. Acontece que uma imagem contém apenas áreas bem iluminadas:
Além disso, esta imagem de áreas brilhantes é desfocada. A severidade do efeito é essencialmente determinada pela força e pelo raio do filtro de desfoque aplicado:
A imagem borrada resultante de áreas brilhantes é a base do efeito final de halos em torno de objetos brilhantes. Essa textura é simplesmente misturada com a imagem HDR original da cena. Como as áreas brilhantes foram borradas, seus tamanhos aumentaram, o que, em última análise, fornece um efeito visual de luminosidade que ultrapassa os limites das fontes de luz:
Como você pode ver, o bloom não é a técnica mais sofisticada, mas alcançar sua alta qualidade visual e confiabilidade nem sempre é fácil. Na maior parte, o efeito depende da qualidade e do tipo de filtro de desfoque aplicado. Mesmo pequenas alterações nos parâmetros do filtro podem alterar drasticamente a qualidade final do equipamento.
Portanto, as ações acima fornecem um algoritmo passo a passo do efeito pós-processamento para o efeito bloom. A imagem abaixo resume as ações necessárias:
Antes de tudo, precisamos de informações sobre as partes brilhantes da cena com base em um determinado valor limite. É isso que faremos.
Extrair destaques
Então, para iniciantes, precisamos obter duas imagens com base em nossa cena. Seria ingênuo renderizar duas vezes, mas use o método
MRT (
Multiple Render Targets ) mais avançado: especificamos mais de uma saída no shader de fragmento final e, graças a isso, duas imagens podem ser extraídas em uma única passagem! Para especificar em qual buffer de cores o sombreador será produzido, o especificador de
layout é usado:
layout (location = 0) out vec4 FragColor; layout (location = 1) out vec4 BrightColor;
Obviamente, o método só funcionará se tivermos preparado vários buffers para a escrita. Em outras palavras, para implementar várias saídas do shader de fragmento, o buffer de quadro usado neste momento deve conter um número suficiente de buffers de cores conectados. Se passarmos para a lição sobre o
buffer de quadros , lembre-se de que, ao vincular a textura como um buffer de cores, poderíamos indicar o
número do anexo de cores . Até agora, não precisávamos usar um anexo que não
fosse GL_COLOR_ATTACHMENT0 , mas desta vez
GL_COLOR_ATTACHMENT1 será útil, pois precisamos de dois objetivos para gravar ao mesmo tempo:
Além disso, chamando
glDrawBuffers , você precisará informar explicitamente ao OpenGL que iremos
gerar vários buffers. Caso contrário, a biblioteca ainda produzirá apenas o primeiro anexo, ignorando as operações de gravação em outros anexos. Como argumento para a função, é passada uma matriz de identificadores dos anexos usados da enumeração correspondente:
unsigned int attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 }; glDrawBuffers(2, attachments);
Para esse buffer de quadro, qualquer sombreador de fragmento que especifique um especificador de
local para suas saídas será gravado no buffer de cores correspondente. E isso é uma ótima notícia, pois dessa maneira evitamos o passe de renderização desnecessário para extrair dados sobre as partes brilhantes da cena - você pode fazer tudo de uma vez em um único sombreador:
#version 330 core layout (location = 0) out vec4 FragColor; layout (location = 1) out vec4 BrightColor; [...] void main() { [...]
Neste fragmento, a parte que contém o código típico para calcular a iluminação é omitida. Seu resultado é gravado na primeira saída do shader - a variável
FragColor . Em seguida, a cor resultante do fragmento é usada para calcular o valor do brilho. Para isso, é realizada uma tradução ponderada em escala de cinza (por multiplicação escalar, multiplicamos os componentes correspondentes dos vetores e os adicionamos, resultando em um único valor). Então, quando o brilho de um fragmento de um determinado limite é excedido, registramos sua cor na segunda saída do shader. Para cubos substituindo fontes de luz, esse sombreador também é executado.
Tendo descoberto o algoritmo, podemos entender por que essa técnica funciona tão bem com a renderização HDR. A renderização no formato HDR permite que os componentes de cor ultrapassem o limite superior de 1,0, o que permite ajustar de forma mais flexível o limite de brilho fora do intervalo padrão [0., 1.], fornecendo a capacidade de ajustar com precisão quais partes da cena são consideradas brilhantes. Sem usar o HDR, você terá que se contentar com um limite de brilho no intervalo [0., 1.], o que é bastante aceitável, mas leva a um corte mais "nítido" no brilho, o que geralmente torna a flor muito invasiva e chamativa (imagine-se em um campo de neve no alto das montanhas) .
Após a execução do shader, dois buffers de destino conterão uma imagem normal da cena, bem como uma imagem contendo apenas áreas claras.
Agora, a imagem das áreas claras deve ser processada com o desfoque. Você pode fazer isso com um simples filtro retangular (
caixa ), usado na seção de pós-processamento da lição do
buffer de quadros . Mas um resultado muito melhor é obtido pela
filtragem de Gauss .
Gaussian Blur
A lição de pós-processamento nos deu uma idéia de desfoque usando a média simples de cores dos fragmentos de imagem adjacentes. Esse método de desfoque é simples, mas a imagem resultante pode parecer mais atraente. O desfoque gaussiano é baseado na curva de distribuição em forma de sino com o mesmo nome: valores altos da função estão localizados mais perto do centro da curva e caem nos dois lados dela. Matematicamente, uma curva gaussiana pode ser expressa com diferentes parâmetros, mas a forma geral da curva permanece a seguinte:

O desfoque com pesos com base nos valores da curva de Gauss parece muito melhor do que um filtro retangular: devido ao fato de a curva ter uma área maior nas proximidades de seu centro, o que corresponde a pesos maiores para fragmentos próximos ao centro do núcleo do filtro. Tomando, por exemplo, o núcleo de 32x32, usaremos os fatores de ponderação quanto menor, mais distante o fragmento do central. É essa característica de filtro que fornece um resultado de desfoque Gaussiano visualmente mais satisfatório.
A implementação do filtro exigirá uma matriz bidimensional de coeficientes de ponderação, que poderá ser preenchida com base na expressão bidimensional que descreve a curva gaussiana. No entanto, encontraremos imediatamente um problema de desempenho: mesmo um núcleo de desfoque relativamente pequeno em um fragmento de 32x32 exigirá 1024 amostras de textura para cada fragmento da imagem processada!
Felizmente para nós, a expressão da curva gaussiana tem uma característica matemática muito conveniente - separabilidade, que permitirá criar duas expressões unidimensionais a partir de uma expressão bidimensional que descrevem os componentes horizontais e verticais. Isso permitirá que o desfoque, por sua vez, seja feito em duas abordagens: horizontalmente e verticalmente com conjuntos de pesos correspondentes a cada uma das direções. A imagem resultante será a mesma do processamento de um algoritmo bidimensional, mas exigirá muito menos poder de processamento do processador de vídeo: em vez de 1024 amostras da textura, precisamos apenas de 32 + 32 = 64! Essa é a essência da filtração gaussiana de duas passagens.

Para nós, tudo isso significa uma coisa: o desfoque de uma imagem terá que ser feito duas vezes, e aqui o uso de objetos de buffer de quadro será útil. Aplicamos a chamada técnica de pingue-pongue: existem alguns objetos de buffer de quadro e o conteúdo do buffer de cores de um framebuffer é renderizado com algum processamento no buffer de cores do framebuffer atual; em seguida, o framebuffer de origem e o framebuffer-receiver são trocados e esse processo é repetido um determinado número de vezes. De fato, o buffer de quadro atual para exibir a imagem é simplesmente alternado e, com ele, a textura atual a partir da qual a amostragem é realizada para renderização. A abordagem permite que você desfoque a imagem original colocando-a no primeiro buffer de quadro, depois desfoque o conteúdo do primeiro buffer de quadro, coloque-o no segundo e depois desfoque o segundo, colocando-o no primeiro e assim por diante.
Antes de passar para o código de ajuste do buffer de quadro, vamos dar uma olhada no código do Gaussian Blur Shader:
#version 330 core out vec4 FragColor; in vec2 TexCoords; uniform sampler2D image; uniform bool horizontal; uniform float weight[5] = float[] (0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216); void main() {
Como você pode ver, usamos uma amostra bastante pequena de coeficientes da curva gaussiana, que são usados como pesos para amostras horizontal ou verticalmente em relação ao fragmento atual. O código possui duas ramificações principais que dividem o algoritmo em passagem vertical e horizontal com base no valor do uniforme
horizontal . O deslocamento para cada amostra é definido igual ao tamanho texel, que é definido como o inverso do tamanho da textura (um valor do tipo
vec2 retornado pela função
textureSize ()).
Crie dois buffers de quadro contendo um buffer de cor com base na textura:
unsigned int pingpongFBO[2]; unsigned int pingpongBuffer[2]; glGenFramebuffers(2, pingpongFBO); glGenTextures(2, pingpongBuffer); for (unsigned int i = 0; i < 2; i++) { glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[i]); glBindTexture(GL_TEXTURE_2D, pingpongBuffer[i]); glTexImage2D( GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL ); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glFramebufferTexture2D( GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, pingpongBuffer[i], 0 ); }
Depois de obter a textura HDR da cena e extrair a textura das áreas brilhantes, preenchemos o buffer de cores de um dos pares de framebuffers preparados com a textura de brilho e iniciamos o processo de ping-pong dez vezes (cinco vezes na vertical, cinco na horizontal):
bool horizontal = true, first_iteration = true; int amount = 10; shaderBlur.use(); for (unsigned int i = 0; i < amount; i++) { glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[horizontal]); shaderBlur.setInt("horizontal", horizontal); glBindTexture( GL_TEXTURE_2D, first_iteration ? colorBuffers[1] : pingpongBuffers[!horizontal] ); RenderQuad(); horizontal = !horizontal; if (first_iteration) first_iteration = false; } glBindFramebuffer(GL_FRAMEBUFFER, 0);
Em cada iteração, selecionamos e ancoramos um dos buffers de quadro, com base em se essa iteração será borrada horizontal ou verticalmente, e o buffer de cores do outro buffer de moldura será usado como textura de entrada para o sombreador de desfoque. Na primeira iteração, temos que usar explicitamente uma imagem contendo áreas claras (
brightnessTexture ) - caso contrário, os dois buffers de pingue-pongue permanecerão vazios. Após dez passagens, a imagem original assume a forma de cinco vezes embaçada por um filtro gaussiano completo. A abordagem usada nos permite alterar facilmente o grau de desfoque: quanto mais iterações de pingue-pongue, mais forte o desfoque.
No nosso caso, o resultado do desfoque é mais ou menos assim:
Para concluir o efeito, resta apenas combinar a imagem tremida com a imagem HDR original da cena.
Mistura de textura
Tendo em mãos a textura HDR da cena renderizada e a textura borrada das áreas superexpostas, tudo o que você precisa para perceber o famoso efeito de brilho ou flor é combinar essas duas imagens. O sombreador final do fragmento (muito semelhante ao apresentado na lição sobre o formato
HDR ) faz exatamente isso - ele adiciona duas texturas:
#version 330 core out vec4 FragColor; in vec2 TexCoords; uniform sampler2D scene; uniform sampler2D bloomBlur; uniform float exposure; void main() { const float gamma = 2.2; vec3 hdrColor = texture(scene, TexCoords).rgb; vec3 bloomColor = texture(bloomBlur, TexCoords).rgb; hdrColor += bloomColor;
O que procurar: a mixagem é feita antes de aplicar o
mapeamento de tons . Isso traduzirá corretamente o brilho adicional do efeito para a
faixa LDR (
baixa faixa dinâmica ), mantendo a distribuição relativa do brilho na cena.
O resultado do processamento - todas as áreas brilhantes receberam um efeito de brilho perceptível:
Os cubos que substituem as fontes de luz agora parecem muito mais brilhantes e transmitem melhor a impressão de uma fonte de luz. Essa cena é bastante primitiva, porque a implementação do efeito de entusiasmo especial não causará, mas em cenas complexas com iluminação cuidadosa, um florescimento realizado qualitativamente pode ser um elemento visual crucial que acrescenta drama.
O código fonte do exemplo está
aqui .
Observo que a lição usou um filtro bastante simples com apenas cinco amostras em cada direção. Fazendo mais amostras em um raio maior ou executando várias iterações do filtro, você pode melhorar visualmente o efeito. Além disso, vale dizer que visualmente a qualidade de todo o efeito depende diretamente da qualidade do algoritmo de desfoque usado. Ao melhorar o filtro, você pode obter melhorias significativas e todo o efeito. Por exemplo, um resultado mais impressionante é mostrado pela combinação de vários filtros com diferentes tamanhos de núcleo ou diferentes curvas gaussianas. A seguir, são apresentados recursos adicionais da Kalogirou e da EpicGames, que abordam como melhorar a qualidade da floração, modificando o desfoque gaussiano.
Recursos Adicionais