陈波

法线贴图映射原理解析


  • 为什么要使用法线贴图映射
  • 法线贴图存储方式

动因

我们观察下图,下图使用了一张墙砖的纹理映射到了一个平面上,然后我们仔细观察一下高光,我们发现整个面的高光都是光滑的,这与我们凹凸不平的砖块纹理非常不匹配。这是因为我们在一个光滑的表面使用了一个凹凸不平的砖块纹理。而光照是根据网格几何体来实现的,没有考虑到纹理的内容,因此光照表现出来的效果跟纹理表现出来的质感是完全不一致的。

image
image

解决方案

  • 对网格进行细化,通过细化网格来模拟砖块的实际凹凸裂痕,使得表现跟纹理一样
  • 光照烘焙到纹理上
  • Bump Map和Normal Map

对于第一种方式,细化网格是不切实际的,因为它会使得三角形数量非常多,实时渲染承受不了这样的压力。

对于第二种方式,光照烘焙到纹理上是可行的,但是局限性非常大,因为一单灯光发生移动,模型发生移动,就会产生错误的光照结果。

所以鉴于以上情况,我们需要找到一种既能够表现自身细节,又能够实现正确光照的方式。因为所有的细节来源于纹理,所以我们转变思路考虑从纹理入手,纹理贴图是把颜色添加到多边形上,那么我们只需要使用同样的方式将凹凸信息附加到多边形上就可以了。

如何判断凹凸



我们判断一个物体是粗糙的唯一证据是它表面上的亮度有改变。我们的大脑能够获得这些亮暗不一的图案信息,然后判断出它们是表面中有凹凸的部位。一般来说,怎么知道哪些点要亮,哪些点要暗呢?这不难,绝大多数人生活在这样一种环境下—-这个环境的大多数光源来自上方(比如白天主要的光来自太阳,夜晚主要的光来自天花板上的日光灯)。所以向上倾的地方就会更亮,而向下倾的地方就会更暗。所以这种现象使你的眼睛看到一个物体上亮暗区域时,可以判断出它的凹凸情况。相对亮的块被判断是面向上的,相对暗的块被判断是面向下的。所以只需要给物体上的线条简单得上色就可以实现这样的效果。如下图:

image

如果想要更多的证据,这里还有一幅几乎相同的图,不同于前的是它旋转了180度。所以它是前一幅图倒转的图像。那些先前看起来是凹进去的区域,现在看起来是凸出来的。这个时候我们的大脑并没有被完全欺骗,你脑中存留的视觉印象使你仍然有能力判断出这是前一幅图,只是它的光源变了,是从下往上照的。你的大脑可能强迫性地判断出它是第一幅图。事实上,你只要始终盯着它,并且努力地想像着光是从右下方向照射的,你就会理解它是凹的(因为日常生活的习惯,你会很容易把这些图形判断成凸出的图形,但是因为有了上一幅对照图的印象,你可能才会特别注意到这些图块其实还是凹入的,只是判断方法不符合我们日常生活习惯,因为这时大多数光不是从上方照射,而是从下往上照射)。

image

Bump Map

由于我们只是想要表面的明暗信息,因此我们只需要在物体表面扰乱法线,然后根据法线与光线向量计算出明暗信息。得到明暗信息之后我们也就能够模拟出凹凸。我们使用一张Bump Map来映射物体表面的凹凸信息。BumpMap是灰度图,存储的物体表面高度信息。然后实时计算出法线,利用相邻像素点的高度差向量计算出法线向量。因为一些局限性在实时游戏领域很少会用到它。

Normal Map

Normal Map其实是Dot3 Bump Mapping技术。它其实是将Bump Map转换成了一张Normal Map。其中RGB存储了原高度图该点的法线。我们在渲染的时候直接将该点的法线与光线向量点乘,得到明暗系数。

通过Height Map生成Normal Map



因为我们采用纹理来映射凹凸,而且是使用的高度图。因此我们使用的是二维数组来存储高度信息。那么如何将高度转换为法线向量呢?我们知道法线是垂直于物体表面的,其实也就是垂直于物体切线的。因为是在三维空间,因此我们需要两条切线来完成这样过程。我们定义切线S(i,j)和T(i,j),定义高度为H(i,j)。其中S(i,j)和T(i,j)表示(i,j)处的切线,H(i,j)表示(i,j)处的高度。高度图的宽度为WxH。那么:

1
2
S(i,j) = (x+1,z,H(i+1,j)) - (x-1,z,H(i-1,j)) = (2, 0, H(i+1,j) - H(i-1,j))
T(i,j) = (x,z+1,H(i,j+1)) - (x,z-1,H(i,j-1)) = (0, 2, H(i,j+1) - H(i,j-1))

