GAMES101(4): Texture Mapping

课程链接:GAMES101-现代计算机图形学入门-闫令琪
课程讲师:闫令琪
本系列笔记为本人根据学习该门课程的笔记,仅分享出来供大家交流,希望大家多多支持GAMES相关讲座及课程,如涉及侵权请联系我删除:albertlidesign@gmail.com

如下图,我们可以看到两个台灯在照亮一个地板和一个球,我们是可以得到光的强度,但是比如球上面,自身有不同的颜色,尽管上面所有的点共用的是同一个着色模型,但是不同位置的漫反射系数发生了改变。对于地板来说也是如此,它有自己的漫反射系数,这个系数反映了木质的质感。因此我们希望在模型的不同位置定义不同的属性,这就要引入纹理映射的最基本的思路,它的根本作用是定义一个点的属性。

那么怎么定义任何一个点它的基本属性呢?我们需要理解我们要定义在物体表面上,那么我们应该怎么样去理解物体表面呢?首先,任何一个三维物体的表面其实都是二维的。例如下图,地球仪可以被展开成世界地图,这也就是说三维物体的表面其实是二维的,多个物体也可以被展开成多个平面,因此通过这种方式,我们可以和一张图来做一个一一对应关系。因此所谓纹理,其实就是一张图,这张图我们可以任意地裁切,用其中一部分,也可以拉伸、压缩等操作,最后把它蒙在三维物体的表面,这个过程就叫做纹理映射(Texture Mapping)

来看一个具体的例子,左上角是一个渲染结果,即Blinn-Phong得到的结果,我们想得到有质感的模型该怎样做呢?根据刚才的思路,要把一张图贴在模型上,我们自然要知道怎么贴。三维空间中最基本的东西是三角形,那么三角形在物体上应该如何映射到纹理上呢?也就是物体上的某一个三角形在纹理上对应的位置在哪?对于任何一个三角形上的顶点都能找到它在纹理上的点。

对于任何一个模型,我们要能够将它展开成一个平面,并且希望其产生的三角形尽可能地少扭曲,这是一个很重大的研究方向叫做参数化(Parameterization),是几何上非常厉害的研究。我们这里不管怎么把三角形映射到纹理上这件事,就假设我们已经知道如何把三角形贴在纹理上,并且知道三角形上的顶点在纹理上的坐标。既然提到了纹理上的坐标,那我们就该在纹理上定义一个坐标系,这个坐标系通常会使用(u,v)(u,v)来表示纹理上任何一个点。例如下图所示

一般来说,都认为纹理的范围是在[0,1][0,1]中,这可以方便处理,不管分辨率、长宽比是多少。

纹理可以应用在各种各样的物体表面,如果我们把纹理的坐标显示出来,就会看到下图结果

纹理映射就像贴瓷砖一样不断地重复纹理,最后的渲染结果就是下图

因此纹理可以重复多次,但是纹理在重复的过程中会产生缝隙,就容易被人发展破绽。但是上图这个例子中,它使用的纹理设计的好,使得这些纹理复制和重复的时候可以做到无缝衔接,这种纹理的设计需要各种各样的算法,其中一个叫Wang Tiled。

下一个问题就是,我们已经知道了三角形三个顶点对应的纹理坐标,那么我们如何知道三角形内部的点对应的纹理坐标的uv呢?这里就又涉及到了插值问题。

Barycentric coordinates

让我们来看看如何在三角形内部进行插值,为了实现这一方法,我们引入了一个叫做**重心坐标(Barycentric coordinates)**的概念。

为什么我们要在三角形内部做插值?

(1)首先是因为我们都是对三角形顶点进行操作的,我们希望在三角形內部可以平滑地过渡。也就是说,当顶点处被赋予一个值时,三角形内部地任何一个人也能得到一个过渡的值,这样这个值就能从一个顶点过渡到另外一个顶点。
(2)插值的内容可以有很多,比如贴图坐标,颜色,法向量等。比如在纹理映射中,我们可以把三角形顶点映射到贴图上对应的(u,v)(u,v),那么三角形内部的点对应的uvuv坐标就可以通过插值来计算得到。基本上可以说,插值可以对三角形的任意属性进行插值。

怎么做插值?

