纹理映射

在尝试复制真实世界的外貌时,人们很快意识到几乎任何物质的表面都是有特征的。木材长有纹理。皮肤长有皱纹;布料会有编织结构;油画会有画刷或滚筒留下的痕迹,即使是光滑的塑料也会有制作他的模具的凹凸不平的痕迹,并且光滑金属显示出机器加工过程的痕迹。材料的没有特征的地方很快也会被斑点,凹痕,污点,划痕,指纹和污垢覆盖。

在图形学中,我们把这些现象统称为“空间上表面属性的变化”——表面的属性在每一处地方都在发生着变化,但并没有真正意义上改变表面的形状,考虑到这些因素,所有的建模和渲染系统都提供了一些纹理映射的涵义:使用一个被称作纹理贴图纹理图像,或者仅仅是一个纹理,来存储这些你想要在一个表面上展示的细节,然后进行数学处理,把这个图片“映射”到表面上。

事实证明,一旦存在将图像映射到表面的机制,就会有很多奇淫技巧,这些技巧已经超过了我们介绍表面细节的基本目标。纹理可以用来制作阴影和反射,提供光照信息,甚至定义表面形状。在复杂的交互程序中,纹理经常被用于存储与其作为一张图片毫无干系的的数据(这里不知道怎么组织语言了,举个例子就是我们可以把骨骼动画烘焙到一张纹理中,从而让GPU去执行蒙皮操作(GPU Skining),提高性能)。

本章节讨论使用纹理表示表面细节,阴影和反射。尽管这些基础思想都很简单,但存在一些问题会复杂化纹理的使用。首先,纹理容易失真,并且设计将纹理映射到表面的功能是有挑战性的。另外,纹理映射是一个重新采样的过程,就像重新缩放一张图片一样,正如我们在第九章所说,重采样很容易引入锯齿失真问题。使用纹理贴图和动画的组合很容易产生明显的锯齿效应,很多纹理映射系统很复杂就是因为其中包含为了修复这些失真的抗锯齿方案。

11.1 查询纹理值

首先,让我们考虑一个简单的纹理映射应用,我们有一个有木地板的场景,我们希望地板的漫反射颜色由一个显示着木板木纹的图片控制。不管我们使用光线追踪还是光栅化,用于计算射线表面交点或一个由光栅化器生成的片段的颜色的着色代码都需要知道在光照点的纹理颜色,以便把它作为我们在第十章提到的Lambertian着色模型中的漫反射颜色。

为了得到这个颜色,这个着色器执行纹理查询(texture lookup):它计算出在纹理图片的本地坐标系中对应着色的点,并且读出在这个纹理图片在这个点的颜色,也就是纹理样品(texture sample),这个颜色被用在着色中,并且,由于对于每个在地板上可见的像素都在这个纹理不同的位置上发生了纹理查询,图像中会出现不同的颜色。这代码看起来可能像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Color texture_lookup(Texture t, float u, float v) 
{
int i = round(u * t.width() - 0.5)
int j = round(v * t.height() - 0.5)
return t.get_pixel(i,j)
}
Color shade_surface_point(Surface s, Point p, Texture t)
{
Vector normal = s.get_normal(p)
(u,v) = s.get_texcoord(p)
Color diffuse_color = texture_lookup(u,v)
// compute shading using diffuse_color and normal
// return shading result
}

在这段代码中,着色器查询表面在纹理中的位置,对于我们想要进行着色的每个表面,这个纹理都要能够回应这个查询。这告诉了我们纹理映射的第一个关键要素:我们需要一个函数,他可以从表面映射到纹理,这样我们可以轻松地为每个像素进行计算。这就是纹理坐标函数(图 11.1)

图11.1 就像观察投影映射每个在对象上的表面S到图片上,纹理坐标函数映射每个在对象上的表面S到纹理上的T,恰当的定义函数是所有纹理映射应用的基础

并且我们说它为每个表面上的点分配纹理坐标。从数学的角度讲,他是一个从表面SS到纹理域TT的映射

ϕ:ST\phi : S → T

:(x,y,z)(u,v).: (x, y, z) \mapsto (u, v).

集合TT通常被称作“纹理空间”,通常只是一个矩形,其中包含这个图片;通常使用单位化的正方形(u,v)[0,1]2(u,v)\in\left[0,1\right]^2(在本书中,我们将会使用uu,vv来表示两个纹理坐标),在许多方面他与第七章的讨论的观察投影很相似,例如在本章中被称为ππ的观察投影,用于映射场景中表面上的点到图片中的点;两者都是从3D到2D的映射,还都是需要渲染的——一个需要知道从哪里获取纹理的值,一个需要知道把着色结果放在图片中的哪个位置,但是他们也有一些不同点:ππ几乎总是一个透视或正交投影,而ϕ\phi可以采用多种方式;并且对于一张图片只有一个观察投影,而场景中的每个对象都可能具有完全独立的纹理坐标函数。

当我们的目标是把纹理贴到表面上时,ϕ\phi作为从表面到纹理的映射可能会让人稍感惊讶,但这正是我们所需要的函数。

对于木地板的情况,如果地板只位于常数zz并且平行于xxyy轴的位置点,我们可以使用如下映射

u=ax;v=by,u = ax; v = by,

恰当选择一些缩放因子aabb,指定对于(x,y,z)floor(x,y,z)_{floor}点的纹理坐标(s,t)(s,t),然后使用纹理像素的值,或最接近(u,v)(u,v)的在纹理上(x,y)(x,y)点的texel值。我们可以用这种方式渲染出如图11.2的图片。

图 11.2 一个木地板,直接使用了一个简单使用了和的坐标点的纹理坐标函数

不过这是一个相当局限的例子:如果以相对于xxyy轴一定角度对房间进行建模,或者我们想要把这个木材纹理使用在椅子的弧形靠背上呢?我们将会需要一个更好的方式来为表面上的点计算纹理坐标。

最简单的纹理映射方式引起的另一个问题可以通过从一个非常离谱的掠角来渲染一张高对比度的纹理到一张低分辨率的图片上被显著表现出来。图11.3展示了一个大平面使用同样的方法进行贴图,但是它使用了一个高对比度的网格图案,并且从一个几乎水平的角度来观察。

图11.3 一个大的平面,使用同图11.2同样的方法进行贴图,表现出严重的锯齿失真现象

你可以看到有锯齿失真(前面的还仅仅是有些阶梯状锯齿失真,远处就是波浪和闪闪发光的图案了),它类似于第九章提到的在图片重采样过程未使用恰当的滤波器而出现的现象。虽然我们需要一种极端情况来让这个失真明显的体现在本书上的一张小图上,在动画中,这些图案还会四处移动,并且当他们更加微妙时,会非常分散注意力。

我们现在看到了这种基础纹理映射方式两个主要问题:

  • 定义纹理坐标函数
  • 查询纹理值而不会引起过多的锯齿

这两个顾虑是所有纹理映射程序的基础并且会被在11.2和11.3节讨论。一旦你理解了他们和对应的解决方案,你就理解了纹理映射。剩余的是如何将基础的纹理处理工具应用于各种不同的目的,这将会在11.4节进行讨论。

11.2 纹理坐标函数

设计好一个纹理坐标函数ϕ\phi是获取好的纹理映射结果的关键所在。你可以认为它将决定如何变形一个平面或者说矩形图片以便与你想要绘制的3D表面相符合。或者,你正在将表面变得平整,而不是使其起褶皱,撕裂或折叠,以便他平整的贴在图片上。有些时候他是简单的:有时这个3D模型已经是一个平面矩形了!在其他情况下它将会非常棘手:3D形状可能非常复杂,就像一个角色的身体。

定义纹理坐标函数的问题对于计算机图形学已经不是一个新问题了。制图师在设计覆盖地球表面大面积地图时面临着完全相同的问题:从弯曲的地球到平面地图的映射不可避免的会导致地区,角度,距离的变形,这很容易导致地图产生误导。在数个世纪中提出的多个地图投影方案,都在平衡同样的竞争关系——最小化在一个连续区域中覆盖大面积区域导致的各种变形问题——这也正是纹理映射所面临的问题。

在一些应用中(就像我们将要在此章节的后面看到的)有一个明显的原因使用特定的映射。但是在更多的情况下,设计纹理坐标映射并平衡竞争关系是一个艰巨的任务,以至于娴熟的建模人员会投入大量的精力。

