本文同时发布在我的个人博客上:https://dragon_boy.gitee.io
模型的网格构成其结构,纹理赋予其光影,但如果观察我们之前的所有例子,都会发现模型的表面都是扁平的,就算赋予了纹理也看起来不真实,因为现实生活中的大部分物体都是表面粗糙,凹凸不平的。
比如,一个贴有砖块纹理的平面。砖墙本身应该表面凹凸不平,有缝隙,有划痕,有孔洞,运用我们之前学过的技术,我们会在之前的平面上贴上纹理来模拟砖墙:
仔细观察的话,所有的凹凸不平、缝隙和孔洞这些细节全都没有表现出来,平面看上去非常扁平。我们其实可以利用一些技术来表现细节,比如使用高光贴图来让某些地方照亮的更少,但这不算是一种真正的解决方式。
如果我们从灯光的角度去思考的话:平面是怎么样被渲染为完全扁平的平面的?我们可以想到是平面的法线,决定物体形状的就是它的法线。平面使用的是单独的法线,所以表面没有起伏变化,那么如果我们为每个片段计算不同的法线,并操作这些法线产生一些变化的话,表面应该就能看起来起伏了。下面的图说明了这个想法:
通过每个片段使用不同的法线,我们可以欺骗灯光一个平面由许多不同的小片段构成,这样可以增加很多细节。这项技术被称为法线贴图或凹凸贴图。下面是一个对比例子:
可以看到,使用了法线贴图的平面看起来有了非常丰富的细节,并且由于我们只改变了每个片段的法线方向,所以开销也很小。
法线贴图
为了使用法线贴图技术我们需要每个片段的法线。就像使用漫反射贴图和高光贴图,我们可以将每一片段的法线信息存储在一张纹理中。
由于法线向量由几何方式存储,而纹理存储的是颜色信息,所以这二者的转换不那么直接。纹理中颜色由r、g、b三个组件构成向量表示,法线由x、y、z三个组件构成向量表示。法线的组件取值范围为[-1,1],颜色的范围是[0, 1],所以我们需要进行一下映射:
vec3 rgb_normal = normal * 0.5 + 0.5; // transforms from [-1,1] to [0,1]
这样的话,我们就可以将法线信息转化为颜色信息存储在纹理中。下面是砖块纹理的法线贴图:
可以看到纹理图片大部分颜色与蓝色相关,这是因为大部分法线倾向于垂直于平面,也就是倾向于指向z轴,对应的是b通道,所以倾向于蓝色,依此类推解释其它的颜色。
通过这个法线贴图,我们可以结合漫反射纹理来渲染一个平面。(记住,OpenGL纹理的原点在左下角,大部分图片的原点在左上角)我们需要做的就是按常规加载这张法线贴图,并设置相关参数。注意,在片元着色器中我们将使用法线贴图中的法线信息计算光照:
uniform sampler2D normalMap;
void main()
{
// obtain normal from normal map in range [0,1]
normal = texture(normalMap, fs_in.TexCoords).rgb;
// transform normal vector to range [-1,1]
normal = normalize(normal * 2.0 - 1.0);
[...]
// proceed with lighting as normal
}
加载法线贴图,并根据纹理坐标映射后,我们的到法线信息,首先将其标准化到[-1,1],接着照常计算光照。
最后的结果就是这样:
但存在一个限制法线贴图使用的问题,法线贴图中存储的法线信息大部分都是指向z轴的,如果平面也指向z轴,效果的确很好,但如果不是,比如下面的平面指向y轴,结果就不对了:
这是因为从法线贴图中采样的法线信息仍指向z轴,这样的光照计算时仍会将平面的片段误当作指向z轴来计算,结果就是不正确的:
有一种解决这种问题的办法是针对每个平面设置自己的法线贴图,然而,针对包含大量模型的大场景来说这根本不现实。
另一种可行的方法是在不同的坐标空间中计算光照,来保证从法线贴图中采样的法线向量始终指向z轴,其它相关的向量也会转换到相同的坐标空间中。使用这种方法,我们可以复用一张法线贴图,而这种坐标空间被称为切线空间。
切线空间
在法线贴图中的法线向量在切线空间中表示,在这个空间中,法线基本都会大致指向+z轴的方向。切线空间是相对于每个平面三角形的空间,我们将这个空间作为法线贴图自己的空间,用来描述法线向量。当我们想要使用法线贴图中的法线进行计算时,我们就可以使用一个特殊的矩阵变换将法线从切线空间转化到世界空间或视图空间,这样就可以与物体相对应。
所以解决上面法线贴图不正确的方法就是定义一个特殊的矩阵将切线空间中的法线进行一些转化,让法线大致指向+y轴。
而这么一个特殊的矩阵被称为TBN矩阵,每个字母分别代表切线(Tangent)、双切线(Bitangent)、法线(Normal)向量,这三个向量将用来构成一个矩阵。为了获取这三个矩阵,我们定义切线空间的三个轴,上、右、前。
我们已经有了代表上的轴向,即法线向量,右和前轴分别是切线和双切线向量,下面是图例说明:
计算一个切线和双切线向量并不像法线那么直接,下面是计算过程。先看这么一张图:
如上图,我们随意在纹理上绘制一个三角形,来作为三角形的映射区域,将切线所在的轴的坐标称为U,将双切线所在轴的坐标称为V,如果,,,,那么和用和表示就是:
同样,这只是在切线空间的表示,如果我们将每个向量分开表示,就是:
我们将作为两个三角形位置的差,和是纹理坐标的差。上面的两个等式可以写成下面的矩阵形式:
我们进行一下变换,将UV放到左侧:
我们将矩阵的逆用伴随矩阵和行列式表示,等式就是这样:
通过上面的等式我们就可以通过三角形的两条边和纹理坐标来计算切线和双切线向量。
手动计算切线和双切线
我们首先定义一个平面的四个顶点和每个顶点的纹理坐标(123和134两个三角形),以及平面的法线朝向:
// 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);
根据上面提到的步骤,计算第一个三角形的两条边和两条边对应的ΔUV坐标:
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);
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);
[...] // 对第二个三角形进行同样的操作
作为结果,切线和双切线的值应该为(1,0,0)和(0,1,0),它们和法线(0,0,1)构成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;
接着在main方法中创建TBN矩阵:
void main()
{
[...]
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矩阵的方式有两种:
- 我们使用使用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矩阵我们将采样的法线向量转化世界空间:
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));
注意到我们使用的是转置,这是因为TBN矩阵是一个正交矩阵,它的逆等于它的转置,所以我们避免使用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和viewLPos这种向量并不会在片元着色器中更新,同时也可以在顶点着色器中计算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 = transpose(mat3(T, B, N));
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vec3(model * vec4(aPos, 0.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();
最后的结果如下:
这里提供原文代码参考:Code。
复杂模型
针对复杂模型,我们并不经常手动计算切线空间的相关向量。比如在导入模型时,我们可以借助assimp库来帮助我们计算。
assimp库有一个读文件方法有一个配置选项为aiProcess_CalTangentSpace,这样assimp可以为每个顶点都计算切线和双切线:
const aiScene *scene = importer.ReadFile(
path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
);
我们可以像下面这样获取切线:
vector.x = mesh->mTangents[i].x;
vector.y = mesh->mTangents[i].y;
vector.z = mesh->mTangents[i].z;
vertex.Tangent = vector;
这样的话,我们可以为模型加载它的法线贴图,我们使用aiTextureType_Height选项:
vector<Texture> normalMaps = loadMaterialTextures(material, aiTextureType_HEIGHT, "texture_normal");
小技巧
针对复杂模型时,切线往往是经过许多顶点计算的,这样就可以得到一个平均值来获得平滑的结果,这样会造成一个问题,那就是T、B、N三个向量可能不会相互垂直了,也就是TBN矩阵不是正交的了。
针对这一问题,我们可以使用格拉姆施密特方法来重新正交化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);
// 叉乘得B
vec3 B = cross(N, T);
mat3 TBN = mat3(T, B, N)
最后,贴出原文地址供参考:https://learnopengl.com/Advanced-Lighting/Normal-Mapping