使用重心坐标。首先,重心坐标是定义在一个三角形上的,即给定一个三角形,可以得到一套重心坐标。重心坐标是说,在给定三角形的平面内的任何一点都可以表示成三角形A,B,CA,B,C三个点的线性组合,如下图所示,其中α,β,γ\alpha , \beta , \gamma合在一起构成一个坐标用来表示三角形内的点(x,y)(x,y)。也就是说,为了描述一个点的位置,我们不需要构建直角坐标系,给定任意三个点,只要有一个点在这三个点所在的平面上,我们就可以得到用这三个点的线性组合来表示出该点。

观察公式α+β+γ=1\alpha + \beta + \gamma = 1,也就是说实际上我们只需要知道其中两个,就可以求出第三个。除此之外,还需注意,这个点如果在三角形内,那么要求α,β,γ\alpha, \beta, \gamma必须都是非负的,换句话说如果满足三个值都是非负的且它们的和等于1,那么这个点一定在三角形内。

  • 如下图示例,问AA点自己的重心坐标是什么?我们可以从定义可知,令α=1,β=0,γ=0\alpha = 1, \beta = 0, \gamma = 0来得到AA点的重心坐标。
  • 那么为什么重心坐标的三个值之和为11呢?这是为了限制所得到的点是在三角形所在的平面内。
  • 如果在三角形内有任意一点,我们该如何计算它的重心坐标呢?其实可以根据这个点与三点的连线所划分出的三角形的面积来求得,如下图所示。
  • 除此之外,根据重心的定义方法我们可以得到一个非常特殊的点,就是三角形的重心,重心有一个非常好的性质,即如果将重心与三角形的三点相连,会得到三个等面积的小三角形,所以重心的重心坐标就是(13,13,13)(\frac{1}{3},\frac{1}{3},\frac{1}{3})
  • 上面我们知道计算重心坐标需要计算面积,下面给出一个更为简化的方法,直接使用重心坐标的一般表达式(当然也可以用面积来推出这个公式):

    接下来我们就可以用重心坐标来做插值了。有了重心坐标,我们可以用这样的方法来计算顶点上的任意属性,可以是位置、纹理坐标、颜色、深度、材料属性等等。

    需要注意的是,尽管重心坐标应用非常好用,但是它不能应用于投影。假如上图中三角形是空间中的三角形,但是如果我们把它投影到某一平面上,我们可以计算出三个点在投影之后的坐标,但是如果对投影之后的三角形来计算重心坐标就会得到一个不一样的坐标。这是说重心坐标不能保证投影后不变,如果我们想插值三维空间中的属性,就应该取三维空间中的坐标,来计算重心坐标而不能在投影之后再做。这就涉及到了关于深度的问题。在做光栅化,我们把三角形投影到了屏幕上,它会覆盖很多像素,这些像素都有中心,我们可以计算出中心所在的投影之后的三角形的位置,那么我们不可以在投影之后的三角形里面的深度做插值,而是**应该找到这个位置对应在三维空间中的坐标,然后在三维空间中计算出正确的插值,再把结果拿回来。至于怎么把投影到屏幕上的三角形再投影回去,应用逆变换就可以了。
    因此
    在三维空间中的属性一定要在三维空间中做插值。**根本原因就是重心坐标在投影操作下会发生变化。

Applying Textures

了解了重心坐标,下一步就是去了解怎么把纹理应用在实际的渲染中。现在我们知道屏幕上的点在三角形上有一个位置(像素中心),我们也可以计算出贴图上任何一个点对应在三角形上的位置了,使用重心坐标做插值即可,我们只需要从纹理上查询对应的颜色,就可以得到屏幕上的点的颜色,我们可以将其视为Blinn-Phong模型中漫反射的系数kdk_d,这就相当于把贴图贴在了物体上。但是这样简单的操作可能会产生一些问题。

问题一:Txture Magnification (过小的贴图)

假设要渲染一堵墙,定义渲染分辨率为4k,但是使用的纹理只有256×256256×256,这时任意一个点去查找颜色的时候会查到一些非整数的值。也就是说,纹理太小了,纹理就会被拉大,拉大了就会出现如下图所示的现象。一个解决方法是,当查到非整数的值时,直接四舍五入RoundRound成整数,这样在一定范围内,很多像素要查找的是相同的纹理上的像素(texel),这样就会得到下左图结果。

