最爱午后红茶

Texture Queries

日期图标
2023-06-01

纹理过小

想象一个这样的场景:把一个 256x256 的纹理贴在一个 4k 的模型表面(或者说是屏幕)上会产生什么效果?

纹理上的像素(Pixel on texture)叫做纹理元素Texel,也叫纹素)。把一个 256x256 的纹理贴在一个 4k 的模型表面上就意味着,4k 模型表面上有一定范围内的点会对应到相同的纹理元素上。最终展现的效果就是纹理会被放大,同时会导致图像出现锯齿。如下图六左图:

纹理放大
图六:纹理放大

而我们更希望的,是得到图六中间或右边的图像(尽管它有些模糊,但也比有锯齿好)。要实现图六中间的效果,可以采用双线性插值Bilinear Interpolation)的方法。

跟屏幕空间(详见 Triangles 中屏幕空间相关内容)类似,每个纹理元素大小可以认为是 1x1,而且每个纹理元素中心(下图七每个小黑点)都是 (x+0.5,y+0.5)(x + 0.5, y + 0.5)

纹理元素
图七:纹理元素

如上图七,假设模型表面的某个点被映射到纹理元素中的红点处,如果不做任何处理,那我们就会直接应用红点所在的纹理元素的颜色。而双线性插值的方法就是取红点所在像素周围的 4 个像素(红点要在 4 个像素的像素中心连成的正方形内)进行一些插值运算计算出红点的颜色。

取红点所在像素周围的 4 个像素作为插值对象

图八:取红点所在像素周围的 4 个像素作为插值对象

在讨论具体的运算规则前,我们先定义在两个点之间做插值的公式:

  • lerp(x,v0,v1)=v0+x(v1v0)lerp(x, v_0, v_1) = v_0 + x(v_1 - v_0)

x=0x = 0 时,插值的结果为 v0v_0;当 x=1x = 1 时,插值的结果为 v1v_1;当 x=0.5x = 0.5 时,插值的结果为 12(v0+v1)\frac{1}{2}(v_0 + v_1)。即 x 靠近哪个点,其表现的效果就接近哪个点。

双线性插值(Bilinear interpolation)
图九:双线性插值

如上图九,过红点做 u00u10u_{00}-u_{10} 的垂线分别交 u01u11u_{01}-u_{11} 于点 u1u_1,交 u00u10u_{00}-u_{10} 于点 u0u_0。则我们可以计算出 u00u0u_{00}-u_0 的距离为 ss(且 0s10 \leq s \leq 1)。从横向看,根据前面的插值公式,我们可以插值出 u0u_0u1u_1 的颜色:

  • u0=lerp(s,u00,u10)u_0 = lerp(s, u_{00}, u_{10})
  • u1=lerp(s,u01,u11)u_1 = lerp(s, u_{01}, u_{11})

从纵向看,再根据 u0u_0u1u_1 的结果,我们就可以插值出红点的颜色:

  • f(x,y)=lerp(t,u0,u1)f(x, y) = lerp(t, u_{0}, u_{1})

上图六最右边的图对应的是 Bicubic 插值(如上图六最右边的图)。它是取目标点附近 16 个像素进行插值运算,因此效果上要比双线性插值更好,但是也伴随着更大的开销。

纹理过大

如果反过来,如果纹理的尺寸比屏幕的尺寸大会产生什么现象?

失真

这种情况就相当于多个纹理元素会对应到同一个屏幕像素上。举个极端的例子,假如纹理的尺寸是 20x20,屏幕的尺寸是 2x2。那么直接把纹理贴到屏幕上可能会长这样:

纹理过大
图十:纹理过大

这个结果的原因是,它是通过屏幕空间的像素坐标计算出对应纹理元素坐标,然后直接取该纹理元素的值(颜色)作为当前屏幕像素的值(颜色)。然而实际上这个屏幕像素上的值应该由它周围多个纹理元素(比如 10x10)的值构成的才对。脑补一下,如果屏幕尺寸再放大一点点,我们就能看到猫的大体形状,不过应该可以想象得到,一定会有严重的锯齿。

课程教材里面,闫老师是拿了这个图举例:

过大的网格纹理
图十一:过大的网格纹理

