【OpenGL】OpenGL 入门教程(11) 光照优化技术

本篇介绍一下常见的光照优化技术,包括延迟着色和分块着色,这些技术可以有效的提高渲染效率。

PS:这是本系列教程的最后一篇,完结撒花!


OpenGL 入门教程(11) 光照优化技术

引言

到目前为止,已经讲完了常用的光照模型及其常用的材质和投光物的相关知识。

本节讲提高性能的各种优化光照技术,基于帧缓冲的延迟着色技术和分块着色技术。


延迟着色(Deferred Shading)

目前我们用的光照方式叫做正向渲染(Forward Rendering)或者正向着色法(Forward Shading)。

简单地说,在场景中我们根据所有光源照亮一个物体,之后再渲染下一个物体,以此类推。

在前向渲染中,每个对象的光照计算是在片段着色阶段独立完成的,这意味着每个像素可能需要多次光照计算,特别是在场景中有多个光源时。这种情况下,性能开销会随着光源数量的增加而显著增长。

为了解决这个问题,提出延迟着色的渲染方式。

延迟着色法(Deferred Shading)/延迟渲染(Deferred Rendering)分为两个阶段,几何阶段(G-Buffer填充)和光照阶段(Lighting Pass)。

几何阶段 (G-Buffer)

之前我们讲过的帧缓冲在这里就起到作用了。

在这一步骤中,不是直接计算光照,而是将场景几何信息(如位置、法线、纹理坐标、漫反射颜色等)渲染到一组缓冲区中,这组缓冲区统称为G-Buffer(Geometry Buffer或者Geometry Buffer)。 每个像素对应这些信息的一个记录,为后续的光照计算准备数据。

我们以 冯 / 布林冯 模型为例子。

我们同时(或任意顺序)渲染以下几个帧缓冲,需要开启深度测试:

  • 位置缓冲:存储片段的位置向量,用来计算光线/视角方向
  • 法线缓冲:存储片段的单位法向量,用来判断平面的斜率
  • 漫反射缓冲:存储片段的RGB漫反射颜色向量,也就是反照率(Albedo)
  • 镜面缓冲:存储片段的镜面强度(Specular Intensity)值

由于镜面强度是一个浮点数,而漫反射也只用了rgb分量; 因此实际中可以合并这两个缓冲,即rgba的缓冲中rgb为漫反射,a为镜面强度。

这样我们就分开获得了各个属性的G缓冲。

几何阶段片段着色器示例如下:

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
layout (location = 0) out vec3 gPosition;
layout (location = 1) out vec3 gNormal;
layout (location = 2) out vec4 gAlbedoSpec;

in vec2 TexCoords;
in vec3 FragPos;
in vec3 Normal;

uniform sampler2D texture_diffuse1;
uniform sampler2D texture_specular1;

void main()
{
    // 存储第一个G缓冲纹理中的片段位置向量
    gPosition = FragPos;
    // 同样存储对每个逐片段法线到G缓冲中
    gNormal = normalize(Normal);
    // 和漫反射对每个逐片段颜色
    gAlbedoSpec.rgb = texture(texture_diffuse1, TexCoords).rgb;
    // 存储镜面强度到gAlbedoSpec的alpha分量
    gAlbedoSpec.a = texture(texture_specular1, TexCoords).r;
}  

光照阶段

一旦G-Buffer构建完成,渲染器会对从G-Buffer读取几何信息,然后针对每个像素进行光照计算。 由于光照计算是独立于几何体的,每个光源的贡献只需针对G-Buffer中的信息计算一次,然后直接应用到整个屏幕,大大减少了重复的像素着色计算。

这个着色器的原理和之前的布林冯的着色器一致,只是各信息直接从G-Buffer获取,而不是重复计算。

光照阶段片段着色器示例如下:

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

光体积(Light Volumes)优化

虽然延迟渲染少了一些几何阶段的重复计算,但是每个光源依然都作用了一遍。

我们可以通过光体积(Light Volumes)优化,在渲染时跳过不在光照范围内的光源,就可以跳过不必要的光照计算,提高计算效率。

光体积原理

光体积实质上是光源影响范围的可视化表示,通过计算光源的衰减半径,可以限定光照计算的区域,避免对场景中远离光源的像素进行不必要的光照计算,从而大幅节省计算资源。

光体积的大小基于光源的衰减方程计算得出。之前提到的光源的衰减遵循以下公式:

\[F_{light} = \frac{I}{K_c + K_l * d + K_q * d^2}\]

虽然理论上我们想要F=0对应的距离d, 但显然这个式子没有零解。 我们只能取一个靠近0的阈值作为光照的边界,例如0.05或者其他数。

二次方程的求解这里就不赘述了。

当然,对于其他自定义的复杂光照的算法是一致的。这里本质是求解一个阈值对应的距离。