你可以通过任何你能想到的方式来定义ϕ\phi。但这里有一些值得考虑的先决条件:

  • 双射性:在大部分情况下你会想ϕ\phi是双射性的(参见2.1.1节),所以表面上每个点都会映射到纹理空间的不同点上。如果一些点映射到同一的纹理空间点,那么一个在纹理上的点将会影响多个在表面上的点。如果你现在表面重复一个纹理(想象一下墙纸或地毯),故意进行多对一的从表面到纹理的映射是应该的,但是有时你不会想这样。
  • 尺寸变形:纹理的比例应大致恒定在表面的大小。也就是说,表面上任何靠近在一起的点相对于纹理中同样的点都有着同样的距离。就函数ϕ\phi而言,ϕ\phi的导数不应有太大的变化。
  • 形状变形:纹理不应当变形的很严重。也就是说,一个绘制在表面上的小圆应当映射到一个纹理上的合理的圆形上,而不是一个被极端挤压或拉长的形状。就ϕ\phi而言,ϕ\phi的导数不应该在不同的方向有太大的不同。
  • 连续性:不应当有太多的接缝:表面上的相邻点应当映射到纹理上的相邻点。也就是说,ϕ\phi应该是连续的,或者尽可能少的不连续。在很多情况下,一些不连续的情况是不可避免的,我们希望把他们放在不显眼的位置。

通过参数方程式定义的表面(参见2.5.8节)带有内置的纹理坐标函数选项:只需要简单的将定义表面的函数反转,然后使用两个表面的参数作为纹理坐标。这些纹理坐标可能具有,也可能不具有理想的属性,这取决于表面,但是他们确实提供了映射。

但是对于那些隐式定义的,或者仅仅通过一个三角网格定义的表面,我们需要不依赖一个现有的参数的情况下用一些其他的方式来定义纹理坐标。宽泛的说,这两种定义纹理坐标的方式是根据表面点的空间坐标或者对于网格表面,存储他们在顶点的纹理坐标并且在表面上进行插值从而几何的计算他们。让我们一次性看个够这些选项。

11.2.1 几何确定的坐标

几何确定的纹理坐标被用于简单的形状或者特殊的情况,被作为一个快速的解决方案,或者作为一个设计一个手动调整的纹理坐标映射的起点。

我们将通过映射测试图11.4的图片到表面上来说明各种纹理坐标函数。

图11.4 测试图片

图片中的数字让你可以读到大概的坐标(u,vu,v),并且格子让你看到这个映射是如何变形的。

平面投影

从3D到2D的映射中可能最简单的映射是平行投影——与正交视图中使用的映射一样(如图11.5)。

图11.5 如果投影的方向被选择为大致沿着总体法线方向,平面投影为起始处几乎是平坦的对象或对象的一部分提供了一个有用的参数

我们设计的这个机制之前已经看过了(参见7.1节),可以被直接用于定义纹理坐标:就像正交视图归结于通过乘以一个矩阵并丢弃zz分量,通过平面投影生成纹理坐标可以通过一个简单的矩阵乘法实现:

ϕ(x,y,z)=(u,v) ⁣where ⁣[uv1]  =  Mt[xyz1]\phi(x,y,z) = (u,v)\, \!\,where \, \!\,\begin{bmatrix}u\\v\\\ast\\1\end{bmatrix}\;=\;M_t\begin{bmatrix}x\\y\\z\\1\end{bmatrix}

其中纹理矩阵MtM_t表示一个仿射变换,而*表示我们不关心第三个坐标。

他在几乎是平面,并且在平面的法线没有太多变化的表面上工作的很好,并且通过取法线的平均值可以得到一个好的投影方向。对于任何闭合的形状,然而一个平面投影并不具备内射性:在前面和后面的点都会映射到纹理空间中的相同的点(图 11.6)

图 11.6 在一个封闭对象上使用一个平面投影总会产生一个非内射的结果,一对多的映射,并且当投影方向与表面相切时其附近的点将会极端变形

通过简单地将透视投影替换为正交投影,我们得到透视纹理坐标(图 11.7)

图 11.7 一个使用了类似观察变换的透视纹理变换会投影到一个点上

现在这个4X4的矩阵PtP_t表现了一个投影(不一定是仿射)变换——也就是说最后一行可能不是[0,0,0,1][0,0,0,1]

ϕ(x,y,z)=(u~w,v~w) ⁣where ⁣[u~v~w]  =  Pt[xyz1]\phi(x,y,z)=(\frac{\displaystyle\widetilde u}w,\frac{\displaystyle\widetilde v}w)\, \!\,where\, \!\,\begin{bmatrix}\widetilde u\\\widetilde v\\\ast\\w\end{bmatrix}\;=\;P_t\begin{bmatrix}x\\y\\z\\1\end{bmatrix}

投影纹理坐标在阴影贴图技术中是很重要的,将会在11.4.4节讨论。

球坐标

对于球体,维度/经度参数化是众所周知并且已经被广泛使用。极点附近有很多可以导致困难的变形,但是仅沿着一条纬线可以不连续的覆盖整个球体。

大致的球形表面可以使用一个映射表面上的点到球上的点的(通过径向投影(从球体中心到平面上的点画一条线,并找到与球的交点))纹理坐标函数来被参数化。此交点的球坐标是在这个表面上起始点的纹理坐标。

另一种说法是,你可以使用球坐标(ρ\rho,θ,ϕ\phi)来表示表面的点,然后丢弃ρ\rho坐标并将θ和ϕ\phi分别映射到[0,1]范围内。该公式依赖于球坐标的特性;在2.5.8节有提及。

ϕ(x,y,z)  =(  [π  +  tan2(y,x)]2π,[π    cos1(zx)π]π)\phi(x,y,z)\;=(\frac{\;\lbrack\pi\;+\;\tan^{-2}(y,x)\rbrack}{2\pi},\frac{\lbrack\pi\;-\;{\displaystyle\frac{\cos^{-1}\left({\displaystyle\frac z{\left|\left|x\right|\right|}}\right)}\pi}\rbrack}\pi)

一个球坐标映射在所有地方都是双射的,除非在极点处(从其出发整个表面都是可见的)。极点附近继承了就像经纬映射一样的变形。图11.8 展示了一个球坐标提供了一个合适的纹理坐标函数的对象。

图11.8 对于这个比较圆润的cube,将每个点投影到以这个物体中心为中心的球上提供了一个内射的映射,他使用了曾经被用于地球上的纹理贴图。注意一些远离中心的区域被放大了(表面的点在纹理空间中拥挤在一起),靠近中心的点被缩小了。

圆柱坐标

对于柱状而非球状的物体,从一个轴到圆柱的投影可能比从一个点到球的投影工作的更好(如图 11.9所示)。

如图 11.9 一个非球形花瓶球形投影会产生很大的差异扭曲(左),但圆柱投影就会有非常好的效果(右)

类似于球形投影,这相当于转换为圆柱坐标并舍弃半径:

ϕ(x,y,z)  =(  [π  +  tan2(y,x)]2π,[1+z]2)\phi(x,y,z)\;=(\frac{\;\lbrack\pi\;+\;\tan^{-2}(y,x)\rbrack}{2\pi},\frac{\lbrack1+z\rbrack}2)

立方体贴图

使用球坐标来参数化球或类似球形的图像导致了在几点附近的形状和区域的高度变形,这通常导致肉眼可见的失真,会有两个特殊的点显示着纹理异常。一个流行的替代方案更加整齐,但代价是更多的不连续性。这个想法是投影到一个立方体上,而不是一个球体,然后对立方体的六个面使用六个单独的正方形纹理。这个六个正方形纹理集合被称为立方体贴图。这会在每个立方体边上引起中断,但是他保持着形状和区域的低失真。

计算立方体贴图纹理坐标同样比球形的代价小,因为投影到一个平面上仅仅需要一个除法——本质上和观察透视投影相同。举个例子,对于一个投影在立方体+zz面的点:

(x,y,z)  (xz,yz)(x,y,z)\;\mapsto(\frac xz,\frac yz)

立方体贴图的一个令人困惑的方面是如何建立被定义在六个面上uuvv的方向的约定。任何约定都可以,但是这个约定会影响到纹理的内容,所以标准化是重要的。

因为立方体纹理经常被用在在立方体内部观察的纹理(参见11.4.5节中环境映射),这个常见的约定包含定向的uuvv轴所以从内部看uu是相对于vv的顺时针方向的。这个在OpenGL中被使用的约定为:

ϕx(x,y,z)  =  [1  +  (+z,  y)x]2,ϕ+x(x,y,z)  =  [1  +  (z,  y)x]2,ϕy(x,y,z)  =  [1  +  (+x,  z)y]2,ϕ+y(x,y,z)  =  [1  +  (+x,  +z)y]2,ϕz(x,y,z)  =  [1  +  (x,  y)z]2,ϕ+z(x,y,z)  =  [1  +  (+x,  y)z]2.\phi_{-x}(x,y,z)\;=\;\frac{\lbrack1\;+\;{\displaystyle\frac{(+z,\;-y)}{\left|x\right|}}\rbrack}2,\\\phi_{+x}(x,y,z)\;=\;\frac{\lbrack1\;+\;{\displaystyle\frac{(-z,\;-y)}{\left|x\right|}}\rbrack}2,\\\phi_{-y}(x,y,z)\;=\;\frac{\lbrack1\;+\;{\displaystyle\frac{(+x,\;-z)}{\left|y\right|}}\rbrack}2,\\\phi_{+y}(x,y,z)\;=\;\frac{\lbrack1\;+\;{\displaystyle\frac{(+x,\;+z)}{\left|y\right|}}\rbrack}2,\\\phi_{-z}(x,y,z)\;=\;\frac{\lbrack1\;+\;{\displaystyle\frac{(-x,\;-y)}{\left|z\right|}}\rbrack}2,\\\phi_{+z}(x,y,z)\;=\;\frac{\lbrack1\;+\;{\displaystyle\frac{(+x,\;-y)}{\left|z\right|}}\rbrack}2.

