實時渲染領域內,紋理拾取、映射及過濾涉及大量理論知識,本文是對這些知識的總結和梳理,方便日后查閱。本文本應該在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。