因此,使用不动点法,牛顿法,或者是其他的求解方法都是可以的。

使用光体积

由于 GPU 的并行执行特性,很多时候 GPU 执行 if 会变成执行两种情况再判断选择哪个。 这就使我们在着色器中使用 if 筛选光源变得无意义。

因此我们使用另一种方式。

在渲染阶段,把光源看作光体积半径的球体,将这些球体(光体积)作为额外的几何体进行渲染。由于球体的大小与光源的光体积半径一致,因此只对实际受到光源影响的像素进行光照计算。 这样可以确保只有位于光体积内的像素调用复杂的光照着色器,避免了对远处或不受光源直接影响的像素进行计算,极大地节省了计算资源。

设物体数量为m, 光照数量为n, 则时间复杂度从 O(mn) 降到了 O(m+n)。

需要注意的是,如果不开启面剔除,每个光都会被渲染两次; 但是开启后在光源内部可能会因为背面剔除使得该光不被渲染。

这可以使用模板缓冲解决。 在渲染光体积时,先使用模板测试标记出受光源影响的像素区域,然后再进行光照计算。 这样,即便观察者位于光源内部,模板缓冲也能确保正确渲染光源效果,避免了光源消失的问题。

延迟光照

虽然渲染光体积显著提升了处理大量光源的效率,但仍有进一步优化的空间。

可以对光体积也创建一个和 Gbuffer 类似的 buffer, 按照延迟渲染的思路做光照。

具体地说,先将所有光源的光体积渲染到一个额外的缓冲中,记录下光照计算的必要信息(如光照方向、颜色等)。 在后续的光照阶段,从这个缓冲中读取信息,对每个像素进行光照计算。这种方法避免了重复计算,提高了效率。


分块着色

虽然我们一路优化到现在,但是仍有进一步优化的空间。

试想一下,我们如果把屏幕分成多个块,那么可能左上角的块不会受到右下角的块中的光照,因此这里虽然整个屏幕对应的纹理受n个光照,但是分割后的每一块受到的光照数量都小于n.

这就是分块着色的思想。

切片式延迟着色(Tile-based Deferred Shading)与分区延迟着色(Clustered Deferred Shading)都是分块着色的,针对延迟着色技术的优化方案,它们通过将屏幕分割成多个小区域来减少全局光照计算的成本,从而提高渲染效率。

切片式延迟着色(Tile-based Deferred Shading)

切片式延迟着色是一种将屏幕空间分割成多个固定大小的切片(tiles)的技术。 每个切片通常对应于屏幕上的一个矩形区域。

在这一技术中,光照计算不是在整个屏幕空间上进行,而是针对每个切片独立进行。

在切片式延迟着色中,G-Buffer的构建阶段与标准的延迟着色相同。 但在光照计算阶段,每帧只处理当前切片内的像素,而不是整个屏幕。 这允许算法更高效地管理内存带宽和计算资源,因为只有当前切片的数据需要被加载到高速缓存中进行光照计算。

此外,切片式延迟着色还利用了光源相对于切片的局部性。 在光照计算阶段,每个切片仅考虑影响该切片的光源。 如果一个光源影响多个切片,则它的计算会被分散到这些切片的光照计算过程中,这样就可以避免在整个屏幕上重复计算同一个光源的贡献。

分区延迟着色(Clustered Deferred Shading)

分区延迟着色是切片式延迟着色的一种变种,它将屏幕空间分割成动态大小的分区(clusters)。 与切片不同,分区的大小可以根据光源的分布和强度动态调整,以进一步优化光照计算。

在分区延迟着色中,屏幕被分割成多个分区,每个分区可以包含不同的数量的像素。 这种动态分区的机制使得算法能够更加智能地分配计算资源。 例如,当一个区域内有大量光源时,可以将该区域划分为较小的分区,以便更精细地控制光照计算; 相反,当一个区域内的光源较少时,可以将该区域划分为较大的分区,以减少不必要的计算。

分区延迟着色同样在光照计算阶段利用光源的局部性,但与切片式延迟着色相比,它可以更灵活地适应场景中的光源分布,从而提供更好的性能优化。

总的来说,通过光体积、延迟光照和切片式延迟着色法等技术,延迟渲染能够在处理大量光源时保持高性能,同时维持良好的图像质量和抗锯齿效果。


至此,读者已经了解常见的延迟着色优化方法了。


到目前为止,我认为基础的 OpenGL 的使用方法已经介绍完了。

如果要进一步讲,要么是讲 PBR /光线追踪,要么是讲一些动画技术,要么是讲一些优化技术。

这些每一个都值得单独开一个系列教程了,放在 OpenGL 教程里太臃肿,也没有必要。

因此这个系列就到此为止了,感谢读者朋友的追读,完结撒花!

PS:接下来想先更一些数学相关的博文,一段时间后再更计算机系列的喔。