这个跟前面举的例子道理应该是一样的,这里是透视投影的画面(我理解透视投影有画面越远,对应到同一个屏幕像素上的纹理元素越多的特点,尤其是纹理尺寸很大的时候),左右两张图都同时给出了远近的画面对比:

  • 在近处,只有较少数量的纹理元素对应到同一个屏幕像素上,此时取计算出来的单个纹理元素的颜色跟取该纹理元素附近的几个元素的平均相比,差别可能还不是太明显(不过我们依然可以看到近处的锯齿)。
  • 在远处,实际上应该会有很大一片区域的纹理元素会对应到同一个像素上,这时再取计算出来的单个纹理元素的颜色作为该像素的颜色就代表不了这个像素应该代表的那一片纹理元素了(我们可以看到这样会导致远处产生摩尔纹)。

Antialiasing 里面,我们分析了锯齿或摩尔纹这类失真现象的原因是采样不足导致的。在这里,它是怎样体现在纹理过大的这个问题上的呢?

上面我们说,当纹理尺寸比屏幕尺寸大很多时,会有多个纹理元素对应到同一个屏幕像素上。这就相当于一个屏幕像素里面包含很高频的信息,这就希望我们在计算屏幕像素的实际颜色时,要用到同样(或者是接近)高频的采样频率(也就是样本数要匹配该像素对应的纹理元素的数量),这样才能减少失真;而不是只取该像素代表的那片纹理元素中的一个做为像素的值。

很自然地我们想到可以通过增加样本数来解决这个问题,比如我们可以在计算每个屏幕像素的值的时候,取它对应的纹理元素周围 512 个元素的平均。这样我们可以得到一个不错的结果:

增加样本数处理纹理过大
图十二:增加样本数处理纹理过大

但这并不是一个合理的方案,因为它带来的开销太大了!

Mipmap

纹理过大的情况下,要避免失真,我们就需要计算每一个屏幕像素对应的纹理元素附近一片区域的平均值,关键就在于计算一定区域的平均值。如果每次都直接计算,这就相当于是在做点查询Point Query);像上面说的,在这个场景中这会带来很大的开销。要是我们能马上知道每个区域的平均值就好了。实际上我们确实有办法做到,这种方式就叫做范围查询Range Query)。范围查询的形式有很多,包括查询指定范围的平均值、最小值、最大值等,而我们这里需要的,是指定范围平均值的查询。

在透视投影中,有画面越远,对应到同一个屏幕像素上的纹理元素越多的特点(前提是用同一张纹理)。也就是说,这种范围查询方法,需要支持不同的区域大小。这样就能根据画面的远近使用不同大小的区域的值。

Mipmap 就是一种满足以上需求的范围查询方案。它具有以下三个特点:

  • 快(fast)
  • 结果只是近似,并不准确(approx)
  • 只支持正方形区域的查询(square)
Mipmap
图十三:Mipmap

如上图十三所示,Mipmap 的原理就是通过给定的一张图(纹理)生成一系列(或者说多层)小图,每加深一层,图片尺寸比上一层减小一半。图中为了方便演示把每层图片放大成同一尺寸,相当于层数越大,单个像素在视觉上看起来也越大了。而且在 Mipmap 里,存在这样的对应关系:

  • 在 Level 0 里,相当于 1 个屏幕像素对应 1 个纹理元素。
  • 在 Level 1 里,相当于 1 个屏幕像素对应 4 个(即 2x2)纹理元素。
  • 在 Level 2 里,相当于 1 个屏幕像素对应 16 个(即 4x4)纹理元素。
  • 在 Level 3 里,相当于 1 个屏幕像素对应 64 个(即 8x8)纹理元素。
  • ...
  • 在 Level d 里,相当于 1 个屏幕像素对应 22d2^{2d} 个(即 2d×2d2^d \times 2^d)纹理元素。

我们需要在进行纹理映射等操作之前,把该纹理的 Mipmap 生成出来并存储好。很显然这会引入额外的存储量,那么总共会引入多大的存储量呢?

我们假设纹理的边长是 ww,Mipmap 的层数是 dd,那原本需要的存储量为 w2w^2,引入的存储量总和为:

  • S=(w2)2+(w4)2+(w8)2+(w16)2+S = (\frac{w}{2})^2 + (\frac{w}{4})^2 + (\frac{w}{8})^2 + (\frac{w}{16})^2 + \cdots

也就是:

  • S=w2(14+116+164+1256+)S = w^2(\frac{1}{4} + \frac{1}{16} + \frac{1}{64} + \frac{1}{256} + \cdots)

