纹理映射(Texture mapping)
本文由 简悦 SimpRead 转码, 原文地址 zhuanlan.zhihu.com
为什么需要纹理映射
在上一篇文章中,我们学习了 Blinn-Phong 反射模型与着色频率,假设我们使用 Phong 着色并且只考虑漫反射的情况,我们知道一个着色点也就是一个像素的颜色应该是由下面这个式子算出来了。
那么如果我们要把一个长方体着色为白色(如下图),只需要每个着色点的 kd 值都为白色即可,也就是每个着色点的 kd 值都相同。
但是大千世界千变万化,怎么可能都是纯色的东西呢,如果我们要把长方体着色成墙一样(如下图),那么 kd 的值应该怎么设置?
从图中可以看出,此时每个像素的颜色基本都不一样,也就是说每个着色点的 kd 值都不相同,那么我们应该怎么来设置这个 kd 值,总不可能一个个着色点去单独设置吧,此时纹理映射就可以帮我们来解决这样的问题。
物体表面与纹理
我们知道我们显示的三维物体,其实都是三维物体的表面,而这些表面其实本质上都是二维的。这些物体表面由一个个三角形组成,我们只需要从中撕开一些口子,就可以把它们展开成一个二维的表面,就像生活中的剥橘子一样。
那么这样,我们就可以把三维物体表面与二维的图联系在一起了,就好像地球仪与地图的关系(如下图),三维的地球仪上的任意一个地方(一点)可以同样对应到二维地图上的这个地方。
纹理映射的纹理,指定就是我们前面所说的这些二维的图片。我们假设这些图我们可以随意的拉伸扭曲,那么我们就可以把这些图包裹到三维物体上,那么这个过程我们就可以称之为纹理映射。例如把地图裹到一个球上面,那么这个球就变成了地球仪。
而我们之前例子中的长方体的纹理就如下图:
何为映射
前面所说的映射是把纹理包裹到三维物体上,是一个很笼统的说法。实际上纹理映射,我们只需要知道物体表面上的点(也就是着色点)与纹理的对应关系,即三维表面的某个着色点是纹理上的哪个位置,然后着色时 kd 的取值按照纹理上的值即可。
例如下图,我们有个狗狗的模型(资源来自 Dog Knight PBR Polyart),通过前面学习的着色,我们可以得到如下效果:
然后我们下面这张纹理来与之对应

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

这张图同样是一张模糊的图,不过我们可以从中发现很多的锯齿,本质上模糊和锯齿的原理是一样的。
我们的纹理其实就是一张图片,因此它也存在自身的分辨率,即由像素组成,每个像素有自己的下标,纹理上的像素我们常称为 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)是什么,其实它就类似于我们的直线重心坐标,如下图:

图中的每个小方格我们当做是一个纹理像素,小蓝点即使纹理像素的中心点。此时我们有个屏幕像素对应到了纹理像素的红点 Q 位置。
若按照四舍五入的方式,那么此时 Q 点得到的颜色就是 B 点所在纹理像素的颜色,会造成的问题也就是我们前面所说的模糊。
那么双线性插值会怎么计算 Q 的值呢?首先它会找到红点 Q 附近的四个纹理像素,也就是图中的 ABCD 四个点,然后利用这四个点的值插值出 Q 点的值。如下图:
既然是周边的四个顶点,那么肯定会有个左下角点,也就是图中的 C 点,我们设 ,
,那么 Q 点就可以用 (s, t) 的方式来表示。其中 s 和 t 的范围都是 0-1。并且 s 和 v 我们都是可以通过 ABCDQ 五个点的 uv 坐标计算出来的。
看到这,是不是很容易就能联想到我们前面的线性插值了?没错,我们可以通过线性插值很容易求出 G,H,I,J 四个点插值出来的颜色。当然我们只需要知道其中两个点的即可,I 和 J 或者 G 和 H,这里我们先求 I 和 J 的值,套用上面的公式,即为:
然后 Q 不就 IJ 之间了么,我们又可以通过 IJ 做一次线性插值,即可得到 Q 插值后的颜色:
因为前后一共做了两趟线性插值(虽然第一趟做了两次),所以我们称之为双线性插值。双线性插值后,Q 的颜色就会和边上四个像素结合起来,而不再简单的等于 B 的颜色。这样当多个屏幕像素对应到一个像素上时,这几个屏幕像素的颜色也会有一个线性的变化,而不再一模一样,模糊或锯齿的效果就得到减弱。
除了双线性插值外,还有双三次插值(Bicubic interpolation),得到的效果就会更好。双三次插值取得则是周围十六个纹理像素做插值(该插值方法不是线性插值),具体原理这里就不过多介绍了。
利用它们得到的效果如下图,可以明显发现画质效果变好了。
摩尔纹
现在我们现在有如下一个纹理
我们把它应用到一个平面上,如下图:
我们可以发现,近处变模糊了,而远处就更离谱了,直接就看不清是啥了,对于远处这种效果,我们称之为摩尔纹。那么为什么会变得近处模糊,远处摩尔纹呢?我们还是从屏幕像素和纹理像素的对应关系入手理解。
从纹理图我们可以看出,我们的格子其实都是一样大小的,也就是说每个格子所占的纹理像素是一样多的。但是因为透视投影的近大远小效果,我们近处格子看起来会很大,也就是说会有很多的屏幕像素来显示一个格子,那不就导致多个屏幕像素会对应到一个纹理像素了么,也就是我们前面所说的模糊问题。而在远处就恰恰相反了,用极少的像素显示一个甚至多个格子,也就是说我们一个屏幕像素会对应到多个纹理像素,这就产生了摩尔纹。
画成示意图的话,就如下:

