diff --git a/05 Advanced Lighting/04 Normal Mapping.md b/05 Advanced Lighting/04 Normal Mapping.md new file mode 100644 index 0000000..de09369 --- /dev/null +++ b/05 Advanced Lighting/04 Normal Mapping.md @@ -0,0 +1,923 @@ +本文作者JoeyDeVries,由Django翻译自[http://learnopengl.com](http://learnopengl.com) + +## 法线贴图 (Normal Mapping) + +我们的场景中已经充满了多边形物体,其中每个都可能由成百上千平坦的三角形组成。我们以向三角形上附加纹理的方式来增加额外细节,提升真实感,隐藏多边形几何体是由无数三角形组成的事实。纹理确有助益,然而当你近看它们时,这个事实便隐藏不住了。现实中的物体表面并非是平坦的,而是表现出无数(凹凸不平的)细节。 + +例如,砖块的表面。砖块的表面非常粗糙,显然不是完全平坦的:它包含着接缝处水泥凹痕,以及非常多的细小的空洞。如果我们在一个有光的场景中看这样一个砖块的表面,问题就出来了。下图中我们可以看到砖块纹理应用到了平坦的表面,并被一个点光源照亮。 + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_flat.png) + +光照并没有呈现出任何裂痕和孔洞,完全忽略了砖块之间凹进去的线条;表面看起来完全就是平的。我们可以使用specular贴图根据深度或其他细节阻止部分表面被照的更亮,以此部分地解决问题,但这并不是一个好方案。我们需要的是某种可以告知光照系统给所有有关物体表面类似深度这样的细节的方式。 + +如果我们一光的视角来看这个问题:是什么使表面被视为完全平坦的表面来照亮?答案会是表面的法线向量。以光照算法的视角考虑的话,只有一件事决定物体的形状,这就是垂直于它的法线向量。砖块表面只有一个法线向量,表面完全根据这个法线向量被以一致的方式照亮。如果每个fragment都是用自己的不同的法线会怎样?这样我们就可以根据表面细微的细节对法线向量进行改变;这样就会获得一种表面看起来要复杂得多的幻觉: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_surfaces.png) + +每个fragment使用了自己的法线,我们就可以让光照相信一个表面由很多微小的(垂直于法线向量的)平面所组成,物体表面的细节将会得到极大提升。这种每个fragment使用各自的法线,替代一个面上所有fragment使用同一个法线的技术叫做法线贴图(normal mapping)或凹凸贴图(bump mapping)。应用到砖墙上,效果像这样: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_compare.png) + +你可以看到细节获得了极大提升,开销却不大。因为我们只需要改变每个fragment的法线向量,并不需要改变所有光照公式。现在我们是为每个fragment传递一个法线,不再使用插值表面法线。这样光照使表面拥有了自己的细节。 + + + +### 法线贴图 + +为使法线贴图工作,我们需要为每个fragment提供一个法线。像diffuse贴图和specular贴图一样,我们可以使用一个2D纹理来储存法线数据。2D纹理不仅可以储存颜色和光照数据,还可以储存法线向量。这样我们可以从2D纹理中采样得到特定纹理的法线向量。 + +由于法线向量是个几何工具,而纹理通常只用于储存颜色信息,用纹理储存法线向量不是非常直接。如果你想一想,就会知道纹理中的颜色向量用r、g、b元素代表一个3D向量。类似的我们也可以将法线向量的x、y、z元素储存到纹理中,代替颜色的r、g、b元素。法线向量的范围在-1到1之间,所以我们先要将其映射到0到1的范围: + + +1 +vec3 rgb_normal = normal * 0.5 - 0.5; // transforms from [-1,1] to [0,1] +将法线向量变换为像这样的RGB颜色元素,我们就能把根据表面的形状的fragment的法线保存在2D纹理中。教程开头展示的那个砖块的例子的法线贴图如下所示: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_normal_map.png) + +这会是一种偏蓝色调的纹理(你在网上找到的几乎所有法线贴图都是这样的)。这是因为所有法线的指向都偏向z轴(0, 0, 1)这是一种偏蓝的颜色。法线向量从z轴方向也向其他方向轻微偏移,颜色也就发生了轻微变化,这样看起来便有了一种深度。例如,你可以看到在每个砖块的顶部,颜色倾向于偏绿,这是因为砖块的顶部的法线偏向于指向正y轴方向(0, 1, 0),这样它就是绿色的了。 + +在一个简单的朝向正z轴的平面上,我们可以用这个diffuse纹理和这个法线贴图来渲染前面部分的图片。要注意的是这个链接里的法线贴图和上面展示的那个不一样。原因是OpenGL读取的纹理的y(或V)坐标和纹理通常被创建的方式相反。链接里的法线贴图的y(或绿色)元素是相反的(你可以看到绿色现在在下边);如果你没考虑这个,光照就不正确了(译注:如果你现在不再使用SOIL了,那就不要用链接里的那个法线贴图,这个问题是SOIL载入纹理上下颠倒所致,它也会把法线在y方向上颠倒)。加载纹理,把它们绑定到合适的纹理单元,然后使用下面的改变了的像素着色器来渲染一个平面: + +```c++ +uniform sampler2D normalMap; + +void main() +{ + // 从法线贴图范围[0,1]获取法线 + normal = texture(normalMap, fs_in.TexCoords).rgb; + // 将法线向量转换为范围[-1,1] + normal = normalize(normal * 2.0 - 1.0); + + [...] + // 像往常那样处理光照 +} +``` + +这里我们将被采样的法线颜色从0到1重新映射回-1到1,便能将RGB颜色重新处理成法线,然后使用采样出的法线向量应用于光照的计算。在例子中我们使用的是Blinn-Phong着色器。 + +通过慢慢随着时间慢慢移动光源,你就能明白法线贴图是什么意思了。运行这个例子你就能得到本教程开始的那个效果: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_correct.png) + +你可以在这里找到这个简单demo的源代码及其顶点和像素着色器。 + +然而有个问题限制了刚才讲的那种法线贴图的使用。我们使用的那个法线贴图里面的所有法线向量都是指向正z方向的。上面的例子能用,是因为那个平面的表面法线也是指向正z方向的。可是,如果我们在表面法线指向正y方向的平面上使用同一个法线贴图会发生什么? + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_ground.png) + +光照看起来完全不对!发生这种情况是平面的表面法线现在指向了y,而采样得到的法线仍然指向的是z。结果就是光照仍然认为表面法线和之前朝向正z方向时一样;这样光照就不对了。下面的图片展示了这个表面上采样的法线的近似情况: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_ground_normals.png) + +你可以看到所有法线都指向z方向,它们本该朝着表面法线指向y方向的。一个可行方案是为每个表面制作一个单独的法线贴图。如果是一个立方体的话我们就需要6个法线贴图,但是如果模型上有无数的朝向不同方向的表面,这就不可行了(译注:实际上对于复杂模型可以把朝向各个方向的法线储存在同一张贴图上,你可能看到过不只是蓝色的法线贴图,不过用那样的法线贴图有个问题是你必须记住模型的起始朝向,如果模型运动了还要记录模型的变换,这是非常不方便的;此外就像作者所说的,如果把一个diffuse纹理应用在同一个物体的不同表面上,就像立方体那样的,就需要做6个法线贴图,这也不可取)。 + +另一个稍微有点难的解决方案是,在一个不同的坐标空间中进行光照,这个坐标空间里,法线贴图向量总是指向这个坐标空间的正z方向;所有的光照向量都相对与这个正z方向进行变换。这样我们就能始终使用同样的法线贴图,不管朝向问题。这个坐标空间叫做切线空间(tangent space)。 + + + +### 切线空间 + +法线贴图中的法线向量在切线空间中,法线永远指着正z方向。切线空间是位于三角形表面之上的空间:法线相对于单个三角形的本地参考框架。它就像法线贴图向量的本地空间;它们都被定义为指向正z方向,无论最终变换到什么方向。使用一个特定的矩阵我们就能将本地/切线空寂中的法线向量转成世界或视图坐标,使它们转向到最终的贴图表面的方向。 + +我们可以说,上个部分那个朝向正y的法线贴图错误的贴到了表面上。法线贴图被定义在切线空间中,所以一种解决问题的方式是计算出一种矩阵,把法线从切线空间变换到一个不同的空间,这样它们就能和表面法线方向对齐了:法线向量都会指向正y方向。切线空间的一大好处是我们可以为任何类型的表面计算出一个这样的矩阵,由此我们可以把切线空间的z方向和表面的法线方向对齐。 + +这种矩阵叫做TBN矩阵这三个字母分别代表tangent、bitangent和normal向量。这是建构这个矩阵所需的向量。要建构这样一个把切线空间转变为不同空间的变异矩阵,我们需要三个相互垂直的向量,它们沿一个表面的法线贴图对齐于:上、右、前;这和我们在[摄像机教程](http://learnopengl-cn.readthedocs.org/zh/latest/01%20Getting%20started/09%20Camera/)中做的类似。 + +已知上向量是表面的法线向量。右和前向量是切线和副切线向量。下面的图片展示了一个表面的三个向量: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_tbn_vectors.png) + +计算出切线和副切线并不像法线向量那么容易。从图中可以看到法线贴图的切线和副切线与纹理坐标的两个方向对齐。我们就是用到这个特性计算每个表面的切线和副切线的。需要用到一些数学才能得到它们;请看下图: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_surface_edges.png) + +上图中我们可以看到边 + + E + 2 + +纹理坐标的不同, + + E + 2 + +是一个三角形的边,这个三角形的另外两条边是 + Δ + + U + 2 + + + Δ + + V + 2 + +,它们与切线向量*T*和副切线向量*B*方向相同。这样我们可以把边和 + + E + 1 + + + + E + 2 + +用切线向量 *T* 和副切线向量 *B* 的线性组合表示出来(译注:注意*T*和*B*都是单位长度,在*TB*平面中所有点的*T*、*B*坐标都在0到1之间,因此可以进行这样的组合): + +```math +E_1 = \Delta U_1T + \Delta V_1B + +E_2 = \Delta U_2T + \Delta V_2B +``` +我们也可以写成这样: + +```math +(E_{1x}, E_{1y}, E_{1z}) = \Delta U_1(T_x, T_y, T_z) + \Delta V_1(B_x, B_y, B_z) +``` + +*E*是两个向量位置的差,*U*和*V*是纹理坐标的差。然后我们得到两个未知数(切线*T*和副切线*B*)和两个等式。你可能想起你的代数课了,这是让我们去接*T*和*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 + + + + + ] + + + +尝试会以一下矩阵乘法,它们确实是同一种等式。把等式写成矩阵形式的好处是,解*T*和*B*会因此变得很容易。两边都乘以 + Δ + 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 + + + + + ] + + + +这样我们就可以解出*T*和*B*了。这需要我们计算出delta纹理坐标矩阵的拟阵。我不打算讲解计算逆矩阵的细节,但大致是把它变化为,1除以矩阵的行列式,再乘以它的共轭矩阵。 + + + + [ + + + + + 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 + + + + + + ] + + + +有了最后这个等式,我们就可以用公式、三角形的两条边以及纹理坐标计算出切线向量*T*和副切线*B*。 + +如果你对这些数学内容不理解也不用担心。当你知道我们可以用一个三角形的顶点和纹理坐标(因为纹理坐标和切线向量在同一空间中)计算出切线和副切线你就已经部分地达到目的了(译注:上面的推导已经很清楚了,如果你不明白可以参考任意线性代数教材,就像作者所说的记住求得切线空间的公式也行,不过不管怎样都得理解切线空间的含义)。 + +### 手工计算切线和副切线 + +这个教程的demo场景中有一个简单的2D平面,它朝向正z方向。这次我们会使用切线空间来实现法线贴图,所以我们可以使平面朝向任意方向,法线贴图仍然能够工作。使用前面讨论的数学方法,我们来手工计算出表面的切线和副切线向量。 + +假设平面使用下面的向量建立起来(1、2、3和1、3、4,它们是两个三角形): +// positions +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); +// texture coordinates +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); +// normal vector +glm::vec3 nm(0.0, 0.0, 1.0); + + +我们先计算第一个三角形的边和deltaUV坐标: + + +1 +2 +3 +4 +glm::vec3 edge1 = pos2 - pos1; +glm::vec3 edge2 = pos3 - pos1; +glm::vec2 deltaUV1 = uv2 - uv1; +glm::vec2 deltaUV2 = uv3 - uv1; + + +有了计算切线和副切线的必备数据,我们就可以开始写出来自于前面部分中的下列等式: + +```c++ +GLfloat 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); +tangent1 = glm::normalize(tangent1); + +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); +bitangent1 = glm::normalize(bitangent1); + +[...] // similar procedure for calculating tangent/bitangent for plane's second triangle +``` + +我们预先计算出等式的分数部分f,然后把它和每个向量的元素进行相应矩阵乘法。如果你把代码和最终的等式对比你会发现,这就是直接套用。最后我们还要进行标准化,来确保切线/副切线向量最后是单位向量。 + +因为一个三角形永远是平坦的形状,我们只需为每个三角形计算一个切线/副切线,它们对于每个三角形上的顶点都是一样的。要注意的是大多数实现通常三角形和三角形之间都会共享顶点。这种情况下开发者通常将每个顶点的法线和切线/副切线等顶点属性平均化,以获得更加柔和的效果。我们的平面的三角形之间分享了一些顶点,但是因为两个三角形相互并行,因此并不需要将结果平均化,但无论何时只要你遇到这种情况记住它就是件好事。 + +最后的切线和副切线向量的值应该是(1, 0, 0)和(0, 1, 0),它们和法线(0, 0, 1)组成相互垂直的TBN矩阵。在平面上显示出来TBN应该是这样的: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_tbn_shown.png) + +每个顶点定义了切线和副切线向量,我们就可以开始实现正确的法线贴图了。 + + +### 切线空间法线贴图 + +为让法线贴图工作,我们先得在着色器中创建一个TBN矩阵。我们先将前面计算出来的切线和副切线向量传给顶点着色器,作为它的属性: + +```c++ +#version 330 core +layout (location = 0) in vec3 position; +layout (location = 1) in vec3 normal; +layout (location = 2) in vec2 texCoords; +layout (location = 3) in vec3 tangent; +layout (location = 4) in vec3 bitangent; +``` + +在顶点着色器的main函数中我们创建TBN矩阵: + +```c++ +void main() +{ + [...] + vec3 T = normalize(vec3(model * vec4(tangent, 0.0))); + vec3 B = normalize(vec3(model * vec4(bitangent, 0.0))); + vec3 N = normalize(vec3(model * vec4(normal, 0.0))); + mat3 TBN = mat3(T, B, N) +} +``` + +我们先将所有TBN向量变换到我们所操作的坐标系中,现在是世界空间,我们可以乘以model矩阵。然后我们创建实际的TBN矩阵,直接把相应的向量应用到mat3构造器就行。注意,如果我们希望更精确的话就不要讲TBN向量乘以model矩阵,而是使用法线矩阵,但我们只关心向量的方向,不会平移也和缩放这个变换。 + +从技术上讲,顶点着色器中无需副切线。所有的这三个TBN向量都是相互垂直的所以我们可以在顶点着色器中庸T和N向量的叉乘,自己计算出副切线:vec3 B = cross(T, N); +现在我们有了TBN矩阵,如果来使用它呢?基本有两种方式可以使用,我们会把这两种方式都说明一下: + +我们可以用TBN矩阵把所有向量从切线空间转到世界空间,传给像素着色器,然后把采样得到的法线用TBN矩阵从切线空间变换到世界空间;法线就处于和其他光照变量一样的空间中了。 +我们用TBN的逆矩阵把所有世界空间的向量转换到切线空间,使用这个矩阵将除法线以外的所有相关光照变量转换到切线空间中;这样法线也能和其他光照变量处于同一空间之中。 +我们来看看第一种情况。我们从法线贴图重采样得来的法线向量,是以切线空间表达的,尽管其他光照向量是以世界空间表达的。把TBN传给像素着色器,我们就能将采样得来的切线空间的法线乘以这个TBN矩阵,将法线向量变换到和其他光照向量一样的参考空间中。这种方式随后所有光照计算都可以简单的理解。 + +把TBN矩阵发给像素着色器很简单: + + +```c++ +out VS_OUT { + vec3 FragPos; + vec2 TexCoords; + mat3 TBN; +} vs_out; + +void main() +{ + [...] + vs_out.TBN = mat3(T, B, N); +} +``` + +在像素着色器中我们用mat3作为输入变量: + +```c++ +in VS_OUT { + vec3 FragPos; + vec2 TexCoords; + mat3 TBN; +} fs_in; +``` + +有了TBN矩阵我们现在就可以更新法线贴图代码,引入切线到世界空间变换: + +```c++ +normal = texture(normalMap, fs_in.TexCoords).rgb; +normal = normalize(normal * 2.0 - 1.0); +normal = normalize(fs_in.TBN * normal); +``` + +因为最后的normal现在在世界空间中了,就不用改变其他像素着色器的代码了,因为光照代码就是假设法线向量在世界空间中。 + +我们同样看看第二种情况,我们用TBN矩阵的逆矩阵将所有相关的世界空间向量转变到采样所得法线向量的空间:切线空间。TBN的建构还是一样,但我们在将其发送给像素着色器之前先要求逆矩阵: + +```c++ +vs_out.TBN = transpose(mat3(T, B, N)); +``` + +注意,这里我们使用transpose函数,而不是inverse函数。正交矩阵(每个轴既是单位向量同时相互垂直)的一大属性是一个正交矩阵的置换矩阵与它的逆矩阵相等。这个属性和重要因为逆矩阵的求得比求置换开销大;结果却是一样的。 + +在像素着色器中我们不用对法线向量变换,但我们要把其他相关向量转换到切线空间,它们是lightDir和viewDir。这样每个向量还是在同一个空间(切线空间)中了。 + + +```c++ +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不是每个fragment运行都要改变,对于fs_in.FragPos,我们也可以在顶点着色器计算它的切线空间位置。基本上,不需要把任何向量在像素着色器中进行变换,而第一种方法中就是必须的,因为采样出来的法线向量对于每个像素着色器都不一样。 + +所以现在不是把TBN矩阵的逆矩阵发送给像素着色器,而是将切线空间的光源位置,观察位置以及顶点位置发送给像素着色器。这样我们就不用在像素着色器里进行矩阵乘法了。这是一个极佳的优化,因为顶点着色器通常比像素着色器运行的少。这也是为什么这种方法是一种更好的实现方式的原因。 + +```c++ +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(position, 0.0)); +} +``` + +在像素着色器中我们使用这些新的输入变量来计算切线空间的光照。因为法线向量已经在切线空间中了,光照就有意义了。 + +将法线贴图应用到切线空间上,我们会得到混合教程一开始那个例子相似的结果,但这次我们可以将平面朝向各个方向,光照一直都会是正确的: + +```c++ +glm::mat4 model; +model = glm::rotate(model, (GLfloat)glfwGetTime() * -10, glm::normalize(glm::vec3(1.0, 0.0, 1.0))); +glUniformMatrix4fv(modelLoc 1, GL_FALSE, glm::value_ptr(model)); +RenderQuad(); +``` + +看起来是正确的法线贴图: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_correct_tangent.png) + +你可以在这里找到[源代码](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/normal_mapping)、[顶点](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/normal_mapping&type=vertex)和[像素](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/normal_mapping&type=fragment)着色器。 + +### 复杂的物体 + +我们已经说明了如何通过手工计算切线和副切线向量,来使用切线空间和法线贴图。幸运的是,计算这些切线和副切线向量对于你来说不是经常能遇到的事;大多数时候,在模型加载器中实现了一次就行了,我们是在使用了Assimp的那个加载器中实现的。 + +Assimp有个很有用的配置,在我们加载模型的时候调用aiProcess_CalcTangentSpace。当aiProcess_CalcTangentSpace应用到Assimp的ReadFile函数时,Assimp会为每个加载的顶点计算出柔和的切线和副切线向量,它所使用的方法和我们本教程使用的类似。 + +```c++ +const aiScene* scene = importer.ReadFile( + path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace +); +``` + + +我们可以通过下面的代码用Assimp获取计算出来的切线空间: + +```c++ +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却能,所以我们经常这样加载它们: + +```c++ +vector specularMaps = this->loadMaterialTextures( + material, aiTextureType_HEIGHT, "texture_normal" +); +``` + + +当然,对于每个模型的类型和文件格式来说都是不同的。同样了解aiProcess_CalcTangentSpace并不能总是很好的工作也很重要。计算切线是需要根据纹理坐标的,有些模型制作者使用一些纹理小技巧比如镜像一个模型上的纹理表面时也镜像了另一半的纹理坐标;这样当不考虑这个镜像的特别操作的时候(Assimp就不考虑)结果就不对了。 + +运行程序,用新的模型加载器,加载一个有specular和法线贴图的模型,看起来会像这样: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_complex_compare.png) + +你可以看到在没有太多点的额外开销的情况下法线贴图难以置信地提升了物体的细节。 + +使用法线贴图也是一种提升你的场景的表现的重要方式。在使用法线贴图之前你不得不使用相当多的顶点才能表现出一个更精细的网格,但使用了法线贴图我们可以使用更少的顶点表现出同样丰富的细节。下图来自Paolo Cignoni,图中对比了两种方式: + +![](http://learnopengl.com/img/advanced-lighting/normal_mapping_comparison.png) + +高精度网格和使用法线贴图的低精度网格几乎区分不出来。所以法线贴图不仅看起来漂亮,它也是一个将高精度多边形转换为低精度多边形而不失细节的重要工具。 + + + +### 最后一件事 + +关于法线贴图还有最后一个技巧要讨论,它可以在不必花费太多性能开销的情况下稍稍提升画质表现。 + +当在更大的网格上计算切线向量的时候,它们往往有很大数量的共享顶点,当发下贴图应用到这些表面时将切线向量平均化通常能获得更好更平滑的结果。这样做有个问题,就是TBN向量可能会不能互相垂直,这意味着TBN矩阵不再是正交矩阵了。法线贴图可能会稍稍偏移,但这仍然可以改进。 + +使用叫做*格拉姆-施密特*正交化过程(Gram-Schmidt process)的数学技巧,我们可以对TBN向量进行重正交化,这样每个向量就又会重新垂直了。在顶点着色器中我们这样做: + +```c++ +vec3 T = normalize(vec3(model * vec4(tangent, 0.0))); +vec3 N = normalize(vec3(model * vec4(tangent, 0.0))); +// re-orthogonalize T with respect to N +T = normalize(T - dot(T, N) * N); +// then retrieve perpendicular vector B with the cross product of T and N +vec3 B = cross(T, N); + +mat3 TBN = mat3(T, B, N) +``` + +这样稍微花费一些性能开销就能对法线贴图进行一点提升。看看最后的那个附加资源: Normal Mapping Mathematics视频,里面有对这个过程的解释。 + +### 附加资源 + +* [Tutorial 26: Normal Mapping](http://ogldev.atspace.co.uk/www/tutorial26/tutorial26.html):ogldev的法线贴图教程。 +* [How Normal Mapping Works](https://www.youtube.com/watch?v=LIOPYmknj5Q):TheBennyBox的讲述法线贴图如何工作的视频。 +* [Normal Mapping Mathematics](https://www.youtube.com/watch?v=4FaWLgsctqY):TheBennyBox关于法线贴图的数学原理的教程。 +* [Tutorial 13: Normal Mapping](http://www.opengl-tutorial.org/intermediate-tutorials/tutorial-13-normal-mapping/):opengl-tutorial.org提供的法线贴图教程。 \ No newline at end of file diff --git a/05 Advanced Lighting/04 Shadow Mapping.md b/05 Advanced Lighting/04 Shadow Mapping.md deleted file mode 100644 index 689e595..0000000 --- a/05 Advanced Lighting/04 Shadow Mapping.md +++ /dev/null @@ -1,481 +0,0 @@ -本文作者JoeyDeVries,由Django翻译自[http://learnopengl.com](http://learnopengl.com) - -## 点光源阴影(Point Shadows) - -上个教程我们学到了如何使用阴影映射技术创建动态阴影。效果不错,但它只适合定向光,因为阴影只是在单一定向光源下生成的。所以它也叫定向阴影映射,深度(阴影)贴图生成自定向光的视角。 - -!!! Important - - 本节我们的焦点是在各种方向生成动态阴影。这个技术可以适用于点光源,生成所有方向上的阴影。 - -这个技术叫做点光阴影,过去的名字是万向阴影贴图(omnidirectional shadow maps)技术。 - -本节代码基于前面的阴影映射教程,所以如果你对传统阴影映射不熟悉,还是建议先读一读阴影映射教程。 -算法和定向阴影映射差不多:我们从光的透视图生成一个深度贴图,基于当前fragment位置来对深度贴图采样,然后用储存的深度值和每个fragment进行对比,看看它是否在阴影中。定向阴影映射和万向阴影映射的主要不同在于深度贴图的使用上。 - -对于深度贴图,我们需要从一个点光源的所有渲染场景,普通2D深度贴图不能工作;如果我们使用cubemap会怎样?因为cubemap可以储存6个面的环境数据,它可以将整个场景渲染到cubemap的每个面上,把它们当作点光源四周的深度值来采样。 - -![](http://learnopengl.com/img/advanced-lighting/point_shadows_diagram.png) - -生成后的深度cubemap被传递到光照像素着色器,它会用一个方向向量来采样cubemap,从而得到当前的fragment的深度(从光的透视图)。大部分复杂的事情已经在阴影映射教程中讨论过了。算法只是在深度cubemap生成上稍微复杂一点。 - -#### 生成深度cubemap - -为创建一个光周围的深度值的cubemap,我们必须渲染场景6次:每次一个面。显然渲染场景6次需要6个不同的视图矩阵,每次把一个不同的cubemap面附加到帧缓冲对象上。这看起来是这样的: - -```c++ -for(int i = 0; i < 6; i++) -{ - GLuint face = GL_TEXTURE_CUBE_MAP_POSITIVE_X + i; - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, face, depthCubemap, 0); - BindViewMatrix(lightViewMatrices[i]); - RenderScene(); -} -``` - -这会很耗费性能因为一个深度贴图下需要进行很多渲染调用。这个教程中我们将转而使用另外的一个小技巧来做这件事,几何着色器允许我们使用一次渲染过程来建立深度cubemap。 - -首先,我们需要创建一个cubemap: - -```c++ -GLuint depthCubemap; -glGenTextures(1, &depthCubemap); -``` - -然后生成cubemap的每个面,将它们作为2D深度值纹理图像: - -```c++ -const GLuint SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024; -glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap); -for (GLuint i = 0; i < 6; ++i) - glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_DEPTH_COMPONENT, - SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL); -``` - -不要忘记设置合适的纹理参数: - -```c++ -glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST); -glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST); -glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); -glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); -glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); -``` - -正常情况下,我们把cubemap纹理的一个面附加到帧缓冲对象上,渲染场景6次,每次将帧缓冲的深度缓冲目标改成不同cubemap面。由于我们将使用一个几何着色器,它允许我们把所有面在一个过程渲染,我们可以使用glFramebufferTexture直接把cubemap附加成帧缓冲的深度附件: - -```c++ -glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); -glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0); -glDrawBuffer(GL_NONE); -glReadBuffer(GL_NONE); -glBindFramebuffer(GL_FRAMEBUFFER, 0); -``` - -还要记得调用glDrawBuffer和glReadBuffer:当生成一个深度cubemap时我们只关心深度值,所以我们必须显式告诉OpenGL这个帧缓冲对象不会渲染到一个颜色缓冲里。 - -万向阴影贴图有两个渲染阶段:首先我们生成深度贴图,然后我们正常使用深度贴图渲染,在场景中创建阴影。帧缓冲对象和cubemap的处理看起是这样的: - -```c++ -// 1. first render to depth cubemap -glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT); -glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); - glClear(GL_DEPTH_BUFFER_BIT); - ConfigureShaderAndMatrices(); - RenderScene(); -glBindFramebuffer(GL_FRAMEBUFFER, 0); -// 2. then render scene as normal with shadow mapping (using depth cubemap) -glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT); -glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); -ConfigureShaderAndMatrices(); -glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap); -RenderScene(); -``` - -这个过程和默认的阴影映射一样,尽管这次我们渲染和使用的是一个cubemap深度纹理,而不是2D深度纹理。在我们实际开始从光的视角的所有方向渲染场景之前,我们先得计算出合适的变换矩阵。 - -### 光空间的变换 - -设置了帧缓冲和cubemap,我们需要一些方法来讲场景的所有几何体变换到6个光的方向中相应的光空间。与阴影映射教程类似,我们将需要一个光空间的变换矩阵T,但是这次是每个面都有一个。 - -每个光空间的变换矩阵包含了投影和视图矩阵。对于投影矩阵来说,我们将使用一个透视投影矩阵;光源代表一个空间中的点,所以透视投影矩阵更有意义。每个光空间变换矩阵使用同样的投影矩阵: - -```c++ -GLfloat aspect = (GLfloat)SHADOW_WIDTH/(GLfloat)SHADOW_HEIGHT; -GLfloat near = 1.0f; -GLfloat far = 25.0f; -glm::mat4 shadowProj = glm::perspective(90.0f, aspect, near, far); -``` - -非常重要的一点是,这里glm::perspective的视野参数,设置为90度。90度我们才能保证视野足够大到可以合适地填满cubemap的一个面,cubemap的所有面都能与其他面在边缘对齐。 - -因为投影矩阵在每个方向上并不会改变,我们可以在6个变换矩阵中重复使用。我们要为每个方向提供一个不同的视图矩阵。用glm::lookAt创建6个观察方向,每个都按顺序注视着cubemap的的一个方向:右、左、上、下、近、远: - -```c++ -std::vector shadowTransforms; -shadowTransforms.push_back(shadowProj * - glm::lookAt(lightPos, lightPos + glm::vec3(1.0,0.0,0.0), glm::vec3(0.0,-1.0,0.0)); -shadowTransforms.push_back(shadowProj * - glm::lookAt(lightPos, lightPos + glm::vec3(-1.0,0.0,0.0), glm::vec3(0.0,-1.0,0.0)); -shadowTransforms.push_back(shadowProj * - glm::lookAt(lightPos, lightPos + glm::vec3(0.0,1.0,0.0), glm::vec3(0.0,0.0,1.0)); -shadowTransforms.push_back(shadowProj * - glm::lookAt(lightPos, lightPos + glm::vec3(0.0,-1.0,0.0), glm::vec3(0.0,0.0,-1.0)); -shadowTransforms.push_back(shadowProj * - glm::lookAt(lightPos, lightPos + glm::vec3(0.0,0.0,1.0), glm::vec3(0.0,-1.0,0.0)); -shadowTransforms.push_back(shadowProj * - glm::lookAt(lightPos, lightPos + glm::vec3(0.0,0.0,-1.0), glm::vec3(0.0,-1.0,0.0)); -``` - -这里我们创建了6个视图矩阵,把它们乘以投影矩阵,来得到6个不同的光空间变换矩阵。glm::lookAt的target参数是它注视的cubemap的面的一个方向。 - -这些变换矩阵发送到着色器渲染到cubemap里。 - - - -### 深度着色器 - -为了把值渲染到深度cubemap,我们将需要3个着色器:顶点和像素着色器,以及一个它们之间的几何着色器。 - -几何着色器是负责将所有世界空间的顶点变换到6个不同的光空间的着色器。因此顶点着色器简单地将顶点变换到世界空间,然后直接发送到几何着色器: - -```c++ -#version 330 core -layout (location = 0) in vec3 position; - -uniform mat4 model; - -void main() -{ - gl_Position = model * vec4(position, 1.0); -} -``` - -紧接着几何着色器以3个三角形的顶点作为输入,它还有一个光空间变换矩阵的uniform数组。几何着色器接下来会负责将顶点变换到光空间;这里它开始变得有趣了。 - -几何着色器有一个内建变量叫做gl_Layer,它指定发散出基本图形送到cubemap的哪个面。当不管它时,几何着色器就会像往常一样把它的基本图形发送到输送管道的下一阶段,但当我们更新这个变量就能控制每个基本图形将渲染到cubemap的哪一个面。当然这只有当我们有了一个附加到激活的帧缓冲的cubemap纹理才有效: - -```c++ -#version 330 core -layout (triangles) in; -layout (triangle_strip, max_vertices=18) out; - -uniform mat4 shadowMatrices[6]; - -out vec4 FragPos; // FragPos from GS (output per emitvertex) - -void main() -{ - for(int face = 0; face < 6; ++face) - { - gl_Layer = face; // built-in variable that specifies to which face we render. - for(int i = 0; i < 3; ++i) // for each triangle's vertices - { - FragPos = gl_in[i].gl_Position; - gl_Position = shadowMatrices[face] * FragPos; - EmitVertex(); - } - EndPrimitive(); - } -} -``` - -几何着色器相对简单。我们输入一个三角形,输出总共6个三角形(6*3顶点,所以总共18个顶点)。在main函数中,我们遍历cubemap的6个面,我们每个面指定为一个输出面,把这个面的interger(整数)存到gl_Layer。然后,我们通过把面的光空间变换矩阵乘以FragPos,将每个世界空间顶点变换到相关的光空间,生成每个三角形。注意,我们还要将最后的FragPos变量发送给像素着色器,我们需要计算一个深度值。 - -上个教程,我们使用的是一个空的像素着色器,让OpenGL配置深度贴图的深度值。这次我们将计算自己的深度,这个深度就是每个fragment位置和光源位置之间的线性距离。计算自己的深度值使得之后的阴影计算更加直观。 - -```c++ -#version 330 core -in vec4 FragPos; - -uniform vec3 lightPos; -uniform float far_plane; - -void main() -{ - // get distance between fragment and light source - float lightDistance = length(FragPos.xyz - lightPos); - - // map to [0;1] range by dividing by far_plane - lightDistance = lightDistance / far_plane; - - // Write this as modified depth - gl_FragDepth = gl_FragCoord.z; -} -``` - -像素着色器将来自几何着色器的FragPos、光的位置向量和视锥的远平面值作为输入。这里我们把fragment和光源之间的距离,映射到0到1的范围,把它写入为fragment的深度值。 - -使用这些着色器渲染场景,cubemap附加的帧缓冲对象激活以后,你会得到一个完全填充的深度cubemap,以便于进行第二阶段的阴影计算。 - -### 万向阴影贴图 - -所有事情都做好了,是时候来渲染万向阴影了。这个过程和定向阴影映射教程相似,尽管这次我们绑定的深度贴图是一个cubemap,而不是2D纹理,并且将光的投影的远平面发送给了着色器。 - -```c++ -glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT); -glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); -shader.Use(); -// ... send uniforms to shader (including light's far_plane value) -glActiveTexture(GL_TEXTURE0); -glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap); -// ... bind other textures -RenderScene(); -``` - -这里的renderScene函数在一个大立方体房间中渲染一些立方体,它们散落在大立方体各处,光源在场景中央。 - -顶点着色器和像素着色器和原来的阴影映射着色器大部分都一样:不同之处是在光空间中像素着色器不再需要一个fragment位置,现在我们可以使用一个方向向量采样深度值。 - -因为这个顶点着色器不再需要将他的位置向量变换到光空间,所以我们可以去掉FragPosLightSpace变量: - -```c++ -#version 330 core -layout (location = 0) in vec3 position; -layout (location = 1) in vec3 normal; -layout (location = 2) in vec2 texCoords; - -out vec2 TexCoords; - -out VS_OUT { - vec3 FragPos; - vec3 Normal; - vec2 TexCoords; -} vs_out; - -uniform mat4 projection; -uniform mat4 view; -uniform mat4 model; - -void main() -{ - gl_Position = projection * view * model * vec4(position, 1.0f); - vs_out.FragPos = vec3(model * vec4(position, 1.0)); - vs_out.Normal = transpose(inverse(mat3(model))) * normal; - vs_out.TexCoords = texCoords; -} -``` - -片段着色器的Blinn-Phong光照代码和我们之前阴影相乘的结尾部分一样: - -```c++ -#version 330 core -out vec4 FragColor; - -in VS_OUT { - vec3 FragPos; - vec3 Normal; - vec2 TexCoords; -} fs_in; - -uniform sampler2D diffuseTexture; -uniform samplerCube depthMap; - -uniform vec3 lightPos; -uniform vec3 viewPos; - -uniform float far_plane; - -float ShadowCalculation(vec3 fragPos) -{ - [...] -} - -void main() -{ - vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb; - vec3 normal = normalize(fs_in.Normal); - vec3 lightColor = vec3(0.3); - // Ambient - vec3 ambient = 0.3 * color; - // Diffuse - vec3 lightDir = normalize(lightPos - fs_in.FragPos); - float diff = max(dot(lightDir, normal), 0.0); - vec3 diffuse = diff * lightColor; - // Specular - vec3 viewDir = normalize(viewPos - fs_in.FragPos); - vec3 reflectDir = reflect(-lightDir, normal); - float spec = 0.0; - vec3 halfwayDir = normalize(lightDir + viewDir); - spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0); - vec3 specular = spec * lightColor; - // Calculate shadow - float shadow = ShadowCalculation(fs_in.FragPos); - vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color; - - FragColor = vec4(lighting, 1.0f); -} -``` - -有一些细微的不同:光照代码一样,但我们现在有了一个uniform变量samplerCube,shadowCalculation函数用fragment的位置作为它的参数,取代了光空间的fragment位置。我们现在还要引入光的视锥的远平面值,后面我们会需要它。像素着色器的最后,我们计算出阴影元素,当fragment在阴影中时它是1.0,不在阴影中时是0.0。我们使用计算出来的阴影元素去影响光照的diffuse和specular元素。 - -在ShadowCalculation函数中有很多不同之处,现在是从cubemap中进行采样,不再使用2D纹理了。我们来一步一步的讨论一下的它的内容。 - -我们需要做的第一件事是获取cubemap的森都。你可能已经从教程的cubemap部分想到,我们已经将深度储存为fragment和光位置之间的距离了;我们这里采用相似的处理方式: - -```c++ -float ShadowCalculation(vec3 fragPos) -{ - vec3 fragToLight = fragPos - lightPos; - float closestDepth = texture(depthMap, fragToLight).r; -} -``` - -在这里,我们得到了fragment的位置与光的位置之间的不同的向量,使用这个向量作为一个方向向量去对cubemap进行采样。方向向量不需要是单位向量,所以无需对它进行标准化。最后的closestDepth是光源和它最接近的可见fragment之间的标准化的深度值。 - -closestDepth值现在在0到1的范围内了,所以我们先将其转换会0到far_plane的范围,这需要把他乘以far_plane: - -```c++ -closestDepth *= far_plane; -``` - -下一步我们获取当前fragment和光源之间的深度值,我们可以简单的使用fragToLight的长度来获取它,这取决于我们如何计算cubemap中的深度值: - -```c++ -float currentDepth = length(fragToLight); -``` - -返回的是和closestDepth范围相同的深度值。 - -现在我们可以将两个深度值对比一下,看看哪一个更接近,以此决定当前的fragment是否在阴影当中。我们还要包含一个阴影偏移,所以才能避免阴影失真,这在前面教程中已经讨论过了。 - -```c++ -float bias = 0.05; -float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0; -``` - -完整的ShadowCalculation现在变成了这样: - -```c++ -float ShadowCalculation(vec3 fragPos) -{ - // Get vector between fragment position and light position - vec3 fragToLight = fragPos - lightPos; - // Use the light to fragment vector to sample from the depth map - float closestDepth = texture(depthMap, fragToLight).r; - // It is currently in linear range between [0,1]. Re-transform back to original value - closestDepth *= far_plane; - // Now get current linear depth as the length between the fragment and light position - float currentDepth = length(fragToLight); - // Now test for shadows - float bias = 0.05; - float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0; - - return shadow; -} -``` - -有了这些着色器,我们已经能得到非常好的阴影效果了,这次从一个点光源所有周围方向上都有阴影。有一个位于场景中心的点光源,看起来会像这样: - -![](http://learnopengl.com/img/advanced-lighting/point_shadows.png) - -你可以从这里找到这个[demo的源码](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/point_shadows)、[顶点](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/point_shadows&type=vertex)和[片段](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/point_shadows&type=fragment)着色器。 - -#### 把cubemap深度缓冲显示出来 - -如果你想我一样第一次并没有做对,那么就要进行调试排错,将深度贴图显示出来以检查其是否正确。因为我们不再用2D深度贴图纹理,深度贴图的显示不会那么显而易见。 - -一个简单的把深度缓冲显示出来的技巧是,在ShadowCalculation函数中计算标准化的closestDepth变量,把变量显示为: - -```c++ -FragColor = vec4(vec3(closestDepth / far_plane), 1.0); -``` - -结果是一个灰度场景,每个颜色代表着场景的线性深度值: - -![](http://learnopengl.com/img/advanced-lighting/point_shadows_depth_cubemap.png) - -你可能也注意到了带阴影部分在墙外。如果看起来和这个差不多,你就知道深度cubemap生成的没错。否则你可能做错了什么,也许是closestDepth仍然还在0到far_plane的范围。 - -#### PCF - -由于万向阴影贴图基于传统阴影映射的原则,它便也继承了由解析度产生的非真实感。如果你放大就会看到锯齿边了。PCF或称Percentage-closer filtering允许我们通过对fragment位置周围过滤多个样本,并对结果平均化。 - -如果我们用和前面教程同样的那个简单的PCF过滤器,并加入第三个维度,就是这样的: - -```c+++ -float shadow = 0.0; -float bias = 0.05; -float samples = 4.0; -float offset = 0.1; -for(float x = -offset; x < offset; x += offset / (samples * 0.5)) -{ - for(float y = -offset; y < offset; y += offset / (samples * 0.5)) - { - for(float z = -offset; z < offset; z += offset / (samples * 0.5)) - { - float closestDepth = texture(depthMap, fragToLight + vec3(x, y, z)).r; - closestDepth *= far_plane; // Undo mapping [0;1] - if(currentDepth - bias > closestDepth) - shadow += 1.0; - } - } -} -shadow /= (samples * samples * samples); -``` - -这段代码和我们传统的阴影映射没有多少不同。这里我们根据样本的数量动态计算了纹理偏移量,我们在三个轴向采样三次,最后对子样本进行平均化。 - -现在阴影看起来更加柔和平滑了,由此得到更加真实的效果: - -![](http://learnopengl.com/img/advanced-lighting/point_shadows_soft.png) - -然而,samples设置为4.0,每个fragment我们会得到总共64个样本,这太多了! - -大多数这些样本都是多余的,它们在原始方向向量近处采样,不如在采样方向向量的垂直方向进行采样更有意义。可是,没有(简单的)方式能够指出哪一个子方向是多余的,这就难了。有个技巧可以使用,用一个偏移量方向数组,它们差不多都是分开的,每一个指向完全不同的方向,剔除彼此接近的那些子方向。下面就是一个有着20个偏移方向的数组: - -```c++ -vec3 sampleOffsetDirections[20] = vec3[] -( - vec3( 1, 1, 1), vec3( 1, -1, 1), vec3(-1, -1, 1), vec3(-1, 1, 1), - vec3( 1, 1, -1), vec3( 1, -1, -1), vec3(-1, -1, -1), vec3(-1, 1, -1), - vec3( 1, 1, 0), vec3( 1, -1, 0), vec3(-1, -1, 0), vec3(-1, 1, 0), - vec3( 1, 0, 1), vec3(-1, 0, 1), vec3( 1, 0, -1), vec3(-1, 0, -1), - vec3( 0, 1, 1), vec3( 0, -1, 1), vec3( 0, -1, -1), vec3( 0, 1, -1) -); -``` - -然后我们把PCF算法与从sampleOffsetDirections得到的样本数量进行适配,使用它们从cubemap里采样。这么做的好处是与之前的PCF算法相比,我们需要的样本数量变少了。 - -```c++ -float shadow = 0.0; -float bias = 0.15; -int samples = 20; -float viewDistance = length(viewPos - fragPos); -float diskRadius = 0.05; -for(int i = 0; i < samples; ++i) -{ - float closestDepth = texture(depthMap, fragToLight + sampleOffsetDirections[i] * diskRadius).r; - closestDepth *= far_plane; // Undo mapping [0;1] - if(currentDepth - bias > closestDepth) - shadow += 1.0; -} -shadow /= float(samples); -``` - -这里我们把一个偏移量添加到指定的diskRadius中,它在fragToLight方向向量周围从cubemap里采样。 - -另一个在这里可以应用的有意思的技巧是,我们可以基于观察者里一个fragment的距离来改变diskRadius;这样我们就能根据观察者的距离来增加偏移半径了,当距离更远的时候阴影更柔和,更近了就更锐利。 - -```c++ -float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0; -``` - -PCF算法的结果如果没有变得更好,也是非常不错的,这是柔和的阴影效果: - -![](http://learnopengl.com/img/advanced-lighting/point_shadows_soft_better.png) - -当然了,我们添加到每个样本的bias(偏移)高度依赖于上下文,总是要根据场景进行微调的。试试这些值,看看怎样影响了场景。 -这里是最终版本的顶点和像素着色器。 - -我还要提醒一下使用几何着色器来生成深度贴图不会一定比每个面渲染场景6次更快。使用几何着色器有它自己的性能局限,在第一个阶段使用它可能获得更好的性能表现。这取决于环境的类型,以及特定的显卡驱动等等,所以如果你很关心性能,就要确保对两种方法有大致了解,然后选择对你场景来说更高效的那个。我个人还是喜欢使用几何着色器来进行阴影映射,原因很简单,因为它们使用起来更简单。 - - - -### 附加资源 - -[Shadow Mapping for point light sources in OpenGL](http://www.sunandblackcat.com/tipFullView.php?l=eng&topicid=36):sunandblackcat的万向阴影映射教程。 - -[Multipass Shadow Mapping With Point Lights](http://ogldev.atspace.co.uk/www/tutorial43/tutorial43.html):ogldev的万向阴影映射教程。 - -[Omni-directional Shadows](http://www.cg.tuwien.ac.at/~husky/RTR/OmnidirShadows-whyCaps.pdf):Peter Houska的关于万向阴影映射的一组很好的ppt。 \ No newline at end of file diff --git a/05 Advanced Lighting/05 Parallax Mapping.md b/05 Advanced Lighting/05 Parallax Mapping.md new file mode 100644 index 0000000..a515005 --- /dev/null +++ b/05 Advanced Lighting/05 Parallax Mapping.md @@ -0,0 +1,302 @@ +本文作者JoeyDeVries,由Django翻译自[http://learnopengl.com](http://learnopengl.com) + +## 视差贴图(Parallax Mapping) + +视差贴图技术和法线贴图差不多,但它有着不同的原则。和法线贴图一样视差贴图能够极大提升表面细节,使之具有深度感。它也是利用了视错觉,然而对深度有着更好的表达,与法线贴图一起用能够产生难以置信的效果。视差贴图和光照无关,我在这里是作为法线贴图的技术延续来讨论它的。需要注意的是在开始学习视差贴图之前强烈建议先对法线贴图,特别是切线空间有较好的理解。 + +视差贴图属于位移贴图(译注:displacement mapping也叫置换贴图)技术的一种,它对根据储存在纹理中的几何信息对顶点进行位移或偏移。一种实现的方式是比如有1000个顶点,更具纹理中的数据对平面特定区域的顶点的高度进行位移。这样的每个纹理像素包含了高度值纹理叫做高度贴图。一张简单的砖块表面的告诉贴图如下所示: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_height_map.png) + +整个平面上的每个顶点都根据从高度贴图采样出来的高度值进行位移,根据材质的几何属性平坦的平面变换成凹凸不平的表面。例如一个平坦的平面利用上面的高度贴图进行置换能得到以下结果: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_plane_heightmap.png) + +置换顶点有一个问题就是平面必须由很多顶点组成才能获得具有真实感的效果,否则看起来效果并不会很好。一个平坦的表面上有1000个顶点计算量太大了。我们能否不用这么多的顶点就能取得相似的效果呢?事实上,上面的表面就是用6个顶点渲染出来的(两个三角形)。上面的那个表面使用视差贴图技术渲染,位移贴图技术不需要额外的顶点数据来表达深度,它像法线贴图一样采用一种聪明的手段欺骗用户的眼睛。 + +视差贴图背后的思想是修改纹理坐标使一个fragment的表面看起来比实际的更高或者更低,所有这些都根据观察方向和高度贴图。为了理解它如何工作,看看下面砖块表面的图片: + +[](http://learnopengl.com/img/advanced-lighting/parallax_mapping_plane_height.png) + +这里粗糙的红线代表高度贴图中的数值的立体表达,向量V代表观察方向。如果平面进行实际位移,观察者会在点B看到表面。然而我们的平面没有实际上进行位移,观察方向将在点A与平面接触。视差贴图的目的是,在A位置上的fragment不再使用点A的纹理坐标而是使用点B的。随后我们用点B的纹理坐标采样,观察者就像看到了点B一样。 + +这个技巧就是描述如何从点A得到点B的纹理坐标。视差贴图尝试通过对从fragment到观察者的方向向量V进行缩放的方式解决这个问题,缩放的大小是A处fragment的高度。所以我们将V的长度缩放为高度贴图在点A处H(A)采样得来的值。下图展示了经缩放得到的向量P: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_scaled_height.png) + +我们随后选出P以及这个向量与平面对齐的坐标作为纹理坐标的偏移量。这能工作是因为向量P是使用从高度贴图得到的高度值计算出来的,所以一个fragment的高度越高位移的量越大。 + +这个技巧在大多数时候都没问题,但点B是粗略估算得到的。当表面的高度变化很快的时候,看起来就不会真实,因为向量P最终不会和B接近,就像下图这样: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_incorrect_p.png) + +视差贴图的另一个问题是,当表面被任意旋转以后很难指出从P获取哪一个坐标。我们在视差贴图中使用了另一个坐标空间,这个空间P向量的x和y元素总是与纹理表面对齐。如果你看了法线贴图教程,你也许猜到了,我们实现它的方法,是的,我们还是在切线空间中实现视差贴图。 + +将fragment到观察者的向量V转换到切线空间中,经变换的P向量的x和y元素将于表面的切线和副切线向量对齐。由于切线和副切线向量与表面纹理坐标的方向相同,我们可以用P的x和y元素作为纹理坐标的偏移量,这样就不用考虑表面的方向了。 + +理论都有了,下面我们来动手实现视差贴图。 + + + +### 视差贴图 + +我们将使用一个简单的2D平面,在把它发送给GPU之前我们先计算它的切线和副切线向量;和法线贴图教程做的差不多。我们将向平面贴diffuse纹理、法线贴图以及一个位移贴图,你可以点击链接下载。这个例子中我们将视差贴图和法线贴图连用。因为视差贴图生成表面位移了的幻觉,当光照不匹配时这种幻觉就被破坏了。法线贴图通常根据高度贴图生成,法线贴图和高度贴图一起用能保证光照能和位移想匹配。 + +你可能已经注意到,上面链接上的那个位移贴图和教程一开始的那个高度贴图相比是颜色是相反的。这是因为使用反色高度贴图(也叫深度贴图)去模拟深度比模拟高度更容易。下图反映了这个轻微的改变: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_depth.png) + +我们再次获得A和B,但是这次我们用向量V减去点A的纹理坐标得到P。我们通过在着色器中用1.0减去采样得到的高度贴图中的值来取得深度值,而不再是高度值,或者简单地在图片编辑软件中把这个纹理进行反色操作,就像我们对连接中的那个深度贴图所做的一样。 + +位移贴图是在像素着色器中实现的,因为三角形表面的所有位移效果都不同。在像素着色器中我们将需要计算fragment到观察者到方向向量V所以我们需要观察者位置和在切线空间中的fragment位置。法线贴图教程中我们已经有了一个顶点着色器,它把这些向量发送到切线空间,所以我们可以复制那个顶点着色器: + +```c++ +#version 330 core +layout (location = 0) in vec3 position; +layout (location = 1) in vec3 normal; +layout (location = 2) in vec2 texCoords; +layout (location = 3) in vec3 tangent; +layout (location = 4) in vec3 bitangent; + +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(position, 1.0f); + vs_out.FragPos = vec3(model * vec4(position, 1.0)); + vs_out.TexCoords = texCoords; + + vec3 T = normalize(mat3(model) * tangent); + vec3 B = normalize(mat3(model) * bitangent); + vec3 N = normalize(mat3(model) * normal); + mat3 TBN = transpose(mat3(T, B, N)); + + vs_out.TangentLightPos = TBN * lightPos; + vs_out.TangentViewPos = TBN * viewPos; + vs_out.TangentFragPos = TBN * vs_out.FragPos; +} +``` + +在这里有件事很重要,我们需要把position和在切线空间中的观察者的位置viewPos发送给像素着色器。 + +在像素着色器中,我们实现视差贴图的逻辑。像素着色器看起来会是这样的: + +```c++ +#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 depthMap; + +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的函数,它把fragment的纹理坐标作和切线空间中的fragment到观察者的方向向量为输入。这个函数返回经位移的纹理坐标。然后我们使用这些经位移的纹理坐标进行diffuse和法线贴图的采样。最后fragment的diffuse颜色和法线向量就正确的对应于表面的经位移的位置上了。 + +我们来看看ParallaxMapping函数的内部: + +```c++ +vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir) +{ + float height = texture(depthMap, texCoords).r; + vec3 p = viewDir.xy / viewDir.z * (height * height_scale); + return texCoords - p; +} +``` + +这个相对简单的函数是我们所讨论过的内容的直接表述。我们用本来的纹理坐标texCoords从高度贴图中来采样出当前fragment高度H(A)。然后计算出P,x和y元素在切线空间中,viewDir向量除以它的z元素,用fragment的高度对它进行缩放。我们同时引入额一个height_scale的uniform,来进行一些额外的控制,因为视差效果如果没有一个缩放参数通常会过于强烈。然后我们用P减去纹理坐标来获得最终的经过位移纹理坐标。 + +有一个地方需要注意,就是viewDir.xy除以viewDir.z那里。因为viewDir向量是经过了标准化的,viewDir.z会在0.0到1.0之间的某处。当viewDir大致平行于表面时,它的z元素接近于0.0,除法会返回比viewDir垂直于表面的时候更大的P向量。所以基本上我们增加了P的大小,当以一个角度朝向一个表面相比朝向顶部时它对纹理坐标会进行更大程度的缩放;这回在角上获得更大的真实度。 + +有些人更喜欢在等式中不使用viewDir.z,因为普通的视差贴图会在角上产生不想要的结果;这个技术叫做有偏移量限制的视差贴图(Parallax Mapping with Offset Limiting)。选择哪一个技术是个人偏好问题,但我倾向于普通的视差贴图。 + +最后的纹理坐标随后被用来进行采样(diffuse和法线)贴图,下图所展示的位移效果中height_scale等于1: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping.png) + +这里你会看到只用法线贴图和与视差贴图相结合的法线贴图的不同之处。因为视差贴图尝试模拟深度,它实际上能够根据你观察它们的方向使砖块叠加到其他砖块上。 + +在视差贴图的那个平面里你仍然能看到在边上有古怪的失真。原因是在平面的边缘上,纹理坐标超出了0到1的范围进行采样,根据纹理的环绕方式导致了不真实的结果。解决的方法是当它超出默认纹理坐标范围进行采样的时候就丢弃这个fragment: + +```c++ +texCoords = ParallaxMapping(fs_in.TexCoords, viewDir); +if(texCoords.x > 1.0 || texCoords.y > 1.0 || texCoords.x < 0.0 || texCoords.y < 0.0) + discard; +``` + +丢弃了超出默认范围的纹理坐标的所有fragment,视差贴图的表面边缘给出了正确的结果。注意,这个技巧不能在所有类型的表面上都能工作,但是应用于平面上它还是能够是平面看起来真的进行位移了: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_edge_fix.png) + +你可以在这里找到[源代码](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/parallax_mapping),以及[顶点](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/parallax_mapping&type=vertex)和[像素](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/parallax_mapping&type=fragment)着色器。 + +看起来不错,运行起来也很快,因为我们只要给视差贴图提供一个额外的纹理样本就能工作。当从一个角度看过去的时候,会有一些问题产生(和法线贴图相似),陡峭的地方会产生不正确的结果,从下图你可以看到: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_issues.png) + +问题的原因是这只是一个大致近似的视差映射。还有一些技巧让我们在陡峭的高度上能够获得几乎完美的结果,即使当以一定角度观看的时候。例如,我们不再使用单一样本,取而代之使用多样本来找到最近点B会得到怎样的结果? + + + +### 陡峭视差映射(Steep Parallax Mapping) + +陡峭视差映射是视差映射的扩展,原则是一样的,但不是使用一个样本而是多个样本来确定向量P到B。它能得到更好的结果,它将总深度范围分布到同一个深度/高度的多个层中。从每个层中我们沿着P方向移动采样纹理坐标,直到我们找到了一个采样得到的低于当前层的深度值的深度值。看看下面的图片: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_steep_parallax_mapping_diagram.png) + +我们从上到下遍历深度层,我们把每个深度层和储存在深度贴图中的它的深度值进行对比。如果这个层的深度值小于深度贴图的值,就意味着这一层的P向量部分在表面之下。我们继续这个处理过程直到有一层的深度高于储存在深度贴图中的值:这个点就在(经过位移的)表面下方。 + +这个例子中我们可以看到第二层(D(2) = 0.73)的深度贴图的值仍低于第二层的深度值0.4,所以我们继续。下一次迭代,这一层的深度值0.6大于深度贴图中采样的深度值(D(3) = 0.37)。我们便可以假设第三层向量P是可用的位移几何位置。我们可以用从向量P3的纹理坐标偏移T3来对fragment的纹理坐标进行位移。你可以看到随着深度曾的增加精确度也在提高。 + +为实现这个技术,我们只需要改变ParallaxMapping函数,因为所有需要的变量都有了: + +```c++ +vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir) +{ + // number of depth layers + const float numLayers = 10; + // calculate the size of each layer + float layerDepth = 1.0 / numLayers; + // depth of current layer + float currentLayerDepth = 0.0; + // the amount to shift the texture coordinates per layer (from vector P) + vec2 P = viewDir.xy * height_scale; + float deltaTexCoords = P / numLayers; + + [...] +} +``` + +我们先定义层的数量,计算每一层的深度,最后计算纹理坐标偏移,每一层我们必须沿着P的方向进行移动。 + +然后我们遍历所有层,从上开始,知道找到小于这一层的深度值的深度贴图值: + +```c++ +// get initial values +vec2 currentTexCoords = texCoords; +float currentDepthMapValue = texture(depthMap, currentTexCoords).r; + +while(currentLayerDepth < currentDepthMapValue) +{ + // shift texture coordinates along direction of P + currentTexCoords -= deltaTexCoords; + // get depthmap value at current texture coordinates + currentDepthMapValue = texture(depthMap, currentTexCoords).r; + // get depth of next layer + currentLayerDepth += layerDepth; +} + +return texCoords - currentTexCoords; + +``` + +这里我们循环每一层深度,直到沿着P向量找到第一个返回低于(位移)表面的深度的纹理坐标偏移量。从fragment的纹理坐标减去最后的偏移量,来得到最终的经过位移的纹理坐标向量,这次就比传统的视差映射更精确了。 + +有10个样本砖墙从一个角度看上去就已经很好了,但是当有一个强前面展示的木制表面一样陡峭的表面时,陡峭的视差映射的威力就显示出来了: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_steep_parallax_mapping.png) + +我们可以通过对视差贴图的一个属性的利用,对算法进行一点提升。当垂直看一个表面的时候纹理时位移比以一定角度看时的小。我们可以在垂直看时使用更少的样本,以一定角度看时增加样本数量: + +```c++ +const float minLayers = 8; +const float maxLayers = 32; +float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir))); +``` + +这里我们得到viewDir和正z方向的点乘,使用它的结果根据我们看向表面的角度调整样本数量(注意正z方向等于切线空间中的表面的法线)。如果我们所看的方向平行于表面,我们就是用32层。 + +你可以在这里找到最新的像素着色器代码。这里也提供木制玩具箱的表面贴图:diffuse、法线、深度。 + +陡峭视差贴图同样有自己的问题。因为这个技术是基于有限的样本数量的,我们会遇到锯齿效果以及图层之间有明显的断层: + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_steep_artifact.png) + +我们可以通过增加样本的方式减少这个问题,但是很快就会花费很多性能。有些旨在修复这个问题的方法:不适用低于表面的第一个位置,而是在两个接近的深度层进行插值找出更匹配B的。 + +两种最流行的解决方法叫做Relief Parallax Mapping和Parallax Occlusion Mapping,Relief Parallax Mapping更精确一些,但是比Parallax Occlusion Mapping性能开销更多。因为Parallax Occlusion Mapping的效果和前者差不多但是效率更高,因此这种方式更经常使用,所以我们将在下面讨论一下。 + + + +### Parallax Occlusion Mapping + +Parallax Occlusion Mapping和陡峭视差映射的原则相同,但不是用触碰的第一个深度层的纹理坐标,而是在触碰之前和之后,在深度层之间进行线性插值。我们根据表面的高度距离啷个深度层的深度层值的距离来确定线性插值的大小。看看下面的图pain就能了解它是如何工作的: + +[](http://learnopengl.com/img/advanced-lighting/parallax_mapping_parallax_occlusion_mapping_diagram.png) + +你可以看到大部分和陡峭视差映射一样,不一样的地方是有个额外的步骤,两个深度层的纹理坐标围绕着交叉点的线性插值。这也是近似的,但是比陡峭视差映射更精确。 + +Parallax Occlusion Mapping的代码基于陡峭视差映射,所以并不难: + +```c++ +[...] // steep parallax mapping code here + +// get texture coordinates before collision (reverse operations) +vec2 prevTexCoords = currentTexCoords + deltaTexCoords; + +// get depth after and before collision for linear interpolation +float afterDepth = currentDepthMapValue - currentLayerDepth; +float beforeDepth = texture(depthMap, prevTexCoords).r - currentLayerDepth + layerDepth; + +// interpolation of texture coordinates +float weight = afterDepth / (afterDepth - beforeDepth); +vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight); + +return finalTexCoords; +``` + +在对(位移的)表面几何进行交叉,找到深度层之后,我们获取交叉前的纹理坐标。然后我们计算来自相应深度层的几何之间的深度之间的距离,并在两个值之间进行插值。线性插值的方式是在两个层的纹理坐标之间进行的基础插值。函数最后返回最终的经过插值的纹理坐标。 + +Parallax Occlusion Mapping的效果非常好,尽管有一些可以看到的轻微的不真实和锯齿的问题,这仍是一个好交易,因为除非是放得非常大或者观察角度特别陡,否则也看不到。 + +![](http://learnopengl.com/img/advanced-lighting/parallax_mapping_parallax_occlusion_mapping.png) + +你可以在这里找到[源代码](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/parallax_mapping),及其[顶点](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/parallax_mapping&type=vertex)和[像素](http://www.learnopengl.com/code_viewer.php?code=advanced-lighting/parallax_mapping_occlusion&type=fragment)着色器。 + +视差贴图是提升场景细节非常好的技术,但是使用的时候还是要考虑到它会带来一点不自然。大多数时候视差贴图用在地面和墙壁表面,这种情况下查明表面的轮廓并不容易,同时观察角度往往趋向于垂直于表面。这样视差贴图的不自然也就很难能被注意到了,对于提升物体的细节可以祈祷难以置信的效果。 + + + +### 附加资源 + +[Parallax Occlusion Mapping in GLSL](http://sunandblackcat.com/tipFullView.php?topicid=28):sunandblackcat.com上的视差贴图教程。 + +[How Parallax Displacement Mapping Works](https://www.youtube.com/watch?v=xvOT62L-fQI):TheBennyBox的关于视差贴图原理的视频教程。 \ No newline at end of file diff --git a/05 Advanced Lighting/07 Bloom.md b/05 Advanced Lighting/07 Bloom.md new file mode 100644 index 0000000..75da7d6 --- /dev/null +++ b/05 Advanced Lighting/07 Bloom.md @@ -0,0 +1,319 @@ +本文作者JoeyDeVries,由Django翻译自[http://learnopengl.com](http://learnopengl.com) + +## Bloom + +明亮的光源和区域经常很难向观察者表达出来,因为监视器的亮度范围是有限的。一种区分明亮光源的方式是使它们在监视器上发出光芒,光源的的光芒向四周发散。这样观察者就会产生光源或亮区的确是强光区。(译注:这个问题的提出简单来说是为了解决这样的问题:例如有一张在阳光下的白纸,白纸在监视器上显示出是出白色,而前方的太阳也是纯白色的,所以基本上白纸和太阳就是一样的了,给太阳加一个光晕,这样太阳看起来似乎就比白纸更亮了) + +光晕效果可以使用一个后处理特效bloom来实现。bloom使所有明亮区域产生光晕效果。下面是一个使用了和没有使用光晕的对比(图片生成自虚幻引擎): + +![](http://learnopengl.com/img/advanced-lighting/bloom_example.png) + +Bloom是我们能够注意到一个明亮的物体真的有种明亮的感觉。bloom可以极大提升场景中的光照效果,并提供了极大的效果提升,尽管做到这一切只需一点改变。 + +Bloom和HDR结合使用效果很好。常见的一个误解是HDR和bloom是一样的,很多人认为两种技术是可以互换的。但是它们是两种不同的技术,用于各自不同的目的上。可以使用默认的8位精确度的帧缓冲,也可以在不使用bloom效果的时候,使用HDR。只不过在有了HDR之后再实现bloom就更简单了。 + +为实现bloom,我们像平时那样渲染一个有光场景,提取出场景的HDR颜色缓冲以及只有这个场景明亮区域可见的图片。被提取的带有亮度的图片接着被模糊,结果被添加到HDR场景上面。 + +我们来一步一步解释这个处理过程。我们在场景中渲染一个带有4个立方体形式不同颜色的明亮的光源。带有颜色的发光立方体的亮度在1.5到15.0之间。如果我们将其渲染至HDR颜色缓冲,场景看起来会是这样的: + +![](http://learnopengl.com/img/advanced-lighting/bloom_scene.png) + +我们得到这个HDR颜色缓冲纹理,提取所有超出一定亮度的fragment。这样我们就会获得一个只有fragment超过了一定阈限的颜色区域: + +![](http://learnopengl.com/img/advanced-lighting/bloom_extracted.png) + +我们将这个超过一定亮度阈限的纹理进行模糊。bloom效果的强度很大程度上被模糊过滤器的范围和强度所决定。 + +![](http://learnopengl.com/img/advanced-lighting/bloom_blurred.png) + +最终的被模糊化的纹理就是我们用来获得发出光晕效果的东西。这个已模糊的纹理要添加到原来的HDR场景纹理的上部。因为模糊过滤器的应用明亮区域发出光晕,所以明亮区域在长和宽上都有所扩展。 + +![](http://learnopengl.com/img/advanced-lighting/bloom_small.png) + +bloom本身并不是个复杂的技术,但很难获得正确的效果。它的品质很大程度上取决于所用的模糊过滤器的质量和类型。简单的改改模糊过滤器就会极大的改变bloom效果的品质。 + +下面这几步就是bloom后处理特效的过程,它总结了实现bloom所需的步骤。 + +![](http://learnopengl.com/img/advanced-lighting/bloom_steps.png) + +首先我们需要根据一定的阈限提取所有明亮的颜色。我们先来做这件事。 + + + +### 提取亮色 + +第一步我们要从渲染出来的场景中提取两张图片。我们可以渲染场景两次,每次使用一个不同的不同的着色器渲染到不同的帧缓冲中,但我们可以使用一个叫做MRT(Multiple Render Targets多渲染目标)的小技巧,这样我们就能定义多个像素着色器了;有了它我们还能够在一个单独渲染处理中提取头两个图片。在像素着色器的输出前,我们指定一个布局location标识符,这样我们便可控制一个像素着色器写入到哪个颜色缓冲: + +```c++ +layout (location = 0) out vec4 FragColor; +layout (location = 1) out vec4 BrightColor; +``` + +只有我们真的具有多个地方可写的时候这才能工作。使用多个像素着色器输出的必要条件是,有多个颜色缓冲附加到了当前绑定的帧缓冲对象上。你可能从帧缓冲教程那里回忆起,当把一个纹理链接到帧缓冲的颜色缓冲上时,我们可以指定一个颜色附件。直到现在,我们一直使用着GL_COLOR_ATTACHMENT0,但通过使用GL_COLOR_ATTACHMENT1,我们可以得到一个附加了两个颜色缓冲的帧缓冲对象: + +```c++ +// Set up floating point framebuffer to render scene to +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); + // attach texture to framebuffer + glFramebufferTexture2D( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, colorBuffers[i], 0 + ); +} +``` + +我们需要显式告知OpenGL我们正在通过glDrawBuffers渲染到多个颜色缓冲,否则OpenGL只会渲染到帧缓冲的第一个颜色附件,而忽略所有其他的。我们可以通过传递多个颜色附件的枚举来做这件事,我们以下面的操作进行渲染: + +```c++ +GLuint attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 }; +glDrawBuffers(2, attachments); +``` + +当渲染到这个帧缓冲中的时候,一个着色器使用一个布局location修饰符,那么fragment就会用相应的颜色缓冲就会被用来渲染。这很棒,因为这样省去了我们为提取明亮区域的额外渲染步骤,因为我们现在可以直接从将被渲染的fragment提取出它们: + +```c++ +#version 330 core +layout (location = 0) out vec4 FragColor; +layout (location = 1) out vec4 BrightColor; + +[...] + +void main() +{ + [...] // first do normal lighting calculations and output results + FragColor = vec4(lighting, 1.0f); + // Check whether fragment output is higher than threshold, if so output as brightness color + float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722)); + if(brightness > 1.0) + BrightColor = vec4(FragColor.rgb, 1.0); +} +``` + +这里我们先正常计算光照,将其传递给第一个像素着色器的输出变量FragColor。然后我们使用当前储存在FragColor的东西来决定它的亮度是否超过了一定阈限。我们通过恰当地将其转为灰度的方式计算一个fragment的亮度,如果它超过了一定阈限,我们就把颜色输出到第二个颜色缓冲,那里保存着所有亮部;渲染发光的立方体也是一样的。 + +这也说明了为什么bloom在HDR基础上能够运行得很好。因为HDR中,我们可以将颜色值指定超过1.0这个默认的范围,我们能够得到对一个图像中的亮度的更好的控制权。没有HDR我们必须将阈限设置为小于1.0的数,虽然可行,但是亮部很容易变得很多,这就导致光晕效果过重。 + +有了两个颜色缓冲,我们就有了一个正常场景的图像和一个提取出的亮区的图像;这些都在一个渲染步骤中完成。 + +![](http://learnopengl.com/img/advanced-lighting/bloom_attachments.png) + +有了一个提取出的亮区图像,我们现在就要把这个图像进行模糊处理。我们可以使用帧缓冲教程后处理部分的那个简单的盒子过滤器,但不过我们最好还是使用一个更高级的更漂亮的模糊过滤器:高斯模糊。 + +### 高斯模糊 + +在后处理教程那里,我们采用的模糊是一个图像中所有周围像素的均值,它的确为我们提供了一个简易实现的模糊,但是效果并不好。高斯模糊基于高斯曲线,高斯曲线通常被描述为一个钟形曲线,中间的值达到最大化,随着距离的增加,两边的值不断减少。高斯曲线在数学上有不同的形式,但是通常是这样的形状: + +![](http://learnopengl.com/img/advanced-lighting/bloom_gaussian.png) + +高斯曲线在它的中间处的面积最大,使用它的值作为权重使得近处的样本拥有最大的优先权。比如,如果我们从fragment的32×32的四方形区域采样,这个权重随着和fragment的距离变大逐渐减小;通常这会得到更好更真实的模糊效果,这种模糊叫做高斯模糊。 + +要实现高斯模糊过滤我们需要一个二维四方形作为权重,从这个二维高斯曲线方程中去获取它。然而这个过程有个问题,就是很快会消耗极大的性能。以一个32×32的模糊kernel为例,我们必须对每个fragment从一个纹理中采样1024次! + +幸运的是,高斯方程有个非常巧妙的特性,它允许我们把二维方程分解为两个更小的方程:一个描述水平权重,另一个描述垂直权重。我们首先用水平权重在整个纹理上进行水平模糊,然后在经改变的纹理上进行垂直模糊。利用这个特性,结果是一样的,但是可以节省难以置信的性能,因为我们现在只需做32+32次采样,不再是1024了!这叫做两步高斯模糊。 + +![](http://learnopengl.com/img/advanced-lighting/bloom_gaussian_two_pass.png) + +这意味着我们如果对一个图像进行模糊处理,至少需要两步,最好使用帧缓冲对象做这件事。具体来说,我们将实现像乒乓球一样的帧缓冲来实现高斯模糊。它的意思是,有一对儿帧缓冲,我们把另一个帧缓冲的颜色缓冲放进当前的帧缓冲的颜色缓冲中,使用不同的着色效果渲染指定的次数。基本上就是不断地切换帧缓冲和纹理去绘制。这样我们先在场景纹理的第一个缓冲中进行模糊,然后在把第一个帧缓冲的颜色缓冲放进第二个帧缓冲进行模糊,接着,将第二个帧缓冲的颜色缓冲放进第一个,循环往复。 + +在我们研究帧缓冲之前,先讨论高斯模糊的像素着色器: + + +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 +#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); // gets size of single texel + vec3 result = texture(image, TexCoords).rgb * weight[0]; // current fragment's contribution + 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); +} + + +这里我们使用一个比较小的高斯权重做例子,每次我们用它来指定当前fragment的水平或垂直样本的特定权重。你会发现我们基本上是将模糊过滤器根据我们在uniform变量horizontal设置的值分割为一个水平和一个垂直部分。通过用1.0除以纹理的大小(从textureSize得到一个vec2)得到一个纹理像素的实际大小,以此作为偏移距离的根据。 + +我们为图像的模糊处理创建两个基本的帧缓冲,每个只有一个颜色缓冲纹理: + + +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +GLuint pingpongFBO[2]; +GLuint pingpongBuffer[2]; +glGenFramebuffers(2, pingpongFBO); +glGenTextures(2, pingpongColorbuffers); +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 + ); +} + + +得到一个HDR纹理后,我们用提取出来的亮区纹理填充一个帧缓冲,然后对其模糊处理10次(5次垂直5次水平): + +```c++ +GLboolean horizontal = true, first_iteration = true; +GLuint amount = 10; +shaderBlur.Use(); +for (GLuint i = 0; i < amount; i++) +{ + glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[horizontal]); + glUniform1i(glGetUniformLocation(shaderBlur.Program, "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); +``` + +每次循环我们根据我们打算渲染的是水平还是垂直来绑定两个缓冲其中之一,而将另一个绑定为纹理进行模糊。第一次迭代,因为两个颜色缓冲都是空的所以我们随意绑定一个去进行模糊处理。重复这个步骤10次,亮区图像就进行一个重复5次的高斯模糊了。这样我们可以对任意图像进行任意次模糊处理;高斯模糊循环次数越多,模糊的强度越大。 + +通过对提取亮区纹理进行5次模糊,我们就得到了一个正确的模糊的场景亮区图像。 + +![](http://learnopengl.com/img/advanced-lighting/bloom_blurred_large.png) + +bloom的最后一步是把模糊处理的图像和场景原来的HDR纹理进行结合。 + + + +### 把两个纹理混合 + +有了场景的HDR纹理和模糊处理的亮区纹理,我们只需把它们结合起来就能实现bloom或称光晕效果了。最终的像素着色器(大部分和HDR教程用的差不多)要把两个纹理混合: + +```c++ +#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; // additive blending + // tone mapping + vec3 result = vec3(1.0) - exp(-hdrColor * exposure); + // also gamma correct while we're at it + result = pow(result, vec3(1.0 / gamma)); + FragColor = vec4(result, 1.0f); +} +``` + +要注意的是我们要在应用色调映射之前添加bloom效果。这样添加的亮区的bloom,也会柔和转换为LDR,光照效果相对会更好。 + +把两个纹理结合以后,场景亮区便有了合适的光晕特效: + +![](http://learnopengl.com/img/advanced-lighting/bloom.png) + +有颜色的立方体看起来仿佛更亮,它向外发射光芒,的确是一个更好的视觉效果。这个场景比较简单,所以bloom效果不算十分令人瞩目,但在更好的场景中合理配置之后效果会有巨大的不同。你可以在这里找到这个简单的例子的源码,以及模糊的顶点和像素着色器、立方体的像素着色器、后处理的顶点和像素着色器。 + +这个教程我们只是用了一个相对简单的高斯模糊过滤器,它在每个方向上只有5个样本。通过沿着更大的半径或重复更多次数的模糊,进行采样我们就可以提升模糊的效果。因为模糊的质量与bloom效果的质量正相关,提升模糊效果就能够提升bloom效果。有些提升将模糊过滤器与不同大小的模糊kernel或采用多个高斯曲线来选择性地结合权重结合起来使用。来自Kalogirou和EpicGames的附加资源讨论了如何通过提升高斯模糊来显著提升bloom效果。 + + + +### 附加资源 + +* [Efficient Gaussian Blur with linear sampling](http://rastergrid.com/blog/2010/09/efficient-gaussian-blur-with-linear-sampling/):非常详细地描述了高斯模糊,以及如何使用OpenGL的双线性纹理采样提升性能。 +* [Bloom Post Process Effect](https://udn.epicgames.com/Three/Bloom.html):来自Epic Games关于通过对权重的多个高斯曲线结合来提升bloom效果的文章。 +* [How to do good bloom for HDR rendering](http://kalogirou.net/2006/05/20/how-to-do-good-bloom-for-hdr-rendering/):Kalogirou的文章描述了如何使用更好的高斯模糊算法来提升bloom效果。 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index b27350b..4b6126f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,7 +36,16 @@ pages: - 实例化: '04 Advanced OpenGL/10 Instancing.md' - 反走样: '04 Advanced OpenGL/11 Anti Aliasing.md' - 高级光照(Advanced Lighting): + - 高级光照: '05 Advanced Lighting/01 Advanced Lighting.md' + - Gamma校正: '05 Advanced Lighting/02 Gamma Correction.md' + - 阴影: + - 阴影映射: '05 Advanced Lighting/03 Shadows/01 Shadow Mapping.md' + - 点阴影: '05 Advanced Lighting/03 Shadows/02 Point Shadows.md' + - CMS: '05 Advanced Lighting/03 Shadows/03 CMS.md' + - 法线贴图: '05 Advanced Lighting/04 Normal Mapping.md' + - 视差贴图: '05 Advanced Lighting/05 Parallax Mapping.md' - HDR: '05 Advanced Lighting/06 HDR.md' + - Bloom: '05 Advanced Lighting/07 Bloom.md' site_name: LearnOpenGL-CN