《Fundamentals of Computer Graphics》第五版 第十一章 纹理映射

123 阅读8分钟

真实世界中,任何物体表面都有其自身特征。计算机图形学将它们归结为空间变化的表面属性(spatially varying surface properties)。为了实现这种效果,各种建模和渲染系统都提供了纹理映射(texture mapping)的方法:用一张图像存储表面上的细节,称为纹理映射(texture map)、纹理图像(texture image)、纹理(texture),然后把图像贴到表面上。

纹理不光可以描述表面细节,还可用于阴影、反射、光照,甚至是定义曲面形状,亦或是存储与图片无关的数据。本章介绍如何使用纹理呈现表面细节、阴影和反射。虽然基本想法很简单,但仍有一些实际问题让纹理的使用变得复杂。首先,纹理容易形变;其次,纹理映射是一个重采样过程,容易引入混淆失真。纹理映射系统的复杂性很大程度上是由抗锯齿措施引入的。

纹理查询

为了找到着色点的纹理颜色,着色器需要执行纹理查询(texture lookup):先计算出对应着色点的纹理坐标,然后读取纹理中该点的颜色,生成纹理样本(texture sample)。伪代码如下:

Color texture_lookup(Texture t, float u, float v) {int i=round(ut.width()0.5)int j=round(vt.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(uv)// 使用 diffuse_color 和 normal 计算着色// 返回着色结果}\begin{aligned} &\text{Color texture\_lookup(Texture $t$, float $u$, float $v$) \{} \\ &\quad\begin{aligned} &\text{int}\ i = \text{round}(u\ast t.\text{width}() - 0.5) \\ &\text{int}\ j = \text{round}(v\ast t.\text{height}() - 0.5) \\ &\text{return}\ t.\text{get\_pixel}(i,j) \\ \end{aligned} \\ &\} \\ &\text{Color shade\_surface\_point(Surface $s$, Point $p$, Texture $t$)}\ \{ \\ &\quad\begin{aligned} &\text{Vector normal = $s$.get\_normal($p$)} \\ &(u, v) = s.\text{get\_texcoord}(p) \\ &\text{Color diffuse\_color = texture\_lookup($u$, $v$)} \\ &\text{// 使用 diffuse\_color 和 normal 计算着色} \\ &\text{// 返回着色结果} \end{aligned} \\ &\} \end{aligned}

上述伪代码引出了纹理映射的第一个关键因素——纹理坐标函数(texture coordinate function),一个从表面 SS 到纹理 TT 的映射:

ϕ:ST:(x,y,z)(u,v)\begin{aligned} \phi&: S\rightarrow T \\ &:(x, y, z)\mapsto(u, v) \end{aligned}

TT 也称为纹理空间(texture space),是一个包含图像的矩形,通常用单位正方形(unit square)(u,v)[0,1]2(u,v)\in[0,1]^{2} 来表示,uuvv 称为纹理坐标(texture coordinates)。ϕ\phi 可以有多种形式,场景中每个物体都可以有自己的纹理坐标函数。纹理中的每个像素也称为纹理像素(texel)。

texture-coordinate-function.png

纹理映射的另一个常见问题是混淆失真。下图是将高对比度纹理渲染到低分辨率图像中,并从接近平行的角度观测到的结果,这里可以明显地看到混淆失真。

aliasing-artifacts-in-texture.png

纹理映射在具体应用中的两个基本问题:

  • 定义纹理坐标函数
  • 不引入过多混淆的前提下查询纹理值

纹理坐标函数

在有计算机图形学之前,地图制图员就已经在处理纹理坐标函数的问题——以尽可能少的形变来覆盖一大片连续区域。

纹理坐标函数有时也称为 UV 映射(UV mapping)或者曲面参数化(surface parameterization)。

纹理坐标函数 ϕ\phi 可以用任何方式来定义,但需要考虑如下因素:

  • 双射(Bijectivity)。大多数情况下,ϕ\phi 是双射。若非如此,纹理中一个点的值将影响曲面上多个点。除非刻意要求曲面上有重复纹理,否则不要出现这种情况。
  • 尺寸畸变(Size distortion)。纹理在整个曲面上的拉伸应尽可能保持恒定,即 ϕ\phi 的导数的大小不应剧烈变化。
  • 形变形状畸变(Shape distortion)。纹理不应太过扭曲,即 ϕ\phi 的导数沿不同方向不应剧烈变化。
  • 连续性(Continuity)。曲面上邻近的点应该映射到纹理中邻近的点,即 ϕ\phi 应尽可能保持连续。

对于参数曲面,参数方程本身就可以看作是一个纹理坐标函数。而对于其它曲面,一般来说,有两种方式定义纹理坐标函数:

  1. 在几何上用明确的公式定义;
  2. 存储顶点的纹理坐标并对曲面插值。

几何公式纹理坐标

几何公式定义的纹理坐标通常用于简单形状和特殊情况,或者为手动设计纹理坐标函数提供一个出发点。

平面投影(Planar Projection)

平行投影是三维到二维的最简单的映射之一,它其实就是正交投影:

ϕ(x,y,z)=(u,v)where[uv1]=Mt[xyz1]\phi(x, y, z) = (u, v)\quad \text{where}\quad \begin{bmatrix} u \\ v \\ \ast \\ 1 \end{bmatrix} = M_{t} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix}

其中,纹理矩阵 MtM_{t} 是一个仿射变换。对于近乎平坦的曲面,这种方法很好用。但对于任何闭合曲面,平面投影不再是单射。

planar-projection.png

类似地,将正交投影换成透视投影,可以得到射影纹理坐标(projective texture coordinates):

ϕ(x,y,z)=(u~/w,v~/w)where[u~v~w]=Pt[xyz1]\phi(x, y, z) = (\tilde{u}/w, \tilde{v}/w)\quad \text{where}\quad \begin{bmatrix} \tilde{u} \\ \tilde{v} \\ \ast \\ w \end{bmatrix} = P_{t} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix}

