我们这节课的重点是法线贴图。
上一课中其实讲了法线贴图和冯氏着色。那么二者的区别在哪里?
- 区别: 我们拥有的信息密度。
在冯氏着色中,我们使用三角形网格中每一个顶点给出的法线向量,在三角形内对它还进行了插值。
然而,法线贴图纹理则提供了高度密集的信息,从而很大程度地提高了渲染细节。
上节课我们已经应用了法线贴图,但是我们使用了全局坐标系统来存储纹理。
这节课我们则开始学习切线空间法线贴图。
下面两张图中,左图则是在全局框架中给出的(从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;
}
};
下面则是使用上述代码渲染出的图像:
好,接下来,为了更好的理解和调试,我移除了皮肤纹理并且应用了一个正方形网格,这个网格中由红色的水平线和蓝色的垂直线组成。
那么,先让我们回想一下冯氏着色是如何工作的——
对于一个三角形中的每一个顶点,我们都有它们的坐标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)。
把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)的函数。
显然,我们只是平移了斜面,没有改变它的倾角,因此F和G两个函数,最陡的上升方向是相同的。
让我们重写一下G的定义:
请注意,中的上标x表示点p的x坐标,而不是指数。
因此,函数G只是两个向量(p-p0)和(A B C)之间的点积。而且,到现在我们仍然不知道向量(A,B,C)的值!
那么我们现在知道的是什么呢?
我们知道,如果我们从点p0到点p2,那么函数G会从0到f2-f0。
换句话说,向量(p2-p0)和(A B C)之间的点积等于F2-F0。
(p1-p0)也是一样的。因此,我们正在寻找与法线n正交的向量ABC,并遵守点积的两个约束:
把它再写成矩阵形式:
因此,我们很容易求解线性矩阵方程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的最后一行使用了插值过的法向量,知道为什么吗?