7-泛光

明亮的光源和区域很难表现出来,因为显示器的亮度范围有限。一种在显示器上区分明亮光源的方式是让他们发出光芒,光芒从光源向四周发散。

这种后处理效果是“泛光(Bloom)”:

简介

泛光提供了一种针对明亮物体的视觉效果。如果用优雅的方式实现它,将会显著增强场景光照并能提供更加有张力的效果。

泛光和HDR结合使用效果最好。泛光和HDR不一样,可以用默认8位精确度的帧缓冲来实现泛光效果,也能只用HDR而不使用泛光。但有了HDR之后再实现泛光就更简单了。

实现思路

为了实现泛光,需要像平时那样渲染一个有光场景,提取出场景的HDR颜色缓冲以及只有这个场景明亮区域可见的图片。然后对提取的亮度图像进行模糊处理,并将结果添加到原始HDR场景图像的上面。

如上图所示,泛光的实现思路如下:

  1. 左上的图片:渲染场景到HDR颜色缓冲;
  2. 左下的图片:根据HDR颜色缓冲纹理,提取所有超出一定亮度的片元,这样就会获得一个只有亮光源的一片区域;
  3. 中下的图片:对左下的图片进行模糊处理;
  4. 右上的图片:将1和3结合。

提取亮色

在第一步中,要从渲染出来的场景里提取两张图片。可以渲染场景两次,每次用一个不同的着色器渲染到不同的帧缓冲中,但这样太麻烦了。

有一种叫做多渲染目标(Multiple Render Targets, MRT)的技巧,可以通过一次渲染获得多张图片。这需要我们在片段着色器上指定要输出的颜色缓冲:

layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;

接下来需要为帧缓冲准备多个颜色附件:

// set up floating point framebuffer to render scene to
unsigned int hdrFBO;
glGenFramebuffers(1, &hdrFBO);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
unsigned int colorBuffers[2];
glGenTextures(2, colorBuffers);
for (unsigned int i = 0; i < 2; i++)
{
    glBindTexture(GL_TEXTURE_2D, colorBuffers[i]);
    glTexImage2D(
        GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, 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);
    // attach texture to framebuffer
    glFramebufferTexture2D(
        GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, colorBuffers[i], 0
    );
}  

然后要显式通知OpenGL我们要一次性渲染到多个颜色附件上:

unsigned int attachments[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1};
glDrawBuffers(2, attachments);

最后我们完善一下片段着色器,将过亮的像素存储到第二个颜色附件中:

void main()
{             
	// 求出FragColor
	...
    // 存储过亮的像素
float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722));
if (brightness > 1.0)
    BrightColor = vec4(FragColor.rgb, 1.0);
else
    BrightColor = vec4(vec3(0.0), 1.0);;
}

这里先将颜色转换为灰度作为片段的亮度,然后查看亮度是否超过阈值,是的话就渲染到第二个颜色附件中。两个颜色附件如下:

高斯模糊

在之前的后处理中,我们使用的是均值模糊,但效果不好。高斯模糊则基于高斯曲线,中间的值最大,随着距离增加两边的值不断减少:

为了实现高斯模糊滤波,需要一个二维滤波核,可以从二维高斯曲线方程中获得权重。过大的二维滤波核会导致性能问题,但高斯方程允许我们将大的二维滤波核分成两个一维滤波核,分别为水平和竖直方向,这样大大提高了性能。这种做法也叫做两步高斯模糊。

因此我们要模糊一张图片两次,当然要使用帧缓冲对象。我们将实现“乒乓”帧缓冲,以应用两步高斯模糊。这是一对帧缓冲对象,在这里将会交换&渲染帧缓冲图片数次,一个专门负责横向滤波,另一个专门负责竖向滤波。

首先看看高斯模糊的片段着色器实现:

#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()
{
	vec2 tex_offset = 1.0 / textureSize(image, 0);				// 获取单个纹素的大小
	vec3 result = texture(image, TexCoords).rgb * weight[0];	// 当前片段的贡献
	if (horizontal)
	{
		for (int i = 1; i < 5; ++i)
		{
			result += texture(image, TexCoords + vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
			result += texture(image, TexCoords - vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
		}
	}
	else
	{
		for (int i = 1; i < 5; ++i)
		{
			result += texture(image, TexCoords + vec2(0.0, tex_offset.y * i)).rgb * weight[i];
			result += texture(image, TexCoords - vec2(0.0, tex_offset.y * i)).rgb * weight[i];
		}
	}
	FragColor = vec4(result, 1.0);
}

这里我们使用一个相对小的高斯权重,每一个都被用来指定水平/垂直样本的权重。通过设置horizontal变量,可以选择水平/竖直滤波。

接下来看看乒乓FBO的实现,是由两个只有一个颜色附件的FBO组成:

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_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, 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
    );
}

接下来在获取到HDR贴图和亮度贴图后,我们首先将乒乓FBO的其中一个绑定亮度贴图,然后将它模糊10次(5次水平,5次竖直):

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); 

模糊后的贴图如下:

混合图片

接下来就要将模糊光贴图和HDR贴图混合了,在最终的片段着色器中,通过叠加的方式进行混合:

#version 330 core
out vec4 FragColor;
  
in vec2 TexCoords;

uniform sampler2D hdrScene;
uniform sampler2D bloomBlur;
uniform float exposure;

void main()
{   
    // 叠加混合
    vec3 hdrColor = texture(hdrScene, TexCoords).rgb;
    vec3 bloomColor = texture(bloomBlur, TexCoords).rgb;
    hdrColor += bloomColor;

    // exposure tone mapping
    vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);

    FragColor = vec4(mapped, 1.0);
}

最终的结果如下:

参考资料

  • 泛光 - LearnOpenGL CN (learnopengl-cn.github.io)
  • LearnOpenGL - Bloom