其中,PtP_{t} 是一个射影变换。

球坐标(Spherical Coordinates)

球面参数化中,经纬度的使用最为广泛,虽然在接近极点的位置会出现形变,但它确实覆盖了整个球面,而且只沿着纬线有间断。

形状近似为球面的曲面可以使用径向投影(radial projection)作为纹理坐标函数:计算球心到曲面上点 r\vec{r} 的连线与球面的交点。这其实等价于球坐标(spherical coordinates):

ϕ(x,y,z)=(π+atan2(y,x)2π,πacos(z/r)π)\phi(x, y, z) = \left(\frac{\pi + \mathrm{atan2}(y, x)}{2\pi}, \frac{\pi - \mathrm{acos}(z/\|\vec{r}\|)}{\pi}\right)

上式中纹理坐标的两个分量依次对应球坐标中的方位角和天顶角。

除了极点外,球坐标映射是双射,而且在极点处具有和经纬度坐标一样的畸变。

spherical-and-cylindrical.png

柱坐标(Cylindrical Coordinates)

对于柱状物体,柱坐标更适合作为纹理坐标函数:

ϕ(x,y,z)=(π+atan2(y,x)2π,1+z2)\phi(x, y, z) = \left(\frac{\pi + \mathrm{atan2}(y, x)}{2\pi}, \frac{1 + z}{2}\right)

本章所讨论的应用纹理坐标函数的物体均处于 [0,1]3[0,1]^3 内部,并位于原点。

立方体映射(Cubemaps)

为了避免球坐标在极点处的畸变,可以将物体表面投影到立方体上,然后再对 6 个面分别使用 6 个独立的方形纹理。这 6 个方形纹理合在一起称为立方体映射(cubemap)。立方体映射在棱上引入了更多的不连续性,但是减弱了畸变;而且,由于立方体映射本质上就是一组透视投影,因此计算速度也比球坐标更快。

根据使用习惯,通常约定在立方体内部观察时,每个面上的 uuvv 的顺时针方向上。OpenGL 所采用的规范是:

ϕ+x(x,y,z)=(1z/x,1y/x)2ϕx(x,y,z)=(1+z/x,1y/x)2ϕ+y(x,y,z)=(1+x/y,1+z/y)2ϕy(x,y,z)=(1+x/y,1z/y)2ϕ+z(x,y,z)=(1+x/z,1y/z)2ϕz(x,y,z)=(1x/z,1y/z)2\begin{aligned} \phi_{+x}(x, y, z) &= \frac{(1-z/|x|, 1-y/|x|)}{2} \\ \phi_{-x}(x, y, z) &= \frac{(1+z/|x|, 1-y/|x|)}{2} \\ \phi_{+y}(x, y, z) &= \frac{(1+x/|y|, 1+z/|y|)}{2} \\ \phi_{-y}(x, y, z) &= \frac{(1+x/|y|, 1-z/|y|)}{2} \\ \phi_{+z}(x, y, z) &= \frac{(1+x/|z|, 1-y/|z|)}{2} \\ \phi_{-z}(x, y, z) &= \frac{(1-x/|z|, 1-y/|z|)}{2} \\ \end{aligned}

点的坐标中绝对值最大的那个分量决定了要投影的那个面。用于立方体映射的纹理包含 6 个方块,通常它们被包装成单个图像用于存储,并以立方体展开后的形式进行排列。

插值纹理坐标

为了精确控制三角形上的纹理坐标,可以先将每个顶点的纹理坐标存储下来,然后再用重心坐标插值。下图给出了一种可视化整个网格面(mesh)上纹理坐标的常用方法:在纹理空间中画出每个三角形。这是一种方便的工具,用于计算纹理坐标,或调试各种纹理映射代码。

interpolated-texture-in-texture-space.png

插值纹理映射的好坏取决于如何为顶点分配纹理坐标。无论如何,只要曲面上的三角形共用顶点,纹理映射总是连续的。但是其它的性质并不总是满足:

  • 单射性要求三角形在纹理空间中不交叠;
  • 低尺寸畸变要求三角形在纹理空间和三维空间中的面积成正比;
  • 低形状畸变要求三角形在纹理空间和三维空间中的形状相似。

