概念引入
对于三维渲染中的物体而言,出 {MOD}的光影渲染往往能够给画面带来质的飞跃提升。由光照方程可见,物体表面的法线对于最终的光照计算结果起着重要的作用,而物体的表面的顶点/面数则对光照没有太大的影响——这为我们的一个想法提供了可能性,也就是说,我们可不可以
通过高模来获取法线,然后用低模渲染物体,并把高模的法线应用到物体上。此时,经过光照计算,呈现在我们眼前的就是高模下的光照细节表现,让我们感觉模型似乎很精细。
(图片来自网络)
基于这个想法,法线贴图诞生了。我们把法线存在一张图片里,通过纹理映射建立法线和像素的对应关系,就能基本还原高模的法线分布。比起高模,法线贴图能够在画面效果不打折扣的条件下,大大减少内存显存的占用量,并提高渲染效率。
以下是不使用法线贴图,使用顶点法线进行渲染的结果:
以下是使用法线贴图进行渲染的结果:
将镜头拉近,我们可以观察到丰富的表面细节:
法线的存储与读取
法线是一个向量,经过归一化计算后,分布在[-1,1]之间,为了把它压缩到[0,1]之内,需要做一个简单的线性映射:
color = (N + 1) / 2
那么类似的,从法线贴图中解析法线的时候,要做一个逆运算:
N = 2 * color - 1
但是,对于解析后得到的法线,我们仍然不能直接使用它,因为出于一种不成文的规矩,我们的法线贴图中的法线并不是记录在世界坐标系下的,而是存储在一个特殊的坐标系下,即切线空间中。
切线空间是什么?对于一个网格模型,我们逐顶点来分析,每个顶点都有着自己的切线空间,如下图所示,我们可以将其称为TBN空间。其中N代表该点处的法线,T(tangent)和B(binormal)都是该点处的切线。由于一个点处的切线有无数条,我们指定T切线是沿着纹理的u坐标方向的,B切线是沿着纹理的v坐标方向的。
那么,对于法线纹理中的法线,它是在TBN空间存储的,具体可能是下图的样子。由图可见图中有两个法线(N),一个是黑 {MOD}的N,另一个是蓝 {MOD}的N',要注意区分这两者。前者是
实际使用的模型中,垂直于当前点的那个法线(点法线),而后者是从法线纹理中读取的法线(像素法线),也就是说,读取的法线不总是垂直于点,而是在原法线的基础上有一点偏移。
在法线贴图中,我们使用切线空间来存储,这也就意味着我们可以很容易从其推导出法线的偏移信息,而这个偏移信息是和具体的模型无关的,也就是说,当我们使用切线空间下的法线贴图时,我们可以将一张贴图应用于不同模型上,无论是球形,圆柱体或是正方体,甚至是更为复杂的模型。
计算TBN矩阵
也许上面的解析不一定能够完全理解,那么可以试着一起动手计算一遍TBN矩阵,来更好地认识前文提及的一些概念。
也就是说,我们需要在给出模型按照三角形排布的点集时,自动计算出它的法线以及两条切线。此时,我们的输入是网格中三角形三个点的
模型空间坐标,以及
uv纹理坐标。
(1) 计算面法线
在已知三个点坐标的情况下,面法线的计算非常简单,只需要求三角形中两个向量的叉积即可。在这个计算过程中,我们可能需要考虑到的一个问题是,垂直于一个面片的法线有两个方向,我们需要保证我们求出的法线是实际我们需要的那个方向的法线。
在OpenGL中,对于组成三角形的三个点对应的法线方向有着这么一个规定,根据输入的三个点的方向,按照右手定则,让四指方向指向三个点的流动方向,大拇指的朝向即为法线方向。所以在计算法线时,我们也需要按照这一规律进行计算。
(2) 计算面切线
由于第二条切线可以通过叉乘得到,在这里我们只计算切线T。由于切线T取得沿着纹理坐标u方向,所以我们实际上需要计算向量u。
如上图,向量e0和e1可以用模型空间下的坐标来表示,即:
e0 = vertex1.position - vetex0.position
e1 = vertex2.position - vertex0.position
也可以使用TBN空间作为基向量来表示:
e0 = t1 *
T + b1 *
B
e1 = t2 *
T + b2 *
B
其中,t1,t2,b1,b2是向量之间的u,v差值。
联立以上方程组,可以求解出T,B两个向量。我们最终保留切线T的计算结果。
(3) 将面法/切线转换到点法/切线
由于我们的计算是基于面来计算的,所以我们得到的实际上是面法线和面切线。但是,我们传入顶点着 {MOD}器中,应该为顶点法线和顶点切线。此处我们还需要经过一次处理,即对于每个点,求其邻接面的面法线/切线的平均值。
我们最终计算的代码如下:
struct VertexData
{
QVector3D position;
QVector3D tangent;
QVector3D normal;
QVector2D texture; // texcoord
int adjoinPlane = 0;
};
void CalNormalAndTangent(VertexData& vertex0, VertexData& vertex1, VertexData& vertex2)
{
float u0 = vertex0.texture.x();
float v0 = vertex0.texture.y();
float u1 = vertex1.texture.x();
float v1 = vertex1.texture.y();
float u2 = vertex2.texture.x();
float v2 = vertex2.texture.y();
float t1 = u1 - u0;
float b1 = v1 - v0;
float t2 = u2 - u0;
float b2 = v2 - v0;
QVector3D e0 = vertex1.position - vertex0.position;
QVector3D e1 = vertex2.position - vertex0.position;
float k = t1 * b2 - b1 * t2;
QVector3D tangent;
tangent = k * QVector3D(b2 * e0.x() - b1 * e1.x(),b2 * e0.y() - b1 * e1.y(),b2 * e0.z() - b1 * e1.z());
QVector3D normal;
normal = QVector3D::crossProduct(e0, e1);
QVector vertexArr = { &vertex0, &vertex1, &vertex2};
for(int i = 0;i < vertexArr.size();i++)
{
vertexArr[i]->adjoinPlane++;
float ratio = 1.0f / vertexArr[i]->adjoinPlane;
vertexArr[i]->normal = vertexArr[i]->normal * (1 - ratio) + normal * ratio;
vertexArr[i]->tangent = vertexArr[i]->tangent * (1 - ratio) + tangent * ratio;
}
}
将法线贴图从切线空间转换到世界空间
为了能够应用法线的计算,我们需要统一计算的坐标空间,我们有两个选择,一个是在切线空间下进行光照的计算,这意味着我们要把光照方向等向量转换到切线空间,另一个是在世界空间上进行光照计算,这意味着我们要把法线转换到世界坐标系。但无论怎样,我们都需要TBN矩阵参与坐标空间的转换运算。
在此我们介绍将法线从切线空间转换到世界空间的方法。
(1) 顶点着 {MOD}器
在这里,我们所做的事情包括像往常一样的把顶点坐标转换到投影空间,并记录顶点的世界坐标,将世界坐标、纹理坐标、法线和切线传递到片元着 {MOD}器。需要注意的是,我们之前求得的法线和切线都是模型坐标系下的,我们也同样要将它们转换到世界坐标系进行计算。
uniform mat4 ModelMatrix;
uniform mat4 IT_ModelMatrix;
uniform mat4 ViewMatrix;
uniform mat4 ProjectMatrix;
attribute vec4 a_position;
attribute vec3 a_normal;
attribute vec3 a_tangent;
attribute vec2 a_texcoord;
varying vec2 v_texcoord;
varying vec3 v_tangent;
varying vec3 v_normal;
varying vec3 worldPos;
void main()
{
gl_Position = ModelMatrix * a_position;
worldPos = vec3(gl_Position);
gl_Position = ViewMatrix * gl_Position;
gl_Position = ProjectMatrix * gl_Position;
v_texcoord = a_texcoord;
v_normal = mat3(IT_ModelMatrix) * a_normal;
v_tangent = mat3(ModelMatrix) * a_tangent;
}
(2) 片元着 {MOD}器
我们首先读取法线,然后将法线进行空间的转换,再像平时一样做光照计算。其中,为了保证T一定垂直于N,需要在片元着 {MOD}器中做一次矫正。然后通过叉乘得到B,以获取TBN矩阵。
uniform sampler2D brick_N;
uniform sampler2D brick_D;
uniform vec3 LightLocation;
uniform vec3 cameraPos;
varying vec3 worldPos;
varying vec3 v_tangent;
varying vec3 v_normal;
varying vec2 v_texcoord;
vec3 UnpackNormal(vec3 normal)
{
vec3 N = normalize(v_normal);
vec3 T = normalize(v_tangent - N * v_tangent * N);
vec3 B = cross(N, T);
mat3 TBN = mat3(T,B,N);
normal = normalize(2 * normal - 1);
normal = normalize(TBN * normal);
return normal;
}
void main()
{
vec3 normal = texture2D(brick_N, v_texcoord);
normal = UnpackNormal(normal);
vec3 lightDir = normalize(LightLocation - worldPos);
vec3 ViewDir = normalize(cameraPos - worldPos);
float diffuse = 0.7 * clamp(dot(normal, lightDir), 0, 1);
float ambient = 0.2;
vec3 reflectDir = normalize(reflect(-lightDir,normal));
float specular = pow(clamp(dot(reflectDir,ViewDir),0,1),5.0);
vec3 color = texture2D(brick_D, v_texcoord);
vec3 finalColor = color * ( specular +diffuse + ambient);
gl_FragColor = vec4(finalColor, 1);
}