找回密码
 立即注册
查看: 568|回复: 0

计算机图形学基础 - 纹理映射

[复制链接]
发表于 2024-8-2 09:43 | 显示全部楼层 |阅读模式
本文章是Fundamental of Computer Graphics一书的读书笔记。
如果我们以在计算机中一比一地再现真实世界为方针,纹理是必不成少的。纹理就是无色的物体上附着的一层皮,其只浮现平面上的细节,而对于物体的形状没有影响。我们可以将纹理映射看作把纹理“贴”到物体上的一个函数。但纹理映射的能力不止于此,这也是它十分重要的一个原因,纹理映射可以用来措置所有平面上的数据,如暗影、反射、光照等,它甚至可以用来措置和纹理完全无关的数据,这个函数将什么数据贴到物体上完全取决于应用场景。简单起见,在这一章我们只考虑纹理相关的操作,当然这些道理在措置其他任何平面上的数据时都是通用的。
在图形管线章节中我们简单提及了纹理映射的道理,但实践证明,这是一个非常复杂的话题,需要我们谨慎措置。主要原因有两个,第一,我们无法保证物体的形状几何,因此在映射的过程中纹理往往会变形,将纹理得当地“贴”到物体上长短常麻烦的一件事情;第二,纹理映射就和位图的缩放变换一样,是一个重采样过程,因此会不成避免地引入走样,这同样长短常棘手的挑战。
纹理值查询(Looking Up Textures Values)

虽说我们是将纹理“贴”到物体上,但在代码中,我们的操作倒是给定物体上的一点,寻找对应的纹理上的点(可能有多个)中的值,然后再措置得到合适的值赋予物体上的那一点。下面展示了一份纹理映射代码,这份代码忽略了纹理值的措置而简单取距离计算得到的(u,v)坐标比来的像素的值:
  1. Color texture_lookup(Texture t, float u, float v):
  2.     int i = round(u * t.width() - 0.5)
  3.     int j = round(v * t.height() - 0.5)
  4.     return t.get_pixel(i, j)
  5. Color shade_surface_point(Surface s, Point p, Texture t):
  6.     Vector normal = s.get_normal(p)
  7.     (u, v) = s.get_texcoord(p)
  8.     Color diffuse_color = texture_lookup(t, u, v)
  9.     // Compute shading using normal and diffuse_color
  10.     // return shading result
复制代码
这此中最关键的一步就是找到对应的纹理上的点,也即代码中的get_texcoord()函数,我们需要一个将三维世界空间上的点映射到二维纹理空间(纹理一般都是平面图片)上的点的映射,即 \phi:(x,y,z)\rightarrow (u,v) .


纹理图片往往有分歧的尺寸,加之物体平面的形状各异,如此单单一个物体上分歧平面和纹理图片的组合就会需要非常多版本的纹理映射函数,这是很不便利的。因此在实践中我们一般将纹理空间尺度化到 (u,v)\in [0,1]^2 上,这时纹理映射函数就只和平面形状有关,而和纹理尺寸无关了,正如我们在上面阿谁代码例子看到的那样,get_texcoord()函数只有平面一个形参。
纹理映射和视角变换一样,都是一个将三维空间上的点映射到二维空间上的点的操作,但纹理映射要比视角变换复杂得多,因为视角变换只有平行投影和透视投影两个选择,而纹理映射却会涉及各种分歧的形状。
让我们看一个纹理映射的简单例子,假设我们要绘制一个只有木地板(有限大)的场景,而这个地板刚好和世界空间的xOy平面平行,我们可以直接写出映射:
u=ax\\ v=by\\
然后直接取距离(u,v)比来的像素值作为颜色值,这样衬着出来的效果是很不错的:


但是如果我们用这种方式去衬着一张高分辩率的地板纹理,同时以几乎与地面平行的视线标的目的去看地板的话,效果非常糟糕:


我们可以在远处看见非常明显的走样条纹,这是我们仅仅考虑纹理值的查阅而忽略正确措置纹理值的后果,总的来说纹理映射需要措置两个棘手的问题:

  • 正确定义从世界空间到纹理空间的映射 \phi
  • 正确措置从纹理空间得到的像素值,最大程度减少走样
世界空间-纹理空间映射