下标指示每个投影对应于立方体的哪一面,例如ϕx\phi_{-x}用于在x=+1x = +1时投影到立方体表面的某点。你可以通过查看坐标最大绝对值来得知一个点被投影到哪个面上,例如:如果x>y|x| > |y|,并且x>z|x| > |z|,这个点投影到+x+xx-x面上,取决于xx的符号。

一个被用于立方体贴图的纹理有六片正方形。(参见图11.10)。

图11.10 一个被投影到一个立方体贴图的表面。表面上的点从中间往外部投影,每个点都会被投影到六个面中的其中一个。

通常他们被打包在一起放在一个图片中存储,像一个立方体被展开似的进行排列。

11.2.2 纹理坐标的插值

要对一个三角网格表面的纹理坐标进行更加精细的控制,你可以显式存储每个顶点处的纹理坐标,并且通过重心插值的方式把它们插入到三角形中(参见8.1.2节)。它的工作方式和你可能在网格上定义的其他任何平滑变化的变量完全相同,例如颜色,法线甚至是3D位置本身。

让我们来看一个单个三角形的例子。图11.11展示了一个被我们熟悉的测试图案的一部分映射的三角形纹理。

图11.11 一个使用了线性插值的纹理坐标的三角形。左图:在纹理空间下被绘制的三角形;右图:3D空间下被绘制的三角形。

通过观察三角形上的图案,我们可以推断出三个顶点的纹理坐标是(0.2,0.2),(0.8,0.2),(0.2,0.8),因为这些是纹理中出现在三角形三个角上的点。就像上一节中的几何确定的映射一样,我们通过提供从表面到纹理域的映射来控制哪里的纹理会贴在表面上,在这种情况下,通过指定每个顶点对应的纹理空间中的位置。定位顶点后,通过三角形的线性(重心)插值负责剩余部分。

在图11.12中,我们在整个网格上展示了一个可视化纹理的常用方法:简单的在纹理空间使用那些在他们纹理坐标中定位的顶点来绘制三角形。

图11.12 一个在纹理空间中坐落它的三角形的二十面体,来达成0失真但是有很多接缝的效果。

这个可视化展示给我们纹理的哪些部分被哪些三角形使用,他是评估纹理坐标的便捷工具,并且可以用来调试各种纹理映射的代码。

纹理坐标映射的质量被取决于顶点纹理坐标——也就是说,取决于网格是如何坐落在纹理空间中的。无论被指定了什么坐标,只要网格中的三角形共享顶点(参见12.1节),它的纹理坐标映射总是连续的,因为相邻的三角形将会在其共享边上的纹理坐标达成一致。但是上面描述的其他不理想的品质将不会这么自动化。内射性意味着三角形不会再纹理空间中重叠,如果他们重叠了,意味着有一些在纹理中的点会出现在表面的多个位置。

当在纹理空间中的三角形区域按比例对应他们在3D空间中的区域时,尺寸失真会比较低。举个例子,如果角色的脸被一个连续的纹理坐标函数映射,往往会因为鼻子被挤压到纹理空间中一个相对比较小的地方而终止,就像图11.13展示的那样。

图11.13 一个脸部模型,使用了纹理映射来获取合理的低形状失真,但是仍然展现出中等程度的失真。

尽管在鼻子上的三角形小于脸颊上的三角形,大小的比例在纹理空间中更加极端。结果是鼻子上的纹理变大了,因为小区域的纹理覆盖大区域的表面。类似的,对比额头和太阳穴,他们三角形在3D空间大小差不多,但是太阳穴附近的三角形在纹理空间中更大些,这就导致了纹理表现得更小些。

类似的,在三角形形状在3D空间和纹理空间类似时,形状失真也较低。这个脸的例子形状失真比较小,但是,举个例子,在图11.17中的球体在极点附近有非常大的形状失真。

11.2.3 平铺,拼接模式,和纹理转换

允许纹理坐标超出纹理图片的边界通常很有用。有时有这样一个细节:纹理坐标中计算舍入误差可能会让一个本该精确落在纹理边界上的顶点落在稍外面一点,在这种情况下,纹理映射机制不应当失败,但是它还可能是一个建模工具。

如果一个纹理仅支持覆盖表面的一部分,但是纹理坐标已经设置为映射整个表面到单位方块上了,一个选项是准备一个纹理图片,这个图片大部分是空白的,只在一块小地方有内容。但是这将会需要一个非常高分辨率纹理图片来获取相关区域足够的细节。另一个选择是缩放所有的纹理坐标来让他们覆盖更大的范围——[-4.5,5.5]x[-4.5,5.5]作为例子,位于表面中心的单位正方形会变成十分之一大小。

对于这种情况,纹理查询在被纹理图片覆盖的单位正方形之外的区域将会返回一个恒定的背景颜色。一种方式是设置一个背景颜色,他将会在查询单位正方形外区域时返回。如果纹理图片已经有一个恒定的背景颜色(举个例子,一个白色背景的Logo),另一种方式是自动拓展背景颜色,他会返回最靠近边缘的纹理点的颜色,通过*拉伸(Clamping)*图片中第一个像素到最后一个像素的uuvv坐标来获取。

有时我们想要一个重复的图案,比如一个棋盘格,瓷砖地板或者一个砖墙。如果图案在一个矩形格内重复,类似于创建一个使用了一些相同数据复制的图片是很浪费的。相反的,纹理查询超过纹理图片时,我们可以使用平铺的索引来处理:当查询点超过纹理图片的右边界时,他会使用左边界平铺。使用整数取余运算可以非常简单的处理像素的坐标:

1
2
3
4
5
6
7
8
9
10
11
12
13
Color texture_lookup_wrap(Texture t, float u, float v) 
{
int i = round(u * t.width() - 0.5)
int j = round(v * t.height() - 0.5)
return t.get_pixel(i % t.width(), j % t.height())
}
Color texture_lookup_wrap(Texture t, float u, float v)
{
int i = round(u * t.width() - 0.5)
int j = round(v * t.height() - 0.5)
return t.get_pixel(max(0, min(i, t.width()-1)),
(max(0, min(j, t.height()-1))))
}

对超过边界的纹理查询时这两个方案的选择可以通过从列表中选择一个拼接模式,这个列表包括平铺,拉伸,以及两者的结合或者变体。在拼接模式里,我们可以自用的将纹理视为一个对无限的2D平面任意一点都会返回一个颜色的函数(如图11.14所示)。

图11.14 通过拼接纹理像素坐标将一个木地板纹理平铺在纹理空间

当我们使用一个图片指定了一个纹理,这些模式描述了如何使用有限的图片数据来定义这个方法。在11.5节中,我们将会看到程序化有限图片数据会自然地在无限的平面上拓展,因为他们不受有限的图像数据限制。由于两者在逻辑上是无限的,两种类型的纹理可以互换。

当调整纹理的缩放和位置时,避免真正修改生成纹理坐标的方法是有益的,或者将纹理坐标存储在网格顶点中,而不是在对纹理进行采样前应用矩阵变换:

ϕ(x)  =  MTϕmodel(x)\phi(x)\;=\;M_T\phi_{model}(x)

ϕmodel\phi_{model}是模型提供的纹理坐标函数,MTM_T是一个3x3的矩阵表示一个使用齐次坐标进行2D纹理坐标的仿射或投影变换。这样的一个变换,有时被限制为只能进行缩放或平移,被大部分使用纹理映射的渲染器支持。

11.2.4 正确的透视插值

对三角形进行纹理坐标插值来获得正确的透视视觉效果是有些不妥之处的,但是我们可以在光栅化阶段解决这个问题。事情不简单的原因是只对在屏幕空间对纹理进行插值导致错误的图片,就像图11.15展示的网格纹理一样。

图11.15 左:正确的透视,右:在屏幕空间的插值

因为在透视视图中的物品会有远大近小的特性,在3D中均匀间隔的线要压缩到2D空间,需要更加仔细地对纹理坐标进行插值来完成这个目标。

我们可以通过对(uu,vv)坐标进行插值来实现三角形上的纹理映射,按8.1.2节修改光栅化的方法,但是这会导致图11.15右图所示的问题。如果屏幕空间的重心坐标被使用在了下面的光栅化代码中,那么类似的的问题会在三角形上出现。