计算出两个切线之后,我们就可以通过向量叉积推导出法线向量。

1
N(i,j) = S(i,j) X T(i,j)

我们定义S(i,j)和T(i,j)的z分量分别为Sz和Tz。那么

1
N(i,j) = (-Sz,-Tz,2)
1
2
3
out.x = a.y * b.z - a.z * b.y;
out.y = a.z * b.x - a.x * b.z;
out.z = a.x * b.y - a.y * b.x;

最后我们将转换出来的法线存储到二维的贴图里面。

image

法线贴图映射



在之前,我们推导了如何使用高度图生成法线贴图。因此我们知道法线贴图的每个纹理元素存储的不是RGB数据,而是法线向量。

image

法线向量编码到颜色分量

我们采用24位图像格式,来为每个颜色分量分配一个字节,这样每个颜色的分量的取值范围就在[0,255]。那么我们如何将一个法线向量压缩到RBG中呢?对于一个单位向量来说,每个坐标的值都在[-1,1]区间。如果我们把这个区间调整到[0,1],乘以255舍去小数,那么结果就是在[0,255]区间的整数。因此:

1
f(x) = (0.5x + 0.5) * 255

其中x为向量的每个分量,f(x)为颜色分量值。

颜色分量解码到法线向量

当给出一个在[0,255]区间内的颜色分量之后,如果将它恢复为[-1,1]区间呢?其实我们只需要将f(x)函数反转过来即可。

1
f(x) = 2x/255 - 1

其中x为颜色分量,f(x)为[-1,1]的法线分量。又因为在GPU中,颜色分量值为[0,1],因此最终解码函数为:

1
g(x)=2x - 1

切空间

理解了法线贴图之后,我们就需要对法线贴图进行映射。法线贴图有两种:

  • 世界空间的Normal Map
  • 切空间的Normal Map

第一种在实时游戏里面没有实用价值,因为世界空间的法线贴图是对世界坐标系的一个法线映射。一旦我们改变了模型的位移缩放旋转等等,法线信息不正确了。因此我们使用第二种,也就是我们常说的一种。

切空间也称之为纹理空间,纹理空间与世界空间,局部空间的意义一样。如果我们将法线保存到纹理空间,那么我们就可以不用考虑模型在场景中出现的位置、朝向、缩放等等细节。我们只需要专注于纹理空间即可。我们通过纹理的U、V轴增长方向以及三角面的法线定义了切空间–TBN。其中U坐标增长的方向为T轴,V增加的方向为B轴,三角面法线为N轴。如下图

image

映射

我们存储的法线向量使用的是纹理空间坐标系。然后场景中的灯光使用的是世界空间坐标系。为了统一,我们需要酱法线向量和灯光变换到同一个坐标系中。因此我们第一步就是将纹理空间与三角形的顶点的物体空间建立联系。只要能够进入到物体空间,那么我们就可以从物体空间变化到世界空间,或者从世界空间变化到物体空间。

现在我们假设 v0、v1、v2为三角形的三个顶点,对应的纹理坐标为(u0,v0)、(u1,v1)、(u2,v2)。如下图:

image

我们设

1
2
e0 = v1 - v0
e1 = v2 - v0

e0、e1为三角形的两个边向量,对应的纹理三角形的边向量为:

1
2
(^u0, ^v0) = (u1 - u0, v1 - v0)
(^u1, ^v1) = (u2 - u0, v2 - v0)

从图中,我们可以看出:

1
2
e0 = ^u0 * T + ^v0 * B
e1 = ^u1 * T + ^v1 * B

那么e0,e1就可以得到一个矩阵方程

image

根据这个矩阵,我们只要知道了三角形顶点的物体空间坐标,那么我们就等于知道了三角形边向量的物体空间坐标。所以矩阵

image

是已知的。

同样我们知道了纹理坐标,所以矩阵

image

也是已知的。

现在我们来求解T和B的物体空间坐标。

image

image

在切线空间和物体空间之间变换

根据前面的推导我们得到了相对于物体空间的T和B,我们加入物体空间的法线向量。就可以构成一个相对于物体空间坐标系的TBN居中。通过这个矩阵,我们就可以将坐标从切线空间转换到物体空间。

通过这个矩阵,我们可以将坐标从纹理空间变换到物体空间。

image

同样我们可以通过该矩阵的逆矩阵,从物体空间变换到纹理空间。

image

因为该矩阵是正交居中,因此它的逆矩阵和转置矩阵相同。