图形学基础知识:着色(二)纹理映射

纹理映射(Texture mapping)

在上一篇文章中,我们学习了Blinn-Phong反射模型与着色频率,假设我们使用Phong着色并且只考虑漫反射的情况,我们知道一个着色点也就是一个像素的颜色应该是由下面这个式子算出来了。

L_d = k_d \times \frac{I}{r^2} \times max(0, \vec{l}\cdot \vec{n})

那么如果我们要把一个长方体着色为白色(如下图),只需要每个着色点的 k_d 值都为白色即可,也就是每个着色点的 k_d 值都相同。

但是大千世界千变万化,怎么可能都是纯色的东西呢,如果我们要把长方体着色成墙一样(如下图),那么 k_d 的值应该怎么设置?

从图中可以看出,此时每个像素的颜色基本都不一样,也就是说每个着色点的  k_d 值都不相同,那么我们应该怎么来设置这个 k_d 值,总不可能一个个着色点去单独设置吧,此时纹理映射就可以帮我们来解决这样的问题。

 

物体表面与纹理

我们知道我们显示的三维物体,其实都是三维物体的表面,而这些表面其实本质上都是二维的。这些物体表面由一个个三角形组成,我们只需要从中撕开一些口子,就可以把它们展开成一个二维的表面,就像生活中的剥橘子一样。

那么这样,我们就可以把三维物体表面与二维的图联系在一起了,就好像地球仪与地图的关系(如下图),三维的地球仪上的任意一个地方(一点)可以同样对应到二维地图上的这个地方。

纹理映射的纹理,指定就是我们前面所说的这些二维的图片。我们假设这些图我们可以随意的拉伸扭曲,那么我们就可以把这些图包裹到三维物体上,那么这个过程我们就可以称之为纹理映射。例如把地图裹到一个球上面,那么这个球就变成了地球仪。

之前例子中的长方体的纹理就如下图:

 

何为映射

前面所说的映射是把纹理包裹到三维物体上,是一个很笼统的说法。实际上纹理映射,我们只需要知道物体表面上的点(也就是着色点)与纹理的对应关系,即三维表面的某个着色点是纹理上的哪个位置,然后着色时 k_d 的取值按照纹理上的值即可。

例如下图,我们有个狗狗的模型(资源来自Dog Knight PBR Polyart),通过前面学习的着色,我们可以得到如下效果:

然后我们下面这张纹理来与之对应

即可得到纹理映射后的效果:

我们来看下他们的对应关系,例如我们来看鼻子这部分,它同样是由三角形组成,这些三角形会对应到纹理右下部分的鼻子那块(如下图),这样我们就知道鼻子这应该显示什么颜色了。

至于为什么这些三角形会对应到纹理的那些部位,这个在美术建模的时候,就由建模软件制定好了关系,我们就当做我们能够知道模型表面上任意一点在纹理的哪个位置即可。

我们再来看个比较有意思的例子,另一只狗狗,如下:

它的纹理是什么样的呢?我们看下图,是这只狗狗的纹理

是不是很神奇,居然都是纯色小块,其实我们从表面的三角形(如下图)可以发现每个三角形确实都是纯色的。

 

无缝贴图

先看下面这个例子

我们可以发现,立方体的六个面都是同一个纹理,也就是一张纹理贴图并不一定要覆盖一个物体表面所有三角形,它可以被重复利用。

如果一个平面重复利用一个纹理,效果如下:

这种情况就贴瓷砖一般,对于墙壁,地板这类面积较大平面非常的适用。但是我们可以发现这种情况下纹理边缘十分的明显,多数情况下我们并不希望这样,更希望它们看起来像个整天。因此无缝贴图很适用这种情况,无缝贴图的平铺效果,可以做到边缘的无缝衔接。例如我们最初的砖块的纹理其实就是一个无缝贴图,应用到上面的表面效果如下:

这个墙壁其实是九张一样的纹理拼接起来的,但是它们如同一个整体一般,我们基本看不出什么缝隙。

 