1
2
3
4
5
6
7
for all x do
for all y do
compute (α, β, γ) for (x, y)
if α ∈ (0, 1) and β ∈ (0, 1) and γ ∈ (0, 1) then
t = αt0 + βt1 + γt2
drawpixel (x, y) with color texture(t) for a solid texture
or with texture(β, γ) for a 2D texture

这个代码会生成有问题的图片。为了解决问题,让我们思考从世界空间qq到齐次点rr再到齐次化的点ss的步骤:

[xqyqzq1]  transform[xryrzrhr]homogenize[xrhryrhrzrhr1][xsyszs1]  \begin{bmatrix}x_q\\y_q\\z_q\\1\end{bmatrix}\;\xrightarrow{transform}\begin{bmatrix}x_r\\y_r\\z_r\\h_r\end{bmatrix}\xrightarrow{homogenize}\begin{bmatrix}\frac{x_r}{h_r}\\\frac{y_r}{h_r}\\\frac{z_r}{h_r}\\1\end{bmatrix}\equiv\begin{bmatrix}x_s\\y_s\\z_s\\1\end{bmatrix}\;

纹理坐标插值问题最简单的形式是当我们有两个纹理坐标(uu,vv)qqQQ时,我们需要在图片中的以ssSS为端点的线上生成他们的纹理坐标,如果世界空间以qqQQ为端点的线上存在一点qq'他会投影到屏幕空间的以ssSS为端点的线上点ss',这两个点会有同样的纹理坐标。

上面那个朴素的屏幕空间的算法表明:对于点s=s+α(Ss)s' = s + α(S - s)我们会使用纹理坐标us+α(uSus)u_s + α(u_S - u_s)vs+α(vSvs)v_s + α(v_S - v_s)表示。这会因为被映射到ss'的世界空间的点qq'不满足公式q=q+α(Qq)q' = q + α(Q - q)而导致公式结果不正确。

但我们从7.4节可以得知在线段qQqQ上的点一定会落到线段sSsS上某个位置上,事实上:

q+t(Qq)s+α(S+s)q + t(Q - q) \mapsto s + α(S + s)

这个插值参数ttαα是不一样的,但我们可以计算他们中的其中一个从而得到另一个:

t(α)  =  wrαwR  +  α(wr    wR)  and  α(t)  =  wRtwr  +  t(wR    wr)t(\alpha)\;=\;\frac{w_r\alpha}{w_R\;+\;\alpha(w_r\;-\;w_R)}\;and\;\alpha(t)\;=\;\frac{w_Rt}{w_r\;+\;t(w_R\;-\;w_r)}

这些方程式为解决屏幕空间的插值提供了一种可能的修复方案。可以通过计算us  =  us  +  t(α)(uS    us)u_s'\;=\;u_s\;+\;t(\alpha)(u_S\;-\;u_s)vs  =  vs  +  t(α)(vS    vs)v_s'\;=\;v_s\;+\;t(\alpha)(v_S\;-\;v_s)得到屏幕空间中点s=s+α(Ss)s' = s + α(S - s)的纹理坐标。这些正是映射到点ss'的点qq'的坐标,所以他仍然有效。但是对于每个片段都计算是很慢的,并且这里有一个更加简便的方案。

关键的观察结果是,因为我们知道透视变换会保留线和面,在三角形上线性插入任何我们想要的属性都是安全的,但是前提是他们必须与点一起经过透视变换。要得到几何理论,请减少尺寸,以便我们有齐次点(xr,yr,wrx_r,y_r,w_r)以及一个被插入的属性uu,属性uu应该是xryrx_r和y_r的线性函数,因此,如果将uu绘制为(xr,yrx_r,y_r)上的高度场,结果会是一个平面。现在我们将uu视为第三个空间坐标(将他称之为uru_r来强调其与其他坐标被同等看待),然后通过透视变换发送整个3D齐次点(xr,yr,zr,wrx_r,y_r,z_r,w_r),结果(xs,ys,zsx_s,y_s,z_s)仍然会生成坐落在平面上的点。平面内部会有一些弯曲,但保持平坦。这意味着usu_s是一个(xs,ysx_s,y_s)的线性函数,也就是说,我们可以使用基于(xs,ysx_s,y_s)坐标的线性插值在任何地方计算usu_s

图11.16 屏幕空间插值的几何推理。 上:被插值为的线性函数。 下:从到的透视变换后,是的线性函数

回到完整的问题,我们需要对作为世界空间坐标(xq,yq,zqx_q,y_q,z_q)线性函数的纹理坐标(uvu,v)进行插值。将这些点转换到屏幕空间,并添加纹理坐标(就像它们是其他坐标一样)之后,

[uv1xryrzrwr]homogenize[uwrvwr1wrxrwr  =  xsyrwr  =  yszrwr  =  zs1]\begin{bmatrix}u\\v\\1\\x_r\\y_r\\z_r\\w_r\end{bmatrix}\xrightarrow{homogenize}\begin{bmatrix}\frac u{w_r}\\\frac v{w_r}\\\frac1{w_r}\\\frac{x_r}{w_r}\;=\;x_s\\\frac{y_r}{w_r}\;=\;y_s\\\frac{z_r}{w_r}\;=\;z_s\\1\end{bmatrix}

上一段的实际含义是,我们可以继续对所有基于(xs,ysx_s,y_s)的值(包括z缓冲区中使用的zsz_s值)进行插值,朴素方法的问题很简单,就是因为我们我们为插值选择了不一致的分量——只要涉及的变量是从透视之前或之后划分的,所有的数据都不会有问题。

剩下的一个问题是(uwr,vwr)(\frac u{w_r},\frac v{w_r})对于查找纹理数据不是直接有用的。我们需要的是(u,v)(u,v)。这解释了我们放入的附加参数的目的(参见11.2),该参数的值始终为1:一旦有了uwr\frac u{w_r}vwr\frac v{w_r}1wr\frac 1{w_r},我们就可以轻松地通过除法来得到(u,vu,v)。

为了确定这些推论是正确的,让我们检查再屏幕空间中对1wr\frac 1{w_r}的插值确实会产生在世界空间中对wrw_r插值的倒数。

