为了提高模型的表现细节而又不增加性能的消耗,一般不会去选择提高模型的面数,而是会给模型的渲染shader使用法线贴图(normal map),通过更改模型上的点的法线方向,增加光影凹凸效果,从而提升模型的表现细节。如下图所示:
最左边的模型面数有4百万,而中间模型仅有500个三角形,通过给中间模型使用法线贴图(最右边模型),可以看出最左图和最右图所表现的细节差不多,但是所渲染的面片数相差较大。因此一般会给模型提供法线贴图,以减少渲染的压力(优化手段之一)。
法线贴图(normal mapping),是凹凸贴图(bump mapping)中的一种。在光照计算的过程中,需要使用到法向量这一变量,同一个平面的法向量可能是一致的;如果使用法线贴图采样得到的法向量用于光照计算,那么会给模型表面提供更多的细节。如下图所示:
法线贴图可以认为是提供了受到扰动的法向量,光照计算时会显得结果凹凸不平,从而展示了模型更多的细节。
法线贴图和其他的贴图一样,在RGB三个通道中存储相关信息,例如材质贴图,贴图中存储的是颜色信息;而在法线贴图中存储的是法向量信息。在贴图中,通道的取值范围在[0,1]之间,而法向量的取值范围在[-1,1]之间,因此在使用法线贴图时,需要做一个简单的映射关系:
vec3 rgb_normal = normal * 0.5 + 0.5; //transform from [-1,1] to [0, 1] 法向量 写进 法线贴图 做的转换
vec3 normal = rgb_normal * 2 - 1; //transform form [0,1] to [-1,1] 法线贴图 读取到 法向量 做的转换
一个法线贴图大概如下图所示:
法线贴图大都是呈现蓝紫色,因此在看模型资源时,哪一个贴图是蓝紫色的,那么那个贴图就是法线贴图。原因:所有法线都是沿着正z轴向外指向,在图片中面朝我们的方向就是z轴的正方向,例如法向量(0,0,1),将该法向量映射到贴图通道的取值范围中,映射结果为(0.5, 0.5, 1),b通道值为1,这就解释了在贴图上看上去大部分区域都是蓝紫色。
因此法线贴图在片元着色器中的应用大概如下所示:
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);
[...]
}
但是上面还不能够正确使用法线贴图,因为上述获取到的法向量仍处于切线空间(tangent space),而用于光照计算的其他向量处于世界空间(world space)。因此还需要将这些向量变换到同一个空间中,在讨论如何变换之前,先看一下什么是切线空间(tangent space)。
法线贴图中的法向量是处于切线空间,并且法向量大致朝向z轴正方向。切线空间是位于三角形面片上的局部空间,z轴正方向为垂直法线贴图表面的方向。在切线空间中法向量大致朝向z轴的方向。使用一个特定的矩阵能够将法向量从切线空间变换到世界坐标系下,使得法向量能够大致垂直模型表面。这个矩阵叫TBN矩阵,是tangent、bitangent和normal向量的缩写,这也是使用这三个向量来构建TBN矩阵。
==在之前的着色器代码中有使用到normal向量,之前normal向量可以用于光照计算,而在normal mapping中该normal向量用于构建切线空间(作为z轴方向),法线贴图采样得到的法向量才用于光照计算,注意两者之间的区别。==
切线空间由up、forward和right向量组成,其中up向量是表面的法向量,right向量是切线向量(tangent vector),forward向量是双切线向量(bitangent vector),如下图所示:
切线向量、双切线向量都与模型表面相切,但是一个模型表面的切线有很多方向,不唯一,因此一般我们都会使切线方向(tangent)与材质贴图的U坐标方向相同,双切线方向(bitangent)与材质贴图的V坐标方向相同。双切线向量之所以被称为双切线,也是因为有两条切线而得名。因此我们标定了切线、双切线向量的方向,下一步就需要通过点的UV坐标来推导出该点的切线向量、双切线向量。
假设一个三角形面片,其UV坐标如下图所示:
现在我们要通过点与点之间UV坐标差来计算切线向量和双切线向量。如上图所示,红色线为切线向量的方向,绿色线为双切线向量的方向,边 E1 E 1 和边 E2 E 2 可以用如下公式来表示:
利用上面的数学公式来计算切线向量和双切线向量,代码如下所示:
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);
[...] // similar procedure for calculating tangent/bitangent for plane's second triangle
计算出来切线向量和双切线向量后,然后在着色器中构造TBN矩阵,再应用相关的变换,渲染的结果如下所示:
但一般模型加载的过程中(已设置计算tangent)就会自动计算切线向量,这样在着色器代码直接使用切线向量这一变量即可。
要使得法线贴图能够正常工作,需要在着色器中创建一个TBN矩阵。首先需要将CPU中生成的切线向量(tangent)和双切线向量(bitangent)数据作为顶点属性传到顶点着色器:
#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矩阵:
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)
}
上述代码中将法向量(normal),切线向量(tangent)和双切线向量(bitangent)与model矩阵相乘,即将TBN这三个向量变换到世界坐标系下,因为我们只关心TBN向量的方向,而不会关心TBN向量的长度等,因此还会对TBN向量进行normalize,最终才构造需要使用到的TBN矩阵。
在顶点着色器中,其实我们不需要使用到双切线向量(bitangent)这个顶点属性,因为所有TBN向量都是两两正交,因此我们可以在顶点着色器中使用法向量(N)和切线向量(T)的叉积来表示双切线向量(B)。新的顶点着色器(vertexShader.glsl)代码如下:
#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; //切线向量
void main()
{
[...]
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
vec3 B = cross(N, T);
mat3 TBN = mat3(T, B, N)
}
如今已经创建了TBN矩阵,在法线贴图中使用TBN矩阵的方法有两种:
1. 使用TBN矩阵将向量从切线空间变换到世界空间。将TBN矩阵传到片元着色器中,从法线贴图中采样得到的法向量(处于切线空间)与TBN矩阵相乘,即可从切线坐标系中变换到世界坐标系下,这样变换后的法向量与其他光照变量处于同一个空间中,方便计算。
2. 使用TBN矩阵的逆将向量从世界空间变换到切线空间。将其他光照变量与TBN矩阵的逆矩阵相乘,使他们从世界空间变换到切线空间中,从法线贴图中采样得到的法向量与其他光照变量同处在切线空间中。
我们从法线贴图中采样得到的法向量处于切线空间中,而用于光照计算的其他向量处于世界空间中,为了正确使用法线贴图,需要使得这些向量处于同一个空间中。因此,将TBN矩阵从顶点着色器传到片元着色器,在片元着色器中,将法线贴图中采样得到的法向量与TBN矩阵相乘,使得法向量从切线空间变换到世界空间中。
顶点着色器(vertexShader.glsl)代码如下:
out VS_OUT{
vec3 FragPos;
vec2 TexCoords;
//normal mapping
mat3 TBN;
} vs_out;
void main(){
[...]
vs_out.TBN = mat3(T,B,N);
}
片元着色器(fragmentShader.glsl)代码如下:
in VS_OUT{
vec3 FragPos;
vec2 TexCoords;
//normal mapping
mat3 TBN;
} fs_in;
void main(){
[...]
vec3 normal;
normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2 - 1);
normal = normalize(fs_in.TBN * normal);
}
上述代码中normal向量经过TBN矩阵变换后,处于世界空间中,与其他光照计算的向量处于同一个空间中,因此后续的光照计算的代码不需要修改。
这种方法与第一种方法相反,将用于光照计算的向量变换到切线空间中。在将TBN矩阵从顶点着色器传到片元着色器之前,需要在顶点着色器中对TBN矩阵求逆。
TBN矩阵是一个正交矩阵(每一列都是正交单位向量),而正交矩阵的一个性质是正交矩阵的逆矩阵等于该正交矩阵的转置矩阵,而且计算一个矩阵的逆的代价较高,因此在代码中使用transpose()
函数来代替矩阵的逆。
顶点着色器(vertexShader.glsl)的代码如下:
out VS_OUT{
vec3 FragPos;
vec2 TexCoords;
//normal mapping
mat3 TBN;
} vs_out;
void main(){
[...]
vs_out.TBN = transpose(mat3(T,B,N));
}
在片元着色器中,使用上述的TBN矩阵去对用于光照计算的向量(如lightDir和viewDir变量)进行变换,而不是对从法线贴图中采样得到的法向量进行变换。
片元着色器(fragmentShader.glsl)的代码如下:
in VS_OUT{
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
vec4 FragPosLightSpace;
//normal mapping
mat3 TBN;
} fs_in;
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矩阵传到片元着色器中,而是将切线空间中的光的位置、视角位置和顶点位置到片元着色器中。这可以在片元着色器中节省一些矩阵相乘的操作。这是一个较好的优化,因为顶点着色器的运行频率远低于片段着色器。这也是为什么大多数人会选择第二种方法的原因。
我的理解是譬如渲染一个三角形,对于第一种方法(从切线空间到世界空间),顶点着色器处理三个点(不变),而在片元着色器中每对法线贴图采样后,就需要对法向量做矩阵变换,构成一个三角形可能会有很多个片元构成,有多少个片元构成就有多少个矩阵变换;对于第二种方法(从世界空间到切线空间),顶点着色器对三个顶点的光变量(lightPos,viewPos,fragPos)做矩阵变换,变换到切线空间,在片元着色器直接使用这些变量进行光照计算(因为已经在同一个空间–切线空间),那么第二种方法只做了三次的矩阵变换,相对于第一种方法要节省一些矩阵运算。
顶点着色器(vertexShader.glsl)的代码如下:
out VS_OUT{
vec3 FragPos;
vec2 TexCoords;
//normal mapping
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} vs_out;
uniform vec3 lightPos;
uniform vec3 viewPos;
[...]
void main(){
[...]
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
vec3 B = cross(N, T);
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.0f));
}
在片元着色器中,可以使用这些Tangent***
变量在切线空间中去计算光照信息。
片元着色器(fragmentShader.glsl)的代码如下:
in VS_OUT{
vec3 FragPos;
vec2 TexCoords;
//normal mapping
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} fs_in;
uniform sampler2D normalMap;
void main(){
//obtain normal from normal map in range[0,1]
vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
//transform normal vector to range [-1,1];
normal = normalize(normal * 2.0 -1.0);
vec3 lightPos = fs_in.TangentLightPos;
vec3 viewPos = fs_in.TangentViewPos;
vec3 FragPos = fs_in.TangentFragPos;
//normal lighting calculation
[...]
}
如上所述,在光照计算的时候直接使用变量lightPos、viewPos、FragPos
进行计算即可,因为他们和变量normal
都处于切线空间中。渲染结果如下图所示,第一张图是没有使用normal mapping的,第二张图是使用了normal mapping。
没有使用法线贴图的渲染效果:
使用了法线贴图的渲染效果:
在实际的应用环境中,模型处理过程中会对顶点的法向量进行平滑操作,即对于那些具有共有顶点来说,这些顶点的法向量是通过共有三角形面的法向量的平均值来定义的,顶点法向量的平滑操作能够导致较好的平滑效果,给我们带来很好的视觉效果。但是在normal mapping中,依据上述方法去计算tangent、bitangent向量时,TBN这三个向量不再两两正交,使得TBN矩阵不再是正交矩阵。下图是没有进行切空间平滑操作的效果图:
既然对顶点的法向量已经进行了平滑操作,并且要正确使用normal mapping的话,就需要对T和B这两个向量进行施密特正交化(Gram-Schmidt orthogonalization):
顶点着色器的代码如下所示:
[...]
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 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(N, T);
mat3 TBN = mat3(T, B, N);
[...]
上述的施密特正交化(Gram-Schmidt orthogonalization)会带来一点的计算量,但是能够提高一定程度的显示效果。
法线贴图并不是把模型的面数提高了,而是使用法线贴图中的法线(法向量)来计算光照,通过明暗效果作假,让观察者误以为模型有凹凸。法线贴图只能在明暗效果(光照计算的过程中)上作假(模拟凹凸),无法控制表面的凹凸程度。即使我们使用图像软件强制调出一个凹凸非常明显的法线贴图,通过仔细观察,法线效果也是有问题的。
这里法线贴图的制作是利用了软件Crazybump来完成的,能够根据纹理贴图来生成相应的法线贴图,可以根据需要来调整生成法线贴图的参数,以满足项目需求。如下图所示:
保存生成的法线贴图后,还需要在3dsmax中对模型应用法线贴图,具体的使用可看笔记【Cephei引擎模型处理与模型导入】。
示例代码已上传到github的Cephei项目上,
参考链接[3]中的法线贴图总结的很好,值得一看!!!!
参考链接:
1. learnopengl – Normal-Mapping
2. GraphicsLab Project之Normal Mapping
3. 法线贴图原理