LearnOpenGL学习笔记(十三) - HDR、泛光、延迟渲染与SSAO

本文最后更新于 2024年8月24日 晚上

冲刺。

HDR

当场景内存在多个高强度光源时,会导致相当一部分片段的颜色值大于1.0。而大于1.0部分的颜色值会被约束在1.0,因此会出现一片一片糊在一起的白色区域。

一种方法是减少场景内的光源强度,但这会引入不切实际的光照参数。更好的方法是允许颜色值超过1.0,然后将其映射到[0,1]之内。

通过使用高动态范围(High Dynamic Range,HDR),我们可以让片段的颜色超过1.0。首先,我们在光照方程中用更大范围的颜色值渲染,然后将所有HDR值转换到低动态范围(Low Dynamic Range, LDR)内。这个转换的过程叫做色调映射(Tone Mapping)。

在实时渲染中,HDR不仅允许让颜色值超过LDR范围,也允许我们根据光源的真实强度指定其强度。

浮点帧缓冲

对于使用标准化定点格式(如GL_RGB)的颜色缓冲,OpenGL会在将这些值存入帧缓冲前将其自动约束到LDR范围。当一个帧缓冲的颜色缓冲的内部格式被设置为GL_RGB16FGL_RGBA16FGL_RGB32FGL_RGBA32F时,该帧缓冲便被称为浮点帧缓冲(Floating Point Buffer)。它可以存储超过LDR范围的浮点值。

1
2
3
4
5
6
7
8
9
10
11
glBindTexture(GL_TEXTURE_2D, colorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// [...] 渲染(光照的)场景
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 现在使用一个不同的着色器将HDR颜色缓冲渲染至2D铺屏四边形上
hdrShader.Use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrColorBufferTexture);
RenderQuad();

色调映射

色调映射(Tone Mapping)用于在损失很小的前提下将浮点颜色值转换至LDR范围,并且伴有特定风格的色平衡(Stylistic Color Balance)。

Reinhard色调映射是最简单的色调映射。它平均地将所有亮度值分散到LDR上,并且通常伴随Gamma矫正过滤:

1
2
3
4
5
6
7
8
void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
vec3 mapped = hdrColor / (hdrColor + vec3(1.0));
mapped = pow(mapped, vec3(1.0 / gamma));
color = vec4(mapped, 1.0);
}

曝光(Exposure)参数用于设置不同光照条件下的光照参数,让画面无论是在高亮度条件还是低亮度条件下都能较好地呈现:

1
2
3
4
5
6
7
8
9
uniform float exposure;
void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
mapped = pow(mapped, vec3(1.0 / gamma));
color = vec4(mapped, 1.0);
}

泛光

泛光(Bloom)是后处理技术的一种,用于在光源附近加上一层光晕。

泛光常常与HDR技术结合使用。其基本原理如下:

对于HDR颜色缓冲纹理,提取所有超过一定亮度的片段,得到一个新纹理。随后对这个新闻里进行高斯模糊处理,然后叠加到原来的颜色缓冲纹理上。

img

提取亮色

多渲染目标(Multiple Render Targets, MRT)用于指定多个片段着色器输出。

使用MRT的必要条件是当前的帧缓冲附加有多个颜色附件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GLuint hdrFBO;
glGenFramebuffers(1, &hdrFBO);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
GLuint colorBuffers[2];
glGenTextures(2, colorBuffers);
for (GLuint i = 0; i < 2; i++)
{
glBindTexture(GL_TEXTURE_2D, colorBuffers[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 + i, GL_TEXTURE_2D, colorBuffers[i], 0
);
}
GLuint attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };
//glDrawBuffers用于告知OpenGL渲染到哪些颜色缓冲,默认仅渲染到GL_COLOR_ATTACHMENT0
glDrawBuffers(2, attachments);

在片段着色器中使用layout关键字定义多个颜色缓冲输出:

1
2
3
4
5
6
7
8
9
10
11
12
#version 330 core
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;
[...]
void main()
{
[...]
FragColor = vec4(lighting, 1.0f);
float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722));
if(brightness > 1.0)
BrightColor = vec4(FragColor.rgb, 1.0);
}

高斯模糊

高斯模糊(Gaussian Blur)是一种基于核的后处理技术。高斯曲线是一种钟形曲线,将其扩展到二维便可以形成一个卷积核的权重。但这样做需要采样很多次,极大消耗性能。