纹理坐标UV

前面既然提到了纹理上的某个位置,那么我们是不是就可以在二维的纹理上定义一个二维的坐标系,这样纹理上任意一点的位置我们就可以使用 (x, y) 的方式来表述了。事实上,就是这样的,不过对于纹理的坐标系我们通常用uv来表示,而不是xy,但是意义是一样的,u代表纹理的横坐标,v代表纹理的纵坐标

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

例如我们前面狗狗的纹理,它的uv如下:

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

 

纹理大小与实际效果(Texture Magnification)

纹理过小

我们来对比下下面两张图:

                 

我们可以发现右图明显的比左图模糊了很多,但是基本纹路来看他们是一模一样的,这是为什么呢?模糊其实就是因为我们纹理过小,在覆盖物体表面时被放大了所导致的。

我们的纹理本质上是一张图片,它也存在自身的分辨率,即由像素组成,每个像素有自己的下标,纹理上的像素我们常称为texel。uv对应的像素坐标我们也是可以计算出来的,例如我们纹理每行有1000个像素,那么当u=0.25时,对应的像素横坐标就是39(下标从0开始),v自然也是同理。

而我们物体表面最终会显示在屏幕上,屏幕自然也有它的像素,屏幕像素我们称之为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),也就是说当我们纹理太小的时候,我们多个屏幕像素会对应到一个相同的纹理像素上,所以产生了模糊

 

双线性插值(Bilinear interpolation)

既然模糊了,我们就要对其进行优化,尽量让即使纹理过小,着色出来的效果也不错。而其中一个优化的技术就是双线性插值,接下来我们来看看它的原理是什么。

我们先来看看线性插值(Linear interpolation)是什么,其实它就类似于我们的直线重心坐标,如下图:

我们有ABC三个点,C点在AB之间,根据长度我们设 \frac{AC}{AB}=x,x的范围为0-1,那么当我们知道AB的属性(例如颜色,法线等)时,C点对应的属性即为:

C=lerp(x, A, B)=A+x(B-A)

lerp就代表线性插值的意思,例如x=0,得到的就是A的属性。

理解了线性插值后,我们再来看看什么是双线性插值,以及怎么在纹理上应用。先看下面这张图片:

图中的每个小方格我们当做是一个纹理像素,小蓝点即使纹理像素的中心点。此时我们有个屏幕像素对应到了纹理像素的红点Q位置。

若按照四舍五入的方式,那么此时Q点得到的颜色就是B点所在纹理像素的颜色,会造成的问题也就是我们前面所说的模糊。

那么双线性插值会怎么计算Q的值呢?首先它会找到红点Q附近的四个纹理像素,也就是图中的ABCD四个点,然后利用这四个点的值插值出Q点的值。如下图:

既然是周边的四个顶点,那么肯定会有个左下角点,也就是图中的C点,我们设CJ的长度为s,CG的长度为t,那么Q点就可以用(s, t) 的方式来表示。其中s和t的范围都是0-1,因为CD和CA的长度为1。并且s和v我们都是可以通过ABCDQ五个点的uv坐标计算出来的。

看到这,是不是很容易就能联想到我们前面的线性插值了?没错,我们可以通过线性插值很容易求出G,H,I,J四个点插值出来的颜色。当然我们只需要知道其中两个点的即可,I和J或者G和H,这里我们先求I和J的值,套用上面的公式,即为:

I=lerp(s, A, B)=A+s(B-A)

J=lerp(s, C, D)=C+s(D-C)

然后Q不就IJ之间了么,我们又可以通过IJ做一次线性插值,即可得到Q插值后的颜色:

Q=lerp(t, J, I)=J+t(I-J)

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

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

它们之间得到的效果如下图,可以明显发现画质效果变好了。

纹理过大

前面我们说的是纹理过小的情况,那么纹理过大会不会产生不良的效果呢?实际上当纹理过大,产生的问题就会更加严重。

例如我们现在有如下一个较大的纹理

我们把它应用到一个平面上,如下图:

我们可以发现,近处变模糊了,而远处就更离谱了,直接就看不清是啥了,对于远处这种效果,我们称之为摩尔纹。那么为什么会变得近处模糊,远处摩尔纹呢?我们还是从屏幕像素和纹理像素的对应关系入手理解。

从纹理图我们可以看出,我们的格子其实都是一样大小的,也就是说每个格子所占的纹理像素是一样多的。但是因为透视投影的近大远小效果,我们近处格子看起来会很大,也就是说会有很多的屏幕像素来显示一个格子,那不就导致多个屏幕像素会对应到一个纹理像素了么,也就是我们前面所说的模糊问题。而在远处就恰恰相反了,用极少的像素显示一个甚至多个格子,也就是说我们一个屏幕像素会对应到多个纹理像素,这就产生了摩尔纹

画成示意图的话,就如下:

我们假设在远处一个屏幕像素覆盖了十六个纹理像素,该屏幕像素中心点对应到图中的Q点。根据前面四舍五入的方法,我们知道Q点的颜色就是A点对应纹理像素的颜色,同时这个颜色也代表了该屏幕像素覆盖的十六个纹理像素的颜色,这显然是不对的。

我们在采样与走样里提过,摩尔纹属于欠采样所造成的,即我们很多纹理像素缺只采样了其中一个像素的值。那么我们只需要利用MSAA的原理,即在一个像素内增加采样点,然后求个平均,来反走样。例如上图,原本我们一个屏幕像素一个采样点,覆盖了十六个纹理像素,那么如果我们一个像素里用十六个采样点,那么覆盖的纹理像素不就基本都可以被采样到,然后利用它们的平均值来代表这个屏幕像素的颜色,而不是某个纹理像素的值来代表。这么做确实能得到不错的效果,但是会增加很大的计算量,造成性能消耗。

那么有没有什么更好的方法呢?有!就是Mipmap。一句骚话就是,采样当频率不足时会造成走样,那么我们不采样不就行了。在介绍Mipmap之前,我们先来介绍下下面两种查询方式。

 

点查询(Point Query)和范围查询(Range Query)

我们前面讲到双线性插值其实就属于一种点查询方式,我们得知纹理上的任意一点,要得知其对应的颜色值。而对于摩尔纹,我们要用的则是范围查询,即我们知道一定范围的纹理像素,要查询出它的平均值。当然应对不同的情况我们也可查询一个范围内的最大值或最小值。那么当我们得知一个范围后,如果能立刻得知它的平均值,不就可以在不增加运算量的情况下,解决摩尔纹的问题了么。

 

Mipmap

Mipmap就是一种可以帮我们实现范围查询的方法,它速度快,但并不是特别的准确,结果是一个近似值,此外它只能做正方形的范围查询。

Mipmap的本质,其实就是一张纹理生成一系列的纹理,如下图:

我们假设原本的纹理是n*n大小的(纹理大小也就是纹理像素的数量),为第0层。然后我们用它增加更多层的纹理,每一层的大小都是上一层的一半,那么总共就会有 logn 层。

这样我们只需要在使用前先生成好mipmap,然后使用时直接使用它做查询,就可以节省下使用时很多的计算时间,从而保证效果还不错。

占用空间

既然多了这么多的纹理,那么势必会增加存储空间,那么一张 n*n 的纹理使用mipmap后会增加多大的存储空间呢?答案是增加了三分之一。我们来看看这个值怎么得到的,我们设原图占的存储空间为 x,那么第一层mipmap,长宽各缩小了一倍,因此占的存储空间为 x/4,第二层再缩小一倍,即为 x/16,...,我们设所有mipmap的占用为y,那么我们就可以得到下面的公式:

y=\frac{x}{4}+\frac{x}{4^2}+\frac{x}{4^3}+...+\frac{x}{4^{log_2n-1}}+\frac{x}{4^{log_2n}}

这个求值方法有点类似我们二叉树的求节点数量,我们可以先两边都乘以3,得到:

3y=\frac{3x}{4}+\frac{3x}{4^2}+\frac{3x}{4^3}+...+\frac{3x}{4^{log_2n-1}}+\frac{3x}{4^{log_2n}}

然后再给它们各加上 \frac{x}{4^{log_2n}} 得到:

3y+\frac{x}{4^{log_2n}}=\frac{3x}{4}+\frac{3x}{4^2}+\frac{3x}{4^3}+...+\frac{3x}{4^{log_2n-1}}+\frac{3x}{4^{log_2n}}+\frac{x}{4^{log_2n}}

此时 \frac{3x}{4^{log_2n}}+\frac{x}{4^{log_2n}} 的值正好为 \frac{x}{4^{log_2n-1}},可以在和前面的项相加得到 \frac{3x}{4^{log_2n-1}}+\frac{x}{4^{log_2n-1}}=\frac{x}{4^{log_2n-2}},然后又可以和前一项相加,一直往前加,我们就可以得到:

3y+\frac{x}{4^{log_2n}}=x

因为当n很大时,\frac{x}{4^{log_2n}} 趋向于无穷小,因此 y = x / 3,即增加了三分之一的存储空间。

我们也可以利用几何的方式来理解,如下图,我们用三倍的mipmap纹理,最终会填充出一个x的大小。

查询范围

使用mipmap我们怎么建立查询关系呢,也就是说我怎么知道我们的某个屏幕像素应该使用哪个层级的纹理。前面我们说了,一个屏幕像素会覆盖多个纹理像素,由于前面说了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的值:

L=max(\sqrt{(\frac{du}{dx})^2+(\frac{dv}{dx})^2},\sqrt{(\frac{du}{dy})^2+(\frac{dv}{dy})^2})

示意图如下:

注:左边代表的屏幕像素,右边代表着纹理像素。

得到的结果如下:

三线性插值(Trilinear interpolation)

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

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

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

 

缺点

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

     

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

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

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

 

Ripmap,各向异性过滤(Anisotropic Filtering)

Ripmap,我们也可称之为各向异性过滤,它和mipmap的不同之处就是它可以支持长方形的查询,生成出来的纹理如下:

左上角为原始纹理,在水平方向只进行宽度的压缩,在竖直方向只进行高度的压缩,那么压缩后的图片任意一点还原到原始图片时,代表的都是一个长方形的区域。从图中我们也可大致看出,使用Ripmap会导致存储空间变为原来的四倍左右,造成较大的显存占用。

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

 

其他一些常见的纹理

纹理映射除了可以定义前面所说的颜色之外,它还可以定义顶点的其他各种属性,例如顶点法线,顶点位移,金属度等,下面举例一些常见的纹理。(有些就简单介绍一下,等之后学习了更多知识之后再详细补充)

法线贴图(Normal mapping)

用纹理来定义顶点的法线,对于这样的纹理我们称之为法线贴图或者凹凸贴图(Bump mapping)。

我们还是以之前的狗狗模型为例,如下图

我们可以发现此时狗狗的皮肤是很光滑的状态,但是我们知道实际上生物的皮肤应该会有各种凹凸不平的褶皱,也就是说更真实一点的话,应该如下图这个样子:

但是如果我们使用三角形来表达这些褶皱,那就需要添加无数个细小的三角形来产生凹凸不平的感觉,这明显是一个很难的工作。因此我们可以利于一个复杂的法线贴图,来定义模型表面三角形各个顶点法线的相对变化。学过着色我们知道,当顶点的法线发生变化,那么着色的结果也就会发生变化,那么就可以得到明暗不同的着色结果,让人产生凹凸感。

因此利用法线贴图并不会改变模型的几何信息,即原本各个顶点位置的不变,只是通过设置一些假的顶点法线来制造出假的着色结果,来给人凹凸不平的感觉。

该狗狗模型的法线贴图如下:

切线空间