那么我们如何才能得到上图右侧两张图的结果呢?本质上看就是如何把查询得到的一个非整数的值来做一个模糊的效果。

Bilinar Interpolation

双线性插值是其中一个方法。假设我们的高分辨率像素的中心映射到了一个非整数的位置上,下图4×44×4的格子为texels,假设映射到了下图红点处,那么我们如何知道纹理在这个点处的值是多少。

在四舍五入的方法中,相当于直接去找离它最近的texel的中心,那当然在该texel里的所有像素都显示了相同的颜色。一个巧妙的方法是先找该点邻近的四个texels。

接下来,再连接四个texels的中心,连成一个四边形,我们可以找出红点距离该四边形左下角的点的分别在水平和竖直方向上的距离,分别记作s,ts,t,我们定义两个相邻texel的距离为1,因此这两个值一定在(0,1)(0,1)之间。

然后我们定义一个操作,叫线性插值:lerp(x,v0,v1)=v0+x(v1v0)lerp(x,v_0,v_1) = v_0 + x(v_1-v_0),如果我们用ss来作线性插值,我们可以求出u0=lerp(s,u00,u10)u_0 = lerp(s, u_{00}, u_{10})u1=lerp(s,u01,u11)u_1 = lerp(s, u_{01}, u_{11}),水平向的插值完成后,我们还可以再对竖直方向做一次插值,使用tt将两个值插值即可,即f(x,y)=lerp(t,u0,u1)f(x,y) = lerp(t,u_0,u_1)

因此我们发现,红点处的颜色综合考虑了它周围四个点的颜色,并且这个红点处的颜色是这四个点平滑过渡的颜色。由于做了水平向和竖直向两类线性插值(顺序无关),因此称为双线性插值(Bilinar Interpolation)

使用双线性插值方法,就消除了因为贴图过小而造成的锯齿效果,但是它的质量并不是最好的,比如在问题一中的图,还有一种方法称为Bicubic插值方法,它和Bilinar插值的区别在于,它取了周围16个texels做插值,只不过每次用4个做插值,这就有三次的插值。因此它的计算量要大,消除锯齿的效果更好。

问题二:Txture Magnification (过大的贴图)

那么如果纹理过大会怎样呢?纹理大了会引起更严重的问题,如下图所示,假设一张面贴了一张纹理,纹理是格子,如果我们还是用像素的中心找纹理坐标,再把这个值写回像素,我们就会得到下右图结果,远处效果为摩尔纹,近处为锯齿,即走样问题。

我们来分析一下问题在哪,因为近处,覆盖的纹理上的区域相对较小,在远处,一个像素覆盖了很大的一个区域。也就是说屏幕上的像素覆盖了纹理上的区域的大小是各不相同的。之前我们做抗锯齿使用了MSAA,也就是对一个像素使用更多的样本来采样,这里我们同样也可以这么做,这样得到的结果也是可以的,但是计算量过大。
=
走样问题就是信号变化过快,我们采样的频率跟不上信号变化的频率。在这个问题上体现在, 当纹理特别大的时候,一个像素里面可能包含很大的频率,这样就需要更高频的采样方法才能够跟上纹理变化的频率。如果我们不想用这么多的采样点改怎么办?我们这里可以避免采样,原本我们做采样是像素在纹理上覆盖很大一块区域,但如果我们立刻就可以知道这个区域里的平均值是多少就好了。我们要解决的问题就是,对于任何一个区域我们立刻就能求出它的平均值,我们可以使用Mipmap。

Mipmap

Mipmap是一个在图形学中广泛运用的经典概念,它能做范围查询(fast, approx, square)。这个算法快,但是只能做近似的、方形范围查询。Mipmap就是从一张图生成一系列图,例如有一张纹理为128×128128×128,称之为第0层纹理,我们可以生成更多更高层的纹理,每一层都是上一层缩小到一半的结果。例如第0层为128×128128×128,第一层为64×6464×64,第二层为32×3232×32,直到最后变成一个像素,这样一共就有loglog层。