为此,我们可以把二维方程拆解为两个一维方程,一个描述水平权重,一个描述垂直权重。如图:

img

该方法称为两步高斯模糊(Two-Pass Gaussian Blur)。我们用帧缓冲对象实现“两步”:在第一个帧缓冲对象进行水平模糊,然后将纹理传入第二个帧缓冲对象渲染时所用的着色器,进行垂直模糊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D image;
uniform bool horizontal; //使用bool指定水平还是垂直渲染
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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
GLuint pingpongFBO[2];
GLuint pingpongBuffer[2];
glGenFramebuffers(2, pingpongFBO);
glGenTextures(2, pingpongBuffer);
//生成两个帧缓冲对象,并分别为它们绑定颜色缓冲
for (GLuint 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
);
}
GLboolean horizontal = true, first_iteration = true;
GLuint amount = 10;
shaderBlur.Use();
//这里的循环与Shader内的循环不同,后者是用于卷积核采样的,前者是进行高斯模糊的次数,越大效果越好
for (GLuint i = 0; i < amount; i++)
{
glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[horizontal]);
glUniform1i(glGetUniformLocation(shaderBlur.Program, "horizontal"), horizontal);
glBindTexture(
//此处colorBuffers存放着MRT,其中1号元素代表对HDR画面进行强度筛选后的RT
//如果是第一次循环,直接采用初始RT
//否则,水平和垂直模糊交替进行
//需注意,pingpongBuffers是blur帧缓冲绑定的颜色纹理附件,每次渲染到帧缓冲,该纹理附件都会变更为渲染后的画面
//绑定FBO时的数组下标与pingpongBuffers的数组下标相反,代表着颜色数据的交换传输
GL_TEXTURE_2D, first_iteration ? colorBuffers[1] : pingpongBuffers[!horizontal]
);
RenderQuad();
horizontal = !horizontal;
if (first_iteration)
first_iteration = false;
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

随后,在片段着色器中定义两个sampler2D,一个是原始画面,一个是经模糊的HDR筛选画面,将二者采样后相加即可得到最终颜色。

延迟着色

目前为止我们使用的光照方式皆为前向渲染(Forward Rendering),在场景中我们根据所有光源渲染一个物体,然后再渲染下一个物体,以此类推。但是,这种方式对于每一个需要渲染的物体,程序都需要对每一个光源进行迭代。当片段和光源数量持续增加时,需要迭代的次数将会爆炸式增长。

为了解决这一问题,我们引入延迟渲染法(Deferred Renderring)。该方法将光照等计算量较大的过程推迟到后期进行。延迟渲染分为两个过程(Pass),几何处理Pass和光照处理Pass。

在几何处理Pass中,首先渲染场景一次,以获取各类几何信息(如位置、颜色、法线、镜面值),并将其存储在被称为G缓冲(G-Buffer)的纹理中。

在光照处理Pass中,使用G缓冲中的数据(而非顶点着色器传入的数据)对每个片段计算场景光照。这种方法避免了无Early-Z情况下的单像素多次渲染,确保了每个像素只调用一次片段着色器。

img

延迟渲染的缺点是:

  • 消耗显存多,因为G-Buffer中要存储大量数据
  • 因为只有最前方的片段信息,所以无法使用Blend渲染半透明物体
  • 因为需要MRT技术支持,延迟渲染需要的RT数量极多,如果使用MSAA的话每个RT的分辨率都要翻倍,需要消耗极大的显存,所以一般说延迟渲染不支持MSAA

G缓冲

G-Buffer用来存储所有跟光照有关的数据,包括:

  • 3D位置向量
  • RGB漫反射颜色向量,即反照率(Albedo)
  • 3D法向量
  • 镜面强度浮点值
  • 光源位置与颜色向量
  • 相机位置

其中,后两点对于所有的片段都相同,可以通过uniform设置。而剩余变量对于每个变量都不同,所以可以使用G-Buffer传输。

使用MRT技术,每个变量对应一个RT。过程如下:

初始化FBO,它包含了若干颜色缓冲和一个深度缓冲对象。位置和法向量使用高精度纹理,反照率和镜面值使用默认精度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
GLuint gBuffer;
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
GLuint gPosition, gNormal, gColorSpec;