好的纹理映射要求正确的从世界空间到纹理空间的映射,对分歧的物体来说定义这个映射的难度也有所分歧。如我们在上一节看到的,如果物体恰好是一个与xOy平面平行的平面,那么我们可以轻松地将纹理“贴”上去;但对于形状复杂多变的物体,如人脸来说,定义这个映射的难度就非同小可了。总的来说,定义世界空间-纹理空间映射 \phi 需要综合考虑多个彼此冲突的因素:

  • 双射性(bijectivity):正如数学上的定义——一个双射既是单射又是满射,拥有双射性的世界空间-纹理空间映射既能够保证在世界空间上的分歧的点能够映射到纹理空间上的分歧点,又能够保证纹理空间上所有的点都有世界空间中的点对应。这是一个相对严格的性质,有时我们会特意让纹理在模型概况反复呈现,比如地板。当然,我们不会但愿这种情况作为不测发生
  • 一致的掉真(size distortion):在将纹理贴到模型概况时,映射该当保证纹理在平面各点上的变形程度大致不异。对于世界空间上几个相距不远的点,颠末映射之后,其仍然是纹理空间中几个大致临近的点。这要求 \phi 的导数的大小(如果可导的话)不应该有大的变化
  • 形状保真(shape distortion):我们追求一致的掉真程度不代表掉真程度的大小不重要,过分大的掉真程度也会很大程度影响纹理映射的成果,因此我们要求世界空间中的任何形状颠末映射之后仍然可以大致保持原形状。这要求 \phi 在各个标的目的上的导数大小大致不异
  • 持续性(Continuity):这一条性质非常符合我们的直不雅观想像,在将纹理贴到物体概况时,不应该发生太多的缝隙。这要求 \phi 是持续的,或者尽可能减少其不持续性对映射成果的影响
我们接下来将讨论两种映射方案,它们对应两种模型:由方程定义的模型和由三角形图元组成的模型,前者使用几何方式(例如将解析式转化为参数式)将空间中的点映射到平面上;后者则使用预先存储的顶点值插值得到分歧片段(fragment)的纹理坐标。
几何方式

一般来说,我们使用几何方式来措置形状较为简单的模型,或者作为一个复杂纹理映射的起点,接下来我们会使用这张图片来作为纹理,图片中的网格会辅佐我们看出纹理映射的扭曲程度,而网格中的数字可以看作其左下顶点的(u,v)坐标。


平面投影

对于足够平整的平面来说,我们可以直接使用平行投影或者透视投影将纹理映射到平面上去:




其数学表达式可以直接写出,此中变换矩阵视具体情况而定:
\phi(x,y,z)=(u,v)\;\;where\;\; \begin{bmatrix} u\\ v\\ \ 1\ \end{bmatrix} = M_t \begin{bmatrix} x\\ y\\ z\\ 1\\ \end{bmatrix}\\ \phi(x,y,z)=(u,v)\;\;where\;\; \begin{bmatrix} u\\ v\\ \ 1\ \end{bmatrix} = P_t \begin{bmatrix} x\\ y\\ z\\ 1\ \end{bmatrix}\\
这种映射方式不适用于几何上封锁的物体,如果强行应用的话会在边缘处造成严重的掉真:


球面投影

对于形状近似球形的物体来说,我们可以换个思路,使用极座标来进行映射。三维的极座标形式为 (\rho,\theta,\phi) ,而对于球面或近似球面的平面来说,上面的点的半径的大小基本不异,因此我们忽略掉极座标的第一个分量,直接使用第三个和第二个分量作为纹理坐标(u,v),即:
\phi(x,y,z)=(u,v)=(\phi,\theta)\\
极座标在三维坐标轴上定义为如下几个量,可以看到, \phi 代表程度标的目的上的角度,所以其对应u,而竖直标的目的上的 \theta 自然对应v:


因此我们可以得到这两个角度和xyz的关系为:
\theta=acos(z/\sqrt{x^2+y^2+z^2})\\ \phi=atan2(y,x)\\
算出来两个角度之后,我们不能忘了对它们进行归一化,最后得到的才是我们需要的纹理坐标:
(u,v)=([\pi+atan2(y,x)]/2\pi,[\pi-acos(z/\sqrt{x^2+y^2+z^2})]/\pi)\\
可以看到,其实球面投影就类似于地舆上的经纬坐标,它在球面的绝大部门都能发生良好的映射——除了两极,由于接近两极的区域平面上的圆的半径很小,这一区域映射后得到的u坐标随距离的变化非常剧烈,因此导致纹理在两极急剧收缩:


圆柱面投影

对于圆柱状的物体,我们可以用类似球面投影的方式进行纹理映射,此时u坐标不需要改换,而v需要更改为对应z坐标而不是 \theta :


假设这个圆柱状物体高为h,那么公式为:
(u,v)=([\pi+atan2(y,x)]/2\pi,[z+h/2]/h) \\
Cubemaps