interpolated-texture-face.png

平铺(Tiling)、环绕模式(Wrapping Modes)和纹理变换(Texture Transformations)

允许纹理坐标越界,可以避免舍入误差带来的问题,同时又可以用于建模。如果只在物体表面的某一区域使用纹理,可以将超出 [0,1]2[0,1]^{2} 区域的纹理坐标 (u,v)(u,v) 截断(clamping)到边界上,这相当于将背景沿整个平面伸展。如果需要重复图案(repeating pattern),可以使用回绕索引(wraparound indexing)——当纹理查询越出右边界时,折回到左边界。

Color texture_lookup_wrap(Texture t, float u, float v) {int i=round(ut.width()0.5)int j=round(vt.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(ut.width()0.5)int j=round(vt.height()0.5)return t.get_pixel(max(0, min(i, t.width()1)),max(0, min(j, t.height()1)))}\begin{aligned} &\text{Color texture\_lookup\_wrap(Texture $t$, float $u$, float $v$) \{} \\ &\quad\begin{aligned} &\text{int}\ i = \text{round}(u \ast t.\text{width}() - 0.5) \\ &\text{int}\ j = \text{round}(v \ast t.\text{height}() - 0.5) \\ &\text{return $t$.get\_pixel($i$ \% $t$.width(), $j$ \% $t$.height())} \end{aligned} \\ &\} \\ &\text{Color texture\_lookup\_wrap(Texture $t$, float $u$, float $v$) \{} \\ &\quad\begin{aligned} &\text{int}\ i = \text{round}(u \ast t.\text{width}() - 0.5) \\ &\text{int}\ j = \text{round}(v \ast t.\text{height}() - 0.5) \\ &\text{return $t$.get\_pixel(} \\ &\quad\begin{aligned} &\text{max}(0,\ \text{min}(i,\ t.\text{width}() - 1)), \\ &\text{max}(0,\ \text{min}(j,\ t.\text{height}() - 1)) \end{aligned} \\ &\text{)} \end{aligned} \\ &\} \end{aligned}

具体采用哪种方式处理越界查询,可以在平铺(tiling)、截断(clamping)以及两者组合或变体中,选择一种环绕模式(wrapping mode)来指定。

tiling.png

借助环绕模式,可以把纹理看作无限大二维平面上的颜色值函数。如果使用一个图像来指定纹理,那么这些模式其实就是在描述如何使用有限大的图像数据来定义该函数。

为了方便地调整纹理的大小和位置,应避免修改纹理坐标函数,或者顶点的纹理坐标,而是在纹理采样前对纹理坐标应用纹理变换:

ϕ(r)=MTϕmodel(r)\phi(\vec{r}) = \mathbf{M}_{T}\phi_{\text{model}}(\vec{r})

其中,ϕmodel\phi_{\text{model}} 是由模型提供的纹理坐标函数,3×33\times 3 矩阵 MT\mathbf{M}_{T} 是一个关于齐次纹理坐标的仿射或射影变换,一般仅限于缩放和平移。

连续性与接缝(Seams)

对于闭合曲面,由于和平面不同胚,因此不存在连续的双射作为纹理坐标函数。为了保证低畸变,通常会引入接缝(seams)——曲面上纹理坐标发生突变的曲线,比如,球坐标、柱坐标中角度从 π\pi 回到 π-\pi,立方体映射中立方体的棱。

对于插值纹理坐标,接缝需要特殊考虑。由于共用顶点的网格模型上纹理坐标自动保持了连续性,因此如果一个三角形跨越了接缝,插值纹理坐标将产生高度畸变或折叠,使得纹理映射非单射。唯一的解决方法是在接缝处不再共用纹理坐标,为那些跨接缝的三角形复制一份顶点,并允许使用回绕索引让纹理坐标越界,比如球坐标中相差 360360^{\circ} 但依然等价的经度。

seams.png

渲染系统中的纹理坐标

虽然纹理映射的基本原理在所有渲染系统中都一样,但具体细节有所不同。作为模型的一部分,场景描述通常需要足够多的信息来定义纹理坐标,比如,将纹理坐标存储为顶点属性。如果渲染系统直接支持三角形以外的几何图元,通常需要为这些图元预设纹理坐标,可能要为每一种图元选择一种纹理映射。

在射线追踪渲染器中,每个支持射线求交的曲面都得能计算出交点的纹理坐标。对于三角形网格,计算交点的程序会根据重心坐标计算出纹理坐标。对于其它类型的几何体,必须直接计算出纹理坐标。

栅格系统通常仅支持三角形图元。纹理坐标可以和模型一起读入,也可以在代码中生成模型的同时计算并存储起来。对于依赖其它顶点属性的纹理坐标,可以在顶点着色器中计算,然后再传入栅格器。

抗锯齿纹理查询(Antialiasing Texture Lookups)

应用纹理映射的第二个基本问题是抗锯齿纹理查询。渲染带有纹理的图像其实就是一个采样过程:先将纹理贴到曲面上,再把曲面投影到像平面上生成一个二维函数,然后对每个像素采样。由于纹理就是为了引入细节,因此直接点采样会产生混淆失真。解决方案是对图像中像素大小的区域求平均——箱式滤波,而不是点采样。类似抗锯齿栅格化和光线追踪,使用过采样,只要样本足够,总可以得到较好的结果。但是细致纹理可能需要非常多的样本,因此高效计算区域平均是关键。由于纹理映射通常表示为栅格图,因此还需要对其重构。

像素的印迹(Footprint)

纹理抗锯齿比其它抗锯齿场景更复杂的原因在于,像平面上的图像和纹理之间的关系总在不断变化。通常像平面上的一个像素指向一个曲面,并对应曲面上的一块区域,进而对应纹理上的一块区域,称为像素的纹理空间印迹(texture space footprint)。

footprint.png

将三维空间到二维图像的投影 π\pi 和纹理坐标函数 ϕ\phi 结合起来可以知道,像素的印迹就是该像素在映射 ψ=ϕπ1\psi=\phi\circ\pi^{-1} 下的像。纹理抗锯齿的核心问题就是在像素印迹上对纹理值求平均。对于位于远处的、复杂形状的物体,像素印迹可能是一个复杂形状的大片区域,甚至可能是多个不连通区域;但一般情况下,像素印迹是单个区域。像素印迹的大小和形状依赖于具体观测情况和纹理坐标函数,比如物体的远近、相机视角、纹理坐标函数的畸变等。

为了让纹理抗锯齿查询的算法更高效,必须要做一些近似。对于光滑函数,线性近似通常很有用:

ψ(r)=[uv]=ψ(r0)+J(rr0)\psi(\vec{r}) = \begin{bmatrix} u \\ v \end{bmatrix} = \psi(\vec{r}_{0}) + \mathbf{J}(\vec{r} - \vec{r}_{0})

J\mathbf{J}雅可比矩阵(Jacobi matrix):

J=[uxuyvxvy]\mathbf{J} = \begin{bmatrix} \frac{\partial u}{\partial x} & \frac{\partial u}{\partial y} \\ \frac{\partial v}{\partial x} & \frac{\partial v}{\partial y} \end{bmatrix}

也就是说,像平面上位于点 r\vec{r} 的、单位尺寸的像素,近似对应纹理空间中位于点 ψ(r)\psi(\vec{r}) 的、由 ψx=(ux,vx)\frac{\partial\psi}{\partial x}=(\frac{\partial u}{\partial x}, \frac{\partial v}{\partial x})ψy=(uy,vy)\frac{\partial\psi}{\partial y}=(\frac{\partial u}{\partial y}, \frac{\partial v}{\partial y}) 所构成的平行四边形。雅可比矩阵近似描绘了像素与其印迹的对应关系,像素印迹的大小和形状完全由 ψx\frac{\partial\psi}{\partial x}ψy\frac{\partial\psi}{\partial y} 决定。

Jacobi-matrix.png

因此,纹理采样值可以使用纹理映射在平行四边形印迹上的平均值来计算。虽然线性近似忽略了高阶项,但已经可以得到较好的结果。然而,即便如此,仍需进一步的近似来提高计算效率。

这里使用的是箱式滤波器对图像采样。有些系统使用高斯像素滤波器,在纹理空间将会变成一个椭圆高斯函数,因而被称为椭圆加权平均(elliptical weighted averaging,简写为 EWA)。

由前文可知,应用纹理映射就是先对纹理重构,再将其映到像平面上,最后再做箱式滤波。由于像平面与纹理空间之间的映射已经完全归结为像素印迹,因此剩下来的就只是先做纹理重构,再在像素印迹上做箱式滤波采样。这与图像放大/缩小时的重采样很类似,但区别在于,像素印迹的形状总在不断变化,因此通常不会把纹理重构和滤波采样合为一个重采样滤波,而是将两者分开考虑。

重构

当印迹小于一个纹理像素时,需要对纹理进行重构以计算纹理空间中任意位置的纹理值。而对于足够小的印迹,完全可以用微分近似积分,也就是说,印迹中心的纹理值就是屏幕空间中相应像素区域的平均纹理值。

图像重采样计算的是规则点阵上的输出样本,这使得可分离滤波器可用于加速计算。而纹理滤波与此不同,输出位置是不规则的,因此大尺寸、高质量的重构滤波器不再可能。介于此,常用的高质量纹理滤波器是双线性插值(bilinear interpolation)。双线性插值就是对邻近的 4 个纹理像素做线性插值。伪代码如下:

Color tex_sample_bilinear(Texture t, float u, float v)up=ut.width0.5vp=vt.height0.5ui0=floor(up); ui1=ui0+1vi0=floor(vp); vi1=vi0+1au=ui1up; bu=1auav=vi1vp; bv=1avreturn auavt[ui0][vi0]+aubvt[ui0][vi1]+buavt[ui1][vi0]+bubvt[ui1][vi1]}\begin{aligned} &\text{Color tex\_sample\_bilinear(Texture $t$, float $u$, float $v$)} \\ &\quad\begin{aligned} &u_{p} = u \ast t.\text{width} - 0.5 \\ &v_{p} = v \ast t.\text{height} - 0.5 \\ &u_{i0} = \text{floor}(u_{p});\ u_{i1} = u_{i0} + 1 \\ &v_{i0} = \text{floor}(v_{p});\ v_{i1} = v_{i0} + 1 \\ &a_{u} = u_{i1} - u_{p};\ b_{u} = 1 - a_{u} \\ &a_{v} = v_{i1} - v_{p};\ b_{v} = 1 - a_{v} \\ &\text{return}\ a_{u}\ast a_{v}\ast t[u_{i0}][v_{i0}] + a_{u}\ast b_{v}\ast t[u_{i0}][v_{i1}] + \\ &\quad\quad\quad b_{u}\ast a_{v}\ast t[u_{i1}][v_{i0}] + b_{u}\ast b_{v}\ast t[u_{i1}][v_{i1}] \end{aligned} \\ &\} \end{aligned}

