实现OpenGL渲染器原理篇(八)——双切线空间法线贴图

我们这节课的重点是法线贴图。

上一课中其实讲了法线贴图冯氏着色。那么二者的区别在哪里?

  • 区别: 我们拥有的信息密度

冯氏着色中,我们使用三角形网格中每一个顶点给出的法线向量,在三角形内对它还进行了插值

然而,法线贴图纹理则提供了高度密集的信息,从而很大程度地提高了渲染细节

上节课我们已经应用了法线贴图,但是我们使用了全局坐标系统来存储纹理

这节课我们则开始学习切线空间法线贴图


下面两张图中,左图则是在全局框架中给出的(从RGB到XYZ法线的直接转换)。然而,右图则是在达布坐标系中。

全局框架与达布框架

为了使用右图中的纹理,对于绘制的每个像素,我们都要计算切线空间(Darboux)空间

在此基础上,一个向量(通常为z)与我们的表面正交,而另外两个向量给出与当前点相切的平面

然后,我们从我们的纹理中读取一个(扰动的)法线向量,将其坐标从Darboux框架转换为全局系统坐标,然后大功告成。

通常,法线贴图会提供法向矢量小扰动,因此纹理主要是蓝色

有人会问,为什么我们不像以前那样使用全局系统呢?

好,假设我们想让模型动起来

  • 例如,我拿了一个黑人模型,让他张开嘴。(很明显,法向量是需要修改的)。
黑人模型

左边的图像给出了张着嘴的头部,但是没有改变对应的(全局坐标)的法线纹理。仔细检查下嘴唇的内部。光线直接照到他的脸上。当嘴巴闭上时,下唇的背面自然是不亮的。然而现在嘴巴是张开的,但是嘴唇依然没有被光线照亮……

右边的图像是正确的,是利用切线空间法线贴图计算出的。

因此,如果我们有一个会动的模型,则为了在全局坐标中正确地进行法线贴图,我们需要在动画的每一帧中使用一个对应的纹理;然而,切线空间会根据模型而变形,因此我们只需要一个纹理


再举一个栗子:


暗黑破坏神模型的纹理

这些是暗黑破坏神模型的纹理。请注意,纹理中只绘制了一只手,而尾部只绘制了一侧

这位艺术家将同样的纹理用在了渲染两只手臂尾巴完整的两边

这意味着在全局坐标系中,我既可以为尾巴的左侧提供法线向量,也可以为右侧的一条提供法线向量,但不用同时为尾巴的两侧提供法线向量!手臂也是一样。
然后,如果我需要左右两边不同的信息,例如,检查左图中的左右颧骨,自然,法线向量指向相反的方向

好!让我们结束动机部分,直接进入计算部分。


一、起点、冯氏着色

这个着色器真的很简单,它就是Phong着色。
代码如下:

// It is Phong shading
struct Shader : public IShader 
{
    mat<2, 3, float> varying_uv;  // triangle uv coordinates, written by the vertex shader
                                  // read by the fragment shader
    mat<3, 3, float> varying_nrm; // normal per vertex to be interpolated by Fragment Shader
    mat<4, 3, float> varying_tri; // triangle coordinates (clip coordinates), written by Vertex Shader
                                  // read by Fragment Shader

    virtual Vec4f vertex(int iface, int nthvert)
    {
        varying_uv.set_col(nthvert, model->uv(iface, nthvert));
        varying_nrm.set_col(nthvert, proj<3>((Projection * ModelView).invert_transpose() * 
            embed<4>(model->normal(iface, nthvert), 0.f)));

        // Projection * ModelView就是矩阵M,可以将世界空间转换为裁剪空间
        // 后面的embed则是将顶点坐标内嵌成4D的
        // 二者相乘后则是转换后的顶点坐标
        Vec4f gl_Vertex = Projection * ModelView * embed<4>(model->vert(iface, nthvert));

        // set_col的意思我猜是 “设置列”
        // 注意:rows[i][1]和rows[i][2]、rows[i][3]不一样
        // 因为gl_vertex也是根据 nthvert = 0, 1, 2 来分别求得的
        varying_tri.set_col(nthvert, gl_Vertex);
        return gl_Vertex;
    }

    virtual bool fragment(Vec3f bar, TGAColor& color) 
    {
        // 这里的bn和nrm,跟上节课的没区别,只是为了加以区分罢了
        Vec3f bn = (varying_nrm * bar).normalize();
        Vec2f uv = varying_uv * bar;

        // diff就是光照强度
        float diff = std::max(0.f, bn * light_dir);

        // diffuse()得到的是纹理坐标(u和v的坐标),通过u和v的坐标,得到纹理的颜色
        // 纹理颜色和光强相乘,最后得到绘在图上的颜色color
        // 最后这个颜色color是在our_gl.cpp文件中的光栅格化器triangle()函数中应用的
        color = model->diffuse(uv) * diff;

        return false;
    }
};

下面则是使用上述代码渲染出的图像

使用tangent space normal mapping渲染出的图像


好,接下来,为了更好的理解和调试,我移除了皮肤纹理并且应用了一个正方形网格,这个网格中由红色的水平线蓝色的垂直线组成

网格图


那么,先让我们回想一下冯氏着色是如何工作的——

How Phong shading works