很明显 w2w^2 后面是个等比数列,而等比数列的求和公式是:

  • Sn=a(1rn)1rS_n = \frac{a(1 - r^n)}{1 - r}

其中 aa首项nn项数rr公比。套用公式我们得到:

  • S=w2(14(1(14)d)114)=w213(1(14)d)S = w^2(\frac{\frac{1}{4}(1 - (\frac{1}{4})^d)}{1 - \frac{1}{4}}) = w^2\frac{1}{3}(1 - (\frac{1}{4})^d)

dd 无限大时,我们得到 S=13w2S = \frac{1}{3}w^2。也就是说,我们引入的额外存储量最大时也只是原来的 13\frac{1}{3}

屏幕像素映射成纹理元素

简单介绍完 Mipmap 后,我们要知道如何去使用它。回想一下我们的目的是要快速得到一个屏幕像素对应的纹理元素附近的一片区域的平均值。这句话里面需要处理两个问题:

  1. 需要知道屏幕像素从屏幕空间映射到纹理空间后能对应(或者说覆盖)多少个纹理元素
  2. 计算第一点里面像素覆盖的所有纹理元素的平均值

我们真正要处理的是第一个问题,因为知道了一个屏幕像素对应多少个纹理元素后,我们通过拿该纹理 Mipamp 对应的层数的纹理去做坐标映射,就能马上得到该屏幕像素对应的那片纹理区域的平均值。

接下来我们去处理问题一。将屏幕空间映射成纹理空间后,原本屏幕空间中每一个屏幕像素对应在纹理空间中的坐标我们是知道的。比如它们可能会有像下图这样的对应关系:

屏幕空间映射成纹理空间(Screen to texture)
图十四:屏幕空间映射成纹理空间

我们拿下图左图(屏幕空间)里面左下角的红点像素举例,它以及它附近的点映射到纹理空间后的位置如下图右图(纹理空间)所示:

屏幕像素映射成纹理元素
图十五:屏幕像素映射成纹理元素

我们以点 AA 跟它附近 4 个点的中点为界限,可以把点 AA 围成一个四边形(如上图十五)。为了计算方便,我们可以近似把它看成是正方形(如下图十六):

把像素对应的纹理区域近似成正方形

图十六:把像素对应的纹理区域近似成正方形

那么这个正方形的边长是多少呢?点 AA 跟它周围 4 个点的距离我们是可以求解的(通过微积分的方式,这里不展开了),它们很有可能是各不相同的,它们的中点连成的四边形的边长也各不相同。既然我们把它近似成正方形了,那么我们可以假设正方形的边长的一半等于点 AA 到它附近 4 个点里面其中一个点(就取最远的那个)的中点的距离;那么正方形的边长可以近似认为就是点 AA 到它附近 4 个点里面其中一个点(就取最远的那个)的距离。假设是 LL

其实不用屏幕像素的中心点而是取该像素四个顶点,映射到纹理空间,然后把映射出来的四边形近似成正方形去计算也是可以的。

那么我们就可以查 Mipmap 看看一个屏幕像素对应 L×LL \times L 个纹理元素(一个纹理元素边长为 1)是在第几层。根据前面的知识,我们知道,它对应的层数(从 0 开始算起)D=log2LD = log_{2}L

最后我们计算出屏幕像素在该层纹理对应的纹理索引,取该索引的值就可以了。

三线性插值

根据前面 Mipmap 的知识我们知道:

  • 在 Level 2 里,相当于 1 个屏幕像素对应 16 个(即 4x4)纹理元素。
  • 在 Level 3 里,相当于 1 个屏幕像素对应 64 个(即 8x8)纹理元素。

如果我们计算出来 1 个屏幕像素对应的纹理元素在 16 和 64 之间(即正方形的边长在 4 和 8 之间),那我们要取哪一层纹理呢?我们当然可以根据实际边长跟哪一层更近作为依据来选。但是如果这样选的话是会出现一些问题的。前面我们说透视投影有画面越远,对应到同一个屏幕像素上的纹理元素越多的特点。这里转换一下意思就是,近处的画面会使用低层的纹理,远处的画面会使用高层的纹理。这是符合我们的实际需求的:我们总是希望离我们近的画面更清晰,而我们对远处画面清晰度的要求就没那么高。如果我们计算出来 1 个屏幕像素对应的纹理区域的边长在 4 和 8 之间,然后从第二层和第三层之间选一层的话,在实际效果中就会导致远近交界处出现明显的“裂缝”(或者是锯齿)。