三线性插值(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 的不同之处就是它可以支持长方形的查询,生成出来的纹理如下:

我们可以发现,立方体的六个面都是同一个纹理,也就是一张纹理贴图并不一定要覆盖一个物体表面所有三角形,它可以被重复利用。
如果一个平面重复利用一个纹理,效果如下:
这种情况就贴瓷砖一般,对于墙壁,地板这类面积较大平面非常的适用。但是我们可以发现这种情况下纹理边缘十分的明显,多数情况下我们并不希望这样,更希望它们看起来像个整体。
因此无缝贴图很适用这种情况,无缝贴图的平铺效果,可以做到边缘的无缝衔接。例如我们最初的砖块的纹理其实就是一个无缝贴图,应用到上面的表面效果如下:
这个墙壁其实是九张一样的纹理拼接起来的,但是因为基本看不出什么缝隙,看起来就如同一个整体一般。
法线贴图(Normal mapping)
用纹理来定义顶点的法线,对于这样的纹理我们称之为法线贴图或者凹凸贴图(Bump mapping)。
我们还是以之前的狗狗模型为例,如下图

但是如果我们使用三角形来表达这些褶皱,那就需要添加无数个细小的三角形来产生凹凸不平的感觉,这明显是一个很难的工作。
因此我们可以利于一个复杂的法线贴图,来定义模型表面三角形各个顶点法线的相对变化。文章最初我们提到了漫反射的公式:
其中向量 n 代表的就是着色点法线,因此当顶点的法线发生变化,那么着色的结果也就会发生变化,那么就可以得到明暗不同的着色结果,让人产生凹凸感。
因此利用法线贴图并不会改变模型的几何信息,即原本各个顶点位置的不变,只是通过设置一些假的顶点法线来制造出假的着色结果,来给人凹凸不平的感觉。
该狗狗模型的法线贴图如下:
切线空间
从法线贴图中我们可以看出,这张图怎么基本都是蓝绿色的?很鸡儿怪。这是因为我们的切线是定义在切线空间当中的。
何为切线空间呢?切线空间其实是一个局部的空间,即每个顶点都会有一个它所对应的切线空间。既然是空间,那么自然会有对应的坐标系,对于空间中的一个坐标系,我们自然要定义它的三个轴的方向以及原点的位置。对于某一个顶点而言,它的切线空间的坐标系的原点就是顶点本身。我们知道每个顶点都有它对应的法线,这个法线方向就是该顶点切线空间的 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),它的颜色如下:
如果我们用这张图作为法线贴图,就会发现,模型表面不会发生任何的变化,因为所有法线依旧保持原样。
位移贴图(Displacement mapping)
前面说了纹理贴图实际上并没有改变三角形顶点的位置,所以在边缘处我们仍旧可以很明显的看出物体表面其实没有凹凸,如下图,球的外边缘依旧是很圆滑的,包括影子也是。

可以看出由于移动了顶点位置,边缘处依旧是凹凸不平的,包括阴影也是。
不过要使用位移贴图,首先需要三角形数量足够多,要跟得上位移贴图定义的频率。在 DirectX 中,提供了一个动态细分的方法,即一开始可以三角形偏少,当需要应用位移贴图时再自动细分三角形,即把一个三角形分成很多个小三角形,来匹配位移贴图的频率。
三维纹理
前面我们定义的纹理都是对于物体表面而言的,例如前面的砖块,但是如果我们这时候把它从中间切开两半,对于内部而言其实就没有纹理与之对应了。
对于这种情况,人们又发明了一种三维纹理,它实际上是三维空间上的一种噪声函数,常见的有柏林噪声(perlin noise)。对于空间中任何一个点它都能够算出这个噪声的值是多少。也就是说三维空间中有个噪声,然后我们经过一系列的处理可以把它变成我们想要的样子,例如大理石的纹理。
此外三维纹理也可应用在体积渲染里。
光照贴图(Lighting mapping)
光照贴图想必大家也不陌生,其原理就是把我们物体的光照信息:颜色,阴影等,烘焙到贴图上。这样在运行时使用光照贴图就可以在没有全局光照的情况下虚拟出场景被光照的感觉。因为全局光照是很耗性能的,使用这种技术可以降低性能的消耗。当然缺点也很明显,因为是事先烘焙好的阴影,因此在运行时这些阴影不会受外界的影响而改变。