LearnOpenGL——高级光照(中)
教程地址:简介 - LearnOpenGL CN
法线映射
- 原文链接:法线贴图 - LearnOpenGL CN
引言
我们的场景中充满了多边形物体,其中每个物体都可能由成百上千个平坦的三角形组成。我们通过向三角形上附加纹理来增加额外细节,提升真实感,以图隐藏多边形几何体是由无数三角形组成的事实。纹理确有助益,然而当你仔细观察它们时,这个事实便隐藏不住了。现实中的物体表面并非完全平坦,而是表现出无数(凹凸不平的)细节。
例如,砖块的表面。砖块的表面非常粗糙,显然不是完全平坦的:它包含着接缝处的水泥凹痕,以及非常多的细小孔洞。如果我们在一个有光的场景中看这样一个砖块的表面,问题就出来了。下面我们可以看到一个砖纹理被应用到一个平坦的表面上,并由点光源照亮。
光照并没有呈现出任何裂痕和孔洞,完全忽略了砖块之间凹进去的线条;表面看起来完全就是平的。我们可以使用镜面反射贴图(specular map)根据深度或其他细节阻止部分表面被照得更亮,以此部分地解决问题,但这并不是一个好方案。我们需要的是某种可以告知光照系统所有有关物体表面类似深度这样的细节的方式。
从光源的视角思考:为什么表面被照亮时像是完全平坦的?答案在于表面的法向量。从光照技术的角度来看,它判断物体形状的唯一依据是表面垂直的法向量。砖面只有一个统一的法向量,因此表面根据这个法向量的方向被均匀照亮。如果每个片段(fragment)都使用自己的不同法线会怎样?这样我们就可以根据表面细微的细节对法线向量进行改变,从而营造出表面更加复杂的错觉:
每个 fragment 使用了自己的法线,我们就可以让光照相信一个表面是由很多微小的(垂直于法线向量的)平面所组成,物体表面的细节将会得到极大提升。这种每个 fragment 使用各自的法线,替代一个面上所有 fragment 使用同一个法线的技术叫做 法线贴图(normal mapping)或 凹凸贴图(bump mapping)。将其应用于砖面平面后,效果大致如下:
法线贴图
要实现法线映射,我们需要为每个fragment提供一个法线。像diffuse贴图和specular贴图一样,我们可以使用一个2D纹理来储存法线数据。这样,我们就可以通过采样二维纹理来获取特定片段的法向量。
由于法线向量是个几何工具,而纹理通常只用于储存颜色信息,用纹理储存法线向量不是非常直接。考虑纹理中的颜色向量,它们用包含 r、g、b 三个分量的三维向量表示。类似地,我们可以将法向量的 x、y、z 分量分别存储在对应的颜色分量中。法向量的范围在 -1 到 1 之间,因此需先将其映射到[0,1]:
vec3 rgb_normal = normal * 0.5 + 0.5; // 从[-1,1]变换到[0,1]
通过将法向量转换为RGB颜色分量,我们就能把根据表面的形状的 fragment 的法线保存在2D纹理中。教程开头展示的那个砖块的例子的法线贴图如下所示:
这张图(以及你在网上找到的几乎所有法线贴图)会有一种偏蓝的色调。这是因为所有的法向量都大致指向正z轴方向(0,0,1),即偏蓝的颜色。颜色的偏差代表法向量相对于正z方向的轻微偏移,从而为纹理赋予深度感。例如,你可以看到每块砖的顶部颜色偏绿,这是合理的,因为砖的顶部法向量更多地指向正y方向(0,1,0),即绿色!
对于一个朝向正z轴的简单平面,我们可以使用这张漫反射纹理和这张法线贴图,渲染出前面的图像。注意,链接中的法线贴图与上图不同。原因在于 OpenGL 读取纹理坐标时,y(或v)坐标与纹理通常的创建方式相反。因此,链接中的法线贴图的 y(或绿色)分量是反转的(你可以看到绿色现在指向下方);若未考虑这一点,光照将会出错(译注:如果你现在不再使用SOIL了,那就不要用链接里的那个法线贴图,这个问题是 SOIL 载入纹理上下颠倒所致,它也会把法线在y方向上颠倒)。加载这两张纹理,将其绑定到正确的纹理单元,并在片段着色器中进行以下修改后渲染平面:
uniform sampler2D normalMap;
void main()
{
// 从法线贴图范围[0,1]获取法线
normal = texture(normalMap, fs_in.TexCoords).rgb;
// 将法线向量转换为范围[-1,1]
normal = normalize(normal * 2.0 - 1.0);
[...]
// 像往常那样处理光照
}
这里我们逆转了将法向量映射到RGB颜色的过程,将采样的法线颜色从[0,1]重新映射回[-1,1],然后使用这些采样得到的法向量进行后续光照计算。在此例中,我们使用了Blinn-Phong 着色器。
通过随时间缓慢移动光源,你会真切感受到法线贴图带来的深度感。运行这个法线映射示例,将得到本章开头展示的效果:
然而,有一个问题极大地限制了法线贴图的使用。我们使用的法线贴图中的法向量都大致指向正z方向。这之所以有效,是因为平面的表面法向量也指向正z方向。但如果我们将相同的法线贴图应用到一个躺在地面上、表面法向量指向正y方向的平面会怎样?
光照看起来完全不对!发生这种情况是平面的表面法线现在指向了y,而采样得到的法线仍然指向的是z。结果就是光照仍然认为表面法线和之前朝向正z方向时一样;这样光照就不对了。下面的图片展示了这个表面上采样的法线的近似情况:
你可以看到所有法线都指向 z 方向,它们本该朝着表面法线指向 y 方向的。一个可行的方案是为每个表面制作一个单独的法线贴图。如果是一个立方体的话,我们就需要 6 个法线贴图,但是如果模型上有无数的朝向不同方向的表面,这就不可行了(译注:实际上对于复杂模型可以把朝向各个方向的法线储存在同一张贴图上,你可能看到过不只是蓝色的法线贴图,不过用那样的法线贴图有个问题是你必须记住模型的起始朝向,如果模型运动了还要记录模型的变换,这是非常不方便的;此外就像作者所说的,如果把一个 diffuse 纹理应用在同一个物体的不同表面上,就像立方体那样的,就需要做 6 个法线贴图,这也不可取)。
另一个稍微有点难的解决方案是,在一个不同的坐标空间中进行光照,这个坐标空间里,法线贴图向量总是指向这个坐标空间的正 z 方向;所有的光照向量都相对于这个正 z 方向进行变换。这样我们就能始终使用同样的法线贴图,不管朝向问题。这个坐标空间叫做 切线空间(tangent space)。
切线空间
法线贴图中的法向量是在切线空间中表达的,在该空间中法向量总是大致指向正z方向。切线空间是三角形表面局部的空间:法向量相对于各个三角形的局部参考系定义。可以将其视为法线贴图向量的局部空间;无论最终变换方向如何,它们都被定义为指向正z方向。通过一个特定的矩阵,我们可以将法向量从这个局部切线空间变换到世界或视图坐标,使其沿最终映射表面的方向对齐。
我们可以说,上个部分那个朝向正y的法线贴图错误的贴到了表面上。法线贴图是在切线空间中定义的,因此解决问题的一种方法是计算一个矩阵,将法向量从切线空间变换到另一个空间,使其与表面的法线方向对齐:这样所有法向量就大致指向正y方向。切线空间的优点在于,我们可以为任何类型的表面计算这个矩阵,从而将切线空间的z方向正确对齐到表面的法线方向。
这样的矩阵被称为TBN矩阵,其中字母分别代表切线(Tangent)、副切线(Bitangent)和法线(Normal)向量,这些是我们构建此矩阵所需的向量。要这样构建一个把切线空间转变其它空间的变异矩阵,我们需要三个垂直的向量,它们沿法线贴图表面对齐:上、右、前向量;类似于我们在摄像机章节中所做的。
已知上向量是表面的法线向量,右向量和前向量分别是切线向量和副切线向量。下图展示了一个表面上的这三个向量:
计算切线和副切线向量不像法向量那样直观。从图中可以看到,法线贴图的切线和副切线向量的方向与我们定义表面纹理坐标的方向对齐。我们将利用这一事实为每个表面计算切线和副切线向量。获取它们需要一些数学运算;请看下图:
从上图中可以看到,边 E 2 E_2 E2 与纹理坐标的差 Δ U 2 \Delta{U_2} ΔU2、 Δ V 2 \Delta{V_2} ΔV2 构成一个三角形。 Δ U 2 \Delta{U_2} ΔU2 与切线向量 T T T 方向相同,而 Δ V 2 \Delta{V_2} ΔV2 与副切线向量 B B B 方向相同。这也就是说,我们可以将三角形的边 E 1 E_1 E1 与 E 2 E_2 E2 写成切线向量 T T T 和副切线向量 B B B 的线性组合:
E
1
=
Δ
U
1
T
+
Δ
V
1
B
E_{1}=\Delta U_1T+\Delta V_1B
E1=ΔU1T+ΔV1B
E
2
=
Δ
U
2
T
+
Δ
V
2
B
E_{2}=\Delta U_2T+\Delta V_2B
E2=ΔU2T+ΔV2B
也可以写成这样
(
E
1
x
,
E
1
y
,
E
1
z
)
=
Δ
U
1
(
T
x
,
T
y
,
T
z
)
+
Δ
V
1
(
B
x
,
B
y
,
B
z
)
(E_{1x},E_{1y},E_{1z})=\Delta U_1(T_x,T_y,T_z)+\Delta V_1(B_x,B_y,B_z)
(E1x,E1y,E1z)=ΔU1(Tx,Ty,Tz)+ΔV1(Bx,By,Bz)
(
E
2
x
,
E
2
y
,
E
2
z
)
=
Δ
U
2
(
T
x
,
T
y
,
T
z
)
+
Δ
V
2
(
B
x
,
B
y
,
B
z
)
(E_{2x},E_{2y},E_{2z})=\Delta U_2(T_x,T_y,T_z)+\Delta V_2(B_x,B_y,B_z)
(E2x,E2y,E2z)=ΔU2(Tx,Ty,Tz)+ΔV2(Bx,By,Bz)
我们可以通过两个三角形位置之间的差向量计算 E E E,并通过它们的纹理坐标差异计算 Δ U \Delta U ΔU 和 Δ V \Delta V ΔV。这样就剩下两个未知量(切线 T T T 和副切线 B B B)和两个方程。你可能还记得代数课上的知识,这种情况允许我们解出 T T T 和 B B B。
上面的方程允许我们把它们写成另一种格式:矩阵乘法
[ E 1 x E 1 y E 1 z E 2 x E 2 y E 2 z ] = [ Δ U 1 Δ V 1 Δ U 2 Δ V 2 ] [ T x T y T z B x B y B z ] \begin{bmatrix}E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z}\end{bmatrix}=\begin{bmatrix}\Delta U_1&\Delta V_1\\\Delta U_2&\Delta V_2\end{bmatrix}\begin{bmatrix}T_x&T_y&T_z\\B_x&B_y&B_z\end{bmatrix} [E1xE2xE1yE2yE1zE2z]=[ΔU1ΔU2ΔV1ΔV2][TxBxTyByTzBz]
试着在脑海中想象矩阵乘法,它们确实是同一种等式。把等式写成矩阵形式的好处是,解 T T T 和 B B B 会因此变得很容易。两边都乘以 Δ U Δ V \Delta U \Delta V ΔUΔV 的逆矩阵等于:
[ Δ U 1 Δ V 1 Δ U 2 Δ V 2 ] − 1 [ E 1 x E 1 y E 1 z E 2 x E 2 y E 2 z ] = [ T x T y T z B x B y B z ] \begin{bmatrix}\Delta U_1&\Delta V_1\\\Delta U_2&\Delta V_2\end{bmatrix}^{-1}\begin{bmatrix}E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z}\end{bmatrix}=\begin{bmatrix}T_x&T_y&T_z\\B_x&B_y&B_z\end{bmatrix} [ΔU1ΔU2ΔV1ΔV2]−1[E1xE2xE1yE2yE1zE2z]=[TxBxTyByTzBz]
这样我们就能够解出 T T T 和 B B B。这需要我们计算纹理坐标差矩阵的逆矩阵。我不会深入探讨计算矩阵逆的数学细节,但大致来说,它等于矩阵行列式的倒数乘以其伴随矩阵( A − 1 = 1 ∣ A ∣ A ∗ A^{-1}=\frac{1}{|A|}A^{*} A−1=∣A∣1A∗,见伴随矩阵求逆法 - 知乎):
[ T x T y T z B x B y B z ] = 1 Δ U 1 Δ V 2 − Δ U 2 Δ V 1 [ Δ V 2 − Δ V 1 − Δ U 2 Δ U 1 ] [ E 1 x E 1 y E 1 z E 2 x E 2 y E 2 z ] \begin{bmatrix}T_x&T_y&T_z\\B_x&B_y&B_z\end{bmatrix}=\frac{1}{\Delta U_1\Delta V_2-\Delta U_2\Delta V_1}\begin{bmatrix}\Delta V_2&-\Delta V_1\\-\Delta U_2&\Delta U_1\end{bmatrix}\begin{bmatrix}E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z}\end{bmatrix} [TxBxTyByTzBz]=ΔU1ΔV2−ΔU2ΔV11[ΔV2−ΔU2−ΔV1ΔU1][E1xE2xE1yE2yE1zE2z]
这个最终方程为我们提供了一个公式,可以从三角形的两条边及其纹理坐标计算切线向量 T T T 和副切线向量 B B B。
如果你没有完全理解背后的数学原理,也无需担心。只要你明白我们能从三角形的顶点及其纹理坐标(因为纹理坐标与切线向量处于同一空间)计算出切线和副切线,就已经足够了。
手动计算切线和副切线
在之前的演示中,我们有一个简单的2D平面,朝向正z方向。这次我们希望使用切线空间实现法线映射,以便随意调整平面的方向而法线贴图仍能正常工作。利用前述的数学方法,我们将手动计算该表面的切线和副切线向量。
假设平面由以下向量构成(以1、2、3和1、3、4作为其两个三角形):
// 位置
glm::vec3 pos1(-1.0, 1.0, 0.0);
glm::vec3 pos2(-1.0, -1.0, 0.0);
glm::vec3 pos3( 1.0, -1.0, 0.0);
glm::vec3 pos4( 1.0, 1.0, 0.0);
// 纹理坐标
glm::vec2 uv1(0.0, 1.0);
glm::vec2 uv2(0.0, 0.0);
glm::vec2 uv3(1.0, 0.0);
glm::vec2 uv4(1.0, 1.0);
// 法向量
glm::vec3 nm(0.0, 0.0, 1.0);
我们首先计算第一个三角形的边和纹理坐标差:
glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;
有了计算切线和副切线所需的数据,我们可以按照上面的方程开始计算:
float f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
[...] // 对平面的第二个三角形执行类似的切线/副切线计算过程
这里我们先预计算方程的分数部分为f,然后对每个向量分量执行对应的矩阵乘法并乘以f。如果你把代码和最终的等式对比,你会发现这就是直接套用公式。由于三角形始终是平坦的形状,我们只需为每个三角形计算一对切线/副切线,因为它们对三角形的每个顶点都相同。
要注意的是,通常在大多数实现中,三角形和三角形之间会共享顶点。这种情况下开发者通常会将每个顶点的法线和切线/副切线等顶点属性平均化,以获得更加柔和的效果。我们的平面三角形之间共享了一些顶点,但是因为两个三角形之间相互并行,因此并不需要将顶点属性平均化。但无论何时,只要你遇到这种情况,记住它是一件好事就行。
计算得到的切线和副切线向量应分别为(1,0,0)和(0,1,0),与法向量(0,0,1)一起构成正交的TBN矩阵。在平面上可视化时,TBN向量如下所示:
对每个顶点定义了切线和副切线向量,我们就可以开始实现正确的法线映射了。
切线空间法线映射
要让法线映射生效,我们首先需要在着色器中创建TBN矩阵。为此,我们将之前计算的切线和副切线向量作为顶点属性传递给顶点着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in vec3 aTangent;
layout (location = 4) in vec3 aBitangent;
然后,在顶点着色器的主函数中创建TBN矩阵:
void main()
{
[...]
vec3 T = normalize(vec3(model * vec4(aTangent, 0.0)));//平移无意义,通过vec4(aTangent, 0.0)隐式忽略平移
vec3 B = normalize(vec3(model * vec4(aBitangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(aNormal, 0.0)));
mat3 TBN = mat3(T, B, N);
}
在这里,我们首先将所有TBN向量变换到我们希望使用的坐标系,本例中是世界空间,因为我们用模型矩阵乘以它们。然后,通过直接为mat3
构造函数提供相关列向量来创建实际的TBN矩阵。注意,若要更精确,我们应使用法线矩阵乘以TBN向量,因为我们只关心向量的方向。
fd Jin:在其他文章里面说 model 矩阵对 T 可以应用,但其他文章里面的 T 相当于本文中的 edge1,和本文的 T 不是同一个向量。来自讨论区
技术上,顶点着色器中并不一定需要副切线变量。TBN三个向量彼此垂直,因此我们可以通过计算T和N向量的叉积在顶点着色器中自行计算副切线:vec3 B = cross(N, T)
。
现在我们有了TBN矩阵,如何使用它呢?在法线映射中,TBN矩阵有两种使用方式,我们将展示这两种方法:
- 使用TBN矩阵(将向量从切线空间变换到世界空间),将其传递给片段着色器,并使用TBN矩阵将采样的法向量从切线空间变换到世界空间;这样,法向量与其他光照变量处于同一空间。
- 使用TBN矩阵的逆矩阵(将向量从世界空间变换到切线空间),用此矩阵变换其他相关光照变量(而非法向量)到切线空间;这样,法向量再次与其他光照变量处于同一空间。
首先来看第一种情况。从法线贴图中采样的法向量是在切线空间中表达的,而其他光照向量(光照方向和视图方向)是在世界空间中表达的。通过将TBN矩阵传递给片段着色器,我们可以用这个TBN矩阵乘以采样的切线空间法向量,将其变换到与其他光照向量相同的参考空间。这样,所有光照计算(特别是点积)就变得合理。
将TBN矩阵发送到片段着色器很简单:
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} vs_out;
void main()
{
[...]
vs_out.TBN = mat3(T, B, N);
}
在片段着色器中,我们同样将 mat3
作为输入变量:
in VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} fs_in;
有了这个TBN矩阵,我们可以更新法线映射代码,加入从切线空间到世界空间的变换:
normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normal * 2.0 - 1.0;
normal = normalize(fs_in.TBN * normal);
由于得到的法向量现在处于世界空间,无需更改片段着色器的其他代码,因为光照代码假定法向量已在世界空间中。
接下来,再看第二种情况。即使用TBN矩阵的逆矩阵将所有相关的世界空间向量变换到采样法向量所在的空间:切线空间。TBN矩阵的构造保持不变,但我们在将其发送到片段着色器前先进行逆变换:
vs_out.TBN = transpose(mat3(T, B, N));
注意,这里我们使用transpose
函数而非inverse
函数。正交矩阵(每个轴都是垂直的单位向量)的一个重要特性是,其转置等于其逆矩阵。这是一个很好的性质,因为求逆运算开销大,而转置运算则不然。
在片段着色器中,我们不变换法向量,而是将其他相关向量(即 lightDir
和 viewDir
)变换到切线空间。这样,所有向量就处于同一坐标空间:切线空间。
void main()
{
vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);
vec3 lightDir = fs_in.TBN * normalize(lightPos - fs_in.FragPos);
vec3 viewDir = fs_in.TBN * normalize(viewPos - fs_in.FragPos);
[...]
}
第二种方法看似工作量更大,还需要在片段着色器中进行矩阵乘法,那为什么还要费心使用第二种方法呢?
将向量从世界空间变换到切线空间有一个额外优势:我们可以在顶点着色器中(而非片段着色器中)将所有相关光照向量变换到切线空间。这之所以可行,是因为 lightPos
和 viewPos
不会在每个片段运行时更新,而对于 fs_in.FragPos
,我们可以在顶点着色器中计算其切线空间位置,让片段插值完成后续工作。使用第二种方法时,实际上无需在片段着色器中将向量变换到切线空间,而第一种方法则必须这样做,因为采样的法向量对于每个片段着色器都不一样。
因此,我们不再将 TBN 矩阵的逆矩阵发送到片段着色器,而是将切线空间的光源位置、视图位置和顶点位置发送到片段着色器。这避免了在片段着色器中进行矩阵乘法。这是一个很好的优化,因为顶点着色器的运行频率远低于片段着色器,这也是这种方法常被优选的原因。
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} vs_out;
uniform vec3 lightPos;
uniform vec3 viewPos;
[...]
void main()
{
[...]
mat3 TBN = transpose(mat3(T, B, N));
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vec3(model * vec4(aPos, 1.0));
}
在片段着色器中,我们使用这些新的输入变量在切线空间中计算光照。由于法向量已在切线空间中,光照计算是合理的。
在切线空间中应用法线映射后,我们应获得与本章开头类似的结果。但这次,我们可以随意调整平面的方向,光照依然正确:
glm::mat4 model = glm::mat4(1.0f);
model = glm::rotate(model, (float)glfwGetTime() * -10.0f, glm::normalize(glm::vec3(1.0, 0.0, 1.0)));
shader.setMat4("model", model);
RenderQuad();
看起来正确地应用了法线贴图:
你可以在这里找到:源代码
项目源代码:法线贴图 - GitCode
复杂物体
我们已经展示了如何通过手动计算切线和副切线向量,来使用法线映射并结合切线空间变换。幸运的是,我们并不需要经常手动计算这些切线和副切线向量。通常,你只需在自定义模型加载器中实现一次,或者像我们这样使用Assimp的模型加载器。
Assimp提供了一个非常有用的配置位,在加载模型时可以设置为aiProcess_CalcTangentSpace
。当将aiProcess_CalcTangentSpace
位提供给Assimp的ReadFile
函数时,Assimp会为每个加载的顶点计算平滑的切线和副切线向量,类似于我们在本章中的做法:
const aiScene *scene = importer.ReadFile(
path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
);
在Assimp中,我们可以通过以下方式检索计算得到的切线:
vector.x = mesh->mTangents[i].x;
vector.y = mesh->mTangents[i].y;
vector.z = mesh->mTangents[i].z;
vertex.Tangent = vector;
接下来,你需要更新模型加载器,使其也能从带纹理的模型中加载法线贴图。Wavefront对象格式(.obj)导出法线贴图的方式与Assimp的惯例略有不同,因为 aiTextureType_NORMAL
无法加载法线贴图,而 aiTextureType_HEIGHT
可以:
vector<Texture> normalMaps = loadMaterialTextures(material, aiTextureType_HEIGHT, "texture_normal");
当然,这对于不同类型的加载模型和文件格式会有所不同。
在更新模型加载器后,使用带有镜面反射和法线贴图的模型运行应用程序,结果如下:
如你所见,法线映射以极低的额外成本显著提升了物体的细节表现。
使用法线贴图还是提升性能的好方法。在法线映射之前,为了让网格具有高细节,必须使用大量顶点。而有了法线映射,我们可以用更少的顶点实现相同的细节水平。下图来自Paolo Cignoni,很好地比较了这两种方法:
高精度网格与低精度网格(加法线映射)对比,细节几乎无法区分。因此,法线映射不仅看起来不错,还是一个极佳的工具,可以用低精度网格替换高精度网格,同时不丢失(太多)细节。
项目源码:模型 - GitCode,修改了 model 类适配法线映射,采用的是 backpack 模型,效果如下:
最后一件事
关于法线贴图还有最后一个技巧值得讨论,它可以在不花费太多性能开销的情况下稍稍提升画质表现。
当在共享大量顶点的大型网格上计算切线向量时,通常会对切线向量进行平均,以获得平滑的效果。这种方法的缺点在于,TBN三个向量可能不再相互垂直,这意味着生成的TBN矩阵不再是正交的。使用非正交TBN矩阵时,法线映射虽然只会有轻微偏差,但这仍然是我们可以改进的地方。
通过一个被称为 Gram-Schmidt正交化过程的数学技巧,我们可以重新正交化TBN向量,使每个向量再次与其他向量垂直。在顶点着色器中,我们可以这样做:
vec3 T = normalize(vec3(model * vec4(aTangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(aNormal, 0.0)));
// 相对于N重新正交化T
T = normalize(T - dot(T, N) * N);
// 然后通过T和N的叉积获取垂直向量B
vec3 B = cross(N, T);
mat3 TBN = mat3(T, B, N);
这虽然提升幅度不大,但通常能以较小的额外成本改善法线映射结果。请查看附加资源中《Normal Mapping Mathematics》视频的结尾部分,那里对这一过程的工作原理给出了精彩的解释。
附加资料
- Tutorial 26: Normal Mapping: ogldev的法线贴图教程。
- How Normal Mapping Works: TheBennyBox的讲述法线贴图如何工作的视频。
- Normal Mapping Mathematics: TheBennyBox关于法线贴图的数学原理的教程。
- Tutorial 13: Normal Mapping: opengl-tutorial.org提供的法线贴图教程。
视差映射
- 原文链接:视差贴图 - LearnOpenGL CN
引言
视差映射(Parallax Mapping)是一种类似于法线映射的技术,但基于不同的原理。与法线映射一样,它能显著提升纹理表面的细节并赋予其深度感。虽然也是一种错觉,但视差映射在传达深度感方面表现更佳,与法线映射结合使用能产生惊艳的真实效果。虽然视差映射并非直接与(高级)光照技术相关,但由于它是法线映射的逻辑延伸,我们仍将在此讨论。值得注意的是,在学习视差映射之前,强烈建议先深入理解法线映射,特别是切线空间。
视差映射与位移映射(Displacement Mapping)技术密切相关,这类技术根据纹理中存储的几何信息对顶点进行位移或偏移。一种实现方法是,拿一个约有1000个顶点的平面,根据纹理中指示该区域高度的值,对每个顶点进行位移。这种包含每个纹素高度值的纹理被称为高度图(height map)。以下是从简单砖面几何属性导出的示例高度图:
当将其应用到一个平面上时,每个顶点会根据高度图中采样的高度值进行位移,从而将一个平坦的平面变换为基于材料几何属性的粗糙凹凸表面。例如,使用上述高度图对一个平坦平面进行位移,结果如下图所示:
这种位移顶点的方法存在一个问题:要实现逼真的位移效果,平面需要包含大量三角形,否则位移看起来会过于块状。由于每个平面可能需要超过10,000个顶点,这就会导致在计算上变得不可行。如果我们能以某种方式在不增加额外顶点的情况下实现类似的真实感会怎样?事实上,如果我告诉你,之前展示的位移表面其实只用了两个三角形来渲染,你会怎么想?上面展示的砖面其实使用了视差映射技术,是位移映射技术的一种实现方式,但不需要额外的顶点数据来表达深度,而是(类似于法线映射)通过巧妙的方法欺骗用户。
视差映射的核心思想是调整纹理坐标,使片段表面看起来比实际更高或更低,这一切基于视角方向和高度图。要理解其工作原理,请看下图中的砖面:
图中,粗红线代表高度图中的值,作为砖面几何表面的表示,而向量 V ‾ \overline V V 代表观察方向(viewDir)。如果平面进行了实际位移,观察者会在点 B B B 处看到表面。然而,由于我们的平面没有实际进行位移,观察方向将会在点 A A A 与平面接触。视差映射的目的是,在 A A A 位置上的 fragment 不再使用点 A A A 的纹理坐标而是使用点 B B B 的。然后,我们使用点B处的纹理坐标进行所有后续纹理采样,使最后看起来像是观察者实际上在看点 B B B。
关键在于如何从点 A A A 获取点 B B B 的纹理坐标。视差映射通过将片段到视角的方向向量 V ‾ \overline V V ,按片段 A A A 处的高度进行缩放来解决这个问题。因此,我们将 V ‾ \overline V V 的长度缩放到等于片段位置 A A A 处从高度图采样的值 H ( A ) H(A) H(A)。下图展示了这个缩放后的向量 P ‾ \overline P P :
然后,我们取向量 P ‾ \overline P P,将其与平面对齐的坐标作为纹理坐标的偏移量。这之所以有效,是因为向量 P ‾ \overline P P 是由高度图中的高度值计算得到的。因此,片段的高度越高,其有效位移就越大。
这个小技巧在大多数情况下都能产生不错的效果,但它仍然是一个非常粗糙的近似来达到点 B B B。当表面高度变化很快时,结果往往看起来不真实,因为向量 P ‾ \overline P P 最终不会接近点 B B B,如下图所示:
视差映射的另一个问题是,当表面以某种方式任意旋转时,很难确定从 P ‾ \overline P P 中提取哪些坐标作为纹理坐标的偏差值。我们更希望在一个不同的坐标空间中进行操作,在该空间中向量 P ‾ \overline P P 的 x x x 和 y y y 分量始终与纹理表面对齐。如果你学习了[法线映射](法线贴图 - LearnOpenGL CN) 章节的内容,你可能会猜到如何实现这一点。是的,我们希望在切线空间中进行视差映射。
通过将片段到视角的方向向量 V ‾ \overline V V 变换到切线空间,变换后的 P ‾ \overline P P 向量的 x x x 和 y y y 分量将与表面的切线和副切线向量对齐。由于切线和副切线向量与表面纹理坐标的方向一致,我们可以直接取 P ‾ \overline P P 的 x x x 和 y y y 分量作为纹理坐标偏移量,而无需考虑表面的朝向。
理论讲得够多了,让我们开始动手实现真正的视差映射吧。
视差映射
对于视差映射,我们将使用一个简单的二维平面,我们已事先计算了它的切线和副切线向量,然后将其发送到GPU;这与我们在法线映射章节中所做的类似。我们将在该平面上附加一个漫反射纹理、法线贴图和位移贴图,你可以从它们的URL下载这些资源。在这个例子中,我们将结合法线映射使用视差映射。因为视差映射通过错觉模拟表面位移,如果光照不匹配,这种错觉就会破裂。由于法线贴图通常是从高度图生成的,将法线贴图与高度图结合使用能确保光照与位移一致。
你可能已经注意到,上面链接的位移贴图是本章开头展示的高度图的反转。在视差映射中,使用高度图的反转版本更有意义,因为在平面上伪造深度比高度更容易。这稍微改变了我们对视差映射的理解,如下图所示:
我们再次获得点 A A A 和点 B B B,但这次我们通过从点 A A A 的纹理坐标中减去向量 V ‾ \overline V V 来获得向量 P ‾ \overline P P。我们可以通过在着色器中从 1.0 减去采样的高度图值来获取深度值而非高度值,或者像我们对上面链接的视差贴图那样,在图像编辑软件中简单地反转其纹理值。
视差映射在片段着色器中实现,因为位移效果在三角形表面上各处不同。在片段着色器中,我们需要计算片段到视角的方向向量 V ‾ \overline V V,因此需要视图位置和切线空间中的片段位置。在法线映射章节中,我们已经有一个顶点着色器将这些向量发送到切线空间,因此我们可以直接复制该章节的顶点着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in vec3 aTangent;
layout (location = 4) in vec3 aBitangent;
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} vs_out;
uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
uniform vec3 lightPos;
uniform vec3 viewPos;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
vs_out.FragPos = vec3(model * vec4(aPos, 1.0));
vs_out.TexCoords = aTexCoords;
vec3 T = normalize(mat3(model) * aTangent);
vec3 B = normalize(mat3(model) * aBitangent);
vec3 N = normalize(mat3(model) * aNormal);
mat3 TBN = transpose(mat3(T, B, N));
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vs_out.FragPos;
}
在片段着色器中,我们实现视差映射的逻辑。片段着色器大致如下:
#version 330 core
out vec4 FragColor;
in VS_OUT {
vec3 FragPos;
vec2 TexCoords;
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} fs_in;
uniform sampler2D diffuseMap;
uniform sampler2D normalMap;
uniform sampler2D parallaxMap;
uniform float height_scale;
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir);
void main()
{
// offset texture coordinates with Parallax Mapping
vec3 viewDir = normalize(fs_in.TangentViewPos - fs_in.TangentFragPos);
vec2 texCoords = ParallaxMapping(fs_in.TexCoords, viewDir);
// then sample textures with new texture coords
vec3 diffuse = texture(diffuseMap, texCoords);
vec3 normal = texture(normalMap, texCoords);
normal = normalize(normal * 2.0 - 1.0);
// proceed with lighting code
[...]
}
我们定义了一个名为 ParallaxMapping
的函数,其输入为片段的纹理坐标和片段到视角的方向向量
V
‾
\overline V
V(处于切线空间中)。该函数返回位移后的纹理坐标。然后,我们使用这些位移后的纹理坐标采样漫反射贴图和法线贴图。最后就可以让片段的漫反射和法向量能够正确对应表面位移后的几何形状。
让我们来看看 ParallaxMapping
函数的内部实现:
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{
float height = texture(parallaxMap, texCoords).r;
vec2 p = viewDir.xy / viewDir.z * (height * height_scale);
return texCoords - p;
}
这个相对简单的函数直接翻译了我们迄今讨论的内容。我们取原始纹理坐标 texCoords
,用它从 parallaxMap
中采样当前片段
A
A
A 处的高度(或深度)作为
H
(
A
)
H(A)
H(A)。然后,我们将
P
‾
\overline P
P 计算为 viewDir
(处于切线空间) 向量的
x
x
x 和
y
y
y 分量除以其
z
z
z 分量,并按
H
(
A
)
H (A)
H(A) 缩放。我们还引入了一个 height_scale
uniform 变量以提供额外控制,因为若无此缩放参数,视差效果通常过强。接着,我们用纹理坐标减去这个向量
P
‾
\overline P
P,得到最终经过位移的纹理坐标。
值得注意的是 viewDir.xy
除以 viewDir.z
的部分。由于 viewDir
向量是归一化的,其 z
分量将在0.0到1.0之间。当 viewDir
与表面近乎平行时,其 z
分量接近0.0,除法会返回一个比 viewDir
与表面近乎垂直时大得多的向量
P
‾
\overline P
P。我们以这种方式调整
P
‾
\overline P
P 的大小,使得以一定角度观察表面时,纹理坐标的偏移量比从顶部观察时更大,这在角度观察时能产生更真实的结果。有些人倾向于省略除以 viewDir.z
的操作,因为默认的视差映射在某些角度可能产生不良结果;这种技术被称为带偏移限制的视差映射(Parallax Mapping with Offset Limiting)。选择哪种技术通常取决于个人偏好。
得到的纹理坐标随后用于采样其他纹理(漫反射和法线),这产生了一个非常 neat 的位移效果,如下图所示,其中 height_scale
大约为0.1:
这里你可以看到单独使用法线映射与结合视差映射和法线映射的区别。因为视差映射试图模拟深度,实际上可以根据视角方向让砖块看似叠在其他砖块之上。
你仍然可以看到视差映射平面边缘的一些奇怪边界 artifacts。这是因为在平面边缘,位移后的纹理坐标可能超出[0, 1]范围采样。根据纹理的环绕模式,这会导致不真实的结果。一个解决此问题的巧妙技巧是,当纹理坐标采样超出默认范围时丢弃该片段:
texCoords = ParallaxMapping(fs_in.TexCoords, viewDir);
if(texCoords.x > 1.0 || texCoords.y > 1.0 || texCoords.x < 0.0 || texCoords.y < 0.0)
discard;
所有位移后纹理坐标超出默认范围的片段都会被丢弃,视差映射在表面边缘就能产生正确的结果。注意,这种技巧并非对所有表面都适用,但在应用于平面时效果出色:
你可以在这里找到源代码
效果看起来很棒,速度也很快,因为视差映射只需额外的一次纹理采样即可生效。然而,它也存在一些问题,比如从某个角度观察时会有些失真(类似法线映射),并且在高度变化剧烈时会产生不正确的结果,如下图所示:
它有时无法正常工作的原因在于,它只是位移映射的一种粗略近似。不过,有一些额外的技巧能让我们在高度变化剧烈甚至从角度观察时仍获得近乎完美的结果。例如,我们不是只采样一次,而是多次采样以找到最接近点 B 的位置。
陡峭视差映射
陡峭视差映射(Steep Parallax Mapping)是对视差映射的扩展,它基于相同原理,但不再只采样一次,而是通过多次采样来更精确地将向量 P ‾ \overline P P 定位到点B。这显著改善了结果,即使在高度变化剧烈时也是如此,因为技术的准确性随着采样次数的增加而提升。
陡峭视差映射的基本思想是将总深度范围分成多个同一高度或深度的层。对于每一层,我们沿着 P ‾ \overline P P 方向采样视差贴图并移动纹理坐标,直到找到一个采样深度值小于当前层深度值为止。请看下图:
我们从上向下遍历深度层,对每一层比较其深度值与视差贴图中存储的深度值。如果当前层的深度值小于视差贴图的深度值,说明该层的 P ‾ \overline P P 向量部分未处于表面之下。我们继续这个过程,直到层的深度值高于视差贴图中存储的深度值:此时该点位于(位移后的)几何表面之下。
在此例中,我们可以看到第二层的视差贴图深度值( D ( 2 ) = 0.73 D(2) = 0.73 D(2)=0.73)低于第二层的深度值 0.4 0.4 0.4,因此我们继续。下一轮迭代中,层的深度值 0.6 0.6 0.6 高于视差贴图采样的深度值( D ( 3 ) = 0.37 D(3) = 0.37 D(3)=0.37)。因此,我们可以假设第三层的 P ‾ \overline P P 向量是最合适的位移几何位置。然后,我们从向量 P ‾ 3 \overline P_3 P3 中取纹理坐标偏移量T3来位移片段的纹理坐标。你可以看到,随着深度层数的增加,精度也会提高。
要实现这一技术,我们只需修改 ParallaxMapping
函数,因为我们已经拥有所需的所有变量:
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{
// 深度层数
const float numLayers = 10;
// 计算每层的大小
float layerDepth = 1.0 / numLayers;
// 当前层的深度
float currentLayerDepth = 0.0;
// 每层沿P向量方向的纹理坐标偏移量
vec2 P = viewDir.xy * height_scale;
vec2 deltaTexCoords = P / numLayers;
[...]
}
这里我们首先指定层数,计算每层的深度偏移,最后计算沿 P P P 向量方向每层需要偏移的纹理坐标量。
然后,我们从顶部开始遍历所有层,直到找到视差贴图深度值小于层深度值为止:
// 获取初始值
vec2 currentTexCoords = texCoords;
float currentParallaxMapValue = texture(parallaxMap, currentTexCoords).r;
while(currentLayerDepth < currentParallaxMapValue)
{
// 沿P方向偏移纹理坐标
currentTexCoords -= deltaTexCoords;
// 获取当前纹理坐标处的视差贴图深度值
currentParallaxMapValue = texture(parallaxMap, currentTexCoords).r;
// 获取下一层的深度
currentLayerDepth += layerDepth;
}
return currentTexCoords;
这里我们循环遍历每个深度层,直到沿着 P ‾ \overline P P 向量找到第一个返回低于(位移)表面深度的纹理坐标偏移量。将片段的纹理坐标减去最后的偏移量,来得到最终的经过位移的纹理坐标向量,与传统视差映射相比,这次的准确性大大提高。
讨论区有说到可以用二分法减少迭代次数,这里给出我的观点(本来想放到讨论区的,但被标记为垃圾数据了😓):
二分法假设深度延向量P方向的分布是大致单调的,如果深度值存在局部波动或噪声,传统的二分法可能无法正确找到第一个交点,可参考下面我画的图,传统的的Steep Parallax Mapping算法应该到T2就结束了,但若用二分法,第一次的mid会在T5的位置,就会继续向前查找,会在T7的位置返回。图画的可能不够严谨,大家理解意思即可。
若深度变化是线性的,那么二分搜索算法如下:
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{
const float minLayers = 8.0;
const float maxLayers = 32.0;
float numLayers = mix(maxLayers, minLayers, max(dot(vec3(0.0, 0.0, 1.0), viewDir), 0.0));
float layerDepth = 1.0 / numLayers;
vec2 P = viewDir.xy * height_scale;
vec2 deltaTexCoords = P / numLayers;
int low = 0;
int high = int(numLayers);
int k_min = high;
// 二分查找找到第一个满足条件的k
while (low < high) {
int mid = (low + high) / 2;
vec2 currentTexCoords = texCoords - deltaTexCoords * float(mid);
float s_mid = texture(material.parallaxMap, currentTexCoords).r;
float d_mid = float(mid) * layerDepth;
if (d_mid < s_mid) {
low = mid + 1;
} else {
high = mid;
}
}
k_min = low;
// 检查是否需要在k_min处再步进一次
vec2 currentTexCoords = texCoords - deltaTexCoords * float(k_min);
float currentDepth = texture(material.parallaxMap, currentTexCoords).r;
float currentLayerDepth = float(k_min) * layerDepth;
// 如果当前层仍不满足条件且未超出深度,步进一次
if (currentLayerDepth < currentDepth && currentLayerDepth + layerDepth <= 1.0) {
k_min += 1;
currentTexCoords -= deltaTexCoords;
}
return currentTexCoords;
}
若深度变化非线性,可考虑使用自适应步长,动态调整 steep 的大小,以减少迭代次数:
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir) {
const float maxLayers = 32.0;
vec2 P = viewDir.xy * height_scale;
vec2 delta = P / maxLayers;
float layerDepth = 1.0 / maxLayers;
float currentDepth = 0.0;
vec2 currentTexCoords = texCoords;
float step = 1.0;
while (currentDepth < 1.0) {
// 尝试步进step层
vec2 nextTexCoords = currentTexCoords - delta * step;
float depth = texture(material.parallaxMap, nextTexCoords).r;
if (currentDepth + layerDepth * step < depth) {
// 满足条件,继续增加步长
currentTexCoords = nextTexCoords;
currentDepth += layerDepth * step;
step *= 2.0;
} else {
// 不满足条件,回退并缩小步长
if (step <= 1.0) break; // 最小步长时退出
step /= 2.0;
}
}
return currentTexCoords;
}
使用大约10个样本时,即使从角度观察,砖面表面看起来也更加真实。陡峭视差映射在处理高度变化剧烈的复杂表面时尤为出色,例如之前展示的木制玩具表面:
我们可以通过利用视差映射的一个特性来略微改进算法。当直视表面时,纹理位移较少;而从角度观察表面时,位移会显著增加(想象两种情况下的视角方向)。通过在直视表面时减少采样次数,在从角度观察时增加采样次数,我们可以仅采样必要的数量:
const float minLayers = 8.0;
const float maxLayers = 32.0;
float numLayers = mix(maxLayers, minLayers, max(dot(vec3(0.0, 0.0, 1.0), viewDir), 0.0));
这里我们计算 viewDir
与正z方向的点积,并利用其结果根据观察表面的角度,在 minLayers
和 maxLayers
之间调整采样层数(注意,正z方向在切线空间中等于表面的法向量)。如果我们从与表面平行的方向观察,将使用总共32层。
你可以在这里找到源代码,漫反射贴图、法线贴图、视差深度贴图
不过,陡峭视差映射也存在一些问题。由于该技术基于有限的采样次数,会产生锯齿效应,各层之间的明显分界也很容易被察觉:
我们可以通过增加采样次数来减少这个问题,但这很快会对性能造成过重负担。有些旨在修复这个问题的方法:不仅使用低于表面的第一个位置,而是在两个接近的深度层进行插值找出更匹配 B B B 的。
其中两种较流行的方法分别是浮雕视差映射(Relief Parallax Mapping)和视差遮挡映射(Parallax Occlusion Mapping)。浮雕视差映射提供最准确的结果,但相较于视差遮挡映射,其性能开销也更大。由于视差遮挡映射的结果几乎与浮雕视差映射相同,且效率更高,因此通常是更优选的方法。
视差遮挡映射
视差遮挡映射(Parallax Occlusion Mapping)基于与陡峭视差映射相同的原理,但它不再只取碰撞后第一个深度层的相关数据计算纹理坐标,而是会在碰撞前后两个深度层之间进行线性插值。我们根据表面高度与两个深度层值的距离来确定线性插值的权重。请看下图以理解其工作原理:
如你所见,它与陡峭视差映射大体相似,只是额外增加了一步,让两个深度层的纹理坐标围绕着交叉点进行线性插值。这仍然是一种近似,但比陡峭视差映射的精度显著提高。
视差遮挡映射的代码是在陡峭视差映射基础上的扩展,实现起来并不太复杂:
[...] // 此处为陡峭视差映射代码
// 获取碰撞前的纹理坐标(逆向操作)
vec2 prevTexCoords = currentTexCoords + deltaTexCoords;
// 获取碰撞前后深度值以进行线性插值
float afterDepth = currentLayerDepth - currentDepthMapValue;//修改处
float beforeDepth = texture(depthMap, prevTexCoords).r - currentLayerDepth + layerDepth;
// 纹理坐标的插值
float weight = afterDepth / (afterDepth + beforeDepth);//修改处
vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);
return finalTexCoords;
我对上面的代码进行了修改:第 7 行与第 11 行,我实在不理解为什么原来的代码是 float afterDepth = currentDepthMapValue - currentLayerDepth;
,这会导致 afterDepth
为负数,然后 float weight = afterDepth / (afterDepth - beforeDepth);
,分母也为负数,就刚好变成了正确结果。进行上述修改之后,更方便理解。
下图描述了交点处的情况,其中 A B = a f t e r D e p t h AB=afterDepth AB=afterDepth, C D = b e f o r e D e p t h CD=beforeDepth CD=beforeDepth,根据相似三角形: A B C D = A O O D = a f t e r D e p t h b e f o r e D e p t h \frac{AB}{CD}=\frac{AO}{OD}=\frac{afterDepth}{beforeDepth} CDAB=ODAO=beforeDepthafterDepth。又根据端点插值公式,记 A点属性为 a,D 点属性为 d,O点属性为 o,则有:
o = a f t e r D e p t h a f t e r D e p t h + b e f o r e D e p t h ∗ d + b e f o r e D e p t h a f t e r D e p t h + b e f o r e D e p t h ∗ a o=\frac{afterDepth}{afterDepth+beforeDepth}*d+\frac{beforeDepth}{afterDepth+beforeDepth}*a o=afterDepth+beforeDepthafterDepth∗d+afterDepth+beforeDepthbeforeDepth∗a
在找到与(位移后)表面几何相交的深度层后,我们还获取交点前一个深度层的纹理坐标。然后我们计算来自相应深度层的几何之间的深度之间的距离,并在两个值之间进行插值。线性插值的方式是对两个层的纹理坐标进行基础插值。函数最后返回最终的经过插值的纹理坐标。
视差遮挡映射的效果出人意料地好,尽管仍有一些轻微的 artifacts 和锯齿问题,但总体上是一个不错的折衷方案,因为除非是放得非常大或者观察角度特别陡,否则看不到明显的问题。
你可以在这里找到源码
视差映射是一种提升场景细节的出色技术,但使用时需考虑其带来的几种 artifacts。通常,视差映射被应用于地板或墙壁类表面,这些表面的轮廓不易辨识,且观察角度通常与表面大致垂直。这样,视差映射的 artifacts 就不太明显,使其成为增强物体细节的一项非常有趣的技术。
项目源代码:视差映射 - GitCode
附加资料
- How Parallax Displacement Mapping Works:TheBennyBox的关于视差映射原理的视频教程。
HDR
- 原文链接:HDR - LearnOpenGL CN
引言
亮度和颜色值在默认情况下存储到帧缓冲区(Framebuffer)时会被限制在0.0到1.0之间。这一看似无害的决定,导致我们必须将光照和颜色值指定在这个范围内,并试图让它们适应场景。这在一般情况下效果尚可,结果也说得过去,但如果我们走进一个非常明亮的区域,里面有多个明亮的光源,其总和超过1.0会怎样?答案是,所有亮度或颜色总和超过1.0的片段都会被限制到1.0,这看起来并不美观:
这是由于大量片段的颜色值都非常接近1.0,在很大一个区域内每一个亮的片段都有相同的白色。这损失了很多的细节,使场景看起来非常假。
解决这个问题的一种方法是降低光源强度,确保场景中没有任何一个片段的亮度超过1.0;但这不是一个好方案,因为它迫使你使用不现实的光照参数。更好的方法是允许颜色值暂时超过1.0,并在最后一步将其转换回0.0到1.0的原始范围,同时不丢失细节。
显示器(非HDR显示器)只能显示0.0到1.0范围内的颜色,但光照方程中并无此限制。通过允许片段颜色超过1.0,我们获得了更大的可用颜色值范围,这一范围被称为 HDR(High Dynamic Range,高动态范围)。有了 HDR,亮的物体可以非常亮,暗的物体可以非常暗,但亮暗区域的细节都能得到一定的保留。
HDR 最初仅用于摄影,摄影师会对同一场景拍摄多张不同曝光度的照片,捕捉大范围的颜色值。将这些照片组合形成 HDR 图像,从而综合不同的曝光等级使得大范围的细节可见。看下面这个例子,左边这张图片在被光照亮的区域充满细节,但是在黑暗的区域就什么都看不见了;但是右边这张图的高曝光却可以让之前看不出来的黑暗区域显现出来。
这与我们眼睛工作的原理非常相似,也是HDR渲染的基础。当光线很弱的时候,人眼会自动调整从而使过暗和过亮的部分变得更清晰,就像人眼有一个能自动根据场景亮度调整的自动曝光滑块。
HDR 渲染的工作原理有些类似。我们允许渲染到一个更大的颜色值范围,收集场景中广泛的暗部和亮部细节,最后将所有 HDR 值转换回 LDR(Low Dynamic Range,低动态范围)的[0.0, 1.0]。这个将HDR值转换为LDR值的过程被称为色调映射(Tone Mapping),存在大量色调映射算法,旨在转换过程中尽可能保留HDR细节。这些算法通常涉及一个曝光参数,可选择性地偏好暗部或亮部区域。
在实时渲染中,HDR 不仅让我们能超出LDR范围[0.0, 1.0]并保留更多细节,还赋予我们以真实强度指定光源的能力。例如,太阳的强度远高于手电筒,因此我们可以按此配置太阳(例如,漫反射亮度设为100.0)。这使我们能以更真实的光照参数去更恰当地配置场景光照,这是LDR渲染无法实现的,因为它们会被直接限制到1.0。
由于(非HDR)显示器只能显示0.0到1.0范围内的颜色,我们需要将当前的 HDR 颜色值转换回显示器的范围。简单地用平均值重新转换颜色效果不佳,因为亮区反而会变得更 dominante。我们可以使用不同的方程或曲线将 HDR 值转换回 LDR,从而完全控制场景的亮度。这就是之前提到的色调映射过程,也是HDR渲染的最后一步。
浮点帧缓冲
在实现 HDR 渲染之前,我们首先需要一些防止颜色值在每一个片段着色器运行后被限制约束的方法。当帧缓冲使用了一个标准化的定点格式(像 GL_RGB
)为其颜色缓冲的内部格式,OpenGL 会在将这些值存入帧缓冲前自动将其约束到 0.0 到 1.0 之间。这一操作对大部分帧缓冲格式都是成立的,除了专门用来存放被拓展范围值的浮点格式。
当帧缓冲区的颜色缓冲区内部格式指定为 GL_RGB16F
、GL_RGBA16F
、GL_RGB32F
或 GL_RGBA32F
时,该帧缓冲区就被称为浮点帧缓冲区(Floating Point Framebuffer),可以存储超出默认0.0到1.0范围的浮点值,所以它非常适合 HDR 渲染!
要创建一个浮点帧缓冲区,我们只需更改其颜色缓冲区的内部格式参数:
glBindTexture(GL_TEXTURE_2D, colorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);
默认的帧缓冲一个颜色分量只占用8位(bits)。当使用一个32位每颜色分量的浮点帧缓冲时(GL_RGB32F
或 GL_RGBA32F
),我们需要四倍的内存来存储这些颜色。所以除非你需要一个非常高的精度,32位不是必须的,使用 GLRGB16F
就足够了。
将浮点颜色缓冲区附加到帧缓冲区后,我们现在可以将场景渲染到这个帧缓冲区中,且颜色值不会被限制在0.0到1.0之间。在本章的示例演示中,我们首先将一个有光照的场景渲染到浮点帧缓冲区,然后将帧缓冲区的颜色缓冲区显示在一个充满屏幕的四边形上,大致过程如下:
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();
这里场景的颜色值存在一个可以包含任意颜色值的浮点颜色缓冲中,值可能是超过1.0的。这个简单的演示中,场景被创建为一个被拉伸的立方体通道和四个点光源,其中一个位于隧道尽头,亮度极高:
std::vector<glm::vec3> lightColors;
lightColors.push_back(glm::vec3(200.0f, 200.0f, 200.0f));
lightColors.push_back(glm::vec3(0.1f, 0.0f, 0.0f));
lightColors.push_back(glm::vec3(0.0f, 0.0f, 0.2f));
lightColors.push_back(glm::vec3(0.0f, 0.1f, 0.0f));
渲染至浮点帧缓冲和渲染至一个普通的帧缓冲是一样的。新的东西就是这个 hdrShader
片段着色器,用来渲染最终拥有浮点颜色缓冲纹理的2D四边形。我们首先来定义一个简单的直通片段着色器(Pass-through Fragment Shader):
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D hdrBuffer;
void main()
{
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
FragColor = vec4(hdrColor, 1.0);
}
在这里,我们直接采样浮点颜色缓冲区,并将其颜色值作为片段着色器的输出。然而,由于2D四边形的输出直接渲染到默认帧缓冲区,所有片段着色器的输出值仍会被限制在0.0到1.0之间,即使浮点颜色纹理中有多个值超过1.0。
很明显,在隧道尽头的强光的值被约束在1.0,因为一大块区域都是白色的,过程中超过1.0的地方损失了所有细节。因为我们直接转换HDR值到LDR值,这就像我们根本就没有应用HDR一样。我们需要做的是将所有浮点颜色值转换到0.0到1.0范围内,同时不丢失任何细节。这就需要应用色调映射。
色调映射
色调映射(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));
// 伽马校正
mapped = pow(mapped, vec3(1.0 / gamma));
FragColor = vec4(mapped, 1.0);
}
应用 Reinhard 色调映射后,我们场景的亮区不再丢失太多细节。不过它稍微偏向于亮区,使得暗区看起来细节和区分度较低:
现在你可以发现:在隧道尽头木头纹理变得可见了。通过这个相对简单的色调映射算法,我们能够正确查看存储在浮点帧缓冲区中整个范围内的HDR值,使我们能在不丢失细节的前提下,对场景光照有精确的控制。
需要注意的是,我们也可以直接在涉及光照计算的着色器(片段着色器)末尾进行色调映射,完全不需要浮点帧缓冲区!然而,随着场景变得更复杂,你会经常发现需要将中间HDR结果存储为浮点缓冲区,因此这是一个很好的实践做法。
色调映射的另一个有趣用途是允许使用曝光参数。你可能还记得在文章开头提到的,HDR图像在不同曝光级别下可以包含大量可见细节。如果场景具有昼夜循环,那么白天可以使用较低的曝光,夜晚使用较高的曝光,这类似于人眼的适应方式。通过这样的曝光参数,我们可以去设置在白天和夜晚有不同光照条件工作的光照参数,只需调整曝光参数即可。
一个相对简单的曝光色调映射算法如下:
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));
FragColor = vec4(mapped, 1.0);
}
在这里我们将 exposure
定义为默认为1.0的 uniform
,从而允许我们更加精确设定我们是要注重黑暗还是明亮的区域的HDR颜色值。举例来说,使用高曝光值,隧道的暗区会显示出更多的细节。相反,使用低曝光值会大幅减少暗区细节,但会让我们看到场景亮区的更多细节。下面这组图片展示了在不同曝光值下的场景渲染结果(PS:标题栏的 FPS 是 exposure,我忘记改了😭):
这个图片清晰地展示了HDR渲染的优点。通过改变曝光等级,我们可以还原场景的很多细节,而这些细节可能在LDR渲染中都被丢失了。以隧道尽头为例,在正常曝光下,木材结构几乎不可见,但低曝光下,详细的木纹图案清晰可见。而对于近处的木头花纹来说,在高曝光下会更加明显。
你可以在这里找到源码
项目源码:HDR - GitCode
- 空格可关闭 HDR
- ↑↓可改变 Exposure
HDR拓展
在这里展示的两个色调映射算法仅仅是大量(更先进)的色调映射算法中的一小部分,这些算法各有长短。一些色调映射算法倾向于特定的某种颜色/强度,也有一些算法同时显示低高曝光颜色从而能够显示更加多彩和精细的图像。也有一些技巧被称作自动曝光调整(Automatic Exposure Adjustment)或者叫人眼适应(Eye Adaptation)技术,它能够检测前一帧场景的亮度并且缓慢调整曝光参数,模仿人眼使得场景在黑暗区域逐渐变亮或者在明亮区域逐渐变暗。
HDR渲染的真正优势在大型且复杂、涉及复杂光照算法的场景中尤为明显。但出于教学目的,创建这样复杂的演示场景是不值得的,所以这个教程用的场景是很小的,而且缺乏细节。但是如此简单的演示也是能够显示出HDR渲染的一些优点:
- 在明亮和黑暗区域无细节损失,因为它们可以通过色调映射重新获得;
- 多个光源叠加的区域不会导致亮度被截断,光照可以被设定为它们原来的亮度值而不是被LDR值所限制。
- 而且,HDR 渲染也使一些有趣的效果更加可行和真实; 其中一个效果叫做泛光(Bloom),我们将在下一节讨论。
附加资料
- 如果泛光效果不被应用HDR渲染还有好处吗?: 一个StackExchange问题,其中有一个答案非常详细地解释HDR渲染的好处。
- 什么是色调映射? 它与HDR有什么联系?: 另一个非常有趣的答案,用了大量图片解释色调映射。
泛光
- 原文链接:泛光 - LearnOpenGL CN
引言
因为显示器的亮度范围有限,明亮的光源和区域经常很难向观察者表达出来。在显示器上区分明亮光源的一种方法是让它们发光,即让光线在光源周围溢出。这能有效地让观众产生一种错觉,感觉这些光源或区域非常明亮。
译注:这个问题的提出简单来说是为了解决这样的问题:例如有一张在阳光下的白纸,白纸在显示器上显示出是出白色,而前方的太阳也是纯白色的,所以基本上白纸和太阳就是一样的了,给太阳加一个光晕,这样太阳看起来似乎就比白纸更亮了
这种发光或光晕效果通过一种称为泛光(Bloom)的后处理效果实现。泛光为场景中所有明亮区域赋予类似发光的效果。以下是有与无泛光效果的场景示例(图片由Epic Games提供):
泛光为物体的亮度提供了显著的视觉线索。当用优雅微妙的方式实现泛光效果(一些游戏在这方面做得过于夸张),将会显著增强您的场景光照并能提供更加有张力的效果。
泛光与 HDR 渲染结合使用效果最佳。泛光与HDR渲染结合使用效果最佳。一个常见的误解是认为HDR与泛光相同,许多人混用这两个术语。然而,它们是完全不同的技术,用于不同的目的。可以在默认的8位精度帧缓冲区中实现泛光,也可以在不使用泛光效果的情况下使用HDR。只是HDR能使泛光的实现更有效(我们稍后会看到)。
要实现泛光,我们像往常一样渲染一个有光照的场景,并提取场景的HDR颜色缓冲区以及仅包含场景明亮区域的图像。然后对提取的亮度图像进行模糊处理,并将结果叠加到原始HDR场景图像上。
让我们逐步说明这个过程。我们渲染一个包含4个明亮光源的场景,这些光源被可视化为彩色立方体。彩色光立方体的亮度值在1.5到15.0之间。如果我们将其渲染到HDR颜色缓冲区,场景如下所示:
得到这个 HDR 颜色缓冲纹理之后,我们提取所有超出一定亮度的 fragment。这样我们就会获得一个只有 fragment 超过了一定阈值的颜色区域:
得到的模糊纹理是我们用来实现发光或光溢效果的关键。这个模糊纹理被叠加到原始HDR场景纹理之上。由于模糊滤波器使明亮区域在宽度和高度上都得到了扩展,场景中的明亮区域看起来像是发光或溢出了光线。
泛光本身并不是一项复杂的技术,但要做到恰到好处却颇具挑战。其视觉质量很大程度上取决于所使用的模糊滤波器的质量和类型。简单的调整模糊滤波器就能显著改变泛光效果的质量。
按照上述步骤,我们就可以实现泛光后处理效果。下一张图片简要总结了实现泛光所需的步骤:
首先我们需要根据某个阈值提取场景中的所有明亮颜色。让我们先探讨这一步。
提取亮色
第一步需要我们从渲染场景中提取两张图像。我们可以渲染场景两次,分别使用不同着色器渲染到不同帧缓冲区,但也可以利用一个巧妙的小技巧,称为 MRT(Multiple Render Targets,多重渲染目标),它允许我们指定多个片段着色器输出;这让我们能在单次渲染中提取前两张图像。通过在片段着色器输出前指定布局 location 修饰符,我们可以控制片段着色器写入哪个颜色缓冲区:
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;
这仅在有多个可写入缓冲区时有效。要使用多个片段着色器输出,当前绑定的帧缓冲区对象需附着多个颜色缓冲区。你可能记得在帧缓冲区章节中,当把一个纹理链接到帧缓冲的颜色缓冲上时,我们可以指定一个颜色附件编号。迄今为止我们一直使用 GL_COLOR_ATTACHMENT0
,但通过同时使用 GL_COLOR_ATTACHMENT1
,我们可以让帧缓冲区对象附着两个颜色缓冲区:
// 设置浮点帧缓冲区以渲染场景
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);
// 将纹理附着到帧缓冲区
glFramebufferTexture2D(
GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, colorBuffers[i], 0
);
}
我们必须通过 glDrawBuffers
显示告诉OpenGL我们将渲染到多个颜色缓冲区。默认情况下,OpenGL仅渲染到帧缓冲区的第一个颜色附件,忽略其他附件。我们可以通过传递一个颜色附件枚举数组来指定后续操作中要渲染的目标:
unsigned int attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };
glDrawBuffers(2, attachments);
渲染到此帧缓冲区时,每当片段着色器使用布局 location 修饰符,相应的颜色缓冲区将被用于渲染片段。这很棒,因为它节省了提取明亮区域的额外渲染过程,我们现在可以直接从待渲染的片段中提取它们:
#version 330 core
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;
[...]
void main()
{
[...] // 首先进行正常的光照计算并输出结果
FragColor = vec4(lighting, 1.0);
// 检查片段输出是否高于阈值,若是则作为亮区颜色输出
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(0.0, 0.0, 0.0, 1.0);
}
这里我们首先按常规计算光照并将其传递给第一个片段着色器输出变量 FragColor
。然后,我们使用当前存储在 FragColor
中的值来判断其亮度是否超过某个阈值。通过先将其适当地转换为灰度(通过两向量的点积,我们有效地将两个向量的各个分量相乘并将结果相加)来计算片段的亮度。如果亮度超过某个阈值,我们将颜色输出到第二个颜色缓冲区。对光立方体也做同样处理。
这也说明了为什么泛光在 HDR 基础上能够工作得更好。因为在 HDR 渲染中,颜色值可以超过1.0,这让我们能指定一个超出默认范围的亮度阈值,从而更精确地控制哪些区域可以被视为明亮的。如果没有HDR,我们必须将阈值设为低于1.0,这仍然可行,但区域会更快地被认为是明亮的。这有时会导致泛光效果过于突出(例如,想想白雪发光的情景)。
有了这两个颜色缓冲区,我们就得到了一个正常的场景图像和一个提取出的明亮区域图像;这些都在一个渲染步骤中完成。
有了提取出的明亮区域图像后,我们需要对图像进行模糊处理。我们可以使用帧缓冲区章节后处理部分提到的简单盒状滤波器来实现,但我们更倾向于使用一种更高级(且效果更好)的模糊滤波器,称为高斯模糊。
高斯模糊
在后处理教程那里,我们采用的模糊算法是取图像所有周围像素的平均值。虽然这确实提供了一种简单方法实现模糊效果,但结果并非最佳。高斯模糊基于高斯曲线,高斯曲线通常被描述为一个钟形曲线,中间的值达到最大化,随着距离的增加,两边的值不断减少。高斯曲线在数学上有不同的形式,但是通常是这样的形状:
由于高斯曲线在其中心附近具有较大的面积,使用其值作为模糊图像的权重能产生更自然的结果,因为靠近的样本应该具有更高的优先级。例如,如果我们在片段周围采样一个32x32的方框,随着与片段距离的增加,我们使用逐渐减小的权重;这会产生更好、更真实的模糊效果,这种模糊处理思想被称为 高斯模糊(Gaussian blur)。
要实现高斯模糊滤波器,我们需要一个二维权重矩阵,这可以从二维高斯曲线方程中获得。然而,这种方法的问题在于性能开销很快变得极高。以一个 32x32 的模糊 kermel 为例,我们必须对每个片段从一个纹理中采样 1024 次!
幸运的是,高斯方程有一个非常巧妙的特性,允许我们将二维方程分解为两个较小的一维方程:一个描述水平权重,另一个描述垂直权重。我们首先用水平权重在整个纹理上进行水平模糊,然后在结果纹理上进行垂直模糊。利用这个特性,结果是一样的,但是可以节省难以置信的性能,因为我们现在只需做 32+32 次采样,不再是 1024 了!这叫做两步高斯模糊(two-pass Gaussian blur)
这意味着我们如果对一个图像进行模糊处理,至少需要两步,而这最好通过帧缓冲区对象来实现。具体来说,我们实现高斯模糊的过程就像打乒乓球一样使用帧缓冲区。意思是,我们有一对帧缓冲,我们把另一个帧缓冲的颜色缓冲放进当前帧缓冲的颜色缓冲中,使用不同的着色效果渲染指定的次数。基本上就是不断地切换帧缓冲和纹理去绘制。我们先在第一个帧缓冲区中模糊场景纹理,然后将第一个帧缓冲区的颜色缓冲区模糊到第二个帧缓冲区,再将第二个帧缓冲区的颜色缓冲区模糊回第一个,循环往复。
在深入探讨帧缓冲区之前,我们先讨论高斯模糊的片段着色器:
#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 uniform 变量的值将模糊滤波器分为水平和垂直两部分。并根据 1.0 除以纹理大小(通过 textureSize
获得的 vec2
)计算偏移距离。
为了模糊图像,我们创建两个基本的帧缓冲区,每个仅附着一个颜色缓冲区纹理:
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 纹理和提取的亮区纹理后,我们首先用亮区纹理填充其中一个乒乓帧缓冲区,然后对图像进行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] : pingpongBuffer[!horizontal]
);
RenderQuad();
horizontal = !horizontal;
if (first_iteration)
first_iteration = false;
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
每次迭代我们根据是否进行水平或垂直模糊绑定两个乒乓帧缓冲区之一,并将另一个帧缓冲区的颜色缓冲区绑定为要模糊的纹理。特别注意第一次迭代时,因为两个乒乓缓冲区是空的,所以我们需要将提取的亮区纹理绑定为要模糊的纹理。通过重复此过程10次,亮区纹理最终获得5次重复的高斯模糊效果。这样我们可以对任意图像进行任意次模糊处理;高斯模糊迭代次数越多,模糊效果越强。
通过对提取的亮区纹理进行5次模糊,我们得到了场景所有明亮区域的正确模糊图像:
完成泛光效果的最后一步是将这个模糊的亮区纹理与原始场景的HDR纹理结合。
把两个纹理混合
有了场景的HDR纹理和场景模糊处理后的亮区纹理,我们只需将两者结合即可实现泛光或称光晕效果。在最终的片段着色器中(与 HDR 章节中使用的着色器大体相似),我们将两张纹理进行加性混合:
#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; // 加性混合
// 色调映射
vec3 result = vec3(1.0) - exp(-hdrColor * exposure);
// 顺便进行伽马校正
result = pow(result, vec3(1.0 / gamma));
FragColor = vec4(result, 1.0);
}
值得注意的是,我们在应用色调映射之前添加了泛光效果。这样,泛光增加的亮度也能被平滑地转换到 LDR 范围,从而获得相对较好的光照效果。
将两张纹理相加后,我们场景中的所有明亮区域现在都获得了不错的发光效果:
有颜色的立方体看起来仿佛更亮,它向外发射光芒,的确是一个更好的视觉效果。这是一个相对简单的场景,因此泛光效果在这里并不算太惊艳,但在光照良好的场景中,适当配置后它能产生显著差异。你可以在这里找到这个简单示例的源代码。
这个教程我们只是用了一个相对简单的高斯模糊过滤器,它在每个方向上只有5个样本。通过沿着更大的半径或重复更多次数的模糊,进行采样我们就可以提升模糊的效果。因为模糊的质量与泛光效果的质量正相关,提升模糊效果就能够提升泛光效果。一些改进方法结合使用不同大小模糊 kernel 的模糊滤波器,或使用多个高斯曲线选择性地组合权重。Kalogirou 和 Epic Games 的附加资料讨论了如何通过改进高斯模糊显著提升泛光效果。
项目源码:泛光 - GitCode
- 按→可切换渲染效果
- 空格可关闭 HDR
- ↑↓可改变 Exposure
附加资料
- Efficient Gaussian Blur with linear sampling:非常详细地描述了高斯模糊,以及如何使用OpenGL的双线性纹理采样提升性能。
- Bloom Post Process Effect:来自Epic Games关于通过对权重的多个高斯曲线结合来提升泛光效果的文章。
- How to do good bloom for HDR rendering:Kalogirou的文章描述了如何使用更好的高斯模糊算法来提升泛光效果。