[OpenGL] 纹理高级篇 - 法线贴图

2019-04-13 15:14发布

        

概念引入

        对于三维渲染中的物体而言,出 {MOD}的光影渲染往往能够给画面带来质的飞跃提升。由光照方程可见,物体表面的法线对于最终的光照计算结果起着重要的作用,而物体的表面的顶点/面数则对光照没有太大的影响——这为我们的一个想法提供了可能性,也就是说,我们可不可以通过高模来获取法线,然后用低模渲染物体,并把高模的法线应用到物体上。此时,经过光照计算,呈现在我们眼前的就是高模下的光照细节表现,让我们感觉模型似乎很精细。 Normal mapping in practice                                                     (图片来自网络)         基于这个想法,法线贴图诞生了。我们把法线存在一张图片里,通过纹理映射建立法线和像素的对应关系,就能基本还原高模的法线分布。比起高模,法线贴图能够在画面效果不打折扣的条件下,大大减少内存显存的占用量,并提高渲染效率。 以下是不使用法线贴图,使用顶点法线进行渲染的结果: 以下是使用法线贴图进行渲染的结果: 将镜头拉近,我们可以观察到丰富的表面细节:

法线的存储与读取

        法线是一个向量,经过归一化计算后,分布在[-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矩阵

       也许上面的解析不一定能够完全理解,那么可以试着一起动手计算一遍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); }