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

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ZJU_fish1996/article/details/83934059

        

概念引入

        对于三维渲染中的物体而言,出色的光影渲染往往能够给画面带来质的飞跃提升。由光照方程可见,物体表面的法线对于最终的光照计算结果起着重要的作用,而物体的表面的顶点/面数则对光照没有太大的影响——这为我们的一个想法提供了可能性,也就是说,我们可不可以通过高模来获取法线,然后用低模渲染物体,并把高模的法线应用到物体上。此时,经过光照计算,呈现在我们眼前的就是高模下的光照细节表现,让我们感觉模型似乎很精细。

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),一个是黑色的N,另一个是蓝色的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) 将面法/切线转换到点法/切线

        由于我们的计算是基于面来计算的,所以我们得到的实际上是面法线和面切线。但是,我们传入顶点着色器中,应该为顶点法线和顶点切线。此处我们还需要经过一次处理,即对于每个点,求其邻接面的面法线/切线的平均值。

       我们最终计算的代码如下:

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<VertexData*> 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) 顶点着色器

      在这里,我们所做的事情包括像往常一样的把顶点坐标转换到投影空间,并记录顶点的世界坐标,将世界坐标、纹理坐标、法线和切线传递到片元着色器。需要注意的是,我们之前求得的法线和切线都是模型坐标系下的,我们也同样要将它们转换到世界坐标系进行计算。

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) 片元着色器

        我们首先读取法线,然后将法线进行空间的转换,再像平时一样做光照计算。其中,为了保证T一定垂直于N,需要在片元着色器中做一次矫正。然后通过叉乘得到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);
}

猜你喜欢

转载自blog.csdn.net/ZJU_fish1996/article/details/83934059