由于之前对纹理的采样函数以及mipmap没有足够的了解,导致错误的认为采样函数只是简单的根据u,v
坐标从纹理中读取相应的纹理信息,没有考虑到如果存在mipmap的话,GPU是怎样选择mip level的。第一次意识到ddx和ddy的存在是在读PS4 Pro的checkerboard算法原理的时候,在实现checkerboard算法的时候会用到梯度调整(Gradient Adjust),用来修正checkerboard算法产生的错误ddx 和 ddy,使得可以正确计算出mip level。下面我将主要介绍一下为什么要建立顶点坐标到纹理坐标的映射以及怎么根据u,v
坐标计算mip level。
通常来说我们在pixel shader中给出u,v
坐标,然后通过采样函数就可以获得相应位置的纹素(texel)。每一个像素点的纹理坐标值是在光栅化的时候计算得到的,纹理坐标映射的功能主要是选择正确的纹素,尤其是使用mip map的时候。使用mip map先要选出正确的mip level,然后在该level上采样。
纹理映射主要分为3个步骤:
- 为三维模型顶点和纹理坐标建立合理的映射关系。
- 在光栅化阶段计算每个屏幕像素的纹理坐标。
- 根据像素的纹理坐标及采样算法获取纹理。
在目前主流的3D实时渲染API中,纹理坐标都使用规格化的[0,1],每个顶点的纹理坐标在模型输入的时候就确定了。当然可能也有其他的方法建立纹理映射关系,但这不在今天的讨论范围内。
在渲染流水线的IA, VS, HS, Tessellator, DS, GS阶段都可以对纹理坐标进行一些变换,最终确定屏幕像素对应的纹理坐标是在光栅化阶段。通常有两种方法得到像素对应的纹理坐标,第一种是线性映射,第二种方法是使用顶点数据透视修正差值。使用线性映射速度最快,性能最佳,但是很多情况下效果不佳,所以我们基本都采用顶点修正差值。如下图所示:
从图中可以看出c
点位于a,b
中间,但是C
点并不是A,B
的中点,如果直接采用线性差值就会产生错误的结果,因此需要对差值结果进行修正,以得到正确的结果。通过观察我们发现C
的属性就像C
在view space下的z
坐标一样是线性变化的,如果可以计算出C
在veiw space下的z
坐标,那么C
的其他属性就可以根据相同的方法计算出来。具体的计算过程请参考透视修正差值计算方法。
从上面的推导过程可以看到,通过s同样可以得到正确的值,不过差值的对象是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.双线性插值。这两个算法是最基本纹理过滤算法,而一些更高级的过滤算法提供了更高质量的采样效果,例如三线性过滤及各项异性过滤,下面将逐一介绍:
假如具体坐标就是(2.56,2.56),由于2.56与3这个整数最接近,所以,采样将发生在(3,3)这个位置。最近点采样性能最优,因为它只需要1次访存;效果自然是最差,无论放大还是缩小,只要当pixel与texel不是一一对应时,失真严重。经典游戏CS使用软渲染方法时,纹理拾取就是最近点采样,有兴趣的可以去看看效果。
双线性插值,与最近点采样算法差不多,只不过把采样数目提升到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命中率相当高,对性能影响有限。
使用上述两种方法,可以根据一个规格化纹理坐标拾取一个颜色值。接下来,一个新的问题出现,试想一张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)。
三线性插值对平行于投影平面的三角形能得到很好的纹理过滤效果。因为平行于投影平面的三角形无论远离还是接近视点,三角形所对应的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。
本小结结合实例演示怎样通过ddx,ddy计算mip level。
下图显示的是一个光栅化的三角形、u,v
坐标以及ddx,ddy
。
提示:右边4个三角形中右上角的那个是空白的,但不代表没有被光栅化,应该光栅化是以2x2为单位的,所以这个像素就有u,v
坐标了。
上面的图显示了ddx,ddy的计算方法,通常在3D Shading API中有相应的函数计算,下面的代码显示的就是计算mip level的详细细节:
#define SUB_TEXTURE_SIZE 512.0
#define SUB_TEXTURE_MIPCOUNT 10
float MipLevel( float2 uv )
{
float2 dx = ddx( uv * SUB_TEXTURE_SIZE );
float2 dy = ddy( uv * SUB_TEXTURE_SIZE );
float d = max( dot( dx, dx ), dot( dy, dy ) );
// Clamp the value to the max mip level counts
const float rangeClamp = pow(2, (SUB_TEXTURE_MIPCOUNT - 1) * 2);
d = clamp(d, 1.0, rangeClamp);
float mipLevel = 0.5 * log2(d);
mipLevel = floor(mipLevel);
return mipLevel;
}