理论上球面投影可以措置所有几何形状封锁的物体,但也如我们前面看到的那样,球面投影会在接近两极的区域留下不讨喜的扭曲,一个代替方案是使用Cubemaps来进行纹理映射,也就是将纹理映射到一个立方体上,如此发生的纹理映射在各个面都不会发生丑恶的掉真,伴随着边界不持续这一代价。便利起见,约定纹理地址的抽象立方体为纹理立方体。


Cubemap的投影道理和透视投影附近,我们从被衬着物体中心出发去“看”纹理地址的立方体,看到的那一个像素就是我们需要的像素值,其对应的点也就是我们需要的映射成果。至于物体上的某一点会看到这个立方体六个面中的哪个,这是由cubemaps的使用者决定的。假设纹理立方体的中心在坐标原点,边长为2,那么从xOz平面上看,x=1这一侧平面处就有这样的相似关系(显然该相似关系对处在衬着立方体内或外的物体都是成立的):


y',z'就是纹理立方体面上纹理的坐标,可以说我们已经算出了(u,v)坐标,接下来我们规定六个面中的uv座标系标的目的:从纹理立方体内部看,所有面上的v坐标轴都是u坐标轴逆时针旋转90度得到的。作为参考,OpenGL的cubemap公式如下:
\phi_{x=+1}(x,y,z)=[1+(-z,-y)/|x|]/2,\\ \phi_{x=-1}(x,y,z)=[1+(+z,-y)/|x|]/2,\\ \phi_{y=+1}(x,y,z)=[1+(+x,+z)/|y|]/2,\\ \phi_{y=-1}(x,y,z)=[1+(+x,-z)/|y|]/2,\\ \phi_{z=+1}(x,y,z)=[1+(+x,-y)/|z|]/2,\\ \phi_{z=-1}(x,y,z)=[1+(-x,-y)/|z|]/2\\
一种决定某个点“看”到哪个平面的简单决定方式是找到xyz三个分量中绝对值最大者,然后按照其符号选择六个面之一。cubemaps的应用场景和上面的推导过程也有密切的联系,其经常被运用于布景的衬着,此时摄像机就正好处在这个“布景箱”的内部不雅察看整个布景。
插值方式

插值方式能够产出比几何方式精细得多的画面,我们事先写入顶点的纹理坐标(这可不容易),然后在衬着时只需插值得到片段的像素值即可。插值方式有一个天生的优势:只要模型的所有三角形图元都共享顶点,纹理映射得到的成果就必然是持续的。但对其它三个性质,插值方式就没法等闲获得了。
就一致掉真来说,只要纹理空间的三角形能够大致等比例地映射到世界空间去,插值方式就可以保证映射是一致掉真的。很不幸,这是一个有些严苛的要求,尤其对于一些形状多变的物体,保证三角形的等比例映射几乎是不成能的,如下面这个例子:


不雅察看其鼻子附近的三角形在世界空间和纹理空间的面积变化,可以看到从世界空间到纹理空间,鼻子区域上的三角形面积变小了,因此世界空间上面积更大的平面对应纹理空间上面积更小的区域,纹理在鼻子附近被扩大了。而在鼻子周围的其他区域的三角形的面积相对没有这么大的变化,因此这个纹理映射无法保证一致的掉真。
我们接下来看看如何插值,如果直接按照之前对顶点颜色插值的思路,我们会写出这样的代码:
  1. for all x:
  2.     for all y:
  3.         compute (alpha, beta, gamma) for (x,y)
  4.         if 0 < alpha < 1 and 0 < beta < 1 and 0 < gamma < 1:
  5.             u = alpha * u0 + beta * u1 + gamma * u2
  6.             v = alpha * v0 + beta * v1 + gamma * v2
  7.             // draw pixel
复制代码
很可惜,这种简单的法子算出来的(u,v)坐标是错的,或者严格来说,在使用透视投影的情况下,我们无法使用这种简单的法子。原因很简单:还记得我们在计算透视投影时执行的最后一步是什么吗?我们进行了透视除法,将齐次坐标的四个分量都除以第四个w分量。计算透视投影之后,输入到衬着代码中的(x,y)坐标都是在屏幕空间上的坐标,而我们是在世界空间进行纹理映射的,这两个空间的纹理坐标相差w倍,贸然进行插值是错误的。