// - 位置颜色缓冲
glGenTextures(1, &gPosition);
glBindTexture(GL_TEXTURE_2D, gPosition);
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_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, gPosition, 0

// - 法线颜色缓冲
glGenTextures(1, &gNormal);
glBindTexture(GL_TEXTURE_2D, gNormal);
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_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, gNormal, 0);

// - 颜色 + 镜面颜色缓冲
//使用GL_RGBA,其中RGB通道渲染颜色,A通道存储镜面强度
glGenTextures(1, &gAlbedoSpec);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT2, GL_TEXTURE_2D, gAlbedoSpec, 0);

// - 告诉OpenGL我们将要使用(帧缓冲的)哪种颜色附件来进行渲染
GLuint attachments[3] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 };
glDrawBuffers(3, attachments);

// 之后同样添加渲染缓冲对象(Render Buffer Object)为深度缓冲(Depth Buffer),并检查完整性
[...]

完成几何处理阶段后,进入光照处理阶段。

此阶段,首先渲染一个铺屏四边形,然后在四边形的每个像素上运行一次片段着色器:

1
2
3
4
5
6
7
8
9
10
11
12
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shaderLightingPass.Use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPosition);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
// 同样发送光照相关的uniform
SendAllLightUniformsToShader(shaderLightingPass);
glUniform3fv(glGetUniformLocation(shaderLightingPass.Program, "viewPos"), 1, &camera.Position[0]);
RenderQuad();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;

uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D gAlbedoSpec;

struct Light {
vec3 Position;
vec3 Color;
};
const int NR_LIGHTS = 32;
uniform Light lights[NR_LIGHTS];
uniform vec3 viewPos;

void main()
{
// 从G缓冲中获取数据
vec3 FragPos = texture(gPosition, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Albedo = texture(gAlbedoSpec, TexCoords).rgb;
float Specular = texture(gAlbedoSpec, TexCoords).a;

// 然后和往常一样地计算光照
vec3 lighting = Albedo * 0.1; // 硬编码环境光照分量
vec3 viewDir = normalize(viewPos - FragPos);
for(int i = 0; i < NR_LIGHTS; ++i)
{
// 漫反射
vec3 lightDir = normalize(lights[i].Position - FragPos);
vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Albedo * lights[i].Color;
lighting += diffuse;
}

FragColor = vec4(lighting, 1.0);
}

结合延迟与前向渲染

为了克服延迟渲染的缺点(如无法Blend),一般采用延迟与正向渲染相结合的方式。

对于光源,我们无需考虑其受光照影响,所以首先渲染光立方体。但渲染完毕后,我们发现,因为没有获取到延迟渲染中的深度信息,光立方体无法正常进行深度测试。为此,我们需要把延迟渲染的几何处理阶段中存储的深度信息输出到默认帧缓冲的深度缓冲中。

使用glBlitFrameBuffer函数将帧缓冲的内容复制到另一个帧缓冲:

1
2
3
4
5
6
glBindFramebuffer(GL_READ_FRAMEBUFFER, gBuffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); // 写入到默认帧缓冲
glBlitFramebuffer(
0, 0, SCR_WIDTH, SCR_HEIGHT, 0, 0, SCR_WIDTH, SCR_HEIGHT, GL_DEPTH_BUFFER_BIT, GL_NEAREST
);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

类似地,我们也可以传输其他缓冲到默认帧缓冲。

光体积

延迟渲染本身并不能支持大量光源,因为即便是在铺屏四边形的逐像素渲染中,假设有1000个光源,我们还是要考虑每个光源对像素的贡献,无论光源有多远。为了解决这一问题,我们引入光体积(Light Volumns)技术:

光体积的基本原理是计算光源的半径,即光能够到达片段的范围。计算光照贡献时,我们只需要考虑覆盖在各个光源范围内的片段就可以了。

计算光源半径

在之前计算点光源衰减时,我们使用了常量+线性量+平方量的高级衰减方程。

Flight=IKc+Kld+Kqd2F_{light} = \frac{I}{K_c + K_l * d + K_q * d^2}

其中,FlightF_{light}是当前片段受光照影响的程度,II代表光照强度值(对于白光光源一般是1.0)。

但这个方程永远不会真正等于0,所以我们需要选择一个合适的“黑暗阈值”。在这里,我们使用5/256作为阈值。

5256=ImaxKc+Kld+Kqd2\frac{5}{256} = \frac{I_{max}}{K_c + K_l * d + K_q * d^2}

其中,ImaxI_{max}代表光源中RGB中最强的光照分量,我们要做的是求出dd的值。

通过求根公式我们可以求出:

d=Kl+Kl24Kq(KcImax2565)2Kqd = \frac{-K_l + \sqrt{K_l^2 - 4 * K_q * (K_c - I_{max} * \frac{256}{5})}}{2 * K_q}

转换为代码:

1
2
3
4
5
6
7
GLfloat constant  = 1.0; 
GLfloat linear = 0.7;
GLfloat quadratic = 1.8;
GLfloat lightMax = std::fmaxf(std::fmaxf(lightColor.r, lightColor.g), lightColor.b);
GLfloat radius =
(-linear + std::sqrtf(linear * linear - 4 * quadratic * (constant - (256.0 / 5.0) * lightMax)))
/ (2 * quadratic);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Light {
[...]
float Radius;
};
void main(){
[...]
for(int i = 0; i < NR_LIGHTS; ++i){
// 计算光源和该片段间距离
float distance = length(lights[i].Position - FragPos);
if(distance < lights[i].Radius){
// 执行大开销光照
[...]
}
}
}

但是问题是,着色器运行时总是会执行if语句所有的分支,因为GPU上的着色器程序是高度并行的,必须要确保每次运行着色器的代码都是一样的。因此,上面的着色器代码中的if判断实际上无用

为了解决这一问题,我们可以使用延迟渲染的方式,将每个光源的光体积球渲染出来,将其作为一个单独的RT,然后在实际计算光照时采样这个RT即可。

**延迟光照(Deferred Lighting)切片式延迟着色法(Tile-based Deferred Shading)**是比渲染光体积更优的方法,并且这两种方法允许MSAA。

SSAO

Phong模型中的环境光照(Ambient Lighting)常量用于模拟光的散射(Scattering)。但在现实里,光线会以任意方向散射,它的强度会不断发生改变。环境光遮蔽(Ambient Occlusion)用于模拟更真实的间接光,它通过将褶皱、孔洞和非常靠近的墙面所处的片段变暗来模拟出间接光照。

img

实现真正意义上的环境光遮蔽需要对每个片段周围的几何体的情况进行确认,开销较大。为解决这一问题,我们引入屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion, SSAO)技术,该技术使用屏幕空间的场景深度而非真实几何体数据来确定屏蔽量。