1wr+α(t)(1wR    1wr)  =  1wr  =  1wr+t(wR    wr)\frac1{w_r}+\alpha(t)(\frac1{w_R}\;-\;\frac1{w_r})\;=\;\frac1{w_r'}\;=\;\frac1{w_r+t(w_R\;-\;w_r)}

这种在转换后的空间中无误差地对1wr\frac 1{w_r}线性插值的能力使我们能够正确构造三角形。我们可以利用这些定理来修改我们的扫描转换代码,获取已经通过观察矩阵但尚未经过齐次化的三个点ti=(xi,yi,zi,wi)t_i = (x_i,y_i,z_i,w_i),并带有纹理坐标ti=(ui,vi)t_i = (u_i,v_i)

1
2
3
4
5
6
7
8
9
10
for all xs do
for all ys do
compute (α, β, γ) for (xs, ys)
if (α ∈ [0, 1] and β ∈ [0, 1] and γ ∈ [0, 1]) then
us = α(u0/w0) + β(u1/w1) + γ(u2/w2)
vs = α(v0/w0) + β(v1/w1) + γ(v2/w2)
1s = α(1/w0) + β(1/w1) + γ(2/w2)
u = us/1s
v = vs/1s
drawpixel (xs, ys) with color texture(u, v)

当然,出现在该伪代码中的许多表达式可在循环之外预先计算以提高速度。对于实体纹理,将原始世界空间坐标xq,yq,zqx_q,y_q,z_q包括在属性列表中,并与uuvv一样对待,这很简单,并且可以获得正确的插值的世界空间坐标,可以将其传递给实体纹理函数。

11.2.5 连续性和接缝

尽管低失真和连续性在纹理坐标函数中具有的良好属性,但不连续性通常是不可避免的。 对于任何封闭的3D曲面,拓扑的基本结果是没有连续的双射函数将整个曲面映射到纹理图像。 必须采取一些措施,并引入接缝(即纹理坐标突然改变的表面上的曲线),我们可以在其他任何地方降低失真。上面讨论的许多几何确定的映射已经包含接缝:在球面和圆柱坐标中,接缝是atan2计算的角度从π到-π的,而在立方体贴图中,接缝沿着立方体的边缘,六个方形纹理之间的映射切换。

使用插值的纹理坐标,接缝需要特别考虑,因为它们不是自然发生的。我们之前观察到,插值纹理坐标在共享顶点网格上自动连续-共享纹理坐标可以保证这一点。但这意味着,如果三角形跨接缝,并且一侧有一些顶点,而另一侧有一些顶点,则插值机制会很乐意提供连续的贴图,但是它可能会高度失真或折叠,从而不会产生内射。图11.17在用球坐标映射的地球仪上说明了这个问题。

图11.17 多边形地球仪:在左侧,所有共享顶点的纹理坐标函数是连续的,但是跨180子午线的三角形必然会出现问题,因为纹理坐标是从180度附近的经度插值到-180度附近的经度。在右侧,某些顶点被复制,具有相同的3D位置,但纹理坐标在经度上恰好相差360度,因此纹理坐标在子午线上而不是在地图上一直插值。

例如,在地球底部附近有一个三角形,在新西兰南岛的顶端有一个顶点,在北岛东北约400公里处的太平洋有一个顶点。飞行员在这些点之间飞行,他会飞越新西兰,但是路径的起点是经度167°s E(+167),结束于179°s W(即经度-179),因此线性插值会选择一条路线在途中穿越南美。这将导致整个地图的向后副本被压缩为跨过第180个子午线的三角形带!解决的办法是用等效经度181°s E标记第二个顶点,但这只会将问题推到下一个三角形。

创建干净过渡的唯一方法是避免在接缝处共享纹理坐标:穿过新西兰的三角形需要插值到+181经度,太平洋中的下一个三角形需要继续从-179经度开始。为此,我们在接缝处复制顶点:对于每个顶点,我们添加一个具有相等经度的第二个顶点,相差360°s,并且接缝相对两侧的三角形使用不同的顶点。 此解决方案显示在图11.17的右半部分,其中纹理空间最左侧和最右侧的顶点是重复的,具有相同的3D位置。

11.3 抗锯齿纹理查询

纹理映射的第二个基本问题是抗锯齿。渲染纹理映射的图像是一个采样过程:将纹理映射到表面上,然后将表面投影到图像中,将在整个图像平面上产生2D函数,我们以像素为单位对其进行采样。就像我们在第9章中看到的那样,当图像包含细节或锐利边缘时,使用点样本执行此操作会产生锯齿失真-并且由于纹理的整个点都要引入细节,因此它们成为像我们看到的锯齿问题的主要来源在图11.3中。

就像线条或三角形的抗锯齿光栅化,抗锯齿光线跟踪(参见第13.4节)或对图像进行降采样(参见第9.4节)一样,解决方案是使每个像素不是点样本,而是图像面积的平均值,区域类似像素的大小。使用用于抗锯齿光栅化和光线跟踪的相同超级采样方法,如果有足够的样本,则可以在不更改纹理映射机制的情况下获得出色的结果:像素区域内的许多样本将落在纹理图中的不同位置,并平均使用不同纹理查找计算出的阴影结果是一种精确的方法,可以估算像素上图像的平均颜色。但是,使用详细的纹理需要很多样本才能获得良好的结果,这很慢。在表面存在纹理的情况下,高效地计算该面积平均值是纹理抗锯齿的第一个重要的主题。

纹理图像通常由光栅图像定义,因此,与升采样图像一样,也要考虑重构问题(参见第9.4节)。解决方案同样可以运用在纹理上:使用重构滤波在纹理像素之间进行插值。

在以下各节中,我们将对每个主题进行扩展

11.3.1 一个像素的覆盖区

使抗锯齿纹理比其他类型的抗锯齿更为复杂的原因在于,渲染图像与纹理之间的关系不断变化。每个像素值都应计算为图像中属于像素的区域的平均颜色,在通常情况下,像素对应单个表面,这相当于对表面上的区域求平均。 如果表面颜色来自纹理,则依次对纹理的相应部分进行取平均,这称为像素的纹理空间覆盖区。 图11.18说明了正方形区域(可能是较低分辨率图像中的像素区域)覆盖区如何映射到地板纹理空间中大小和形状完全不同的区域

图11.18 图像中相同大小的正方形区域在纹理空间中的覆盖区的大小和形状都不同。

回忆一下使用纹理进行渲染所涉及的三个空间:将3D点映射到图像中的投影π和将3D点映射到纹理空间中的纹理坐标函数ϕ\phi。为了处理像素覆盖区,我们需要了解这两个映射的组合:首先向后跟随π,从图像到表面,然后向前跟随ϕ\phi。组合ψ  =  ϕπ1\psi\;=\;\phi\circ\mathrm\pi^{-1}决定了像素覆盖区:像素覆盖区是图片在ψ\psi映射下的像素的方形区域

纹理抗锯齿的核心问题是计算像素覆盖区上纹理的平均值。通常,要精确地做到这一点可能是一件相当复杂的工作:对于一个具有复杂表面形状的遥远物体,覆盖区可能在纹理空间中覆盖了大面积的复杂的形状,或者可能是几个不连续的区域。但是在典型情况下,像素落在表面的平滑区域中,该区域映射到纹理中的单个区域。

因为ψ\psi既包含从图像到表面的映射,又包含从表面到纹理的映射,所以覆盖区的大小和形状取决于观察情况和纹理坐标函数。当表面更靠近相机时,像素覆盖区会更小;当同一表面移得更远时,占地面积会更大。当以倾斜角度观察表面时,像素在表面上的覆盖区会变长,这通常意味着它在纹理空间中也会变长。即使使用固定的视图,纹理坐标函数也会导致覆盖区变化:如果覆盖区变形,则覆盖区的大小也会发生变化;如果变形,则即使是俯视图也可以将其拉长。

但是,为了找到一个用于计算抗锯齿查询的高效算法,需要一些近似值。

图 11.19 可以使用从(x,y)到(u,v)的映射的导数来近似像素的纹理空间覆盖区。 x和y的偏导数与x和y等值线的图像平行(蓝色),并跨过一个平行四边形(橙色阴影),近似于精确轮廓线的弯曲形状(黑色轮廓)

当一个函数是平滑的,一个线性近似值通常是有用的。对于纹理抗锯齿的情况,这意味着从图片空间到纹理空间的ψ\psi映射被近似看作一个从2D到2D的映射:

ψ(x)  =  ψ(x0)  +  J(x    x0)\psi(x)\;=\;\psi(x_0)\;+\;J(x\;-\;x_0)

JJ是一个2X2的,有些类似ψ\psi的导数的矩阵。如果我们表示图片空间的x=(x,y)x = (x,y)点和纹理空间的u=(u,v)u = (u,v)点,他就是

M  =  [dudxdudydvdxdvdy]M\;=\;\begin{bmatrix}\frac{d_u}{d_x}&\frac{d_u}{d_y}\\\frac{d_v}{d_x}&\frac{d_v}{d_y}\end{bmatrix}

这四个导数描述了纹理坐标(u,v)(u,v)是如何随着一个在图片中的点(x,y)(x,y)变化而变化的。

这种近似的几何解释是,它表示图像中以x为中心的单位大小的正方形像素区域将近似映射到纹理空间中以ψ(x)\psi(x)为中心且其边缘与矢量ux=(dudx,dvdx)u_x = (\frac {d_u}{d_x},\frac {d_v}{d_x})ux=(dudy,dvdy)u_x = (\frac {d_u}{d_y},\frac {d_v}{d_y})平行的平行四边形中。

导数矩阵JJ很有用,因为它讲述了整个图像(近似)纹理空间覆盖区变化的整个过程。数量较大的导数表示较大的纹理空间覆盖区,并且导数向量uxu_xuyu_y之间的关系表示形状。当它们正交且长度相同时,覆盖区为正方形,并且它们变得偏斜和/或长度相差很大,覆盖区将会被拉长。

现在,我们已经得到了通常被认为是这种形式问题的“正确答案”:在特定图像空间位置处过滤的纹理样本应该是此点纹理坐标导数定义的平行四边形形状轮廓上的纹理贴图的平均值。这已经有了一些假设,即从图像到纹理的映射是平滑的,但是对于获得出色的图像质量来说它是足够准确的。但是,该平行四边形面积平均值已经太昂贵而无法精确计算,因此使用了各种近似值。纹理抗锯齿的方法在逼近此查找时在速度/质量折衷方面有所不同。我们将在以下各节中讨论这些

11.3.2 重采样

当覆盖区小于纹理像素时,我们会将纹理映射到图像中时将其放大。 这种情况类似于对图像进行上采样,并且主要考虑因素是在纹理像素之间进行插值以生成平滑的图像,其中纹理像素网格不明显。 就像在图像上采样中一样,此平滑过程由重建滤波器定义,该重采样滤波器用于计算纹理空间中任意位置的纹理样本。(参见图11.20。)

图11.20 纹理过滤中的主要问题随覆盖区大小而变化。 对于覆盖区(左),需要在像素之间进行插值以避免块状失真; 对于大覆盖区,挑战在于有效地找到许多像素的平均值

注意事项与图像重采样几乎相同,但有一个重要区别。在图像重采样中,任务是在规则的网格上计算输出样本,并且在可分离的重采样滤波器的情况下,这种规则性实现了重要的优化。在纹理过滤中,查找模式不规则,必须分别计算样本。这意味着大型高质量的重建滤波器使用起来非常昂贵,因此,通常用于纹理的最高质量滤波器就是双线性插值。

双线性插值纹理样本的计算与计算通过双线性插值上采样的图像中的一个像素相同。首先,我们根据纹理像素坐标(实值)表示纹理空间采样点,然后读取四个相邻纹理像素的值并取其平均值。纹理通常在单位平方上进行参数设置,并且纹理像素的位置与任何图像中的像素相同,在uu方向上以1nu\frac {1}{n_u}的间隔和在vv中以1nv\frac {1}{n_v}的间隔放置,纹素(0,0)被插入到边缘半个纹理像素处以保持对称.(有关完整说明,请参见第9章。)

1
2
3
4
5
6
7
8
9
10
11
Color tex_sample_bilinear(Texture t, float u, float v) 
{
u_p = u * t.width - 0.5
v_p = v * t.height - 0.5
iu0 = floor(u_p); iu1 = iu0 + 1
iv0 = floor(v_p); iv1 = iv0 + 1
a_u = (iu1 - u_p); b_u = 1 - a_u
a_v = (iv1 - v_p); b_v = 1 - a_v
return a_u * a_v * t[iu0][iv0] + a_u * b_v * t[iu0][iv1] +
b_u * a_v * t[iu1][iv0] + b_u * b_v * t[iu1][iv1]
}

在许多系统中,此操作成为重要的性能瓶颈,主要是因为从纹理数据中获取四个纹素值涉及到内存延迟。纹理的采样点的模式是不规则的,因为从图像到纹理空间的映射是任意的,但通常是连贯的,因为附近的图像点倾向于映射到可能读取相同纹理像素的附近纹理点。因此,高性能系统具有专门用于纹理采样的特殊硬件,该硬件可处理插值并管理最近使用的纹理数据的缓存,以最大程度地减少从存储纹理数据的内存中提取慢速数据的次数。

阅读第9章后,您可能会抱怨线性插值对于某些要求苛刻的应用程序可能不够平滑。但是,始终可以通过使用更好的滤波器将纹理重新采样到更高的分辨率来使其足够好,从而使纹理足够平滑以至于双线性插值效果良好。

11.3.3 多级渐远纹理

仅在放大纹理的情况下做好插值就足够了:像素占位面积比纹理像素间距小。当像素覆盖区覆盖许多纹理像素时,良好的抗锯齿功能需要计算许多纹理像素的平均值以平滑信号,以便可以对其进行安全采样。

一种计算覆盖区上的平均纹理值的非常准确的方法是找到覆盖区中的所有纹理像素并将其相加。但是,当占用空间很大时,这可能会非常昂贵-仅一次查找就可能需要读取数千个纹素。更好的方法是在不同大小和位置的各个区域上预先计算并存储纹理的平均值。

这个想法的一个非常流行的版本被称为“MIP映射”或mipmapping。Mipmap是一系列纹理,这些纹理均包含相同的图像,但分辨率越来越低。原始的全分辨率纹理图像称为mipmap的基本级别或级别0,而级别1则是通过获取该图像并将其在每个维度中降采样2倍而生成的,从而得到具有像素数量的四分之一。大致来说,此图像中的纹素是0级图像中面积为2乘2纹素的正方形区域的平均值。

可以继续进行此过程,以定义所需的任意多个mipmap级别:通过对k级图像进行降采样2倍来计算k - 1级图像。级别k的纹素对应于原始纹理中面积为2k2^kx2k2^k纹素的正方形区域。例如,从1024×1024纹理图像开始,我们可以生成具有11个级别的mipmap:0级为1024×1024;级别1为512×512,依此类推,直到级别10,只有一个纹理像素。这种具有以一系列越来越低的采样率表示相同内容的图像的结构,被称为图像金字塔,其基于将所有较小图像堆叠在原始图像顶部的视觉隐喻。

11.3.4 使用Mipmap进行基础的纹理滤波

使用mipmap或图像金字塔,与单独访问许多纹理像素相比,可以更有效地完成纹理过滤。当我们需要在大面积上平均的纹理值时,我们只使用来自更高级别的mipmap的值,这些值已经是图像大面积上的平均值。最简单,最快的方法是从mipmap查找单个值,然后选择级别,以使该级别上的纹素覆盖的大小与像素覆盖区的整体大小大致相同。当然,像素覆盖区的形状可能与纹素表示的(始终为正方形)区域的形状完全不同,我们可以预期会产生一些失真。

暂时搁置一个问题,当像素覆盖区具有拉长的形状时该怎么做,假设覆盖区是宽度D的平方,以全分辨率纹理中的像素表示。哪个级别的mipmap适合采样?由于第k级的纹理像素覆盖了宽度为2k2^k的正方形,因此选择k

2kD2^k ≈ D

所以我们让k  =  log2(D)k\;=\;\log_2\left(D\right)。当然,这在大多数情况下会给出k的非整数值,并且我们只存储了整数级别的mipmap图像。 两种可能的解决方案是,仅针对最接近k的整数查找值(高效,但会在级别之间的突然过渡处产生接缝),或者查找针对k的两个最接近整数的值并线性插值(两次,但更流畅)。在我们实际写下对mipmap进行采样的算法之前,我们必须决定当覆盖区不是正方形时如何选择“宽度”D。 一些可能的方法是使用面积的平方根或找到覆盖区的最长轴并将其称为宽度。易于计算的实际折衷方法是使用最长边的长度:

D  =  max{ux,uy}D\;=\;max\{\left|\left|u_x\right|\right|,\left|\left|u_y\right|\right|\}

1
2
3
4
5
6
7
8
9
10
Color mipmap_sample_trilinear(Texture mip[], float u, float v,matrix J) 
{
D = max_column_norm(J)
k = log2(D)
k0 = floor(k); k1 = k0 + 1
a = k1 - k; b = 1 - a
c0 = tex_sample_bilinear(mip[k0], u, v)
c1 = tex_sample_bilinear(mip[k1], u, v)
return a * c0 + b * c1
}

基本的mipmapping可以很好地消除锯齿,但是由于它无法处理细长的或各向异性的像素覆盖区,因此在以掠射角度查看表面时效果不佳。在代表观察者站立的表面的大平面上最常见。 可以以非常陡峭的角度观察地板上较远的点,从而产生非常各向异性的覆盖区,该覆盖区在较大的正方形区域内会发生映射。生成的图像将在水平方向上显得模糊。

11.3.5 各向异性滤波

Mipmap可以与多个查询一起使用,以更好地近似伸长的覆盖区。 这个想法是根据覆盖区的最短轴而不是最大轴选择mipmap级别,然后将沿长轴间隔的几个查找平均在一起。(见图11.21。)

图11.21 使用三种不同策略对具有挑战性的测试场景进行抗锯齿的结果(参考图像左侧显示了详细的结构):简单地通过最近邻插值获取单个点样本; 使用mipmap金字塔对每个像素的纹理中的正方形区域求平均; 使用来自mipmap的几个样本来平均纹理中的各向异性区域。

11.4 纹理映射的应用

一旦理解了为表面定义纹理坐标的想法以及查询纹理值的机制,该机制就会有很多用途。 在本节中,我们研究了纹理映射中一些最重要的技术,但是纹理是非常通用的工具,其应用范围仅受限于程序员想象力。

11.4.1 控制光照参数

纹理映射的最基本用途是通过使从光照中计算出来的漫反射颜色(无论是在光线追踪中还是在片段着色器中)依赖于从纹理中查询的值,从而引入颜色变化。纹理的漫反射部分可用于在表面上粘贴贴花,涂鸦或打印文本,它还可模拟材质颜色的变化,例如木材或石材。

但是,我们不仅能更改漫反射颜色。任何其他参数(例如镜面反射率或镜面粗糙度)也可以进行纹理处理。例如,粘贴有透明胶带的纸板箱在任何地方都可能具有相同的漫反射颜色,但较之其他地方的胶带,镜面反射率更高,粗糙度更低的地方更亮。在许多情况下,不同参数的映射是相关的:例如,上面印有Logo的光滑白色陶瓷杯在印刷时可能会更粗糙或更暗(图11.22)

图11.22 陶瓷杯的镜面粗糙度由漫反射色纹理的反转副本控制

并且书本的标题用金属墨水印刷可能会同时改变漫反射颜色,镜面反射颜色和粗糙度。

11.4.2 法线贴图和凹凸贴图

对着色重要的另一个变量是表面法线。对于法线插值(第8.2节),我们知道着色法线不必与基础表面的几何法线相同。法线贴图通过使着色法线依赖于从纹理图读取的值来利用这一事实。最简单的方法是将法线存储在纹理中,在每个纹理像素处存储三个被插值的数字,而不是颜色的三个分量作为法线向量的3D坐标。

但是,在使用法线贴图之前,我们需要知道从贴图读取的法线表示在哪个坐标系中。将法线直接存储在模型空间中以及用于表示表面几何形状本身的同一坐标系中是最简单的:从贴图读取的法线可以与表面本身所表示的法线完全相同地使用:在大多数情况下,将其转换到世界空间以进行光照计算,就像几何体附带的法线一样。

但是,存储在模型空间中的法线贴图固有地与曲面几何相关联,即使法线贴图本身不会有影响,要使几何法线本身结果正确,法线贴图的内容也必须跟随模型表面的方向。此外,如果表面变形,几何法线发生变化,则将不能再使用模型空间法线贴图,因为它将继续提供相同的着色法线。

解决方案是为附着到表面的法线定义坐标系。可以基于表面的切线空间定义此类坐标系(参见第2.5节):选择一组切线向量,并使用它们定义标准正交基(参见第2.4.5节)。纹理坐标函数本身提供了一种选择一组切线向量的有效方法:使用与常数uuvv的线相切的方向。这些切线通常不是正交的,但是我们可以使用第2.4.7节中的“平方向上”方法,或者可以使用表面法线和一个切向量定义它。

切线空间中的法线与正常法线差异很小:因为它们大多指向平滑曲面的法线方向,因此它们将位于法线贴图中的向量(0,0,1)T(0,0,1)^T附近。

法线贴图来自哪里?通常,它们是根据更详细的模型计算得出的,光滑的表面近似于该模型。有时它们可以直接从真实表面测量。它们也可以作为建模过程的一部分进行创作。在这种情况下,通常最好使用凹凸贴图间接指定法线。这个想法是,凹凸贴图是一个高度场:该函数给出光滑表面上方详细表面的局部高度。值较高的地方(如果将贴图显示为图像,则贴图看起来很亮),该表面突出于平滑表面之外;值较低的地方(贴图看起来较暗),表面会往下凹陷。例如,凹凸贴图中的黑色细线是凹陷,小的白点是突出。

从凹凸贴图导出法线贴图很简单:法线贴图(在切线空间中表示)是凹凸贴图的衍生图。

图11.23显示了纹理贴图,该纹理贴图用于创建木纹颜色并模拟由于精加工浸入木材的较多孔部分而导致的表面粗糙度增加,以及凹凸贴图(bump map)创建不完美的饰面和木板之间的缝隙,从而制作出逼真的木材地板

图11.23 使用纹理贴图渲染木地板来控制着色。 (a)仅漫反射颜色由纹理贴图控制。 (b)镜面粗糙度也通过第二纹理图来控制。 (c)通过凹凸贴图修改表面法线

11.4.3 置换贴图

法线贴图的一个问题是它们实际上根本不会改变表面;他们只是一个着色技巧。当法线贴图所表示的几何形状应该在3D中引起明显效果时,这一点变得显而易见。在静止图像中,首先要注意的问题通常是,尽管内部出现了凹凸,但物体的轮廓仍保持平滑。在动画中,视差的缺乏消除了高低感,尽管令人信服,但实际上仍然只是“绘制”在表面上。

但是,纹理不仅可以用于着色,还可以用于改变几何体。置换贴图是此想法最简单的版本之一。该概念与凹凸贴图相同:标量(单通道)贴图,给出高于“平均地形”的高度。但是效果不同。置换贴图实际上是在更改表面,是将每个点沿平滑表面的法线移动到新的位置,而不是从凹凸贴图导出着色法线。在每种情况下,法线大致相同,但是表面不同。实践置换贴图的最常见方法是使用大量小三角形对光滑表面进行细分,然后使用置换贴图置换所得网格的顶点。在图形管线中,可以在顶点着色器阶段使用纹理查询来完成此操作,这对于地形制作特别方便。

11.4.4 阴影贴图

阴影是场景中对象关系的重要线索,正如我们已经看到的那样,阴影很容易包含在光线追踪的图像中。但是,如何在光栅化的渲染中获得阴影并不明显,因为一次只能独立地考虑一个表面。阴影贴图是一种使用纹理映射机制从点光源获取阴影的技术。

阴影贴图的想法是表示点光源所照亮的空间大小。想一想像聚光灯或视频投影仪这样的光源,它从一个点向指定的方向范围内发出光。被照亮的体积(如果您将其握在手中,则将在手上看到光的点集)是线段的并集,这些线段将光源沿着每条离开该点的光线照射到最近的表面点。

有趣的是,此体积与位于与光源相同的点处的透视相机可见的体积相同:当且仅当从光源位置可见该点时,光源才会照亮该点。在这两种情况下,都需要评估场景中各个点的可见性:对于可见性,我们需要知道相机是否可以看到片段,是否需要在图像中绘制片段;对于阴影,我们需要知道光源是否可见一个片段,要知道该片段是否被该光源照亮。 (参见图11.24。)

图11.24 上:被点光源照亮的区域。下:大概被10像素宽阴影贴图映射到的区域。

在这两种情况下,解决方案都是相同的:深度贴图表明沿一束射线到最近表面的距离。在可见性情况下,这是z-buffer(第8.2.3节),对于阴影情况,它称为阴影贴图。

在这两种情况下,通过将新片段的深度与缓冲区中存储的深度进行比较来评估可见性,如果该表面的深度大于最近的可见表面的深度,则该表面对于投影点是隐藏的(被遮挡或阴影的) 。不同之处在于,z缓冲区用于追踪到目前为止所看到的最接近表面,并在渲染过程中进行更新,而阴影贴图指示整个场景中到最接近表面的距离。

提前在单独的渲染通道中计算阴影贴图:像往常一样简单地光栅化整个场景,并保留生成的深度贴图(无需费心计算像素值)。然后,在拥有阴影贴图的情况下,执行普通的渲染流程,并且当您需要知道片段是否对源可见时,您可以在阴影贴图中投影其位置(使用用于渲染阴影贴图的透视投影矩阵),然后将查找值dmapd_{map}与到光源的实际距离dd进行比较。如果距离相同,则片段的点被照亮;如果d>dmapd>d_{map},则意味着存在一个更靠近光源的表面,因此将其显示为阴影。

“如果距离相同”这句话会在您的脑海中出现警告:由于涉及的所有变量都是近似值,精度有限,因此我们不能期望它们完全相同。对于可见点,ddmapd≈d_{map},但有时dd会大一点,有时会小一点。因此,需要一个容差ε\varepsilon:如果dεd≥\varepsilon,则认为一个点已照亮。如果ddmap<εd - d_{map} < \varepsilon。这个容差ε\varepsilon也称为阴影偏差

在阴影贴图中查询时,在贴图中记录的深度值之间进行插值并没有多大意义。虽然这可能会导致在光滑区域中获得更准确的深度(需要更少的阴影偏差),但会在阴影边界附近(深度值突然变化)带来更大的问题。因此,阴影贴图中的纹理查找是使用最近邻重采样完成的。为了减少锯齿,可以使用多次采样,对1或0阴影结果(而不是深度)进行平均。这称为百分比更近过滤

11.4.5 环境贴图

正如纹理便于在表面上的着色中引入细节而不必在模型中添加更多细节一样,纹理也可以用于将细节引入光照中而不必对复杂的光源几何结构建模。当光源很远时,场景中点到点的照明变化很小。我们就假设光照仅取决于您所看的方向,并且场景中的所有点都相同,然后使用环境贴图来表示光照对方向的这种依赖关系。

环境贴图的思想是,在3D方向上定义的函数是单位球面上的函数,因此可以使用纹理贴图来表示它,就像在球形物体上表示颜色变化一样。代替从表面点的3D坐标计算纹理坐标,我们使用完全相同的公式从表示要知道照明的方向的单位矢量的3D坐标计算纹理坐标。环境贴图最简单的应用是为光线追踪中不会碰到任何物体的光线赋予颜色:

1
2
3
4
5
6
7
8
9
10
11
12
trace_ray(ray, scene) 
{
if (surface = scene.intersect(ray))
{
return surface.shade(ray)
}
else
{
u, v = spheremap_coords(r.direction)
return texture_lookup(scene.env_map, u, v)
}
}

通过更改光线跟踪,现在可以反射其他场景对象的发光对象也可以反射背景环境。

在光栅化上下文中,可以通过向着色计算中添加镜面反射来实现类似的效果,该镜面反射的计算方式与光线追踪器中的计算方式相同,但只是直接在环境贴图中查找,而无需考虑场景中与该对象相关的其他对象。

1
2
3
4
5
6
7
shade_fragment(view_dir, normal) 
{
out_color = diffuse_shading(k_d, normal)
out_color += specular_shading(k_s, view_dir, normal)
u, v = spheremap_coords(reflect(view_dir, normal))
out_color += k_m * texture_lookup(environment_map, u, v)
}

此技术称为反射贴图

使用更高级的环境贴图可计算环境贴图的所有光照,而不仅仅是镜面反射。 这是环境光照,可以使用蒙特卡洛积分在光线追踪器中进行计算,也可以通过使用点光源集合近似环境并计算许多阴影贴图来进行光栅化处理。 环境贴图可以存储在可用于映射球体的任何坐标中。 球形(经度-纬度)坐标是一种流行的选择,尽管极点处的纹理压缩会浪费纹理分辨率,并会在极点处失真。 立方体贴图是一种更有效的选择,广泛用于交互式应用程序(图11.25)。

图11.25 一张圣彼得大教堂的立方体贴图,其中六个面以展开的“水平十字”排列形式存储在图像中。 纹理:Emil Persson)