为了更透彻地舆解这个问题,我们可以将u,v看作齐次坐标的新的分量(颠末摄像机变换之后顶点的纹理坐标不变,在这个问题上两个空间可以当作是一样的),让纹理坐标和顶点坐标一起进行透视投影变换:
\begin{bmatrix} u\\ v\\ x_{camera}\\ y_{camera}\\ z_{camera}\\ w_{camera} \end{bmatrix} \xrightarrow{pers\;proj} \begin{bmatrix} u/w_{camera}\\ v/w_{camera}\\ x_{camera}/w_{camera}\\ y_{camera}/w_{camera}\\ z_{camera}/w_{camera}\\ 1 \end{bmatrix}\\
这时插值颠末透视除法的纹理坐标才是正确的,但插值得到的纹理坐标还是屏幕空间的纹理坐标:
u/w_{camera}=\alpha (u_0/w_{camera}) + \beta (u_1/w_{camera}) + \gamma (u_2/w_{camera})\\ v/w_{camera}=\alpha (v_0/w_{camera}) + \beta (v_1/w_{camera}) + \gamma (v_2/w_{camera})\\
因此我们为了得到正确的纹理坐标,需要先将顶点的纹理坐标除以各自的w分量,然后直接进行插值,最后再将插值得到的屏幕空间的纹理坐标乘以每个片段的 w_{camera} . 因此我们需要找到每个片段的 w_{camera} 分量,这可犯了难了,可以看到这个分量已经在透视投影的计算过程中丢掉了,变成了各个项的分母,而且插值所需的三个值都是在屏幕空间上得到的,莫非我们为了得到 w_{camera} 还要回溯到摄像机空间去从头算起吗?
不必如此,我们可以在齐次坐标中再添加一个分量,其值恒为1:
\begin{bmatrix} u\\ v\\ 1\\ x_{camera}\\ y_{camera}\\ z_{camera}\\ w_{camera} \end{bmatrix} \xrightarrow{pers\;proj} \begin{bmatrix} u/w_{camera}\\ v/w_{camera}\\ 1/w_{camera}\\ x_{camera}/w_{camera}\\ y_{camera}/w_{camera}\\ z_{camera}/w_{camera}\\ 1 \end{bmatrix} \\
颠末归一化之后,新增加的分量自然变成了 1/w_{camera} ,这也是一个可以进行插值的顶点属性!因此插值得到的屏幕空间的纹理坐标再除以同样是插值得到的 1/w_{camera} 就可以得到世界空间的纹理坐标了,代码如下:
  1. for all x:
  2.     for all y:
  3.         compute (alpha, beta, gamma) for (x,y)
  4.         read (1w0, 1w1, 1w2) for (x,y)
  5.         if 0 < alpha < 1 and 0 < beta < 1 and 0 < gamma < 1:
  6.             uw = alpha * u0 * 1w0 + beta * u1 * 1w1 + gamma * u2 * 1w2
  7.             vw = alpha * v0 * 1w0 + beta * v1 * 1w1 + gamma * v2 * 1w2
  8.             1w = alpha * 1w0 + beta * 1w1 + gamma * 1w2
  9.             u = uw / 1w
  10.             v = vw / 1w
  11.             // draw pixel
复制代码
取舍之道

对任何一个纹理映射来说,同时满足前面提到的四个性质都是十分困难的,而拓扑学更是对封锁几何体宣判了死刑——应用在封锁几何体上的纹理映射不成能同时拥有持续性和双射性。我们只能从中选择一个性质,容忍纹理概况的裂缝,或是放弃精确的映射成果。
我们前面讨论的几个几何方式都在必然程度上存在不持续性质,球面投影和圆柱面投影城市在 -\pi 到 \pi 过渡处呈现裂缝,而cubemaps则会在各个边上发生不持续。平面投影倒是保证了持续性,但其应用到封锁几何体上的映射成果也确实惨不忍睹。
下面我们来看看在插值方式下,两种性质的取舍,插值方式并不天生带有哪种性质,其表示如何完全取决于我们如何使用三角形分割纹理,下面是对同一幅纹理使用两种截然分歧的分割方式的成果:


左边的分割方式选择了持续性,右边的分割方式选择了双射性,不合呈此刻了本初子午线附近,左边为了保证持续性,发生了数个横跨整个经度的三角形,但其在世界空间上对应的三角形却只是球面的很小一部门,因此我们就可以在左边的多边形球面上看见异常的条带状纹理;而右边不需要考虑持续性,因此所有纹理空间中的三角形都有着较为合理的形状,最后映射到球面上的纹理相对正常。
纹理映射中的抗锯齿

我们已经大致讨论了如何将世界空间中模型上的点映射到纹理空间上,在找到了对应的(u,v)坐标之后,我们就需要考虑如何找到适合这个坐标的颜色值了。这是一个重建的法式,为了减少输出图片中的走样,我们不能简单寻找距离这个坐标比来的像素值,而是像之前那样,对这个坐标周围的一片像素进行加权平均,我们接下来就要找到如何高效地计算这个加权平均的方式
像素足迹(The footprint of a Pixel)