由于从内存中读取纹理值的时间过长,上述操作在许多系统中都是性能瓶颈。虽然像平面到纹理的映射是任意的,但仍有一定的一致性,比如近邻性一般还会保持。介于此,高性能系统配有纹理采样专用硬件,用于插值和管理最近使用的纹理缓存,以减少从内存中读取纹理数据的次数。

虽然双线性插值不够光滑,但可以使用更好的滤波器,先将纹理重采样到更高的分辨率,使得纹理足够光滑,从而让双线性插值足够有用。

MIP 映射(Mip mapping)

上述方法仅适用于纹理放大的情形,即像素印迹比纹理像素小。当像素印迹较大时,纹理抗锯齿需要对多个纹理像素求平均。对印迹中所有纹理像素求和虽然精确,但却耗时。更好的方法是,事先计算出各种尺寸、各个位置的纹理平均值并存储起来。一种流行的做法是 MIP 映射(MIP mapping,或称为 mipmapping)——一个包含了同一图像但分辨率越来越低的纹理序列。原始的全分辨率纹理图像称为 00(level 0, 或称为 base level),kk 级图像是对 k1k-1 级图像做 22 倍降采样后的结果。kk 级图像中的一个纹理像素对应原始纹理中 2k×2k2^{k}\times 2^{k} 个纹理像素。这种结构也称为图像金字塔(image pyramid)。

