4.纹理映射

本系列的主要目的是复盘并总结计算机图形学这个学科常用算法与原理,会从基本的数学理论讲起,同时会附带部分算法的代码实现,由浅入深,只需初中的数学基础就行。主要参考了GAMES101-现代计算机图形学入门这门课(具体引用见文章末尾),同时附加上自己的理解。有兴趣的也可直接看视频。

纹理映射

上面我们说完了着色,可以知道一个模型反射出来的颜色是由一个系数k来决定的。但是你想想,一个模型只有一个颜色系数吗?那这样的话是不是一个模型只有一个颜色呢?如果是每一个点都有一个自己的颜色系数的话,怎么存储这么多系数呢?关于这类问题,我们用纹理映射来解决。

首先举个简单的例子,我们都知道地球仪,如果把地球仪的表层去掉,那它就是一个球体。这个球体就相当于模型本身,而被去掉的表层,就是它的纹理。所以说,纹理就是一个二维的平面。

image-20220917164232228

我们只需要把三维的地球仪上的任意一个地方(一点),对应到二维地图上,就可以显示出正确的纹理了。

下面我们拿这个手来举例子,可以看到这个手上看起来挺真实的

image-20220917164817449

它对应的纹理贴图如下

image-20220917165638363

纹理与模型之间的对应关系,我们通常用二维坐标系来表达,不过不是xy,而是uv,但是它们的意义是一样的,u代表纹理的横坐标,v代表纹理的纵坐标

我们知道不同的纹理,它们的大小可能都是都是不一样的,甚至有些可能是正方形,有些是长方形,因此纹理坐标uv的定义和纹理尺寸以及形状没有关系。我们认为对于任何一个纹理,它的u和v的值都是从0到1,例如uv(0,0)代表纹理的左下角,uv(0.5,0.5)代表纹理的中心的,uv(1,1)代表纹理的右上角。

例如这个手套的纹理,它的uv如下:

image-20220917170235563

好了,看起来很不错,那么问题来了,我们怎么做才能让模型与纹理正确的对应上呢?

重心坐标派上用场了,当我们知道一个三角形三个顶点对应到纹理上的uv坐标后,我们就可以通过重心坐标来计算出该三角形内任意一点所对应的uv。然后我们通过uv坐标即可在纹理上采样到对应的颜色值作为改点的漫反射系数 的值,这样就等于把贴图贴到(映射到)了物体上。

重心坐标

先来看看重心坐标是什么,这里我不愿意用复杂来数学推导来给重心坐标下定义(我不知道为什么别人的推导公式那么长,我学不来),而是用一些直观的几何方法来解释什么是重心坐标。

image-20220917184834350

如图所示,三角形内的每一个点都有一个重心的表示方法。对图中的红点,我们认为它的重心坐标是,同时这三个数加起来必须为1,且都是非负的。的值就是其对应小三角形与整个三角形的面积的比例。

image-20220917185147987

来说,就是A点对面的三角形的面积与整个三角形面积之比,也是类似。那么三角形的顶点的重心坐标怎么表示呢?

image-20220917185443052

很简单,如上图所示,A点的重心坐标就是(1,0,0)

如果我们知道三角形的顶点的坐标,想求任意一个点的重心坐标,计算公式如下

image-20220917185711265

重心坐标的用处很广,除了纹理,还可以用于计算颜色、位置、法线、深度等等方面,下面马上就会再用到。

image-20220917190019723

模糊、锯齿

问题就此解决了吗?并没有,我们来看这张图

image-20220917190413801

这张图看起来很模糊,而且存在很多锯齿。这是因为我们纹理过小,在覆盖物体表面时被放大了所导致的。

我们的纹理其实就是一张图片,因此它也存在自身的分辨率,即由像素组成,每个像素有自己的下标,纹理上的像素我们常称为texel。而我们物体表面最终会显示在屏幕上,屏幕自然也有它的像素,屏幕像素我们称之为pixel

我们每个屏幕像素都会对应到三角形内的一个点,而三角形内的点会有它对应的uv坐标,然后我们通过uv坐标可以找到纹理上对应的纹理像素。也就是说在使用纹理映射时,屏幕像素会对应到纹理像素上当我们纹理太小的时候,我们多个屏幕像素会对应到一个相同的纹理像素上,所以产生了模糊或者锯齿

举个例子,例如我们屏幕像素(50, 50) 对应到纹理像素(5,5),屏幕像素(51, 50) 对应到纹理像素(5.1,5),屏幕像素(52, 50) 对应到纹理像素(5.2,5),然后对于浮点数我们会四舍五入成整数,那么屏幕像素(50, 50),(51, 50),(52, 50)对应的纹理像素都是(5,5)