SSAO的底层原理是:对于铺屏四边形上的每个片段,都根据周围的深度值计算一个遮蔽因子(Occlusion Factor)。这个遮蔽因子在之后用于抵消片段的环境光照分量。

遮蔽因子通过采集片段周围球形核(Kernel)的多个深度样本,并和当前片段的深度值对比得到。高于片段深度值样本的个数就是遮蔽因子。

img

如图,黑色折线代表几何体表面,球形代表核的范围。灰色采样点代表位于几何体内部的采样点,它们能对遮蔽因子产生影响。灰色样本越多,遮蔽因子越大,简介光照越暗。

对于这种方式,采样的样本数量和最终效果有着非常直接的关系。若样本数量太低,精度就会急剧减少,导致波纹(Banding)效果;若样本数量太高,又会影响性能。

为此,我们通过给采样核心(Sample Kernel)引入随机性,从而在减少样本数的情况下优化效果。但是随机性会引入噪点,因此,给结果叠加一个模糊处理效果会更好。如图:

img

即便是平整的墙面,球形采样核心也会囊括入约一半的采样点。但只有狭窄、内缩的表面才需要进行遮蔽。因此,我们使用沿表面法向量的半球形采样核心(被称为法向半球体,Normal-oriented Hemisphere)。如图:

img

样本缓冲

使用SSAO,我们需要获取以下数据:

  • 片段位置向量
  • 片段法线向量
  • 片段反射颜色
  • 采样核心
  • 用于旋转采样核心的随机旋转向量

SSAO是一种屏幕空间技术,因此不难发现,它非常适合在延迟渲染管线中使用,因为它可以直接通过G-Buffer获取所有需要的屏幕空间数据。

我们在片段着色器中,使用内建变量gl_FragCoord.z获取非线性片段深度,并可以将其变为线性深度值:

1
2
3
4
float LinearizeDepth(float depth){
float z = depth * 2.0 - 1.0; // 回到NDC
return (2.0 * NEAR * FAR) / (FAR + NEAR - z * (FAR - NEAR));
}