mip 源于拉丁语 multim in parvo,意为小中见大。

mag-min.png

使用 MIP 映射做纹理滤波

假定印迹是一个宽度 DD 的方块(以 0 级纹理图像中的纹理像素为单位)。mipmap 中适合采样的等级 kk 应满足:

2kD2^{k} \approx D

klog2Dk\approx\log_{2}D。通常 kk 不是整数,解决方法有两种:

  1. 找出最接近 kk 的那个整数(高效,但会产生突变)
  2. 找出最接近 kk 的两个整数,然后线性内插(两倍计算量,但更光滑)

对于非方形印迹,宽度 DD 可以使用面积的开方来定义,也可以使用印迹的长轴来定义,但实用的折中方案是用长边的长度:

D=max{ψx,ψy}D = \max\left\{\left\|\frac{\partial\psi}{\partial x}\right\|, \left\|\frac{\partial\psi}{\partial y}\right\|\right\}

mipmap 纹理查询的伪代码如下:

Color mipmap_sample_trilinear(Texture mip[ ], float u, float v, matrix J) {D=max_column_norm(J)k=log2Dk0=floor(k);k1=k0+1a=k1k;b=1ac0=tex_sample_bilinear(mip[k0],u,v)c1=tex_sample_bilinear(mip[k1],u,v)return ac0+bc1}\begin{aligned} &\text{Color mipmap\_sample\_trilinear(Texture mip[ ], float $u$, float $v$, matrix \textbf{J}) \{} \\ &\quad\begin{aligned} &D = \text{max\_column\_norm}(\mathbf{J}) \\ &k = \log_{2}D \\ &k_{0} = \text{floor}(k); k_{1} = k_{0} + 1 \\ &a = k_{1} - k; b = 1 - a \\ &c_{0} = \text{tex\_sample\_bilinear}(\mathrm{mip}[k_{0}], u, v) \\ &c_{1} = \text{tex\_sample\_bilinear}(\mathrm{mip}[k_{1}], u, v) \\ &\text{return}\ a \ast c_{0} + b \ast c_{1} \end{aligned} \\ &\} \end{aligned}

上述算法可以消除混淆,但没有处理各向异性的(anisotropic)印迹。当沿着掠入射角观测远处的曲面时,会产生各向异性的印迹,而上述算法使用一个更大的方格来近似,从而导致生成的图像看起来更加模糊。

各向异性印迹可以通过多次查询来近似:先根据短轴选择 mipmap 等级,然后沿着长轴每隔一段距离查询一次,最后再对多次查询的结果求平均。

mipmapping.png

纹理映射的应用

纹理是一种非常通用的工具,使用者的想象力是它唯一的限制。

控制着色参数