双线性插值

既然模糊了,我们就要对其进行优化,尽量让即使纹理过小,着色出来的效果也不错。而其中一个优化的技术就是双线性插值(名字高级,其实简单的一批),它的本质也是重心坐标的应用。

先说一下线性插值,可以类比为直线的重心坐标

image-20220917192146183

我们有ABC三个点,C点在AB之间,ABC点的位置信息我们都知道,根据长度我们设的范围为0-1,那么当我们知道AB的其他属性(例如颜色,法线等)时,C点对应的属性即为: lerp就代表线性插值的意思,如果x=0,得到的就是A点。

好了,回到纹理这边,来看下图中的问题

image-20220917191205940

双线性插值的计算方法是这样的:

image-20220917192028152

总结一下:就是先算出横着的两个差值点,然后根据那两个差值点再做一次竖着的线性插值。

因为前后一共做了两趟线性插值(虽然第一趟做了两次),所以我们称之为双线性插值。双线性插值后,Q的颜色就会和边上四个像素结合起来,而不再简单的等于B的颜色。这样当多个屏幕像素对应到一个像素上时,这几个屏幕像素的颜色也会有一个线性的变化,而不再一模一样,模糊或锯齿的效果就得到减弱。

除了双线性插值外,还有双三次插值(Bicubic interpolation),得到的效果就会更好。双三次插值取得则是周围十六个纹理像素做插值(该插值方法不是线性插值),具体原理这里就不过多介绍了。

image-20220917192753438

摩尔纹

一些相对复杂的纹理容易发生摩尔纹现象

image-20220917193240865

image-20221005115941528

我们可以看到,图片的近处有锯齿,远处有摩尔纹。锯齿的原因我们知道了,是纹理太少,屏幕像素太多导致的。那么类比一下就知道了,纹理太多而屏幕像素太少就会导致摩尔纹。(插个题外话,从纹理图我们可以看出,我们的格子其实都是一样大小的。但是因为透视投影的近大远小效果,所以显得近处的格子大而梳,远处的格子小而密)

在走样那一章我们知道,摩尔纹属于欠采样所造成的,即我们很多纹理像素却只采样了其中一个像素的值。那么只需要利用MSAA的原理,即在一个像素内增加采样点,然后求个平均,来反走样解决问题。真的这么简单吗?看看效果

image-20220917193858452

我们发现远处不再有斑斑点点了,但是也不太分辨的清楚,与原图相比起来还是不够好。有没有别的办法呢?当然有

Mipmap

我们上面提到的双线性插值其实属于一种点查询方式,就是说我们知道纹理上的任意一点,要查出其对应的颜色值。而对于摩尔纹,我们要用的则是范围查询,即我们知道一定范围的纹理像素,要查询出它的平均值。

image-20220917194313791

Mipmap就是一种可以帮我们实现范围查询的方法,它速度快,但并不是特别的准确,结果是一个近似值,此外它只能做正方形的范围查询。它的本质其实就是一张纹理生成一系列的纹理,如下图:

image-20220917194409069

我们假设原本的纹理是n*n大小的(纹理大小也就是纹理像素的数量),为第0层。然后我们用它增加更多层的纹理,每一层的长宽大小都是上一层的一半,那么总共就会有 层。这样我们只需要在使用前先生成好mipmap,然后使用时直接使用它做查询,就可以节省下使用时很多的计算时间,从而保证效果还不错。

你可能会情不自禁的算一下这种方法需要多用多少空间,其实很少,只需要多用三分之一就可以了。算的方法很简单,这里不多赘述,给一张图,相信你能看明白

image-20220917195014892

接下来说一下它要怎么查:

上面我们说了mipmap只能进行正方形查询,因此我们就要把覆盖范围近似成正方形,我们假设边长为L。那么假如一个屏幕像素覆盖了4个左右的纹理像素,即L=2,那么我们自然要使用第一层的mipmap,而要是覆盖了16个左右像素,L=4,就应该用第二层的。也就是说如果一个屏幕像素覆盖了L*L个纹理像素,那么就应该使用 logL 层mipmap。那么我们就要知道我们的一个屏幕像素到底覆盖了多少的纹理像素,也就是求L的值。

怎么算?例如我们屏幕像素(x,y) 对应的一块纹理像素的中心点为(u,v),那么我们再取它周边的一个屏幕像素,例如(x+dx,y+dy),算出对应的纹理像素,假设为(u+du, v+dv),那么我们就可以近似的求出L的值。

