实时渲染领域内,纹理拾取、映射及过滤涉及大量理论知识,本文是对这些知识的总结和梳理,方便日后查阅。本文本应该在2011年末的时候出现,由于从那时起我就被无尽的加班缠绕,直到最近才得以解脱,所以到现在才有时间完成这个总结。
纹理拾取、映射及过滤在实际应用主要集中在pixel shader阶段。Shader Model 3.0引入vertex texture fetch(VTF,可实现Height Mapping和Displacement Mapping),开启了在非pixel shader阶段进行纹理拾取的大门,而最新的Shader Model5.0,纹理拾取可以在任意shader阶段进行(例如tessellation后在domain shader进行Displacement Mapping),但是,在实际编码过程中有一些疑问:为什么tex2D(或TextureObject.Sample)函数只能在pixel shader内使用?Pixel的纹理坐标就真如某些入门书籍说的那样简单的线性插值就可以得出?为什么会出现mipmap chain?纹理LOD又是什么,如何确定?这篇总结阐述渲染流水线内纹理映射的过程及原理,当理解原理后,问题的正确答案就会出现。
纹理映射过程主要分为三个步骤:1.为三维模型顶点与纹理坐标建立合理的映射关系;2.通过渲染流水线光栅化,顶点纹理坐标将转换为屏幕像素对应的纹理坐标;3.根据像素对应的纹理坐标及采样算法拾取纹理。
建立映射关系
顶点与纹理坐标建立映射关系,这种关系可以是数学的(例如球体,圆柱体的纹理坐标可以通过计算得到),也可以是手工指定的(例如不规则形状的纹理坐标)。这种映射关系可以在建模的时候建立并保存在三维模型的顶点数据内,也可以推迟到渲染的时候才确定(例如在vertex shader内指定),甚至还可以两者结合。
目前主流3D实时渲染 API,纹理坐标都是使用规格化[0,1]区间表示,这样做的好处就是建模时不需要考虑纹理的具体分辨率。规格化纹理坐标和纹理分辨率之间的转换逻辑由纹理采样算法处理,也就是在GPU的TMU内处理。
屏幕像素对应的纹理坐标
建立好映射关系的顶点数据,将交由渲染流水线进行渲染。在渲染流水线的Geometry Stage(包括Input Assembly、Vertex Shader、Hull Shader、Tessellator、Domain Shader、Geometry Shader)内可以对纹理坐标做一些转换,例如旋转和平移(texture animation)。当顶点数据通过Geometry Stage后,将到达Rasterizer Stage(GPU对应的模块ROP),经过Rasterizer Stage的Triangle Setup和Triangle Traversal后,得到待shading的pixel(Triangle Traversal就是光栅化,扫描triangle覆盖的fragment,这些fragment将会提交到pixel shader处理),这些pixel将得到来自triangle顶点经插值后的数据,例如颜色(color),纹理坐标(texcoord)和normal。值得注意的是,插值算法常见有两种:1.线性插值;2.经透视修插值(perspective-correct interpolation);从D3D10开始,顶点数据到像素数据的插值算法可手动指定。
顶点数据线性插值
使用线性插值,性能最佳,但应用于纹理坐标时,失真严重:
如上图,左边是纹理坐标线性插值的结果,右边是透视修正插值的结果。理论上,vertex属性都应该使用透视修正插值,但是,有些属性即使使用线性插值,造成的失真也不易被察觉,例如color属性,所以,出于性能的考虑(透视修正插值需要除法运算),一些早期(十几年前)的图形加速器对vertex的color使用的就是线性插值。
顶点数据透视修正插值
vertex属性在screen space下线性插值得到的结果并不准确,如下图
在screen space下线性插值c点属性intensity是0.5,因为c是ab的中点。事实上,c点由C透视投影所得,C属性intensity并不是0.5,因为C并不是AB的中点。计算c点intensity属性的值其实就是计算C点intensity属性。再观察,C的属性就如C的view space下z坐标一样是线性变化的,如果能计算出C的view space下z坐标,C的其他属性就可以根据相同的方法计算出来,如下图:
观察得知,由于C点view space下z坐标Zt与C点属性都由AB线性插值得到,只要计算出Zt与s的关系,c的属性It也可以用同样的方法得到。Zt与s的关系推导过程如下:
首先,根据上图,可得到如下结论
根据线性插值,得到
把(4)和(5)代入(3)得到
把(1)和(2)代入(7)得到
把(6)代入(8)得到
于是得到
把(10)代入(6)得到
化简
等式(12)表示,Zt倒数可由Z1、Z2倒数线性插值得到。需要注意的是,这里的Z1、Z2、Zt均是view space下的z坐标,也就是经过projection transform之后的w。得到等式(12)后,It的计算就易于反掌了,其推导过程如下:
首先,It表示为
把(10)代入得到
可整理成
观察分母为Zt的倒数,所以得到
所以,通过s以线性插值方式仍然可以得到正确的It,但插值的对象是I1/Z1、I2/Z2,插值之后还要除以1/Zt。
纹理拾取
经过Triangle Traversal得到的pixel将提交到pixel shader处理,这些pixel对应的经插值后的vertex属性将成为pixel shader的输入。需要注意的是,Triangle Traversal并不是按扫描线一行一行的进行,而是按“Z”形式进行,顺序如:
1 2 5 6
3 4 7 8
9 10
11 12
并且,pixel shader也会以最小为4并行度执行。按“Z”形式光栅化及最小为4并行执行pixel shader为纹理拾取及过滤提供基础支持,要理解这种设计就需要了解纹理拾取过程遇到的问题和解决方法。
纹理拾取过程中,由于纹理坐标是规格化[0,1]区间内的浮点数,在换算成具体的纹理坐标时,可能得到非整数的情况:坐标值在两个texel之间,例如纹理分辨率为256*256,规格化纹理坐标是u,v(0.01,0.01),对应的具体坐标就是(2.56,2.56)。通过规格化纹理坐标采样纹理的方法称为纹理过滤算法。就上述情况,有两个过滤算法可供选择:1.最近点采样;2.双线性插值。这两个算法是最基本纹理过滤算法,而一些更高级的过滤算法提供了更高质量的采样效果,例如三线性过滤及各项异性过滤,下面将逐一介绍:
最近点采样 Nearest Point Sampling
假如具体坐标就是(2.56,2.56),由于2.56与3这个整数最接近,所以,采样将发生在(3,3)这个位置。最近点采样性能最优,因为它只需要1次访存;效果自然是最差,无论放大还是缩小,只要当pixel与texel不是一一对应时,失真严重。经典游戏CS使用软渲染方法时,纹理拾取就是最近点采样,有兴趣的可以去看看效果。
双线性插值 Bilinear Interpolation
双线性插值,与最近点采样算法差不多,只不过把采样数目提升到4,然后在混合得到最终pixel的颜色。例如具体坐标(2.56,2.56),则会在(2,2) (2,3) (3,2) (3,3)进行采样,然后在u和v方向各做一次线性插值,得到最后的颜色。
双线性插值会产生4次访存,由于现时的GPU texture cache(对了,请注意cache的发音与cash相同,也可以用$来代表,例如L2 $就是只L2 cache)系统也是按“Z”形式进行prefetch,所以这4次访存的cache命中率相当高,对性能影响有限。
三线性插值 Trilinear Interpolation
使用上述两种方法,可以根据一个规格化纹理坐标拾取一个颜色值。接下来,一个新的问题出现,试想一张256*256的texture映射到一个远离投影平面的三角形上,这个三角形只覆盖了4个pixel,那么,这4个pixel无论使用最近点采样还是双线性插值,都无法反映整个texture的内容,因为这4个pixel只反映了texture的很小一部分内容,也就是说产生了失真。
要消除失真,就要弄明白失真产生的原理。一个pixel覆盖多少个texel,是由pixel对应的三角形到投影平面的距离决定,距离越远,覆盖的texel就越多。当pixel覆盖多个texel,失真就产生了,因为采样频率——pixel严重低于信号频率——texel。那么,消除失真就是a.提升采样频率,用足够多的采样确定一个pixel;b.降低信号频率,降低texture的细节,当pixel一一对应texel时,失真也就消除了。
按常理来说,方法a是首选的,而事实上,解决纹理映射失真使用的是b方法。这是因为,要求解一个pixel需要多高的采样频率并按这个频率进行采样再混合,是无法在常量资源需求及常量时间内完成,所以这个方法不适用于实时渲染。方法b降低texture细节通过mipmap实现。
mipmap生成一个金字塔,最底层是原始texture,然后每增加一层,就缩小1/4大小(4texel混合为一个texel),直到只剩下一个texel为止,如图:
当一个pixel进行纹理映射(texture mapping)时,如果pixel覆盖的texel少,就使用较底层的mipmap进行采样,如果覆盖像素多,就使用较顶层的mipmap采样,务求保证pixel一一对应texel,减少失真。实际操作过程中,一个pixel使用哪一层(或两层)mipmap进行采样并不是由pixel覆盖多少个texel来决定(如方法a,这种计算不适用于实时渲染),而是由pixel纹理坐标变化率决定,当变化率越大,就说明pixel覆盖的texel数量越多。
如何计算出pixel的纹理坐标变化率?似乎与微分、导数有关,而实际应用中,计算pixel与邻近pixel属性(例如纹理坐标)的一阶差分,可近似得到属性的数值导数。计算一阶差分由HLSL内置函数ddx,ddy完成。ddx是pixel的属性延屏幕x方向的数值导数,ddy就是pixel的属性延屏幕y方向的数值导数。注意,ddx和ddy只能在pixel shader内使用,这是因为:1.只有pixel shader阶段,才有屏幕方向的概念,pixel shader处理的正是屏幕上的pixel,其他shader阶段根本就没有pixel的概念;2.pixel的属性只是计算过程的中间变量,要计算邻近pixel中间变量之差,这些邻近pixel必须在同一时间处理,而且在屏幕x及y方向最小并行粒度必须是2。前面提及,光栅化过程是以“Z”形式进行,而且pixel shader并行度最小为4,这都是为了ddx及ddy而产生的设计。下面举例说明ddx(原理和ddy类似)是怎么执行的。假如下面4个像素对应的pixel shader并行执行到ddx函数:
A B
C D
负责A像素的pixel shader调用ddx(texcoord),那么,将可能返回A.texcoord - B.texcoord,B像素的pixel shader调用ddx(texcoord)时,将可能返回B.texcoord - A.texcoord。
使用ddx和ddy计算出变化率后,就可以根据变化率确定mipmap层。一般情况下,使用变化率最大值来决定mipmap层,这个值就叫LOD(level of detail)。实际上LOD通常不是整数,即落在两层mipmap之间,这时,采样会先在相关的两层mipmap内进行一次双线性插值采样,得到两个参考值,然后根据这两个参考值,使用LOD再次进行双线性插值,得到最终结果。这就是三线性插值。这个过程在pixel shader内可使用TextureObject.Sample(sampler_state, uv)来完成,其实质就是TextureObject.SampleGrad(sampler_state, uv, ddx(uv), ddy(uv))。因为TextureObject.Sample隐含依赖ddx和ddy,所以这个方法只能在pixel shader内使用。在非pixle shader阶段采样纹理,就需要手动指定LOD,使用方法TextureObject.SampleLevel(sampler_state, uv, lod)。
各向异性过滤 Anisotropic Filter
三线性插值对平行于投影平面的三角形能得到很好的纹理过滤效果。因为平行于投影平面的三角形无论远离还是接近视点,三角形所对应的pixel在屏幕坐标x和y方向对纹理坐标uv的变化是相等的——即各向同性(isotropy),而mipmap的产生方式也是各向同性,uv方向都为1/4,所以两者配合得天衣无缝。
而实际应用中,三角形与投影平面总是带一定的夹角,这使得pixel在屏幕坐标x和y方向对纹理坐标uv的变化不相等——各向异性(anisotropy),例如沥青路面的pixel纹理坐标u变化少,但v变化大,如果使用三线性过滤,沥青路面远处就会出现模糊现象。因为三线性过滤的依据使用变化率最大值来决定LOD,又由于三线性过滤是各向同性过滤,所以采样结果是纹理坐标u方向和v都混合了,但我们希望,远处依然清晰可辨,即u方向的混合程度要少于v方向。能够得到这个效果的纹理过滤方法就叫做各向异性过滤(anisotropic filter)。
下面是各向异性过滤的一种实现方式,如图:
这种方法,屏幕像素反向投影到纹理空间,在纹理空间形成一个不规则的4边形。这个4边形的短边用以确定mipmap层(LOD)。用短边确定mipmap层保证纹理细节(高频率)。4变形长边方向,生成一条贯穿4变形中心的线段,按过滤等级高低,在线段上进行多次采样并合成,得到最终采样结果。随着过滤等级的提高(16x),在纹理上的采样频率也会提升,最大限度保证纹理的还原度,这种方法的思路也符合前面提到的消除失真方法的a方法。
这种方式实现的各向异性过滤没有任何方向性的约束,纹理与投影平面无论方向如何、夹角如何,都能得到最佳效果(记得早些年某些GPU各向异性过滤效果会在某些角度失真,它们的实现方式可能是ripmap或者summed-area table)。当然,这种方式会触发大量的纹理拾取,GPU的texture cache机制要足够的强大才能保证性能不会有大的损失。
结束
pixel shader通过纹理过滤算法拾取纹理,把拾取结果用于光照计算,并根据具体情况,决定是否把计算结果输出到Output Merge阶段(pixel shader clip/discard)。pixel shader一旦决定把计算结果输出到Output Merge阶段,这些计算结果就会参与z test、stencil test,当全部test都pass后,这些结果就会记录到render target对应的位置。当这个render target是back buffer时,在present之后,就可以通过屏幕观察到这些pixel。