纹理映射的最基本的应用就是让漫反射颜色(diffuse color)依赖于纹理值。任何其它参数,比如镜面反射率(specular reflectance)、镜面粗糙度(specular roughness),都可以被纹理化(textured)。而且,很多情况下,各种参数是互相关联的,比如,带有 logo 的陶瓷杯上颜色越深的地方越粗糙,书籍封面上的金属油墨标题同时改变漫反射颜色、镜面颜色和粗糙度。

ceramic-cup.png

法向映射(Normal Maps)、凹凸映射(Bump Maps)

曲面法向量对于着色也很重要。法向映射(normal mapping)就是让法向量依赖于纹理值。最简单的做法是将法向量存储到纹理中,每个纹理像素中存储三个数字用于表示法向量的三个分量。

法向映射中的法向量需要约定一个坐标系。最简单的做法是约定为物体空间(object space),与表示曲面几何所使用的坐标系一样。当然,光照计算时需要转换到世界空间。存储在物体空间的法向映射和曲面几何是捆绑在一起的,当曲面发生形变时,这种法向映射无法使用。

解决方案是为法向量定义一个固连在曲面上的坐标系。这可以基于曲面的切空间(tangent space)来定义:使用一对切向量来定义规范正交基。根据纹理坐标函数,曲面上等 uu 线和等 vv 线的切向量可用于确定这组正交基。以这种方式在低精度模型的切空间中表示出的高精度模型法向量,基本上都在 (0,0,1)T(0,0,1)^{T} 附近,变化很小。

法向映射可以从精细模型(detailed model)中计算出来,或直接从真实表面中直接测量出来,也可以作为建模的一部分,此时,经常使用凹凸映射(bump map)来间接指明法向量。凹凸映射就是一个高度场:精细曲面相对于光滑曲面的局部高度。对凹凸映射求导可以得到切标架(tangent frame)下的法向映射。

精细模型(精细曲面)是对实际模型的精细描述,光滑曲面是忽略了精细结构之后的近似描述,此时精细结构由纹理负责。

bump-map.png

位移映射(Displacement Maps)

法向映射只能用于着色,而无法实质上改变曲面几何。而纹理不仅可用于着色,还可以改变曲面几何。最简单的想法是位移映射(displacement map):一个表示相对于平均地势(average terrain)的高度的标量(单通道)映射。和凹凸映射不同的是,位移映射改变了曲面,它把每个点沿着光滑曲面的法向移动到了一个新的位置。

位移映射的最常见的实现方式是为光滑曲面镶嵌大量的小三角形,然后使用位移映射移动顶点的位置。这可以在顶点处理阶段使用纹理查询来实现,尤其适用于地形图的场景。

阴影映射(Shadow Maps)

与射线追踪不同的是,在栅格系统中,由于每个物体都是独立渲染的,因此如何获取阴影不那么显然。阴影映射(shadow maps)是一种利用纹理映射获取点光源阴影的技术。它的基本想法是把点光源的光照区域表示出来。类似透视观测,可以使用一个存储最近曲面距离的映射来解决这一问题,称为阴影映射(shadow map)。与 z-buffer 不同的是,阴影映射保存的是整个场景中的——而非迄今为止的——最近曲面距离。

shadow-map.png

阴影映射可以提前在一个单独的渲染过程(rendering pass)中计算出来。在随后的正常渲染过程中,将点的位置投影到阴影映射中,对比查询值 dmapd_{\text{map}} 和到点光源的距离 dd,如果 ddmap<ϵd-d_{\text{map}}<\epsilon 则被光照,否则为阴影。其中 ϵ\epsilon 是为了规避数值误差而引入的阴影偏差(shadow bias)。

对阴影映射插值虽然可以让光滑区域的深度值更加精确,但却在阴影边界——深度值突变的位置——带来更大的问题。因此,阴影映射纹理查询使用最近邻重构。为了降低锯齿,可以使用百分比近邻滤波(percentage closer filtering,简称为 PCF):把多个样本的 1/0 阴影结果——而非深度——的平均值作为阴影亮度。

环境映射(Environment Maps)

纹理可以在不给光源建立复杂几何模型的前提下,为光照引入细致结构。当光源离场景的距离远大于场景本身的范围时,光照在整个场景中是均匀的,而且仅依赖于方向,此时可以用环境映射(environment maps)来表示这种光照对方向的依赖性。环境映射的基本思想是,关于三维方向的函数就是单位球上的函数。环境映射的最简单的应用是,在射线追踪器中,给那些没有命中任何物体的射线赋予一个颜色:

trace_ray(ray, scene) {if (surface = scene.intersect(ray)) {return surface.shade(ray)} else {uv = spheremap_coords(ray.direction)return texture_lookup(scene.env_map, uv)}}\begin{aligned} &\text{trace\_ray(ray, scene) \{} \\ &\quad\begin{aligned} &\text{if (surface = scene.intersect(ray)) \{} \\ &\quad\text{return surface.shade(ray)} \\ &\text{\} else \{} \\ &\quad\begin{aligned} &\text{$u$, $v$ = spheremap\_coords(ray.direction)} \\ &\text{return texture\_lookup(scene.env\_map, $u$, $v$)} \end{aligned} \\ &\text{\}} \end{aligned} \\ &\} \end{aligned}

