纹理映射
前言:为什么需要纹理映射?
上一篇文章中,我们知道了phong着色模型,并且在只考虑漫反射的情况下,可以很轻松的用公式求出一个着色点,或者说一个像素的颜色。
那么说如果我们要把一个长方体着色为白色,只需要每个点的kd值都为白色即可,也就是每个着色点的kd值都相同。
但是大千世界,怎么有那么多纯色的东西呢?
看下图:
无论是球上的图案,还是地板上的木头的纹理都呈现了不同的颜色信息,
那么在回想讲解Blinn-Phong反射模型是曾经提到过,一个点的颜色是由其漫反射系数决定的,反射什么颜色的光,人眼就能看到什么颜色。那么针对上面这幅图,难道要去针对每一个点自己去设定一个颜色吗?还是说有什么更方便的方法呢?那就是纹理映射了!
我们可以将三维物体上的任意一个点都映射到一个2维平面之上,举一个简单例子,地球仪:
倘若拥有从三维world space到二维texture space 的一个映射关系,那么只需要将每个点的信息即漫反射系数存储在二维的texture上,每次利用光照模型进行计算的时候根据映射关系就能查到这个点的漫反射系数是多少,所有点计算完之后,结果就像最左边的screen space之中,整个texture被贴到了模型当中。
纹理映射的纹理,指定就是我们前面所说的这些二维的图片。我们假设这些图我们可以随意的拉伸扭曲,那么我们就可以把这些图包裹到三维物体上,那么这个过程我们就可以称之为纹理映射。例如把地图裹到一个球上面,那么这个球就变成了地球仪。
而我们之前例子中的长方体的纹理就如下图:
何为映射?
这是几何上的一个命题,几何上关于如何得到纹理和几何物体间的映射,叫做参数化。
映射关系一般是一组关于x,y,z的函数。输入物体表面上x,y,z的值,即可得到u,v的值,完成映射。
我们只需要知道物体表面上的点(也就是着色点)与纹理的对应关系,即三维表面的某个着色点是 纹理上的哪个位置,然后着色时kd的值按照纹理上的值即可。
纹理坐标
前面提到了纹理上的某个位置,因此我们就可以在二维的纹理上定义一个二维的坐标系,纹理坐标系我们一把都用uv来表示,u表示纹理的横坐标,v表示纹理的纵坐标。
不同的纹理,大小和形状可能都不同,但是对于任何一个纹理,它的u和v的值都是从0到1,例如uv(0,0)代表纹理的左下角,uv(0.5,0.5)代表纹理的中心,uv(1,1)代表纹理的右上角。
这样,当我们知道一个三角形三个顶点对应到纹理上的uv坐标后,我们就可以通过重心坐标来计算出该三角形内任意一点所对应的uv。然后我们通过uv坐标即可在纹理上采样到对应的颜色值作为改点的漫反射系数 kd 的值,这样就等于把改图贴到(映射到)了物体上。
横轴和纵轴的最大值都为1,为什么整幅texture图可视化之后是红色和绿色呢?
可以将(u,v)坐标的两点想象成red和green就能明白了。一幅Texture上的任意一点都可以用一个(u,v)坐标来表示(0<=u<=1,0<=v<=1)。因此只需要在三维world space中每个顶点的信息之中存储下该顶点在texture space的(u,v)坐标信息,自然而然的就直接的得到了这种映射关系。至于一个顶点所对应在纹理空间的坐标是怎么得到的,这就并不是程序员们关心的了,美术大大们会帮我们最好的(当然读者有兴趣也可以自行搜集资料)。
有一种特殊的纹理称为tile,这种纹理的特征是重复拼接之后上下左右都是连续的,因此这种纹理可以复制很多张贴在墙面或地板上。
一个具体重复利用这种贴图的例子如下:
最后给出一个纹理坐标使用的伪代码:
简言之就是对每个光栅化的屏幕坐标算出它的uv坐标,利用(三角形顶点重心坐标插值),再利用这个uv坐标去查询texture上的颜色,把这个颜色信息当做漫反射系数kd;
纹理过小引发的问题:
模糊是纹理过小导致的问题。
我们的纹理其实就是一张图片,因此它也存在自身的分辨率,即由像素组成,每个像素有自己的下标,纹理上的像素我们常称为texel。uv对应的像素坐标我们也是可以计算出来的,例如我们纹理每行有1000个像素,那么当u=0.25时,对应的像素横坐标就是249(下标从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),也就是说当我们纹理太小的时候,我们多个屏幕像素会对应到一个相同的纹理像素上,所以产生了模糊或者锯齿。
(可以理解成,由于纹理太小,只能一个纹素管一大块像素,采样频率不够了)
怎么解决纹理过小的问题呢?
双线性插值来缓解走样问题。
我们依然取上图的点作为例子,解释双线性插值。 第一步,取出离红色点最近的4个黑色顶点,分别算出,该红色点在水平及竖直方向偏移的比率s,t,图示如下:
接着先利用s,可以线性插值出如下图所示的u0,u1点的颜色值。
那么下一步相信读者也能猜到了,利用比例t,颜色值u0,u1插值出红色点的颜色值
如此这样利用两次线性插值,考虑到了所有4个点的颜色值,能够很好的缓解走样失真现象,并且计算速度较高。
(tips:还有一种插值方法叫做双三次插值(Bicubic),是利用三次方程来进行两次插值,效果可能更好,但是计算速度很低不在这里具体讨论了),双三次插值使用了16个点,不是一种线性插值。
最后以一张闫老师课上的例子看看这3种方法效果的对比
纹理过大引发的问题:摩尔纹
可能对于我们的第一直觉来说,纹理小确实会引发问题,但是纹理大那不是更好吗,为什么会引发问题呢?但事实是纹理过大所引发的走样甚至会更加严重。 想象一张很大的地板,在上面铺满了重复的方格贴图,我们所期望看到的结果应该是这样的:
嗯,非常符合透视关系,不错,当然这只是一个参考。再来看看利用在第一章所提到的计算纹理颜色的伪代码来计算的结果呢:
一种解释
从纹理图我们可以看出,我们的格子其实都是一样大小的,也就是说每个格子所占的纹理像素是一样多的。但是因为透视投影的近大远小效果,我们近处格子看起来会很大,也就是说会有很多的屏幕像素来显示一个格子,那不就导致多个屏幕像素会对应到一个纹理像素了么,也就是我们前面所说的模糊问题。而在远处就恰恰相反了,用极少的像素显示一个甚至多个格子,也就是说我们一个屏幕像素会对应到多个纹理像素,这就产生了摩尔纹。
另一种解释:
-
1 如开头所说,地板上铺满了重复的方格贴图,根据近大远小,远处的一张完整的贴图可能在屏幕空间中仅仅是几个像素的大小,那么必然屏幕空间的一个像素对应了纹理贴图上的一片范围的点,这其实就是纹理过大所导致的,直观来说想用一个点采样的结果代替纹理空间一片范围的颜色信息,必然会导致严重失真!
(从信号的角度来说就是,采样频率过低无法还原信号原貌) -
2 换一种想法,考虑离相机很远的一个三角形面,假设该三角形面真正在纹理贴图上对应的一片区域有10个像素点。但是由于透视的关系,距离很远的三角形面投影到近平面时可能只有1个或2个像素点的大小(远远小于10个像素的原来大小),那么这1个或2个像素采样texture的结果就要代表原来这个三角形面10个像素点的颜色信息,自然会导致失真!
(tips:可能有读者一开始会疑惑(包括我也是)为什么1个屏幕空间像素点覆盖多个纹理空间像素就是纹理过大呢,想象一下纹理贴图大小500x500,屏幕空间100x100,将屏幕空间的像素点均匀分布在纹理空间之中,那么1个屏幕空间像素点所占的平均大小就是5x5=25个纹理空间像素,因此这就是纹理过大所导致的结果)
这种现象被形象的成为屏幕像素在texture空间的footprint。如上图所示一个屏幕空间的蓝色像素点离相机越远,对应在texture空间的范围也就越大。
其实也就是越来越欠采样,那么一种直观的解决方法就是Supersampling,如果一个像素点不足以代表一个区域的颜色信息,那么便把一个像素细分为更多个小的采样点不就可以解决这个问题了吗?对,确实是这样,可以看看如下图512x超采样的结果
效果虽称不上完美但也极大缓解了走样现象,但问题是什么?
计算量太大了,一个像素点被分为了512x512个采样点,计算量几乎多出了25万倍!这显然不是所希望看到的,并且随着屏幕空间的点离相机距离更远,更多的texels(纹理空间的像素)会在屏幕像素的一个footprint里面,会要更高的超采样频率。
摩尔纹的另一种解释:
看作者解释摩尔纹还不到位,这里我来给出最合理的解释。这个东西就是光栅化的算法导致的。
我们知道,一个三角形有顶点坐标和纹理坐标,纹理坐标范围是【0-1】。光栅化的过程就是把三角形在屏幕上以一个个像素显示出来,插值计算三角形内部每个像素的顶点的数据,包括常见的深度与纹理坐标。如果这个三角形距离camera近,也就是说在屏幕上占了较多的像素,那么相邻两个像素的纹理坐标是接近的,这样通过纹理坐标获得纹理贴图上的纹素值也是接近的,这样这俩个像素看起来就是较顺接流畅,视觉上不突兀,同时gpu读取也快速,因为大部分纹素读取是在cache中。
而如果这个三角形距离camera较远,也就是在屏幕上只占了很少的像素,这种情况就是一个小物体应用了一个大纹理,光栅化后,相邻两个像素的纹理坐标会差别好大,读出来的纹素也会差别很大,会很突兀,尤其是camera移动时特别难看,产生闪烁,火花现像,除此之外,gpu读取性能也很低效,因为两个相邻的像素所对应的纹素,一个可能在cache中,另一个还没有加载到cache中呢。而解决这种视觉和性能问题,mipmap就登场了。
uv展开的方法可能如下:一般一个三角形的三个顶点映射到屏幕空间,光栅化的时候可以通过各个顶点的uv插值计算得到三条边的uv,在利用扫描线算法,计算三角形内部,每个像素所对应的uv坐标
点查询和范围查询
我们前面讲到双线性插值其实就属于一种点查询方式,我们得知纹理上的任意一点,要得知其对应的颜色值。
而对于摩尔纹,我们要用的则是范围查询,即我们知道一定范围的纹理像素,要查询出它的平均值。当然应对不同的情况我们也可查询一个范围内的最大值或最小值。那么当我们得知一个范围后,如果能立刻得知它的平均值,不就可以在不增加运算量的情况下,解决摩尔纹的问题了么。
MipMap
回顾一下屏幕像素在Texture空间里的footprint的这张图:
正如上文所提,一个采样点的颜色信息不足以代表 “footprint”里一个区域的颜色信息,如果可以求出这样一个区域里面所有颜色的均值,是不是就是一种可行的方法呢?
没错我们的目标就是从点查询Point Query迈向区域查询Range Query。但依然存在一个问题,从上图不难看出,不同的屏幕像素所对应的footprint size是不一样大小的,看下图这样一个例子:
远处圆圈里的footprint必然比近处的要大,因此必须要准备不同level的区域查询才可以,而这正是Mipmap。
Mipmap就是一种可以帮我们实现范围查询的方法,它速度快,但并不是特别的准确,结果是一个近似值,此外它只能做正方形的范围查询。
Mipmap的本质,其实就是一张纹理生成一系列的纹理,如下图:
level 0代表的是原始texture,也是精度最高的纹理,随着level的提升,每提升一级将4个相邻像素点求均值合为一个像素点,因此越高的level也就代表了更大的footprint的区域查询。接下来要做的就是根据屏幕像素的footprint大小选定不同level的texture,再进行点查询即可,而这其实就相当于在原始texture上进行了区域查询!
那么如何去确定使用哪个level的texture呢?利用屏幕像素的相邻像素点估算footprint大小再确定level D!如下图:
在屏幕空间中取当前像素点的右方和上方的两个相邻像素点(4个全取也可以),分别查询得到这3个点对应在Texture space的坐标,计算出当前像素点与右方像素点和上方像素点在Texture space的距离,二者取最大值,计算公式如图中所示,那么level D就是这个距离的log2值 (D = log2L) ! 这不难理解,读者可以具体取几个例子比如L = 1,L = 2,L = 4,看看是否符合这样的计算即可。
但是这里D值算出来是一个连续值,并不是一个整数,有两种对应的方法:
1 四舍五入取得最近的那个level D
2 利用D值在 向下和向上取整的两个不同level进行3线性插值
第一个方法很容易理解,具体讲述一下第二个方法,如图:
所谓3线性插值,就是在向下取整的D level上进行一次双线性插值(前文提过),再在D+1 level之上进行一次双线性插值,这二者数据再根据实际的连续D值在向下和向上取整的两个不同level之间的比例,再来一次线性插值,而这整体就是一个三线性插值了。
好了!根据上述的方法算出屏幕上每一个像素点所对应的Mipmap level,再进行三线性插值得到颜色值,是否就能很好的解决走样问题了呢?很遗憾,在本文的那个地板的例子之中,费了这么大力气依然不能完美解决,如下图结果:
虽然和一开始的point sample有了很大的进步,但是有一个严重的问题是,远处的地板产生一种过曝的现象,完全糊在了一起。该如何解决这个最后的问题呢——各向异性过滤ripmap。
各向异性过滤ripmap
好,接着上文的远处产生过曝的问题继续来谈,产生这种现象的原因是因为,所采用的不同level的Mipmap默认的都是正方形区域的Range Query,然而真实情况并不是如此,见下图:
可以看出不同screen space的像素点所对应的footprint是不同的,有长方形,甚至是不规则图形,那么针对这种情况,有的所需要的是仅仅是水平方向的高level,有的需要的仅仅是竖直方向上的高level,因此这也就启发了各向异性的过滤:
左上角为原始纹理,在水平方向只进行宽度的压缩,在竖直方向只进行高度的压缩,那么压缩后的图片任意一点还原到原始图片时,代表的都是一个长方形的区域。
从图中我们也可大致看出,使用Ripmap会导致存储空间变为原来的四倍左右,造成较大的显存占用。但是当压缩的层级x越高,增加的空间也会越小,例如当x=5和x=10,其实图片大小差距不大,所以打游戏的时候,设置里可以开的越高越好。
Ripmap可以很好的解决覆盖范围为长方形的情况,但是对于更奇怪的覆盖范围,例如斜条等,同样不能很好的解决问题,对于这类情况我们还可以使用EWA Filtering来解决,当然运算量又会增加,具体EWA Filtering的实现原理,这里就不过多介绍了。
纹理映射除了可以定义前面所说的颜色之外,它还可以定义顶点的其他各种属性,例如顶点法线,顶点位移,金属度等,下面举例一些常见的纹理。(有些就简单介绍一下,等之后学习了更多知识之后再详细补充)
各种贴图介绍:
无缝贴图
先看下面这个例子
我们可以发现,立方体的六个面都是同一个纹理,也就是一张纹理贴图并不一定要覆盖一个物体表面所有三角形,它可以被重复利用。
如果一个平面重复利用一个纹理,效果如下:
这种情况就贴瓷砖一般,对于墙壁,地板这类面积较大平面非常的适用。但是我们可以发现这种情况下纹理边缘十分的明显,多数情况下我们并不希望这样,更希望它们看起来像个整体。
因此无缝贴图很适用这种情况,无缝贴图的平铺效果,可以做到边缘的无缝衔接。例如我们最初的砖块的纹理其实就是一个无缝贴图,应用到上面的表面效果如下:
这个墙壁其实是九张一样的纹理拼接起来的,但是因为基本看不出什么缝隙,看起来就如同一个整体一般。
法线贴图(Normal mapping)
用纹理来定义顶点的法线,对于这样的纹理我们称之为法线贴图或者凹凸贴图(Bump mapping)。
我们可以发现此时狗狗的皮肤是很光滑的状态,但是我们知道实际上生物的皮肤应该会有各种凹凸不平的褶皱,也就是说更真实一点的话,应该如下图这个样子:
但是如果我们使用三角形来表达这些褶皱,那就需要添加无数个细小的三角形来产生凹凸不平的感觉,这明显是一个很难的工作。
因此我们可以利用一个复杂的法线贴图,来定义模型表面三角形各个顶点法线的相对变化。文章最初我们提到了漫反射的公式:
其中向量n代表的就是着色点法线,因此当顶点的法线发生变化,那么着色的结果也就会发生变化,那么就可以得到明暗不同的着色结果,让人产生凹凸感。
因此利用法线贴图并不会改变模型的几何信息,即原本各个顶点位置的不变,只是通过设置一些假的顶点法线来制造出假的着色结果,来给人凹凸不平的感觉。
该狗狗模型的法线贴图如下:
切线空间
从法线贴图中我们可以看出,这张图怎么基本都是蓝绿色的?很鸡儿怪。这是因为我们的法线是定义在切线空间当中的。
何为切线空间呢?切线空间其实是一个局部的空间,即每个顶点都会有一个它所对应的切线空间。
既然是空间,那么自然会有对应的坐标系,对于空间中的一个坐标系,我们自然要定义它的三个轴的方向以及原点的位置。对于某一个顶点而言,它的切线空间的坐标系的原点就是顶点本身。我们知道每个顶点都有它对应的法线,这个法线方向就是该顶点切线空间的Z轴方向,如下图:
B为球面上的一个顶点,其法线方向即为该顶点的切线空间的z轴方向
那么接下来我们自然还要定义x轴和y轴,我们知道x,y垂直于z,那么x,y自然在一个与法线垂直且相交于B的平面上。但是对于一个平面而言可以找出无数条垂直于z轴的线,但模型一般会给定每个顶点一个切线(tangent),因此我们就用该切线作为切线空间的x轴,那么y轴自然可以由x和z叉乘得到,y轴方向我们称为该顶点的副切线。
因此点B的切线空间坐标轴如下(emmm,图画的不是很好,抽象的理解下):
定义好坐标系后,我们就可以用这个坐标系的(x,y,z)来代表法线了,切线空间的(0,0,1)就代表原本的法线,如果要使法线和原本法线有便宜,那么降低z轴的值,增加x和y的值即可。
我们知道(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
当然也可以逆变换回去:
normal = rgb * 2 - 1
**那么我们原本法线(0,0,1)对应的rgb值即为((0+1)*0.5 ,(0+1)0.5, (1+1)0.5)=(0.5, 0.5, 1),它的颜色如下:
如果我们用这张图作为法线贴图,就会发现,模型表面不会发生任何的变化,因为所有法线依旧保持原样。
位移贴图
前面说了纹理贴图实际上并没有改变三角形顶点的位置,所以在边缘处我们仍旧可以很明显的看出物体表面其实没有凹凸,如下图,球的外边缘依旧是很圆滑的,包括影子也是。、
现在有一种贴图称为位移贴图,它就是真的改变了三角形顶点的位置而产生凹凸感,效果也要好很多,如下图:
可以看出由于移动了顶点位置,边缘处依旧是凹凸不平的,包括阴影也是。
不过要使用位移贴图,首先需要三角形数量足够多,要跟得上位移贴图定义的频率。在DirectX中,提供了一个动态细分的方法,即一开始可以三角形偏少,当需要应用位移贴图时再自动细分三角形,即把一个三角形分成很多个小三角形,来匹配位移贴图的频率。
三维纹理
前面我们定义的纹理都是对于物体表面而言的,例如前面的砖块,但是如果我们这时候把它从中间切开两半,对于内部而言其实就没有纹理与之对应了。
对于这种情况,人们又发明了一种三维纹理,它实际上是三维空间上的一种噪声函数,常见的有柏林噪声(perlin noise)。对于空间中任何一个点它都能够算出这个噪声的值是多少。也就是说三维空间中有个噪声,然后我们经过一系列的处理可以把它变成我们想要的样子,例如大理石的纹理。
此外三维纹理也可应用在体积渲染里。
光照贴图(Lighting mapping)
光照贴图想必大家也不陌生,其原理就是把我们物体的光照信息:颜色,阴影等,烘焙到贴图上。这样在运行时使用光照贴图就可以在没有全局光照的情况下虚拟出场景被光照的感觉。因为全局光照是很耗性能的,使用这种技术可以降低性能的消耗。当然缺点也很明显,因为是事先烘焙好的阴影,因此在运行时这些阴影不会受外界的影响而改变。