从法线贴图中我们可以看出,这张图怎么基本都是蓝绿色的?很鸡儿怪。这是因为我们的切线是定义在切线空间当中的,何为切线空间呢?切线空间其实是一个局部的空间,即每个顶点都会有一个它所对应的切线空间。既然是空间,那么自然会有对应的坐标系,对于空间中的一个坐标系,我们自然要定义它的三个轴的方向以及原点的位置。对于某一个顶点而言,它的切线空间的坐标系的原点就是顶点本身。我们知道每个顶点都有它对应的法线,这个法线方向就是改顶点切线空间的Z轴方向,如下图。

B为球面上的一个顶点,其法线方向即为该顶点的切线空间的z轴方向。那么接下来我们自然还要定义x轴和y轴,我们知道x,y垂直于z,那么x,y自然在一个与法线垂直且相交于B的平面上。但是对于一个平面而言可以找出无数条垂直于z轴的线,但模型一般会给定该顶点的一个切线(tangent),因此我们就用该切线作为切线空间的x轴,那么y轴自然可以由x和z叉乘得到,y轴方向我们称为该顶点的副切线,因此点B的切线空间坐标轴如下:

定义好坐标系后,我们就可以用这个坐标系的(x,y,z)来代表法线了,切线空间的(0,0,1)就代表原本的法线。

我们知道(0,0,1)在rbg里代表蓝色,那么是不是说我顶点对应的法线贴图是蓝色,就代表这个点的法线没有发生变化呢?答案是错误的!在切线空间里(0,0,1)确实代表着法线,但是由于我们的法线它在xyz三个轴上可能是负数,例如(0,0,-1)代表该点新的法线和原法线相反,而(0,0,-1)并不能对应到rbg颜色上。

也就是说我们切线空间上x,y,z的取值范围是 -1到1,而rbg的取值范围是 0到1,因此我们需要一个转换,转换公式如下:

rgb = (normal + 1) * 0.5

那么我们原本法线(0,0,1)对应的rgb值即为((0+1)*0.5 ,(0+1)*0.5, (1+1)*0.5)=(0.5, 0.5, 1),它的颜色如下:

如果我们用这张图作为法线贴图,就会发现,模型表面不会发生任何的变化,因为所有法线依旧保持原样。

 

位移贴图(Displacement mapping)

前面说了纹理贴图实际上并没有改变三角形顶点的位置,所以在边缘处我们仍旧可以很明显的看出物体表面其实没有凹凸,如下图,球的外边缘依旧是很圆滑的,包括影子也是。

现在有一张贴图称为位移贴图,它就是真的改变了三角形顶点的位置而产生凹凸感,效果也要好很多,如下图:

可以看出由于移动了顶点位置,边缘处依旧是凹凸不平的。不过要使用位移贴图,首先需要三角形数量足够多,要跟得上位移贴图定义的频率。不过在DirectX中,提供了一个动态细分的方法,即一开始可以三角形偏少,当需要应用位移贴图时再自动细分三角形,即把一个三角形分成很多个小三角形,来匹配位移贴图的频率。

 

三维纹理

前面我们定义的纹理都是对于物体表面而言的,例如前面的砖块。但是如果我们这时候把它从中间切开两半,对于内部而言其实就没有纹理与之对应了。对于这种情况,人们又发明了一种三维纹理,它实际上是三维空间上的一种噪声函数(常见的有柏林噪声(perlin noise)),对于空间中任何一个点它都能够算出这个噪声的值是多少。也就是说三维空间中有个噪声,然后我们经过一系列的处理可以把它变成我们想要的样子,例如大理石的纹理。

此外三维纹理也可应用在体积渲染里。

 

光照贴图(Lighting mapping)

光照贴图想必大家也不陌生,其原理就是把我们物体的光照信息:颜色,阴影等,烘焙到贴图上。这样在运行时使用光照贴图就可以在没有全局光照的情况下虚拟出场景被光照的感觉。因为全局光照是很耗性能的,使用这种技术可以降低性能的消耗。当然缺点也很明显,因为是事先烘焙好的阴影,因此在运行时这些阴影不会受外界的影响而改变。

 

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值