回忆一下,我们在衬着阶段是以屏幕上的像素为单元进行计算的,或者说片段(fragment),那么我们研究纹理映射的抗锯齿就需要在屏幕空间和纹理空间中进行了。


可以看到,这是一个从二维空间到二维空间的映射,而屏幕空间中一个方块状的像素颠末映射后呈此刻纹理空间中的四边形就是像素足迹,也即上图右侧我们看到的几个形状各异,大小纷歧的四边形。从屏幕像素到像素足迹需要先颠末透视投影变换 \pi 的逆变换 \pi^{-1} 将屏幕像素还原成世界空间上的点,然后再颠末世界空间-纹理空间映射 \phi 变换到纹理空间上的像素,即 \psi=\phi\;\circ\;\pi^{-1}
于是我们的任务就变成了计算像素足迹覆盖的像素的平均值,从上图我们可以直不雅观地感到感染到,很多时候像素足迹的变化是相当随机的,这也给我们的抗锯齿算法带来了很大的挑战,精确计算它们覆盖到的像素是相当麻烦且昂贵的,近似地计算像素值是一个更好的选择。
对于任何足够光滑的函数,我们都可以很好地线性近似之。因此我们可以使用平行四边形去近似这些像素足迹:


因此从屏幕空间到纹理空间的映射 \psi 可以近似写为:
\psi \begin{bmatrix} x\\ y \end{bmatrix} = \psi \begin{bmatrix} x_0\\ y_0 \end{bmatrix} + \textbf{J} ( \begin{bmatrix} x\\ y \end{bmatrix} - \begin{bmatrix} x_0\\ y_0 \end{bmatrix} )\\
此中 \textbf{J} 是对映射 \psi 的导数的近似矩阵,写为:
\textbf{J} = \begin{bmatrix} \frac{du}{dx}&\frac{du}{dy}\\ \frac{dv}{dx}&\frac{dv}{dy} \end{bmatrix}\\
这个矩阵的两列分袂对应映射 \psi 对x和y的偏导数,即 \textbf{u}_x=(du/dx,dv/dx) 和 \textbf{u}_y=(du/dy,dv/dy) ,这两个向量很好地衡量了像素足迹的形状和大小,形状由向量标的目的浮现,而大小由向量的模浮现。乍一看这四个导数或许很难计算,但我们也并不需要它们的精确数值,这里我们再进行一次近似,将导数近似为u和v在x和y两个标的目的上的变化量:


上面四个公式中的dx和dy都是1,也就是我们使用相邻的像素的(u,v)值计算这四个导数,这一方式使用简单的减法代替了更为复杂的求导运算。
像素重构

在进行重构时,像素足迹和纹理像素的相对大小是一个很重要的影响因素,如果像素足迹小于纹理像素,重构就会面临精度不足的问题;如果像素足迹大于纹理像素,重构就需要高效地措置多个纹理像素的平均值。


对于这两种情况,我们有分歧的策略来对于可能呈现的走样。
双线性插值(Bilinear interpolation)

当像素足迹小于纹理像素时,如果我们直接使用像素足迹地址的纹理像素值,那么就相当于使用半径为半个像素的箱式过滤器重建,产出的画面因此就会呈现大量锯齿样的图案,这长短常糟糕的。这时我们自然会想到上一章介绍的其他更优秀的过滤器,但很可惜,它们在纹理映射下并不好用,和图片的重采样分歧,纹理映射中的采样区域是犯警则的,这导致其他更加光滑的过滤器此时变得非常昂贵。我们的选择是双线性插值(bilinear interpolation),它产出的画面质量并不突出,但在保证性能的前提下足够好。
双线性插值的思想很简单,就是在两个标的目的上分袂进行加权平均,因为此时像素足迹是小于纹理像素的,我们只需要考虑像素足迹附近的四块纹理像素,其代码如下:
  1. Color tex_sample_bilinear(Texture t, float u, float v):
  2.     u_p = u * t.width - 0.5
  3.     v_p = v * t.height - 0.5
  4.     iu0 = floor(u_p)
  5.     iu1 = iu0 + 1
  6.     iv0 = floor(v_p)
  7.     iv1 = iv0 + 1
  8.     ratio_u0 = u_p - iu0
  9.     ratio_u1 = 1 - ratio_u0
  10.     ratio_v0 = v_p - iv0
  11.     ratio_v1 = 1 - ratio_v0
  12.     return ratio_v0 * (ratio_u0 * t[iu0][iv0] + ratio_u1 * t[iu1][iv0]) +
  13.            ratio_v1 * (ratio_u0 * t[iu0][iv1] + ratio_u1 * t[iu1][iv1])