提取的线性深度基于观察空间,因此需要确保G-Buffer中的位置和法线都处于观察空间。

我们也可以通过深度值重构位置向量,这是一种优化技巧:

Position From Depth 3: Back In The Habit – The Danger Zone (wordpress.com)

法向半球

对每个表面法线方向生成采样核心比较困难,所以我们会在切线空间生成采样核心,所有核心的朝向都指向正z方向,即表面法向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::uniform_real_distribution<GLfloat> randomFloats(0.0, 1.0); // 随机浮点数,范围0.0 - 1.0
std::default_random_engine generator;
std::vector<glm::vec3> ssaoKernel;
for (GLuint i = 0; i < 64; ++i)
{
//向x和y方向随机偏移(-1,1)
glm::vec3 sample(
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) //z方向不能为负,因为是半球
);
sample = glm::normalize(sample);
sample *= randomFloats(generator); //用于调整向量长度,使向量终点在半球内均匀分布
GLfloat scale = GLfloat(i) / 64.0;
scale = lerp(0.1f, 1.0f, scale * scale); //缩放因子用于调整向量的分布方式,这里使得采样点更靠近片段原点
sample *= scale;
ssaoKernel.push_back(sample);
}

随机核心转动

因为我们不可能在每次片段计算时都生成随机数,所以上面提到的带有随机性的采样核心会被运用至所有片段,但这会导致随机性偏少。

为了解决这一问题,我们创建一个随机旋转向量纹理并将其平铺至屏幕,通过采样来获取旋转向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::vector<glm::vec3> ssaoNoise;
for (GLuint i = 0; i < 16; i++)
{
glm::vec3 noise(
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
0.0f);
ssaoNoise.push_back(noise);
}
GLuint noiseTexture;
glGenTextures(1, &noiseTexture);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
//这个纹理本身只有4*4,我们通过设置环绕方式为GL_REPEAT来将其平铺在屏幕上
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, 4, 4, 0, GL_RGB, GL_FLOAT, &ssaoNoise[0]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

SSAO着色器

SSAO着色器计算的是逐片段的遮蔽值而非最终的颜色,因此我们使用一个独立的帧缓冲计算SSAO结果,并将结果存储至纹理附件供后续叠加使用。

为什么不用MRT?

SSAO与最终颜色输出之间存在先后关系,凡是存在先后关系的都不能用MRT。

1
2
3
4
5
6
7
8
9
10
11
12
GLuint ssaoFBO;
glGenFramebuffers(1, &ssaoFBO);
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
GLuint ssaoColorBuffer;
glGenTextures(1, &ssaoColorBuffer);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
//InnerFormat参数为GL_RED,因为SSAO结果值为灰度值
//尽管Format参数是GL_RGB,但由于源数据为NULL,所以这个参数取什么值都不会影响结果
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoColorBuffer, 0);

SSAO完整渲染流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 几何处理阶段: 渲染到G缓冲中
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
[...]
glBindFramebuffer(GL_FRAMEBUFFER, 0);

// 使用G缓冲渲染SSAO纹理
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
glClear(GL_COLOR_BUFFER_BIT);
shaderSSAO.Use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPositionDepth);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
SendKernelSamplesToShader();
glUniformMatrix4fv(projLocation, 1, GL_FALSE, glm::value_ptr(projection));
RenderQuad();
glBindFramebuffer(GL_FRAMEBUFFER, 0);

// 光照处理阶段: 渲染场景光照
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shaderLightingPass.Use();
[...]
glActiveTexture(GL_TEXTURE3);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
[...]
RenderQuad();