我们可以在渲染之前把这些Mipmap都生成,问题在于,我们生成了这所有的Mipmap相比于原本的图,占用了多大的存储量呢?答案是1+14+116+...=431+\frac{1}{4} + \frac{1}{16} +... = \frac{4}{3},也就是说,原本的图存储量是11,生成它的所有Mipmap只比原来多占用了原本存储量的13\frac{1}{3}

接下来我们要用Mipmap近似地在一个正方形区域内做范围查询,要立刻得到范围内的平均值。可以想象到,任何一个像素都可以映射到纹理上一个区域,那我们该如何得到这个区域呢?很简单,例如图中我们像素上的蓝色和红色采样点,蓝色点有它的邻居,红色点也有它的邻居,如果我们想算红点所占据的像素的覆盖面积,我们可以取它自己的中心和它邻居的中心分别投影到贴图空间上去,在屏幕空间中,所有点到其邻点的距离都是一个像素,那么我们能求得它们映射到纹理贴图上的距离,映射后会得到一个不规则的区域,我们可以使用一个正方形来近似这个不规则的区域。



下面的问题是,我们如何根据计算好的Mipmap来计算这个边长为LL的正方形的区域的平均值?我们只需求log2Llog_2L,即可求出这个正方形应该所在的层数,这样就能查出这个区域的平均值了。

这样一来,离摄像机近的点就会在很低层去查询,离摄像机远的就会在很高的层去查询。但是会发现,不同层之间点的颜色可能不是连续的,因为我们只算了离散的若干层,例如我们算了第11层,算了第22层,但是不知道第1.81.8层的结果。

怎么解决这个问题呢?还是插值,我们得到了第11层和第22层,那我们对这两层内部分别使用双线性插值,做出来之后我们可以把这两层双线性插值的值合在一块就可以在层与层之间再做一次插值,这样就是第三个不同的插值,这里我们叫三线性插值(Trilinear Interpolation)。这样,在纹理的内部,不管坐标是否为整数坐标都可以双线性插值出一个平滑过渡的值,在层与层之间也可以插值出一个平滑过渡的值,这样就可以对于任何一个像素中心,做一次查询就可以得到所覆盖的区域的平均值。使用三线性插值,我们得到了下图所示结果(由于几何造成的问题暂时忽略),它在游戏、实时渲染领域得到了非常广泛的运用,因为它可以得到一个完全连续的表达,并且开销很小。

回到我们前面的示例,Mipmap是否能够真的解决问题呢?我们假设一个像素做512个采样点来得到的结果是一个准确的结果,如下图所示。

那如果我们使用Mipmap,得到如下结果,会发现在远处Mipmap把所有的细节全部都忽略掉了,远处出现了完全不应该糊掉的区域,我们称之为Overblur。为什么会出现这种情况呢?因为它只能查询一个方块的区域内的平均值,如果不是方形那就没办法。

Antisotropic Filtering

有一个办法可以部分解决Mipmap产生的问题,就是各向异性过滤(Antisotropic Filtering)。它的效果要比Mipmap要好。Mipmap本身是将原始的一张图,将其长宽各不断地缩小一半,Mipmap其实是计算反映在对角线上的图片,而各向异性过滤比Mipmap多了不均匀的水平和竖直的压缩,各向异性就是水平向和竖直向都有压缩,如下图所示,比如对于卫星来说,每一行都是高度不变,宽度变,每一列都是高度变,宽度不变。也就是说通过这种方式的预计算,我们可以查询到任何一个被压扁的图上的一个位置,这样我们就可以查询到一个矩形的区域而不被限制在正方形。

这是因为屏幕上的像素映射到纹理上以后很有可能是一个不规则矩形,如果我们近似成一个正方形,就会求一个很大的区域,这样就会造成overblur。如果我们使用了各向异性过滤,就可以得到一个矩形区域,自然得到的结果就会好很多。但是假如一个像素对应到贴图中是一个斜45度的矩形,各向异性过滤仍然不能很好的解决问题,因此它只是部分解决问题。

因此人们又发明了另外一种方法,称为EWA过滤,它是将映射后的不规则的形状拆成很多不同的圆形去覆盖这个不规则形状,比如一个椭圆可以被拆成三层椭圆进行多次查询来得到结果,但是代价就是查询量大。