复制代码
Mipmap

当像素足迹大于纹理像素时,我们就需要考虑如何高效地计算平均值了。事实证明在衬着时做这件事长短常昂贵的,出格是当物体距离摄像机非常远时,此时屏幕上的一个像素可能就对应整张纹理,如果我们每次都从头开始算平均值,游戏玩家恐怕只能享受一份ppt了。
我们的策略是事先算好分歧缩放比例下的平均像素,然后存储在一系列缩小了的纹理图片中,这就是mipmap. 我们一般选择2作为缩放因子,一路缩放至最小尺寸,例如对于一张1024x1024的图片,一共有11级mipmap,第零级mipmap的大小就是原纹理的尺寸1024x1024,第十级mipmap的尺寸就是一个像素1x1。总的来说一张照片一共有 \lfloor log_2(min\{width,height\})\rfloor 级mipmap.
第零级mipmap以外的mipmap都将纹理像素“压缩”了,像素足迹在被压缩的纹理中也变小了,当压缩程度足够高时,像素足迹会被缩小到和压缩后的纹理像素不异的大小,此时我们就可以再度运用已经介绍的双线性插值。那么我们如何找到阿谁足够高的压缩程度呢?这时我们就需要按照像素足迹的大小来计算mipmap等级,最简单的估算方式就是计算 \textbf{u}_x=(du/dx,dv/dx) 和 \textbf{u}_y=(du/dy,dv/dy) 中的模长最大者,再取其关于2的对数就可以得到这个像素足迹的mipmap等级。mipmap等级在本来的双线性插值上又增加了一个插值的维度,完整的纹理映射代码因此使用的是三线性插值(trilinear interpolation):
  1. Color mipmap_sample_trilinear(Texture mipmap[], float u, float v, matrix J):
  2.     L = max_column_norm(J)
  3.     mipmap_level = log2(L)
  4.     level0 = floor(mipmap_level)
  5.     level1 = level0 + 1
  6.     ratio_level0 = mipmap_level - level0
  7.     ratio_level1 = 1 - ratio_level0
  8.     color_level0 = tex_sample_bilinear(mipmap[level0], u, v)
  9.     color_level1 = tex_sample_bilinear(mipmap[level1], u, v)
  10.     return ratio_level0 * color_level0 + ratio_level1 * color_level1
复制代码
三线性插值在大大都时候都可以产出质量较好的画面,但它在衬着远处的地板时会导致不必要的模糊:


这是因为远处的屏幕像素对应的像素足迹被“拉长”了,此时纹理坐标在x和y两个标的目的上的变化程度相差几个倍数,我们在变化快的阿谁标的目的上的采样不足所以导致了模糊,我们需要更多的取样来减少模糊,这就是各异向性过滤(Anisotropic filtering),这一算法是对三线性插值的改良,其仍然使用双线性插值对每张单独的mipmap进行采样,只是此时会在变化较快的标的目的上取更多的样。
首先我们需要找出哪一个标的目的的变化较快,哪一个较慢:
P_x=\sqrt{(\frac{du}{dx})^2+(\frac{dv}{dx})^2},\\ P_y=\sqrt{(\frac{du}{dy})^2+(\frac{dv}{dy})^2},\\ P_{max}=max{P_x,P_y},\\ P_{min}=min{P_x,P_y}.
在三线性插值中我们使用 P_{max} 来决定mipmap等级,而这里我们需要更多的信息,因此选择使用 P_{min} 来决定mipmap等级,从信息更多的mipmap中采样,而且以两者的比作为采样数量:
N = \frac{P_{max}}{P_{min}},\\ \lambda = log_2(P_{min}).
不外在实践中出于性能考虑,我们会给各异向性过滤设置一个采样数量的上限,因为距离越远,像素足迹的拉伸就越严重,此时的采样数量就越大,我们通过设置上限 maxAniso 阻止在极远处进行毫无必要的大量采样,修正后的公式为:
N = min\{\lceil\frac{P_{max}}{P_{min}}\rceil,\;maxAniso\},\\ \lambda = log_2(\frac{P_{max}}{N}).
然后我们就可以在变化较快的标的目的上等距离进行采样了:
Color=\sum_{i=1}^Nsample(u(x-\frac{1}{2}+\frac{i}{N+1},\;y),\;v(x-\frac{1}{2}+\frac{i}{N+1},\;y),\;\lambda),\;\;P_x>P_y,\\ Color=\sum_{i=1}^Nsample(u(x,\;y-\frac{1}{2}+\frac{i}{N+1}),\;v(x,\;y-\frac{1}{2}+\frac{i}{N+1}),\;\lambda),\;\;P_y\leq P_x.\\
此中u(x,y)和v(x,y)代表纹理映射,sample()代表双线性插值。
纹理映射的应用