SSAO着色器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#version 330 core
out float FragColor;
in vec2 TexCoords;
uniform sampler2D gPositionDepth;
uniform sampler2D gNormal;
uniform sampler2D texNoise;
uniform vec3 samples[64];
uniform mat4 projection;
// 屏幕的平铺噪声纹理会根据屏幕分辨率除以噪声大小的值来决定
// 该值用于对纹理坐标进行缩放,使其能够根据本身的分辨率以及屏幕分辨率合理平铺
const vec2 noiseScale = vec2(800.0/4.0, 600.0/4.0); // 屏幕 = 800x600
void main(){
// 采样G-Buffer数据
vec3 fragPos = texture(gPositionDepth, TexCoords).xyz;
vec3 normal = texture(gNormal, TexCoords).rgb;
vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;
// 有了数据后,可计算TBN矩阵,将基于切线空间的SSAO向量转换到观察空间
// TBN向量位于哪个空间,他就可以把坐标从切线空间转换到哪个空间
// 这里使用了施密特正交化,让TBN矩阵形成的正交基根据randomVec值略微倾斜
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);
sample = fragPos + sample * radius;
float occlusion = 0.0; //最终的遮蔽值
//kernelSize用于调整采样点个数,默认64
for(int i = 0; i < kernelSize; ++i){
// 获取样本位置(本质上是为了获取采样点的UV坐标)
vec3 sample = TBN * samples[i]; // 切线->观察空间
//radius用于调整采样半球半径,默认1.0
sample = fragPos + sample * radius;
vec4 offset = vec4(sample, 1.0);
offset = projection * offset; // 观察->裁剪空间
offset.xyz /= offset.w; // 透视划分
offset.xyz = offset.xyz * 0.5 + 0.5; // 变换到0.0 - 1.0的值域
//取反是因为,sample.z是观察空间下的深度值,越小越远
float sampleDepth = -texture(gPositionDepth, offset.xy).w;
//注意,这里sampleDepth和sample.z都是负数,越小,代表越远
//深度差超过radius时,被限制到1.0,否则无论深度差多大,处于背景物体的片段都会产生AO
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0) * rangeCheck;
}
//最终的遮蔽值是用于缩放光照分量的,所以遮蔽越强,occlusion越接近0
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = occlusion;
}

环境遮蔽模糊

现在这版会由于采样点数量以及随机性的限制,出现噪点。所以我们需要对SSAO输出进行模糊。

由于存在先后关系(先SSAO,后Blur),所以使用FBO:

1
2
3
4
5
6
7
8
9
GLuint ssaoBlurFBO, ssaoColorBufferBlur;
glGenFramebuffers(1, &ssaoBlurFBO);
glBindFramebuffer(GL_FRAMEBUFFER, ssaoBlurFBO);
glGenTextures(1, &ssaoColorBufferBlur);
glBindTexture(GL_TEXTURE_2D, ssaoColorBufferBlur);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoColorBufferBlur, 0);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#version 330 core
in vec2 TexCoords;
out float fragColor;

uniform sampler2D ssaoInput;

void main() {
vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 0));
float result = 0.0;
for (int x = -2; x < 2; ++x)
{
for (int y = -2; y < 2; ++y)
{
vec2 offset = vec2(float(x), float(y)) * texelSize;
result += texture(ssaoInput, TexCoords + offset).r;
}
}
fragColor = result / (4.0 * 4.0);
}

应用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;

uniform sampler2D gPositionDepth;
uniform sampler2D gNormal;
uniform sampler2D gAlbedo;
uniform sampler2D ssao;

struct Light {
vec3 Position;
vec3 Color;

float Linear;
float Quadratic;
float Radius;
};
uniform Light light;

void main()
{
// 从G缓冲中提取数据
vec3 FragPos = texture(gPositionDepth, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Diffuse = texture(gAlbedo, TexCoords).rgb;
float AmbientOcclusion = texture(ssao, TexCoords).r;

// Blinn-Phong (观察空间中)
vec3 ambient = vec3(0.3 * AmbientOcclusion); // 这里我们加上遮蔽因子
vec3 lighting = ambient;
vec3 viewDir = normalize(-FragPos); // Viewpos 为 (0.0.0),在观察空间中
// 漫反射
vec3 lightDir = normalize(light.Position - FragPos);
vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Diffuse * light.Color;
// 镜面
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(Normal, halfwayDir), 0.0), 8.0);
vec3 specular = light.Color * spec;
// 衰减
float dist = length(light.Position - FragPos);
float attenuation = 1.0 / (1.0 + light.Linear * dist + light.Quadratic * dist * dist);
diffuse *= attenuation;
specular *= attenuation;
lighting += diffuse + specular;

FragColor = vec4(lighting, 1.0);
}

LearnOpenGL学习笔记(十三) - HDR、泛光、延迟渲染与SSAO
http://example.com/2024/08/24/LearnOpenGL学习笔记(十三)-HDR、泛光、延迟渲染与SSAO/
作者
Yoi
发布于
2024年8月24日
许可协议