注意,各向异性过滤的额外开销是原本的3倍,而Mipmap仅为原本的13\frac{1}{3}。在游戏中我们经常能看到有个“多少xx”的选项,意思就是计算多少层,比如2x2x就是方向上压缩一次,4x4x就是各方向压缩两次,随着xx的增加最后存储量达到原始的33倍,它和显存关系很大和计算力关系不大,这意味着如果显存足够可以把各向异性过滤开到最高,对性能几乎不会有影响。

到目前为止,除了阴影没讲解,其他整个渲染过程都已经完成了。

Apllications of textures

根据前面的内容,我们知道给定一个网格,我们可以做着色,例如Flat Shading得到一个个格子的shading,也可以做Phone Shading。接着可以对它做各种贴图,下面讲解一些高级的纹理应用。

首先纹理就是一张图,我们说它可以做各种各样的应用,在现代GPU贴图,我们可以把纹理理解成一块内存,并且我们可以对纹理上的这块区域进行一个范围查询(或着说过滤),并且查询速度非常快。因此纹理完全可以理解成一块数据,可以做不同类型的查询,没有必要完全限制在一个图像上。从这个角度出发,它可以表示的东西就太多了。

环境光照

环境光照,也有叫环境光映射或者环境贴图。假如我们站在一个房间里面往四面八方看,会发现有来自四面八方的光,如果我们把任何一个方向的光记录下来,就能得到环境贴图(环境光照),因此我们可以用这幅图来做渲染,使它能够反射出任何方向来的光,例如下图中的茶壶反映出了窗户。因此我们可以用纹理去描述环境光,这就比只用一个点光源要好很多,具体的计算方法后面再说。

正常来讲,我们记录环境光信息不能只记录方向,物体在空间内的不同位置会有不同的环境光效果,但是这里在环境光贴图中只记录环境光的方向。

环境光是怎么得到的呢?我们可以在空间中放一个镜面球,它所反射出来的就是环境光。因此我们可以把环境光存储在一个球上,然后把它展开得到环境光贴图。这就是Spherical Environment Map。

但是如果我们把它展开,这里会有一个扭曲问题,这可以参考世界地图,南极洲的实际大小受到了球展开时扭曲的影响。后来,人们就发现了一个办法来解决这个问题,他们将球用一个包围盒包住,接着我们用从球心到球上某一位置的连线作延长,直到接触到包围盒的表面上,这样就将这些信息存到了立方体的表面上,就能得到六张图,因为一个立方体可以展开成六个面。因此,我们可以把环境光记录在一个立方体所对应的各个表面上再展开,立方体的各个面都是均匀的,因此它能够避免扭曲,这一方法称为Cube Map。但是这一方法也有它的问题,给定一个方向,如果要找对应的颜色则需要先判断它在立方体的哪张面上,增加了计算量。

凹凸贴图

纹理的另一个很重要的应用是凹凸贴图。之前我们用纹理是为了替换Blinn-Phong模型里的参数kdk_d,除此之外,纹理还可以定义任何属性。比如它可以定义在一个模型上的点的相对高度。一个球假设它原本有一个基础的表面,然后纹理可以定义这个表面上的点沿着法线方向的相对高度。

比如上右图所示,一个类似橘子的球,如果我们用三角形来表示则需要无数个三角形面,而如果我们用纹理来定义橘子表面上的凹凸质感,就可以定义任何一个点的相对高度。我们知道,相对高度变了意味着法线就会变,法线变换后着色就会变化,人们在看到一定程度的明暗变化后就会认为这里有凸起的质感,因此,使用贴图可以人为地制作出假的法线,从而得到一个假的着色结果最终欺骗人眼,就能既做出凹凸贴图的质感又能避免复杂的几何。这就是凹凸贴图的基本原理。

我们再具体来看,我们知道了,通过凹凸贴图,我们可以定义一个复杂的纹理,但是并不改变几何信息,也就是说三角形数不变。然后我们对每一个像素的法线做一个扰动,通过定义的不同位置的高度,根据邻近不同位置的高度差来重新计算它的法线。也就是说,纹理定义的是任何一个点相对高度的移动。如上图所示,黑色线为原始的物体表面,然后我们运用了一个贴图,来告诉我们这个点的相对高度应该如何变化。对于任何一个点pp,它原本的法线应该是垂直于黑色表面的,但是凹凸贴图改变了它的相对高度,其法线自然也发生了改变,变成了垂直于黄色线表面。那么我们应该如何计算它的变化呢?

