法线贴图(Normal Mapping)
在网格上使用纹理给我们带来真实感,但是网格实际上由扁平的三角形组成,如果我们拉近视角观看,我们能够看到表面都是平整的。现实中物体的表面并不都是平整的,这样的表面忽略了很多的细节。看下面直接使用一张砖墙纹理渲染的平面:
- 从光源的角度,物体的形状是由它的垂直法向量决定的。对于上图中砖墙的表面,只有一个法向量,因此砖墙渲染出来十分平整。如果我们不是使用单个垂直于平面的法向量而是使用每个片元的法向量,那么我们就能够赋予表面更多细节,使其看起来更真实。(图片取自书中)
- 相比每个平面使用一个法向量,这种使用每个片元一个法向量的技术称为法线贴图或凹凸贴图(normal mapping or bump mapping)。
1. 法向量映射
- 要使用法向量映射,我们需要将每个片元的法向量数据存储到一个2D纹理,然后我们再对纹理进行采样。要将法向量数据存储到纹理,我们可以将法向量的x, y和z分量分别对应颜色的r, g和b分量进行存储。由于法向量的范围为[-1, 1],所以我们需要先映射到[0, 1]。
vec3 rgb_normal = normal * 0.5 + 0.5;
- 下面是一个砖墙的法向量图。我们可以看到图像感觉偏蓝,这是因为所有的法向量都大致指向z轴的正方向,即(0, 0, 1),这刚好与蓝色对应。图中部分与蓝色差异的颜色则代表了法向量与z轴正向存在偏移,同时也为纹理产生一种不同深度的感觉。(图像来自网站素材)
- 注意:OpenGL读取纹理的y坐标刚好与纹理生成时相反,因此需要在加载纹理进行反转处理。
- 在加载和绑定纹理后,我们在片元着色器做如下修改:
uniform sampler2D normalMap;
void main()
{
// 从法线贴图读取范围为[0, 1]的法向量
normal = texture(normalMap, fs_in.TexCoords).rgb;
// 将范围映射到[-1, 1]
normal = normalize(normal * 2.0 - 1.0);
...
}
-
渲染效果。
- 但是有一个问题限制了法线贴图的使用,上面的渲染中我们使用的法线贴图中所有的法向量都指向z轴的正方向,如果我们将墙面渲染在水平面,即墙面的法向量指向y轴的正方向。这时候光照效果就会存在问题,因为表面采样的法向量依然指向z轴的正方向。
- 针对上述问题的一个解决方法就是我们在另外一个坐标空间进行光照计算:一个法向量总是指向z轴正方向的坐标空间。我们将所有光照矢量转换到该空间,那么不管物体面向那个方向我们都可以使用相同的法线贴图,这个空间称为切线空间(tangent space)。
2. 切线空间(Tangent space)
- 法线贴图中的法向量是处于切线空间的,在该空间中所有的法向量都指向z轴正方向。我们可以将切线空间看作是法线贴图中向量的本地空间,使用特定的矩阵我们能够将切线空间的法向量转换到世界空间或视空间,使法向量与最终表面的方向一致。
- 切线空间的好处在于不管是什么类型的表面,我们都能够计算出一个矩阵使切线空间中的z轴正向与表面的法向量方向保持一致,这个矩阵我们称为TBN矩阵。TBN的三个字母分别代表Tangent, Bitangent和Normal矢量,这也是我们构建矩阵的三个矢量。
- 要构建一个基底变换矩阵我们需要与法线贴图平面对齐的三个相互垂直的矢量,与相机类似,一个向上、向右和向前的矢量。其中向上矢量为法向量,向右和向前则分别是切线矢量(tangent vector)和双切线矢量(bitangent vector)。见下图:(图片取自书中)
- 想要直接计算切线和双切线矢量并不容易,但是从图中我们可以看出这两个矢量与定义了纹理坐标的平面方向对齐。根据这个事实,我们可以运用一些数学知识进行计算。(图片取自书中)
- 切线矢量和双切线矢量的计算过程大致如下:
- 上图中三角形的边和可以表示为
- 将切线矢量和双切线矢量以坐标法表示:
- 将两个等式转换为矩阵乘法:
- 变换等式,将切线与双切线矢量转换到左侧:
- 根据逆矩阵的计算方法,我们可以将等式转换为如下形式(具体逆矩阵的计算请自行查找相关资料):
2.1 手动计算切线和双切线矢量
- 首先,假设我们的平面由如下矢量构成(以顶点顺序分别为1,2,3和1,3,4的两个三角形构成)。
// 顶点位置
glm::vec3 pos1(-1.0f, 1.0f, 0.0f);
glm::vec3 pos2(-1.0f, -1.0f, 0.0f);
glm::vec3 pos3( 1.0f, -1.0f, 0.0f);
glm::vec3 pos4( 1.0f, 1.0f, 0.0f);
// 纹理坐标
glm::vec2 uv1(0.0f, 1.0f);
glm::vec2 uv2(0.0f, 0.0f);
glm::vec2 uv3(1.0f, 0.0f);
glm::vec2 uv4(1.0f, 1.0f);
// 法向量
glm::vec3 nm(0.0f, 0.0f, 1.0f);
- 我们先计算三角形的边和。
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);
// 第二个三角形与上述计算方式相似
2.2 使用切线空间的法线贴图
- 要在着色器中创建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()
{
...
// 使用model转换到世界空间
vec3 T = normalize(vec3(model * 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矩阵,我们可以有两种方式来使用它。
- 我们将TBN矩阵传递到片元着色器,将从切线空间采样的法向量转换到世界空间,这样法向量就与其他光照变量处于相同的空间。
- 我们对TBN矩阵取反,获得一个可以将矢量从世界空间转换到切线空间的矩阵。然后,我们使用该矩阵将其他光照变量转换到切线空间,这样也能让法向量与其他光照变量处于相同的空间。
- 对于第一种方法,我们首先将TBN矩阵传递到片元着色器。
// 顶点着色器
...
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} vs_out;
void main()
{
...
vs_out.TBN = mat3(T, B, N);
}
// 片元着色器
...
in VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} fs_in;
...
- 其次,我们使用TBN矩阵转换法向量到世界空间。
vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normal * 2.0 - 1.0;
normal = normalize(fs_in.TBN * normal);
- 对于第二种方法,我们先在顶点着色器对TBN矩阵取反。
vs_out.TBN = transpose(mat3(T, B, N));
-
注意:这里我们使用
transpose
函数而不是inverse
函数,是因为正射投影矩阵(每个坐标轴是相互垂直的单位矢量)的转置矩阵(transpose matrix) 与逆矩阵(inverse matrix) 刚好相等。而inverse
操作相对更耗性能。 - 其次,我们在片元着色器中转换光照方向和相机方向矢量。
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
我们也能在顶点着色器中计算其切线空间的坐标,然后让片元着色器本身的插值算法完成剩下的工作。这样我们就无需在片元着色器中将变量转换到切线空间,但是第一种方法是必须在片元着色器中进行法向量采样的。所以使用第二种方法我们可以在顶点着色器完成计算然后将转换为切线空间的光照位置、相机位置和顶点位置传递到片元着色器,我们就可以避免在片元着色器中进行矩阵乘法运算,而且由于顶点数量远远少于片元数量,即顶点着色器运行的次数远远低于片元着色器运行的次数,这也能大大提高我们程序的性能。 - 优化后的第二种方法的顶点着色器大致如下:
...
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 = mat3(T, B, N);
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vs_out.FragPos;
}
- 片元着色器中我们只需在切线空间中完成光照计算即可,计算过程与世界空间中的计算一致。
- 现在,我们就可以将平面放置到空间的任意位置而且保证光照计算的正确性。
glm::mat4 model = glm::mat4(1.0f);
model = glm::rotate(model, glm::radians((float)glfwGetTime() * -10.0f), glm::normalize(glm::vec3(1.0, 0.0, 1.0)));
shader.setMat4("model", model);
RenderQuad();
-
渲染效果。
3. 模型
- 对于切线和双切线矢量的计算,我们一般在模型加载类中实现,这样就无需每次进行计算。前面我们使用的Assimp有一个配置位
aiProcess_CalcTangentSpace
我们可以在模型加载时设置。当我们在ReadFile
函数设置该位后,Assimp会自动为每个顶点计算切线和双切线矢量,就像我们前面实现的一样。
const aiScene* scene = importer.ReadFile(path,
aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace);
- 加载完模型后我们就可以在代码中检索切线矢量。
vector.x = mesh->Tangents[i].x;
vector.y = mesh->Tangents[i].y;
vector.z = mesh->Tangents[i].z;
vertex.Tangent = vector;
- 当模型加载引入切线矢量后,我们还需要调整纹理加载函数来加载法线贴图。.obj格式导出的法线贴图与Assimp的命名约定不同,采用的是
aiTextureType_HEIGHT
而不是aiTextureType_NORMAL
。
vector<Texture> normalMaps = loadMaterialTextures(material,
aiTextureType_HEIGHT, "texture_normal");
- 因为法线贴图能够增加物体的细节,因此使用法线贴图是提高程序性能的一种好方法。如果没有法线贴图,我们想要在一个网格上获取大量细节我们就需要大量的顶点。而使用法线贴图我们想要获得同等细节效果需要的顶点数量将少得多。见下图:(图片取自书中)
4. 最后一点
- 当在大量共享一定数量顶点的网格渲染中进行切线矢量计算时,为了获得良好且平滑的渲染效果,我们一般会对切线矢量取均值。这种方法会导致TBN矩阵的三个矢量不再相互垂直,这意味着TBN矩阵不再是正射投影矩阵。虽然使用非正射投影的TBN矩阵,法线贴图效果变化并不严重,但是也是一个我们可以优化的地方。
- 使用一个叫做施密特正交化过程(Gram-Schmidt Process) 的数学技巧,我们可以将TBN矩阵重新正交化(re-orthogonalize)。该技巧在顶点着色器中的实现如下:
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);
// 叉积N和T获得B
vec3 B = cross(N, T);
mat3 TBN = mat3(T, B, N);