光线追踪器中引入这一变化之后,光泽物体也可以反射背景环境。在栅格系统中,可以通过在着色计算中添加镜面反射来实现类似效果,这种技术称为反射映射(reflection mapping):

shade_fragment(view_dir, normal) {out_color = diffuse_shading(kd, normal)out_color += specular_shading(ks, view_dir, normal)uv = spheremap_coords(reflect(view_dir, normal))out_color += km texture_lookup(environment_map, uv)}\begin{aligned} &\text{shade\_fragment(view\_dir, normal) \{} \\ &\quad\begin{aligned} &\text{out\_color = diffuse\_shading($k_{d}$, normal)} \\ &\text{out\_color += specular\_shading($k_{s}$, view\_dir, normal)} \\ &\text{$u$, $v$ = spheremap\_coords(reflect(view\_dir, normal))} \\ &\text{out\_color += $k_{m} \ast$ texture\_lookup(environment\_map, $u$, $v$)} \end{aligned} \\ &\text{\}} \end{aligned}

环境映射的一个更高级的用法是计算所有光照——不仅是镜面反射,也就是环境光照(environment lighting)。它可以在光线追踪器中使用蒙特卡洛积分来得到,也可以在栅格器中使用一组点光源来近似环境并计算许多阴影映射来得到。

使用球坐标存储环境映射很流行,但会在极点压缩纹理,导致浪费纹理分辨率,而且产生失真。立方体映射比较高效,而且广泛用于交互应用中。

environment-cube-map.png

程序化 3D 纹理(Procedural 3D Textures)

前面使用的漫反射率(diffuse reflectance)kdk_{d} 是一个依赖于物体的常数。对于非纯色物体,漫反射率可以用一个 3D 空间映射到 RGB 颜色的函数 kd(r)k_{d}(\vec{r}) 来表示。

一个 3D 纹理(3D texture)就是为 3D 空间中每个点定义一个 RGB 值。使用时只需对曲面上的点调用即可,由于曲面本身已经嵌入在 3D 空间中,因此从曲面到纹理空间的纹理坐标函数不存在畸变。这一策略适用于从固体介质雕刻出的表面,比如大理石雕塑。

然而,存储 3D 栅格图占用过多内存,因此,通常使用程序化纹理(procedural textures):根据数学方法计算出纹理值——而不是根据纹理图像查询纹理值。2D 程序化纹理也是类似的,只不过用栅格图存储 2D 纹理更常用。

3D 条纹纹理(3D Stripe Textures)

假定 c0c_{0}c1c_{1} 是两个用于产生条纹的颜色,并选择正弦函数作为振荡函数用于在两个颜色之间互相切换,那么宽度 ww 的条纹可使用如下程序生成:

RGB stripe(point r, real w)if (sin(πx/w)>0) thenreturn c0elsereturn c1\begin{aligned} &\text{RGB stripe(point $\vec{r}$, real $w$)} \\ &\quad\begin{aligned} &\textbf{if}\ (\sin(\pi x / w) > 0)\ \textbf{then} \\ &\quad\textbf{return}\ c_{0} \\ &\textbf{else} \\ &\quad\textbf{return}\ c_{1} \end{aligned} \end{aligned}

如果希望条纹在两种颜色间光滑过渡,可以使用振荡参数 tt 对颜色 c0c_{0}c1c_{1} 线性插值:

RGB stripe(point r, real w)t=(1+sin(πx/w))/2return (1t)c0+tc1\begin{aligned} &\text{RGB stripe(point $\vec{r}$, real $w$)} \\ &\quad\begin{aligned} &t = (1 + \sin(\pi x / w)) / 2 \\ &\textbf{return}\ (1 - t)c_{0} + tc_{1} \end{aligned} \end{aligned}

stripe-texture.png

体噪声(Solid Noise)

当需要斑驳状纹理时,可以使用一种名为佩林噪声(Perlin noise)的体噪声(solid noise)。因其对电影行业的影响,它的发明者 Ken Perlin 于 1997 年获得了第 69 届奥斯卡技术成就奖。

斑驳状纹理的特点是光滑但又不规则,完全随机的白噪声不适合这一场景,对它做模糊化也不实用。因而产生了另一个想法,生成一个晶格点阵,阵列中每个点都带有一个随机纹理值,点阵间的纹理值通过插值来获得。然而,这种方法让晶格看起来过于明显,为了解决这一问题,Perlin 使用了一系列技巧来改善上述点阵技术。Perlin 的方法与线性插值相比有三处不同:

  1. 使用厄米插值(Hermite interpolation)规避马赫带(mach bands)。
  2. 使用随机向量点乘获取随机数。这让极小值和极大值偏离格点,使得网格结构看起来不那么明显。
  3. 使用一维数组并对其散列(hash),以生成三维伪随机向量阵列。这增加了计算量,但减少了内存占用。