如上图所示,我们先从一维来考虑,假设原本的表面是一个平面,蓝色为凹凸贴图定义出来的,那么原本点pp的法线应该是(0,1)(0,1)。现在要求凹凸贴图变化后的点pp的法线,只需先求出该点在凹凸贴图定义的函数上的点的导数dp=c[h(p+1)h(p)]dp = c[h(p+1)-h(p)],这里定义了cc为一个常数,来表示凹凸贴图的影响。这样我们就得到了切线。接着,我们知道法线就是垂直于切线的方向,只需要将切线逆时针旋转90°即可得到法线,法线为n(p)=(dp,1).normalized()n(p) = (-dp, 1).normalized()
所以整个思路就是,我们用凹凸贴图来定义切线,再通过切线来计算法线。
那么在三维中,假设表面pp上的法线n(p)=(0,0,1)n(p) = (0,0,1),那么贴图如何影响该点的法线呢?我们仍然可以先求出它的梯度,即dpdu=c1[h(u+1)h(u)]\frac{dp}{du} = c_1[h(u+1)-h(u)]dpdv=c2[h(v+1)h(v)]\frac{dp}{dv} = c_2[h(v+1)-h(v)],这表明了点ppuuvv两个不同方向上的变化,这样就表示出了切线方向,最后法线方向即为n=(dpdu,dpdv,1).normalized()n = (-\frac{dp}{du}, -\frac{dp}{dv}, 1).normalized()。注意,这里我们定义了一个局部坐标系,即认为所有的点一开始的法线都是n(p)=(0,0,1)n(p) = (0,0,1),我们是在这个坐标系中对法线进行了更改,最后再将这个法线变换到世界坐标系中。

Displacement mapping

除了凹凸贴图,还有一个更现代化的做法,叫做位移贴图(Displacement mapping)。它和凹凸贴图一样,都是通过纹理去定义任何一个点的相对高度差,因此输入完全一样,使用了完全相同的纹理,只不过位移贴图会真的将顶点做移动,而不是通过移动位置换算成法线变化来实现假的效果。凹凸贴图不会改变物体的几何,这会在物体的边缘处“露馅”,边缘处的凹凸质感不会被表达出来,凸起的部位也不会投影到自身。而位移贴图会实际地改变物体的几何,它不会有上述问题,但是它对模型的三角形数量有着严格要求,即三角形的数量要足够多来使采样率比纹理定义的频率高(又是采样问题)。

前面所说的各种纹理的应用都是二维的,即我们仍然将纹理当图来看,其实纹理也可以是三维的,如下图所示,有一个球,如果我们将其砍掉一半,我们希望能够看到它的内部是怎样的,这就要求纹理能够定义空间中任何一个点的值。可想而知,实际上这样的纹理的图并没有被生成,只是定义在一个三维空间中的噪声函数,这样给定空间中任何一个点都能算出它的值是多少,这个噪声可以经过一系列处理,就可以变成我们需要的样子,比如大理石的质感。

纹理还可以记录一些之前已经算好的信息,我们已经知道了怎么做着色,但是还不了解阴影。下图我们发现,最右侧眉毛凸起的部位遮挡住了眼圈,这就是说这里产生了阴影,我们之前算shading的时候会考虑不到这里。我们是可以通过**环境光遮蔽(Ambient Occlusion)**来计算出这里的阴影,这里先简单提一句,这个方法就是先计算好这些阴影,再把它写进一张贴图,最后再贴回来,所谓贴回来也就是相乘,可见就是1不可见就是0,也就是说,着色的结果乘以计算好的环境光遮蔽的纹理就能得到右图的结果了。也就是说很多计算我们可以提前去做,然后用纹理来记录这些信息,之后就取决于如何解释。

最后,纹理还可以运用到体积渲染中去,原本我们说光照模型只考虑一个表面,然而在医学里,我们会用CT呈像扫描人体最后返回出三维空间的信息,我们可以通过记录这些信息来做渲染,这些信息我们也称其为纹理。