再说直白点,就是找这个屏幕像素旁边的一个像素和上面的一个像素,看这两个屏幕像素对应的纹理像素原本的纹理像素之间的距离,取大的那个。

image-20220917200230635

三线性插值

前面我们可以通过计算L的值来计算出应该查询哪一层,但是实际情况下,从近到远,我们L的值是线性增长的,比如从1到2到3…到n。但是由于的值取得是整数(设为D),当我们L=1时,D=0,L=2时,D=1,但是当L=3到5时,D都等于2。也就是说当L=3时,不存在D=1.58的情况,那么就会造成屏幕像素的颜色不是线性变化的,而是突然改变的。如下图

image-20220917200612272

为了追求更好的效果,那么我们能不能在知道D=1和D=2的mipmap时,求出D=1.58乃至其他浮点数的mipmap呢?可以,答案还是线性插值,如下图:

image-20220917200805084

我们先用双线性插值求出D层和D+1层的值,然后再线性插值求出D+x层的值(x范围0-1),这样等于在双线性插值的基础上再做了一趟线性插值,所以我们称之为三线性插值。这样就可以使得mipmap层与层之间的颜色变化是连续的。使用三线性插值后,我们之前的模拟图效果就会变为下图所示,看起来丝滑多了。

image-20220917200836530

Ripmap

前面我们说了mipmap可以解决摩尔纹的问题,但是有些情况下,它的效果并不是很好

image-20220917201107553

左边是我们想要得到的结果,而右边是mipmap得到的结果,可以发现在远处变得很模糊了,这是为什么呢?

因为我们的mipmap只能做正方形的查询,在计算覆盖范围时都是按照正方形去考虑的,而实际上屏幕像素的覆盖性质并不可能那么的完美

image-20220917201157830

我们可以发现这种情况下,一个屏幕像素覆盖的纹理像素范围更多的是长方形,甚至是斜条,那么再用正方形去计算就会出现很多的问题,这也正是mipmap的不足之处。而针对覆盖范围更多是长方形的情况,我们有更好的做法,即Ripmap, 各向异性过滤。

Ripmap和mipmap的不同之处就是它可以支持长方形的查询,它生成出来的纹理如下:

image-20220917201259885

左上角为原始纹理,在水平方向只进行宽度的压缩,在竖直方向只进行高度的压缩,那么压缩后的图片任意一点还原到原始图片时,代表的都是一个长方形的区域。

从图中我们也可大致看出,使用Ripmap会导致存储空间变为原来的四倍左右,造成较大的显存占用。但是当压缩的层级x越高,增加的空间也会越小,例如当x=5和x=10,其实图片大小差距不大,所以打游戏的时候,设置里可以开的越高越好。

Ripmap可以很好的解决覆盖范围为长方形的情况,但是对于更奇怪的覆盖范围,例如斜条等,同样不能很好的解决问题,对于这类情况我们还可以使用EWA Filtering来解决,当然运算量又会增加,具体EWA Filtering的实现原理,这里就不深挖了,有兴趣的老铁再细查吧。

image-20220917201429649

其他应用

纹理不仅仅只能记录颜色,它还能记录环境光、位移、法线等等。(这里浅浅了解一下,后面有兴趣再深挖)

比如下图这种纹理,就可以把周围的环境光都记录在球面上,叫做光照贴图,它能够模拟出全局光照的感觉。但是缺点也很明显,因为是事先烘焙好的阴影,因此在运行时这些阴影不会受外界的影响而改变。

image-20220918123327181

还有法线贴图,也叫凹凸贴图,它可以改变模型表面的法线。我们前面讲漫反射的时候提到表面的法线会改变光照。那么法线贴图就是通过设置一些假的顶点法线来制造出假的着色结果,来给人凹凸不平的感觉。因此利用法线贴图并不会改变模型的几何信息,即原本各个顶点位置的不变。

image-20220918123751955

而与之相比的位移贴图,就是真的改变了三角形顶点的位置而产生凹凸感,效果也要好很多,如下图:

image-20220918123914108

可以看出由于移动了顶点位置,边缘处依旧是凹凸不平的,包括阴影也是。

不过要使用位移贴图,首先需要三角形数量足够多,要跟得上位移贴图定义的频率。在DirectX中,提供了一个动态细分的方法,即一开始可以三角形偏少,当需要应用位移贴图时再自动细分三角形,即把一个三角形分成很多个小三角形,来匹配位移贴图的频率。

除此之外,还有一种三维纹理,它实际上是三维空间上的一种噪声函数。对于空间中任何一个点它都能够算出这个点对应的值是多少。例如大理石的纹理。

image-20220918124140444