对于一个三角形中的每一个顶点,我们都有它们的坐标p、纹理坐标uv和法线向量

为了给当前的片段着色,我们的软件光栅格化器给了我们该片段的重心坐标(alpha, beta, gamma)

这意味着该片段的坐标值可以通过p = alpha p0 + beta p1 + gamma p2来得到。
然后以相同的方式插值纹理坐标和法线向量

坐标、纹理坐标、法线的插值公式

请注意,蓝线和红线分别是u和v的等值线。因此,对于表面的每个点,我们定义了一个所谓的Darboux框架,其中x和y轴平行于蓝线和红线,而z轴正交于该表面。

这个就是切线空间法线贴图所在的框架


二、如何从三个样本中重建一个(3D)线性函数

好的,所以我们的目标是,对我们绘制的每一个像素,计算三个向量(切线空间中)。
我们先把它放在一边,想象一个线性函数F对于每个点(x,y,z)给出了一个实数——F(x,y,z) = Ax + By + Cz + D

唯一的问题是我们不知道A、B、C、D的值,但是我们知道这个函数F在空间的三个不同的点上对应的三个值(p0 p1 p2)

f0, f1, f2和p0, p1, p2的关系

F想象成一个斜面的高度图是很方便的。我们在平面上确定三个不同的(非共线的)点,我们知道这些点上F的值

三角形内的红线表示等高高度,f0、f0 + 1米、f0 + 2米等等(这里没太懂)。对于一个线性函数等值线是平行的(直线)

实际上,我更感兴趣的是与等值线正交的方向。如果我们沿着iso移动,则高度不会改变(嗯,这是等高线呀!)。

如果我们从iso稍微偏离一点高度就会开始改变一点

当我们移动到等值线的正交方向的时候,我们获得了最陡峭的上升。

让我们回顾一下,函数的最陡峭上升方向无非就是它的梯度。

对于一个线性函数F(x,y,z) = Ax + By + Cz + D,它的梯度是一个常数向量(A, B, C)。回想一下,我们不知道(A,B,C)的值。我们只知道这个函数的三个样本值

  • 那么我们可以重建A B C吗? 当然可以。

因此,我们有三个点p0,p1,p2和三个对应的值F0,F1,F2。我们需要找到最陡上升的向量(A,B,C)

让我们考虑另一个定义为g(p) = f(p) - f(p0)的函数。

g(p) = f(p) - f(p0)

显然,我们只是平移了斜面,没有改变它的倾角,因此F和G两个函数,最陡的上升方向是相同的


让我们重写一下G的定义:

the definition of g

请注意,中的上标x表示点p的x坐标,而不是指数。

因此,函数G只是两个向量(p-p0)和(A B C)之间的点积。而且,到现在我们仍然不知道向量(A,B,C)的值

那么我们现在知道的是什么呢?
我们知道,如果我们从点p0到点p2,那么函数G会从0到f2-f0

image.png

换句话说,向量(p2-p0)(A B C)之间的点积等于F2-F0

(p1-p0)也是一样的。因此,我们正在寻找与法线n正交的向量ABC,并遵守点积的两个约束:

约束条件

把它再写成矩阵形式:


矩阵形式的约束条件

因此,我们很容易求解线性矩阵方程Ax = b:


求解线性矩阵方程Ax = b

请注意,我将字母A用于两个不同的事物,其含义应从上下文中清楚看出

因此,我们的3x3矩阵A,乘以未知向量x =(A,B,C),得出向量b =(f1-f0,f2-f0,0)

当我们用b乘以A的逆矩阵时,未知向量x变为已知

还要注意,在矩阵A中,我们没有任何与函数F相关的东西。它仅包含有关三角形的一些信息。


三、让我们计算Darboux基础并应用法线的“扰动”

因此,Darboux基是向量(i,j,n)的三元组,其中n是原始法向量,i, j可以计算如下:


向量

(使用切线空间中的法线贴图的代码会更新)

一切都很简单,我计算矩阵A是这么做的:

        mat<3,3,float> A;
        A[0] = ndc_tri.col(1) - ndc_tri.col(0);
        A[1] = ndc_tri.col(2) - ndc_tri.col(0);
        A[2] = bn;

然后计算基于Darboux的两个未知向量(i,j):

mat<3,3,float> AI = A.invert();
Vec3f i = AI * Vec3f(varying_uv[0][1] - varying_uv[0][0], varying_uv[0][2] - varying_uv[0][0], 0);
Vec3f j = AI * Vec3f(varying_uv[1][1] - varying_uv[1][0], varying_uv[1][2] - varying_uv[1][0], 0);

一旦我们有了所有的切线基,我就从纹理中读取扰动的法线,并将切线基的基准变化应用到全局坐标中

回想一下,我已经描述了如何执行基准变更。(在以前的博客中,有空了找找!)

结果图如下:


切线空间最终效果图

四、调试建议

现在是回忆如何绘制直线段的最佳时机。应用红蓝网格作为纹理,为网格的每个顶点绘制向量(i,j)。通常,它们必须与纹理线重合。

你是否注意到通常一个(平面)三角形有一个恒定的法向量,而我在矩阵A的最后一行使用了插值过的法向量,知道为什么吗?

你可能感兴趣的:(实现OpenGL渲染器原理篇(八)——双切线空间法线贴图)