11.5 程序化3D纹理

在前面的章节中,我们使用crc_r作为对象上某个点的漫反射率。对于没有纯色的对象,我们可以将其替换为函数cr(p)c_r(p),该函数将3D点映射为RGB颜色(Peachey,1985; Perlin,1985)。此函数可能只是返回包含p的对象的反射率。但是对于具有纹理的对象,我们应该期望cr(p)c_r(p)随p在整个表面上移动而变化。

定义从3D表面映射到2D纹理域的纹理映射功能的替代方法是创建一个3D纹理,该纹理在3D空间中的每个点定义RGB值。我们仅为表面上的点调用它,但是为所有3D点定义它通常比任意表面上潜在的奇怪2D点子集更容易定义。3D纹理贴图的好处在于,易于定义映射函数,因为表面已经嵌入到3D空间中,并且从3D到纹理空间的映射中不存在任何变形。这种策略显然适用于从固体介质(例如大理石雕塑)“雕刻”的表面。

3D纹理的不利之处在于将它们存储为3D光栅化图像或体积会占用大量内存。因此,3D纹理坐标最常用于程序化纹理,纹理值是使用数学过程而不是通过从纹理图像中查找来计算的。在本节中,我们介绍了一些用于定义程序化纹理的基本工具。这些也可以用于定义2D程序化纹理,尽管在2D中使用光栅化纹理图像更为常见。

