以光照算法的视角考虑的话,只有一件事决定物体的形状,这就是垂直于它的法线向量。砖块表面只有一个法线向量,表面完全根据这个法线向量被以一致的方式照亮。如果每个fragment都是用自己的不同的法线会怎样?这样我们就可以根据表面细微的细节对法线向量进行改变,这样就会获得一种表面看起来要复杂得多的幻觉:
每个fragment
使用了自己的法线,我们就可以让光照相信一个表面由很多微小的(垂直于法线向量的)平面所组成,物体表面的细节将会得到极大提升。这种每个fragment
使用各自的法线,替代一个面上所有fragment
使用同一个法线的技术叫做法线贴图(normal mapping)
或凹凸贴图(bump mapping)
。
像diffuse
贴图和specular
贴图一样,我们可以使用一个2D纹理
来储存法线数据。2D纹理不仅可以储存颜色和光照数据,还可以储存法线向量。将法线向量的x、y、z
元素储存到纹理中,代替颜色的r、g、b
元素。法线向量的范围在-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)
,这样它就是绿色的了。
一般来说,Normal Maps通常分解为R, G和B三个颜色通道。并且每个颜色通道都代表了一个3D空间的一个轴,通常是X(Red), Y(Green) 和 Z(Blue)轴。R通道表示X轴偏移,G通道表示Y轴偏移,而B通道表示Z轴偏移(即,从表面直接向外的“弹出”程度)。例如,如果一个像素在Normal Map上看起来是亮红色,这意味着表面在这里从左到右大幅度变化,相对于“正常”弹出方向有很大的偏移。
通过分析这三个分量的值,我们可以得到表面的微小细节和纹理信息。例如,边缘部分的颜色值即表示边缘的方向和立体感,凹陷或突出的部分由颜色的明暗来表示等等。这样,我们就可以从一个二维图像中解析出三维的信息,实现所谓的“伪”3D效果,使对象看起来更为真实和丰满。
每个颜色分量通道的二维图是通过将该通道的颜色强度(也就是RGB值)可视化出来的。这些颜色强度将被映射为一个灰度级别,范围从黑色(无颜色,值为0)到白色(全颜色,值为1)。
比如我们要绘制红色通道的二维图,我们可以查看每个像素的红色值,然后按照这个值的大小对该像素进行着色。如果一个像素的红色值很高(接近1),那么在红色通道的二维图上这个像素就会是亮的(接近白色)。反之,如果红色值很低(接近0),那么这个像素就会是暗的(接近黑色)。
如果我们在表面法线指向正y
方向的平面上使用同一个法线贴图会发生什么?
平面的表面法线现在指向了y
,而采样得到的法线仍然指向的是z
。结果就是光照仍然认为表面法线和之前朝向正z方向时一样,这样光照就不对了。
在一个不同的坐标空间中进行光照,这个坐标空间里,法线贴图向量总是指向这个坐标空间的正z
方向;所有的光照向量都相对与这个正z
方向进行变换。这样我们就能始终使用同样的法线贴图,不管朝向问题。这个坐标空间叫做切线空间(tangent space)
。切线空间的一大好处是我们可以为任何类型的表面计算出一个矩阵,把法线从切线空间变换到一个不同的空间,这样它们就能和表面法线方向对齐。
这种矩阵叫做TBN矩阵
,这三个字母分别代表tangent
、bitangent
和normal
向量
有了TBN
矩阵,如果来使用它呢?通常来说有两种方式使用它:
- 我们直接使用
TBN
矩阵,这个矩阵可以把切线坐标空间的向量转换到世界坐标空间。因此我们把它传给片段着色器中,把通过采样得到的法线坐标左乘上TBN
矩阵,转换到世界坐标空间中,这样所有法线和其他光照变量就在同一个坐标系中了。 - 我们也可以使用
TBN
矩阵的逆矩阵,这个矩阵可以把世界坐标空间的向量转换到切线坐标空间。因此我们使用这个矩阵左乘其他光照变量,把他们转换到切线空间,这样法线和其他光照变量再一次在一个坐标系中了。
主要利用第二种方式,将向量从世界空间转换到切线空间有个额外好处,我们可以把所有相关向量在顶点着色器中转换到切线空间,不用在像素着色器中做这件事,而且顶点着色器通常比像素着色器运行的少,会节省性能。
// 发送给像素着色器之前先要求逆矩阵:
// 正交矩阵(每个轴既是单位向量同时相互垂直)的一大属性是一个正交矩阵的置换矩阵与它的逆矩阵相等。这个属性很重要因为逆矩阵的求得比求置换开销大, 结果却是一样的。
vs_out.TBN = transpose(mat3(T, B, N));
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;
// 更新模型加载器,用以从带纹理模型中加载法线贴图。
vector<Texture> specularMaps = this->loadMaterialTextures(
material, aiTextureType_HEIGHT, "texture_normal"
);
使用法线贴图也是一种提升你的场景的表现的重要方式。在使用法线贴图之前你不得不使用相当多的顶点才能表现出一个更精细的网格,但使用了法线贴图我们可以使用更少的顶点表现出同样丰富的细节:
高精度网格和使用法线贴图的低精度网格几乎区分不出来。所以法线贴图不仅看起来漂亮,它也是一个将高精度多边形转换为低精度多边形而不失细节的重要工具。