首先介绍一下什么是延迟渲染。延迟渲染是一种先计算场景中的顶点、颜色、法线等信息,将其存入缓冲,再进行光照计算的渲染技术,与直接渲染是相对的概念。为了详细介绍延迟渲染,我们首先需要了解帧缓冲,以及帧缓冲的应用,之后介绍基于帧缓冲技术的延迟渲染。
我们在使用OpenGL去渲染各种效果时,用到许多屏幕缓冲:用于写入颜色值的颜色缓冲、用于写入深度信息的深度缓冲和允许我们根据一些条件丢弃特定片段的模板缓冲。这些缓冲结合起来叫做帧缓冲(Framebuffer),它被存储在内存中。OpenGL允许我们定义我们自己的帧缓冲,也就是说我们能够定义我们自己的颜色缓冲,乃至深度缓冲和模板缓冲。
一个完整的帧缓冲需要满足以下的条件:
纹理附件可以包含颜色纹理、深度纹理、模板纹理。当把一个纹理附加到帧缓冲的时候,所有的渲染指令将会写入到这个纹理中,就像它是一个普通的颜色/深度或模板缓冲一样。使用纹理的优点是,所有渲染操作的结果将会被储存在一个纹理图像中,我们之后可以在着色器中很方便地使用它。
渲染缓冲对象是在纹理之后被引入到OpenGL中,作为一个可用的帧缓冲附件类型的,所以在过去纹理是唯一可用的附件。和纹理图像一样,渲染缓冲对象是一个真正的缓冲,即一系列的字节、整数、像素等。渲染缓冲对象附加的好处是,它会将数据储存为OpenGL原生的渲染格式,它是为离屏渲染到帧缓冲优化过的。渲染缓冲对象直接将所有的渲染数据储存到它的缓冲中,不会做任何针对纹理格式的转换,让它变为一个更快的可写储存介质。然而,渲染缓冲对象通常都是只写的,所以你不能读取它们(比如使用纹理访问)。当然你仍然还是能够使用glReadPixels来读取它,这会从当前绑定的帧缓冲,而不是附件本身,中返回特定区域的像素。
渲染缓冲对象能为你的帧缓冲对象提供一些优化,但知道什么时候使用渲染缓冲对象,什么时候使用纹理是很重要的。通常的规则是,如果你不需要从一个缓冲中采样数据,那么对这个缓冲使用渲染缓冲对象会是明智的选择。如果你需要从缓冲中采样颜色或深度值等数据,那么你应该选择纹理附件。性能方面它不会产生非常大的影响的。
我们可以利用帧缓冲实现一些简单的后处理。
我们可以从屏幕纹理中取颜色值,然后用1.0减去它,对它进行反相:
除此之外,我们可以取屏幕上每个像素所有的颜色分量,将它们平均化,实现灰度效果:
另外,我们可以在当前纹理坐标的周围取一小块区域,对当前纹理值周围的多个纹理值进行采样。
核(Kernel)(或卷积矩阵(Convolution Matrix))是一个类矩阵的数值数组,它的中心为当前的像素,它会用它的核值乘以周围的像素值,并将结果相加变成一个值。所以,基本上我们是在对当前像素周围的纹理坐标添加一个小的偏移量,并根据核将结果合并。下面是核的一个例子:
[
2
2
2
2
?
15
2
2
2
2
]
\begin{bmatrix} 2 & 2 & 2\\ 2 & -15 & 2\\ 2 & 2 & 2 \end{bmatrix}
?222?2?152?222?
?
这个核取了8个周围像素值,将它们乘以2,而把当前的像素乘以-15。这个核的例子将周围的像素乘上了一个权重,并将当前像素乘以一个比较大的负权重来平衡结果。采用这个核来对像素周围进行采样,效果图如下:
帧缓冲的另一个应用是HDR。显示器被限制为只能显示值为0.0到1.0间的颜色,但是在光照方程中却没有这个限制。通过使片段的颜色超过1.0,我们有了一个更大的颜色范围,这也被称作HDR(High Dynamic Range, 高动态范围)。有了HDR,亮的东西可以变得非常亮,暗的东西可以变得非常暗,而且充满细节。
在HDR渲染中,我们允许用更大范围的颜色值渲染从而获取大范围的黑暗与明亮的场景细节,将这些场景细节通过帧缓冲渲染到纹理上,最后将所有HDR值转换成在[0.0, 1.0]范围的LDR(Low Dynamic Range,低动态范围),得到最终结果。转换HDR值到LDR值得过程叫做色调映射(Tone Mapping),现在现存有很多的色调映射算法,这些算法致力于在转换过程中保留尽可能多的HDR细节。这些色调映射算法经常会包含一个选择性倾向黑暗或者明亮区域的参数。
**色调映射(Tone Mapping)**是一个损失很小的转换浮点颜色值至我们所需的LDR[0.0, 1.0]范围内的过程,通常会伴有特定的风格的色平衡(Stylistic Color Balance)。
最简单的色调映射算法是Reinhard色调映射,它涉及到分散整个HDR颜色值到LDR颜色值上,所有的值都有对应。Reinhard色调映射算法平均地将所有亮度值分散到LDR上。我们可以利用Reinhard色调映射对帧缓冲渲染得到的纹理进行应用,并且为了更好的测量加上一个Gamma校正过滤(包括SRGB纹理的使用):
void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
// Reinhard色调映射
vec3 mapped = hdrColor / (hdrColor + vec3(1.0));
// Gamma校正
mapped = pow(mapped, vec3(1.0 / gamma));
color = vec4(mapped, 1.0);
}
上图是应用Reinhard色调映射的渲染结果,我们不再会在场景明亮的地方损失细节。当然,这个算法是倾向明亮的区域的,暗的区域会不那么精细也不那么有区分度。另外一种有趣的色调映射方法是采用曝光参数。如果我们有一个场景要展现日夜交替,我们当然会在白天使用低曝光,在夜间使用高曝光,就像人眼调节方式一样。有了这个曝光参数,我们可以去设置可以同时在白天和夜晚不同光照条件工作的光照参数,我们只需要调整曝光参数就行了。
一个见到的曝光色调映射算法会是这样:
uniform float exposure;
void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
// 曝光色调映射
vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
// Gamma校正
mapped = pow(mapped, vec3(1.0 / gamma));
color = vec4(mapped, 1.0);
}
包含曝光等级的色调映射效果如下:
明亮的光源和区域经常很难向观察者表达出来,因为显示器的亮度范围有限,一种区分明亮光源的方法是使它们在监视器上发出光芒,光源的光芒向四周发散。这样观察者就会产生光源或亮区的确是强光区。
光晕效果可以使用一个后处理特效泛光来实现。泛光使所有明亮区域产生光晕效果。
泛光和HDR结合使用效果很好。常见的一个误解是HDR和泛光是一样的,很多人认为两种技术是可以互换的。但是它们是两种不同的技术,用于各自不同的目的上。
为了实现泛光,我们首先需要渲染一个有光场景,提取出场景的HDR颜色缓冲以及只有这个场景明亮区域可见的图片。被提取的带有亮度的图片接着被模糊,结果被添加到HDR场景上面。泛光的主要流程图如下图所示:
其中每一个中间过程都通过帧缓冲来实现和保存。
泛光的实现效果如下:
接下来开始详细介绍延迟渲染,或延迟着色法。
延迟着色法基于我们**延迟(Defer)或推迟(Postpone)**大部分计算量非常大的渲染(像是光照)到后期进行处理的想法。它包含两个处理阶段(Pass):
在第一个几何处理阶段(Geometry Pass)中,我们先渲染场景一次,之后获取对象的各种几何信息,并储存在一系列叫做G缓冲(G-buffer)的纹理中;想想位置向量(Position Vector)、颜色向量(Color Vector)、法向量(Normal Vector)和/或镜面值(Specular Value)。场景中这些储存在G缓冲中的几何信息将会在之后用来做(更复杂的)光照计算。
我们会在第二个光照处理阶段(Lighting Pass)中使用G缓冲内的纹理数据。在光照处理阶段中,我们渲染一个屏幕大小的方形,并使用G缓冲中的几何数据对每一个片段计算场景的光照;在每个像素中我们都会对G缓冲进行迭代。我们对于渲染过程进行解耦,将它高级的片段处理挪到后期进行,而不是直接将每个对象从顶点着色器带到片段着色器。光照计算过程还是和我们以前一样,但是现在我们需要从对应的G缓冲而不是顶点着色器(和一些uniform变量)那里获取输入变量了。
下面这幅图片很好地展示了延迟着色法的整个过程:
整个过程的伪代码是这样的:
while(...) // 循环
{
// 1. 几何处理阶段:渲染所有的几何/颜色数据到G缓冲
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
gBufferShader.Use();
for(Object obj : Objects)
{
ConfigureShaderTransformsAndUniforms();
obj.Draw();
}
// 2. 光照处理阶段:使用G缓冲计算场景的光照
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClear(GL_COLOR_BUFFER_BIT);
lightingPassShader.Use();
BindAllGBufferTextures();
SetLightingUniforms();
RenderQuad();
}
在代码中,我们使用RGB纹理来存储位置和法线的数据,因为每个对象只有三个分量;将颜色和镜面强度数据合并到一起,存储到一个单独的RGBA纹理里面,这样我们就不需要声明一个额外的颜色缓冲纹理了。
因为有光照计算,所以保证所有变量在一个坐标空间当中至关重要。在这里我们在世界空间中存储(并计算)所有的变量。
延迟着色法的其中一个缺点就是它不能进行[混合](https://learnopengl-cn.github.io/04 Advanced OpenGL/03 Blending/)(Blending),因为G缓冲中所有的数据都是从一个单独的片段中来的,而混合需要对多个片段的组合进行操作。延迟着色法另外一个缺点就是它迫使你对大部分场景的光照使用相同的光照算法,可以通过包含更多关于材质的数据到G缓冲中来减轻这一缺点。
为了克服这些缺点(特别是混合),我们通常分割我们的渲染器为两个部分:一个是延迟渲染的部分,另一个是专门为了混合或者其他不适合延迟渲染管线的着色器效果而设计的的正向渲染的部分。
延迟渲染的初始结果如下:
我们可以将延迟渲染中的深度信息采集下来,存入当前屏幕的帧缓冲中,用于正向渲染,这样就不会产生正向渲染的正方体直接绘制在延迟渲染的物体前方的问题。结合延迟渲染和正向渲染的效果图如下:
延迟渲染一直被称赞的原因就是它能够渲染大量的光源而不消耗大量的性能。然而,延迟渲染它本身并不能支持非常大量的光源,因为我们仍然必须要对场景中每一个光源计算每一个片段的光照分量。真正让大量光源成为可能的是我们能够对延迟渲染管线引用的一个非常棒的优化:光体积(Light Volumes)。
隐藏在光体积背后的想法就是计算光源的半径,或是体积,也就是光能够到达片段的范围。由于大部分光源都使用了某种形式的衰减(Attenuation),我们可以用它来计算光源能够到达的最大路程,或者说是半径。我们接下来只需要对那些在一个或多个光体积内的片段进行繁重的光照运算就行了。这可以给我们省下来很可观的计算量,因为我们现在只在需要的情况下计算光照。
如何计算一个光源的体积或半径?
为了获取一个光源的体积半径,我们需要解一个对于一个我们认为是**黑暗(Dark)**的亮度(Brightness)的衰减方程,它可以是0.0,或者是更亮一点的但仍被认为黑暗的值,像是0.03。为了展示我们如何计算光源的体积半径,我们将会使用一个在[投光物](http://learnopengl-cn.readthedocs.org/zh/latest/02 Lighting/05 Light casters/)这节中引入的一个更加复杂,但非常灵活的衰减方程:
F
l
i
g
h
t
=
I
K
c
+
K
l
?
d
+
K
q
?
d
2
\mathbf F_{light} = \frac{I}{K_c + K_l * d + K_q * d^2}
Flight?=Kc?+Kl??d+Kq??d2I?
我们现在想要在
F
l
i
g
h
t
F_{light}
Flight?等于0的前提下解这个方程,也就是说光在该距离完全是黑暗的。然而这个方程永远不会真正等于0.0,所以它没有解。所以,我们不会求表达式等于0.0时候的解,相反我们会求当亮度值靠近于0.0的解,这时候它还是能被看做是黑暗的。在这个教程的演示场景中,我们选择5/256作为一个合适的光照值;除以256是因为默认的8-bit帧缓冲可以每个分量显示这么多强度值(Intensity)。
我们要求的衰减方程会是这样:
5
256
=
I
m
a
x
A
t
t
e
n
u
a
t
i
o
n
\frac{5}{256} = \frac{I_{max}}{Attenuation}
2565?=AttenuationImax??
在这里,
I
m
a
x
I_{max}
Imax?是光源最亮的颜色分量。我们之所以使用光源最亮的颜色分量是因为解光源最亮的强度值方程最好地反映了理想光体积半径。
继续解方程:
5
256
?
A
t
t
e
n
u
a
t
i
o
n
=
I
m
a
x
5
?
A
t
t
e
n
u
a
t
i
o
n
=
I
m
a
x
?
256
A
t
t
e
n
u
a
t
i
o
n
=
I
m
a
x
?
256
5
K
c
+
K
l
?
d
+
K
q
?
d
2
=
I
m
a
x
?
256
5
K
c
+
K
l
?
d
+
K
q
?
d
2
?
I
m
a
x
?
256
5
=
0
\frac{5}{256} * Attenuation = I_{max} \\ 5 * Attenuation = I_{max} * 256 \\ Attenuation = I_{max} * \frac{256}{5} \\ K_c + K_l * d + K_q * d^2 = I_{max} * \frac{256}{5} \\ K_c + K_l * d + K_q * d^2 - I_{max} * \frac{256}{5} = 0
2565??Attenuation=Imax?5?Attenuation=Imax??256Attenuation=Imax??5256?Kc?+Kl??d+Kq??d2=Imax??5256?Kc?+Kl??d+Kq??d2?Imax??5256?=0
我们可以用求根公式来解这个二次方程:
d
=
?
K
l
+
K
l
?
4
?
K
q
?
(
K
c
?
I
m
a
x
?
256
5
)
2
?
K
q
d = \frac{-K_l + \sqrt{K_l - 4 * K_q * (K_c - I_{max} * \frac{256}{5})}}{2 * K_q}
d=2?Kq??Kl?+Kl??4?Kq??(Kc??Imax??5256?)??
在计算光源与物体的光照时,我们可以先判断一下物体是否在光源的覆盖范围内,如果不在,直接跳过计算。
SSAO(屏幕空间环境光遮蔽)的着色流程与延迟渲染类似,这里也顺带介绍一下SSAO的原理和实现。
环境光遮蔽(Ambient Occlusion, AO)是一种对间接光的模拟,它的原理是通过将褶皱、孔洞和非常靠近的墙面变暗的方法近似模拟出间接光照。这些区域很大程度上是被周围的几何体遮蔽的,光线会很难流失,所以这些地方看起来会更暗一些。
这一小节所谈论的是屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion, SSAO),这一技术使用屏幕空间场景的深度而不是真实的几何体数据来确定遮蔽量。这一做法相对于真正的环境光遮蔽不但速度快,而且能够获得很好的效果。
SSAO背后的原理很简单:对于铺屏四边形(Screen-filled Quad)上的每一个片段,我们都会根据周边深度值计算一个遮蔽因子(Occlusion Factor)。这个遮蔽因子之后会被用来减少或者抵消片段的环境光照分量。遮蔽因子是通过采集片段周围球型核心(Kernel)的多个深度样本,并和当前片段深度值对比而得到的。高于片段深度值样本的个数就是我们想要的遮蔽因子。
采用上图所示的球体进行采样,会导致平整的墙面也会显得灰蒙蒙的,因为核心中一半的样本都会在墙这个几何体上。下面这幅图展示了孤岛危机的SSAO,它清晰地展示了这种灰蒙蒙的感觉:
出于这个原因,我们将不会使用球体的采样核心,而使用一个沿着表面法向量的半球体采样核心。
样本缓冲
SSAO需要获取几何体的信息,因为我们需要一些方式来确定一个片段的遮蔽因子。对于每一个片段,我们将需要这些数据:
SSAO的整个绘制过程如下图所示:
法向半球
我们需要沿着表面法线方向生成大量的样本。由于对每个表面法线方向生成采样核心非常困难,也不合实际,可以在切线空间中生成采样核心,法向量将指向正z方向。
随机核心转动
通过引入一些随机性到采样核心上,我们可以大大减少获得不错结果所需的样本数量。我们可以对场景中每一个片段创建一个随机旋转向量,但这会很快将内存耗尽。所以,更好的方法是创建一个小的随机旋转向量纹理平铺在屏幕上。
最终生成的SSAO渲染结果如下图所示:
https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/05%20Framebuffers/
https://learnopengl-cn.github.io/05%20Advanced%20Lighting/06%20HDR/
https://learnopengl-cn.github.io/05%20Advanced%20Lighting/07%20Bloom/
https://learnopengl-cn.github.io/05%20Advanced%20Lighting/08%20Deferred%20Shading/
https://learnopengl-cn.github.io/05%20Advanced%20Lighting/09%20SSAO/