设置着色参数

在光线追踪一章中我们提到过着色的Blinn-Phong光照模型,这一光照模型包罗三种光照,每种光照都有分歧的参数设置,以此控制物体的反光度、粗拙度等等,我们可以将着色参数写入纹理中实现随片段变化的光照效果。例如下面这个杯子,纹理中的着色参数使得印上logo的区域会看起来粗拙:


凹凸贴图(Bump Mapping)

有时物体光有平面的细节还不够,我们还想要让这些细节在光照下变得立体,增加真实感,例如对于一块地板来说,我们或许会想要看到其概况的凹凸不服的纹理发生细小的暗影。这可以通过增加三角形的数量来做到,但这一方式不是万能灵药,首先更复杂的模型就意味着更长的措置时间,其次不是所有细节都可以被像素完整展现的,当三角形的大小小于屏幕像素大小时,我们就无能为力了。可以看到闭着眼睛增加三角形数量的方式既不经济,也不万能。对于这个问题,我们可以使用一个小技巧来骗过人的眼睛——将高度信息“贴”到物体上。


如果只从纹理来看,两个物体似乎都是凹凸不服的,但是左边球体的边缘和影子表露了其真实形状,这就是凹凸贴图的效果,凹凸贴图有很多种类,而法线贴图是目前最常用的凹凸贴图。
回忆我们之前介绍的Blinn-Phong光照模型,此中除了环境光照之外,其他两种光照的计算都涉及到片段的法向量,因此通过独霸片段的法向量,我们可以控制光照效果,因而发生在物体概况发生本来没有的凹凸效果。最简单的法线贴图就是直接存储定义在模型空间的法向量,我们使用纹理映射从这种法线贴图中读出的法向量可以直接用于光照计算。但这种直不雅观的做法有一个致命的缺陷——我们不能复用法线贴图。对于一个被模型反复多次使用的纹理贴图来说是没有这样的问题的,我们可以大风雅方地将它贴到任何处所而不需要改削纹理贴图中的任何数值。但对于法线贴图来说,其位置的任何变换都可能导致整张贴图里的所有法向量掉效,需要从头计算,这是很不便利的。
因此诞生了此外一种方案,我们将法线贴图和平面绑定,让平面的任何变化都立刻浮现到法线贴图中。要实现这一效果很简单,我们只需要将法线定义在与平面绑定的坐标系中即可,这样当平面发生变化时,坐标系中的基向量也自然会变化,而法线贴图中定义的相对关系永远是正确的。一般来说我们使用平面的法向量和切向量来构造这个坐标系。
说了这么多,你可能会好奇,法线贴图是怎么计算出来的。实际上在法线贴图之前,我们会首先得到一张高度贴图,它或许是和纹理贴图一样是测出来的,或者是画出来的,此中只存储一元的值,即高度值,这张高度贴图其实也可以直接用于光照计算:


当然,我们也可以进一步措置这个贴图得到法线贴图,粗略来说,法线贴图是通过对高度贴图进行求导得到的。对高度贴图中的一个高度值 H ,假设其右边邻近的高度值为 H_r ,其上方邻近的高度值为 H_a ,那么其程度标的目的的差向量为 (1,0,H_r-H) ,数值标的目的的差向量为 (0,1,H_a-H) ,那么这个高度值对应的法向量就等于这两个向量的叉乘:
normal=\frac{(H-H_a,H_g-H,1)}{\sqrt{(H-H_a)^2+(H_g-H)^2+1}}\\
位移贴图(Displayment Mapping)

凹凸贴图是在光滑的概况上缔造凹凸材质的假象,而位移贴图则是真正改变了片段的几何形状,位移贴图中记录了当前片段与原平面在法向量上的偏移量。因此颠末位移贴图计算后的平面的法向量不变,而其几何形状会发生变化,由于这一技术涉及数量复杂的几何计算,其往往只被运用于离线衬着中。


暗影贴图(Shadow Mapping)

暗影是衬着中一个非常重要的组成部门,掉去了暗影的画面,其真实度自然大打折扣,我们已经知道在光线追踪傍边生成暗影是很自然的一件事,我们只需要做出颠末当前点的光线的标的目的向量,然后判断其是否被其它物体遮挡即可。但到了衬着这里,生成暗影就不是那么容易的事情了,给定一个片段,我们如何知道它是否被某束光照射到呢?
但其实这一问题我们已经回答过了,在图形管线中我们使用深度缓存来表示物体的遮挡关系,而我们此刻可以用一模一样的方式来措置光照傍边的物体遮挡关系,只是此时“视线”变成了光线,一个片段是否能够被光线照射就等于光能否“看到”这个片段:


正如我们在视角变换和深度缓存里做的那样,此时光通过一个平面“看”向物体,然后将最小深度值存储在这张平面上,那么这个平面就是我们需要的暗影贴图。所以在衬着一个存在多个光源的场景时,我们需要衬着多轮,首先算出所有光源的暗影贴图,最后才衬着出实际的画面:


当然,分歧类型的光照的暗影贴图的生成方式纷歧样,下面展示了三种典型的光照的暗影贴图的生成方式:


此中spot light的生成方式就和运用透视投影的深度缓存的计算方式不异,只是此时摄像机变成了光源,而directional light则对应平行投影。值得注意的是point light,由于其光照标的目的不定,因此需要一个将其包抄的暗影贴图,图中选择了立方体,也即类似cubemap的映射方式,我们也可以选择球体作为暗影贴图的载体。在生成暗影贴图时我们同样需要考虑在视角变换和深度测试中呈现过的那一系列问题,这里不再赘述。
在生成了暗影贴图之后,我们接下来需要做的就是在衬着时判断当前片段是否会被某束光照射到,判断方式如下:找到当前片段在世界空间的坐标(这可不容易),然后再对其运用这束光的视角变换,得到其暗影纹理坐标,然后用这一点在世界空间的坐标在颠末光束的视角变换得到的深度值z和这一位置存储的深度值做斗劲。
等等,纹理坐标上的深度值应该是多少?当我们在进行深度检测时,我们是使用屏幕空间中的值去和正好在屏幕空间中生成的深度缓存去做斗劲的,也就是说这一查找是对齐的,不会有恰好处于多个值之间的问题。但是暗影贴图和屏幕空间可不是对齐的,我们会频繁遇到纹理坐标恰好处于多个值之间的问题。
这个问题也太熟悉了,我们之前不是已经解决了吗?对深度值进行双线性插值,得到纹理坐标上的深度值,然后再进行斗劲... 这说的通吗?假设当前片段的深度值为49.8,我们计算出的纹理坐标指向了下图中标识表记标帜为x的位置,这里显然有一个较大的落差,我们如何能保证场景在几何上的变化是近似线性的?


我们不能保证,因此线性插值得到的22.9以及随后的斗劲全部都是错误的,我们需要此外方式来减少走样,这里我们耍一点数学上的小聪明,不线性插值深度值,而是对深度值斗劲后的成果(0或1)进行平均:


虽然这一操作并没有实际上的意义,但和直接取比来的深度值进行斗劲对比,它确实减少了暗影在边缘处的走样。
反射贴图(Reflection Mapping)

和暗影一样,反射同样是一个在光线追踪中非常自然,而在衬着里变得非常棘手的一个问题。值得注意的是,在同一个片段上,反射出来的内容只和视线标的目的有关,因此我们可以预先计算出一个反射贴图,其包抄了需要计算反射的物体,上面记录了从这个物体出发看向所有标的目的能得到的颜色值,其坐标为视线标的目的。当我们需要计算反射时,首先取得视线标的目的和法向量,计算得到反射标的目的之后再以其为坐标去反射贴图中寻找对应的颜色值,下图展示了在使用cubemap作为反射贴图的时候纹理映射是如何工作的,蓝色的箭头为法向量,红色叉地址处即为对应的纹理坐标:


应用反射贴图的代码为:
  1. shade_fragment(fragment f, vector view_direction, vector normal, light[] lights, texture reflection_map):
  2.     color = color::black()
  3.     for l in lights:
  4.         color += diffuse_shading(f.k_d, normal, l)
  5.         color += specular_shading(f.k_s, normal, view_direction, l)
  6.     u, v = reflectionmap_coords(reflect(view_direction, normal))
  7.     color += f.k_m * texture_lookup(environment_map, u, v)
复制代码
反射贴图也可以运用于衬着环境,例如天空,两者道理实际上是不异的,此时这种贴图我们称之为环境贴图(environment mapping),它的算法实际上更简单,下面展示了它的光线追踪版本:
  1. trace_ray(ray sight_line, scene s):
  2.     surface intersected_surface
  3.     if (intersected_surface = s.intersect(sight_line)):
  4.         return intersected_surface.shade(sight_line)
  5.     else:
  6.         u,v = reflectionmap_coords(sight_line.direction)
  7.         return texture_lookup(s.environment_map, u, v)
复制代码

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-23 16:13 , Processed in 0.190353 second(s), 28 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表