那我们要如何去解决这个问题?假设我们的一个屏幕像素映射到纹理空间的位置在下图的红点处(左图是 Level4,右图是 Level5,红点的位置都是一样的):

屏幕像素映射到纹理空间
图十七:屏幕像素映射到纹理空间

从上图我们看出,如果此时它取 Level4,那它的颜色就是上图左图二行四列的颜色;而如果它取的是 Level5,那它的颜色就是上图右图一行二列的颜色。基于前面描述的问题我们不能直接这么做。接着我们分别对 Level4 和 Level5 的红点取它们各自周围的 4 个元素中心(注意取的 4 个点要把红点包围住)做双线性插值

在两层分别做双线性插值
图十八:在两层分别做双线性插值

两层都插值完后,我们能得到两个插值后的颜色值。接着我们用这两个颜色值根据实际层与层的距离进行一次线性插值,这样就插值出了一个在 Level4 和 Level5 之间的颜色。也就是说,对任意屏幕像素对应的纹理区域,不管它是不是落在该纹理的 Mipmap 上面,我们都能在只查询一次的情况下计算出这块区域的颜色。这种方法就叫做三线性插值Trilinear Interpolation)。通过三线性插值出来的结果能使远近交界处看起来有平滑过渡的效果。

最后对比下三线性插值前后的效果:

使用三线性插值前
图十九:使用三线性插值前
使用三线性插值后
图二十:使用三线性插值后

各向异性过滤

然而,增加了三线性插值的 Mipmap 也依然存在一些局限性,使用它渲染出来的网格长这样:

Mipmap + 三线性插值
图二十一:Mipmap + 三线性插值

从上图可以看到,近处还好,但远处被过度模糊了(Overblur)。这主要是因为 Mipmap 和三线性插值都是做了近似计算:

  1. 屏幕像素映射到纹理空间后覆盖的区域被近似成正方形,实际上人家可能是各种形状。
  2. 两层分别做一次双线性插值 + 两层之间做一次线性插值进一步影响最终结果。

对于第一点,我们是有办法做进一步改进的,方法就是增加对长方形的支持。

Ripmap
图二十二:Ripmap

上图二十二被称为 Ripmap,仔细观察可以发现如下特征:

  • 从左上到右下这个方向的斜对角包含了整个 Mipmap,即都是正方形。
  • 从每一个正方形往看,都是高度不变,但宽度依次被减半的长方形(竖直长条)。
  • 从每一个正方形往看,都是宽度不变,但高度依次被减半的长方形(横向长条)。

也就是说,如果屏幕像素映射到纹理空间后的区域属于(或者可近似看成)正方形、竖直长条形、横向长条形,那我们都可以从 Ripmap 中找到对应的区域。因此效果也比 Mipmap 要好。

Ripmap 这种方法是属于各向异性过滤Anisotropic Filtering)中的一种。各向异性是指在不同的方向上表现各不相同。另外有一种过滤方法称为各向同性过滤Isotropic Filtering),它指的是在不同的方向的表现相同。

从 Ripmap 的图我们也能直观地看出,Ripmap 会额外增加 3 倍的存储量(Ripmap 被平均分成 4 个正方形,左上角是原图,其余的 3/4 都是额外增加的图片)。在游戏里通常会有各向异性过滤的开关选项,并且可以指定一个 XX 值。其中这个 XX 值就代表各向异性过滤的层数,比如上图的 Ripmap,如果 X=2X = 2,就表示 Ripmap 会取左上角的 4 个图片,当然它的效果会比把 XX 开到最大要差。事实上,只要你的显存足够,而且开启了各项异性过滤,那你 XX 值设置的大小并不会对显存和算力造成太大的影响。因为从图中也可以看出,随着层数的增大,开销是逐渐收敛到原来的 3 倍的。事实上 X=2X = 2 时,开销离原来的 3 倍不远了,继续增大后收敛得很快。

Ripmap 也有它的局限性,它无法处理正方形、竖直长条形和横向长条形以外的形状区域。而有一种叫 EWAElliptical Weighted Average)过滤的方法能针对这个问题做出进一步的改进。

EWA 过滤也属于各向异性过滤。简单来说就是根据屏幕像素映射到纹理空间形成的区域大小和形状的特征,通过计算把它近似成椭圆。其中里面会涉及多次的权重计算,因此它在带来更好效果的同时,也伴随着更大的计算量。

* 未经同意不得转载。