下面是佩林噪声的基本方法:

n(r)=i=xx+1j=yy+1k=zz+1ΩR(rR)ΩR(r)=ΓR[ω(x)ω(y)ω(z)r]\begin{gathered} n(\vec{r}) = \sum_{i=\lfloor x\rfloor}^{\lfloor x\rfloor+1}\sum_{j=\lfloor y\rfloor}^{\lfloor y\rfloor+1}\sum_{k=\lfloor z\rfloor}^{\lfloor z\rfloor+1}\Omega_{\vec{R}}(\vec{r} - \vec{R}) \\ \Omega_{\vec{R}}(\vec{r}) = \Gamma_{\vec{R}}\cdot[\omega(x)\omega(y)\omega(z)\vec{r}] \end{gathered}

其中,r=(x,y,z)\vec{r}=(x,y,z),格点 R=(i,j,k)\vec{R}=(i,j,k)ω(t)\omega(t)三次权重函数(cubic weighting function),它等价于插值点导数为零的两点三次厄米插值:

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

ΓR\Gamma_{\vec{R}} 是位于格点 R\vec{R} 的随机单位向量,它的值由下面的伪随机表定义:

ΓR=G(ϕ(i+ϕ(j+ϕ(k))))\Gamma_{\vec{R}} = \mathbf{G}(\phi(i + \phi(j + \phi(k))))

G\mathbf{G} 是事先算好的由 mm 个随机单位向量构成的数组。ϕ(l)=P[lmodm]\phi(l)=P[l \mod m] 用于散列(hash)格点位置,PP{0,,m1}\{0,\cdots,m-1\} 的一个置换。Perlin 指出 m=256m=256 时已经可以得到较好的结果。

随机单位向量可以使用舍选法(rejection method)生成:先构造三个 [0,1)[0,1) 上的标准随机数 ξ\xiξ\xi'ξ\xi'',然后判断随机向量 (vx,vy,vz)(v_{x},v_{y},v_{z}) 是否超出单位球,如果超出则舍去,否则选中并归一化,从而生成单位长度的随机向量。

vx=2ξ1vy=2ξ1vz=2ξ1\begin{aligned} v_{x} &= 2\xi - 1 \\ v_{y} &= 2\xi' - 1 \\ v_{z} &= 2\xi'' - 1 \end{aligned}

根据 ω(t)\omega(t) 的定义可知,n(r)n(\vec{r}) 也是一种卷积:

n(r)=RΓRf(rR)=Γff(r)=ω(x)ω(y)ω(z)r\begin{gathered} n(\vec{r}) = \sum_{\vec{R}}\Gamma_{\vec{R}}\cdot f(\vec{r} - \vec{R}) = \Gamma\star f \\ f(\vec{r}) = \omega(x)\omega(y)\omega(z)\vec{r} \end{gathered}

其中 Γ\Gamma 是配有狄拉克梳的 ΓR\Gamma_{\vec{R}}。向量场点乘定义的卷积也有相应的卷积定理:

F{Γf}=F{Γ}F{f}\begin{gathered} \mathcal{F}\{\Gamma\star f\} = \mathcal{F}\{\Gamma\}\cdot\mathcal{F}\{f\} \end{gathered}

F{f}(k)\mathcal{F}\{f\}(\vec{k}) 给出了波矢为 k\vec{k} 的平面波的振动方向和振幅。可以看出,与向量场卷积,不仅被振幅滤波,也被振动方向滤波。借助向量分析相关知识可以计算:

n(R)=ΓR\nabla n(\vec{R}) = \Gamma_{\vec{R}}

也就是说,Perlin 噪声是以格点上的随机单位向量 ΓR\Gamma_{\vec{R}} 为梯度进行插值。

体噪声值有正有负,可以先取绝对值再转为颜色。这种做法在原始噪声正负切换时会产生暗线。

solid-noise.png

对于 [1,1][-1,1] 区间上的噪声值,可以使用 (noise+1)/2(\text{noise}+1)/2 让图像变得更光滑。由于接近 111-1 的噪声值比较少,因此这样的图像相当平滑,可以增大伸缩因子以增加对比度。

noise-color.png

湍流(Turbulence)

很多自然纹理都包含了同一纹理的多尺度特征。Perlin 使用的是下面的伪分形湍流函数(pseudofractal turbulence function):

nt(r)=in(2ir)2in_{t}(\vec{r}) = \sum_{i}\frac{|n(2^{i}\vec{r})|}{2^{i}}

turbulence.png

湍流函数可通过调制相位来扭曲条纹函数:

RGB turbstripe(point r, double w)double t=1+sin(k1z+turbulence(k2r)/w)2return tc0+(1t)c1\begin{aligned} &\text{RGB turbstripe(point $\vec{r}$, double $w$)} \\ &\quad\begin{aligned} &\text{double}\ t = \frac{1 + \sin(k_{1}z + \mathrm{turbulence}(k_{2}\vec{r})/w)}{2} \\ &\textbf{return}\ tc_{0} + (1 - t)c_{1} \end{aligned} \end{aligned}

turbulence-stripe.png