11.5.1 3D条状纹理

制作条纹纹理的方法有很多。假设我们要使用两种颜色c0c_0c1c_1来制作条纹颜色。我们需要一些振荡功能才能在两种颜色之间切换。一个简单的就是正弦

1
2
3
4
5
RGB stripe( point p )
if (sin(xp) > 0) then
return c0
else
return c1

我们可以使用ww来控制条纹宽度

1
2
3
4
5
RGB stripe( point p, real w)
if (sin(πxp/w) > 0) then
return c0
else
return c1

如果我们想要在条纹颜色之间平滑插值,我们可以使用一个参数tt来线性区分颜色

1
2
3
RGB stripe( point p, real w )
t = (1 + sin(πpx/w))/2
return (1 − t)c0 + tc1

如图11.26所示

图11.26 在保持z不变的同时绘制规则的xy点数组会产生各种条纹纹理

11.5.2 固体噪音

尽管常规纹理(例如条纹)通常很有用,但我们希望能够制作“斑驳”的纹理,例如在鹌鹑蛋上看到的那样。这通常是通过使用一种“固体噪音”来完成的,通常以其发明人的名字称为Perlin噪音,Perlin噪音因其对电影业的影响而获得了奥斯卡技术奖(Perlin,1985年)。

通过为每个点调用一个随机数来获得嘈杂的外观是不合适的,因为这就像电视静态中的“白噪声”一样。我们希望在不损失随机质量的情况下使其更平滑。一种可能性是使白噪声模糊,但是对此没有实际实现。另一种可能性是在每个晶格点上制作一个随机数的大晶格,然后将这些随机点插值为晶格节点之间的新点。这只是最后一部分中所述的3D纹理数组,数组中具有随机数。此技术使晶格太明显。 Perlin使用了多种技巧来改进这种基本的晶格技术,因此晶格并不是那么明显。这导致了一组颇具巴洛克风格的步骤,但是从线性插值一个随机值的3D数组实际上只有三个更改。第一个更改是使用Hermite插值来避免马赫带,就像常规纹理一样。第二个更改是使用带有点积的随机向量而不是值,以得出随机数。通过将局部最小值和最大值移出网格顶点,可以使底层网格结构在视觉上不太明显。第三个更改是使用1D数组和hash来创建随机向量的虚拟3D数组。这会增加计算量,从而降低内存使用量。这是他的基本方法:

n(x,y,z)  =  i=xx+1i=yy+1i=zz+1Ωijk(xi,yj,zk)n(x,y,z)\;=\;\sum_{i=\left|x\right|}^{\left|x\right|+1}\sum_{i=\left|y\right|}^{\left|y\right|+1}\sum_{i=\left|z\right|}^{\left|z\right|+1}\Omega_{ijk}(x-i,y-j,z-k)

(x,y,z)(x,y,z)是x的直角坐标,且

Ωijk(u,v,w)  =  ω(u)ω(v)ω(w)(Γijk(u,v,w))\Omega_{ijk}(u,v,w)\;=\;\omega(u)\omega(v)\omega(w)(\Gamma_{ijk}\cdot(u,v,w))

并且ω(w)\omega(w)是一个3次函数

ω(t)={2t33t2+1if  t<10otherwise.\omega(t)=\left\{\begin{array}{lc}2\left|t\right|^3-3\left|t\right|^2+1&if\;\left|t\right|<1\\0&otherwise.\end{array}\right.

最后的Γijk\Gamma_{ijk}是一个相对于点(x,y,z)=(i,j,k)(x,y,z) = (i,j,k)的随机单位向量。如果我们想要得到i,j,ki,j,k,我们可以用一个伪随机的表:

Γijk  =  G(ϕ(i+ϕ(j+ϕ(k))))\Gamma_{ijk}\;=\;G(\phi(i+\phi(j+\phi(k))))

式中,G是由n个随机单位矢量组成的预先计算的数组,ϕ(i)  =  P[i  mod  n]\phi(i)\;=\;P\lbrack i\;mod\;n\rbrack,其中P是长度为n的数组,其中包含从0到n-1的整数的排列。实际上,Perlin表示n = 256效果很好。 首先选择随机单位向量(vx,vy,vz)(v_x,v_y,v_z)

vx  =  2ξ    1,vy  =  2ξ    1,vz  =  2ξ    1,v_x\;=\;2\xi\;-\;1,\\v_y\;=\;2\xi'\;-\;1,\\v_z\;=\;2\xi''\;-\;1,

其中ξ,ξ,ξ\xi,\xi',\xi''是规范随机数(区间[0,1]中的均匀值)。然后,如果(vx2+vy2+vz2)<1(v_{x}^2+v_{y}^2+v_{z}^2)<1,则将向量设为单位向量。否则,请继续对其进行随机设置,直到其长度小于1,然后将其设为单位向量。这是一个拒绝方法的示例,将在第14章中进行更多讨论。本质上,“小于”检验会在单位球体内获得一个随机点,并且该点的原点矢量始终是随机的。 对于多维数据集中的随机点而言,情况并非如此,因此我们通过测试“消除”了角落

由于固体噪声可能为正也可能为负,因此必须先进行转换,然后再转换为颜色。 图11.27显示了10×10平方英寸的噪声的绝对值以及拉伸后的版本。

图11.27 固体噪声的绝对值,以及缩放x,y对应的噪声

通过缩放输入到噪声函数的点来扩展这些版本。深色曲线是原始噪声函数从正变为负的位置。由于噪声的变化范围是-1到1,因此通过将(noise + 1)/ 2用于颜色,可以实现更平滑的图像。但是,由于噪声值很少接近1或-1,因此这将是一个相当平滑的图像。较大的缩放比例可以增加对比度(图11.28)

图11.28 上:使用0.5(noise+1)强度 下:使用0.8(noise+1)强度

11.5.3 湍流

许多自然纹理在同一纹理中包含各种特征尺寸。Perlin使用伪分形“湍流”函数:

nt(x)  =  in(2ix)2in_t\left(x\right)\;=\;\sum_i\frac{\left|n(2^ix)\right|}{2^i}

这样可以高效地在其自身顶部重复添加按比例缩放的噪声函数,如图11.29所示。

图11.29 湍流函数示意图

湍流可用于扭曲条纹函数:

1
2
3
RGB turbstripe( point p, double w )
double t = (1 + sin(k1zp + turbulence(k2p))/w)/2
return t ∗ s0 + (1 − t) ∗ s1

不同的k1k_1k2k_2的值被用于生成图11.30

图11.30 使用不同的k1和k2的不同湍流条带纹理。第一行只有湍流系列的第一项

常见问题

我该如何在光线追踪中实现置换贴图?

没有理想的方法可以做到这一点。生成所有三角形并在必要时缓存几何体可以防止内存过载(Pharr和Hanrahan,1996; Pharr,Kolb,Gershbein和Hanrahan,1997)。 当置换函数受到限制时,可以尝试直接与位移表面相交(Patterson,Hoggar和Logie,1991; Heidrich和Seidel,1998; Smits,Shirley和Stark,2000)。

为什么我使用纹理的图片看起来不真实?

人类善于观察表面的小瑕疵。使用纹理贴图获取细节的计算机生成图像通常不存在瑕疵,因此它们看起来“太平滑”了。