今天要介紹的是Jad Khoury在其碩士論文中介紹的使用CS完成物件、地形網(wǎng)格的漸進(jìn)式Tessellation方案,這個(gè)方案目前被業(yè)界很多的引擎所借鑒引用,具有較高知名度。
1.1 Introduction
GPU光柵化在三角面片在屏幕上的投影面積較大(覆蓋像素較多)時(shí)效率會(huì)高一些,當(dāng)其投影面積小于某個(gè)閾值時(shí),就會(huì)導(dǎo)致深度buffer的鋸齒以及shading rate的明顯下降[Riccio 12],而這會(huì)使得復(fù)雜場景的渲染存在很多的挑戰(zhàn),因?yàn)楫?dāng)物件距離相機(jī)越來越遠(yuǎn),就會(huì)導(dǎo)致三角面片在屏幕上的投影面積變得越來越小,本文給出的算法可以為任意的polygon mesh實(shí)現(xiàn)漸進(jìn)式的精修,從而避免上述問題。(為什么漸進(jìn)式精修可以避免問題?)
傳統(tǒng)的模型漸進(jìn)式精修是在CPU上借助不斷迭代的算法如四叉樹或者subdivision surface完成的,但是這種算法的問題在于要想完成渲染需要將數(shù)據(jù)從CPU傳輸?shù)紾PU,而這個(gè)成本是比較高的,因此大家想是不是可以直接在GPU上通過tessellation shader完成這個(gè)精修處理,然而tessellation shader存在著如下的兩個(gè)限制:
- 只支持最高
級的細(xì)分
- 隨著細(xì)分深度的增加,性能會(huì)隨之下降(具體原因暫時(shí)不明)[AMD 13]
本文給出的算法也是在GPU上完成的,不過可以避免上面tessellation shader的相關(guān)問題,而且即使使用再多的細(xì)分層級,其內(nèi)存消耗都會(huì)維持在一個(gè)穩(wěn)定水平。整個(gè)過程是在Compute Shader中完成的,采用的是一種基于三角面片的implicit subdivision scheme,數(shù)據(jù)的讀取跟寫入都是在一個(gè)緊湊的雙緩沖(double buffered)數(shù)組中完成。
1.2 Implicit Triangle Subdivision
1.2.1 Subdivision Rule
面片細(xì)分算法的基礎(chǔ)是面片拆分規(guī)則(subdivision rule),拆分規(guī)則描述的是單個(gè)面片如何被拆解成多個(gè)子面片。本文使用的是一種叫做二分三角形(binary triangle)的拆分規(guī)則(簡稱BTSR),具體可以參考下圖(a)中的示意圖:

如圖所示,BTSR可以將直角等腰三角形拆分成兩個(gè)直角等腰三角形,我們用序號0和1表示,在圖中有對應(yīng)的位置示意,這兩個(gè)子三角形的質(zhì)心空間變換矩陣(barycentric-space transformation matrices)給出如下:

這里需要解釋下什么是質(zhì)心空間變換矩陣,我這邊沒有搜到比較好的匹配答案,通過摸索推測出,通過這個(gè)變換矩陣可以將Cartesian坐標(biāo)系(笛卡爾坐標(biāo)系)下的Parent Triangle的三個(gè)頂點(diǎn)坐標(biāo)(分別用(0, 0, 1), (0, 1, 1),(1, 0, 1)表示,以直角所在頂點(diǎn)為橫縱坐標(biāo)軸的交點(diǎn))轉(zhuǎn)換成Child Triangle的三個(gè)頂點(diǎn)的笛卡爾坐標(biāo)(比如1號子面片的三個(gè)頂點(diǎn)坐標(biāo)(與前面Parent Triangle的三個(gè)頂點(diǎn)一一對應(yīng))分為(1/2, 1/2, 1),(0, 0, 1),(1, 0,1);而0號子面片的三個(gè)頂點(diǎn)則為(1/2, 1/2, 1),(0, 1, 1),(0, 0,1))
mat3 bitToXform (in uint bit )
{
float s = float ( bit ) - 0.5;
vec3 c1 = vec3 ( s, -0.5, 0);
vec3 c2 = vec3 ( -0.5 , -s, 0);
vec3 c3 = vec3 (+0.5 , +0.5 , 1);
return mat3 (c1 , c2 , c3);
}
上面的偽代碼給出了使用GLSL是如何根據(jù)輸入的序號構(gòu)建對應(yīng)的的,從前面的分析可以看到,第N次拆分,就會(huì)生成
個(gè)新的三角面片。
1.2.2 Implicit Representation
根據(jù)前面的描述,經(jīng)過細(xì)分后的三角面片都可以通過多次細(xì)分時(shí)的選擇來表達(dá),比如每次細(xì)分中選擇劃分線左邊用0劃分線右邊用1表示,那么如前面圖中(b),(c)小圖所示,每個(gè)三角形都可以用一個(gè)編碼來表示,這個(gè)編碼我們稱之為key,其中前者表示的是完全劃分(uniform),而后者表示的是部分劃分(adaptive),每個(gè)三角形三個(gè)頂點(diǎn)的坐標(biāo)則可以通過多次劃分是的質(zhì)心空間坐標(biāo)轉(zhuǎn)換矩陣連乘來表示,比如key = 0100的三角形的變換矩陣就可以用如下方法得到:
key是通過一個(gè)uint32來表示的,比如0100的key的二進(jìn)制表示方法為:

可以看到前面有一個(gè)多出來的1,實(shí)際上為了知道整個(gè)key從哪里開始,需要添加一個(gè)起始位,這個(gè)是固定的,再往前的下劃線(通常應(yīng)該填充0)對應(yīng)的是無關(guān)的位數(shù)。
mat3 keyToXform (in uint key )
{
mat3 xf = mat3 (1) ;
while ( key > 1u)
{
xf = bitToXform ( key & 1u) * xf;
key = key >> 1u;
}
return xf;
}
上面的GLSL代碼片段給出了對應(yīng)key的三角面片的變換矩陣計(jì)算方法。
單獨(dú)一個(gè)uint32算上根節(jié)點(diǎn)level最多只能表達(dá)32-1個(gè)劃分level,要想得到更多的劃分層級,需要使用一個(gè)vector,比如使用一個(gè)uvec2可以提供63個(gè)level的劃分。
1.2.3 Iterative Construction
面片拆分過程需要通過遞歸來完成,但是GPU天生是不支持遞歸的,因此需要一種策略來解決這個(gè)問題,原文中給出的算法是使用一個(gè)叫做subdivision buffer的double-buffers,通過ping-pong方法從BufferA讀取寫入BufferB,再從BufferB讀取寫入BufferA,循環(huán)往復(fù)直到所有的key bit都被處理完了,這個(gè)過程是在Compute Shader中完成的(CS線程是如何分配的?到后面sub triangle數(shù)目變多之后,一輪處理是不是不足以覆蓋所有的triangle?list中包含多個(gè)二進(jìn)制編碼的三角形,之后每個(gè)線程直接從中取用即可?)。

每次CS調(diào)用可以允許實(shí)現(xiàn)三種處理:
- 計(jì)算出下一級的相關(guān)數(shù)據(jù)
- 回退到上一級的數(shù)據(jù)
- 維持原樣

因?yàn)橛胟ey作為面片表達(dá)方法的原因,這些操作都可以輕松完成,下面這張圖給出了一個(gè)key以及其對應(yīng)的child triangle和parent triangle的key的數(shù)據(jù)表示:

Parent Key跟Child Key的計(jì)算邏輯可以用如下的GLSL代碼表示,因?yàn)槭切┗镜囊莆徊僮饕约斑壿嫴僮?,所以成本很低?/p>
uint parentKey (in uint key )
{
return ( key >> 1u);
}
void childrenKeys (in uint key , out uint children [2])
{
children [0] = ( key << 1u) | 0u;
children [1] = ( key << 1u) | 1u;
}
下面給出的是在CS中對subdivision buffer進(jìn)行更新的GLSL代碼,簡單來說,如果某個(gè)key需要被拆分,那就會(huì)生成兩個(gè)Child Key并塞入到subdivision buffer中(ping-pong),同時(shí)拋棄當(dāng)前key;而如果兩個(gè)Child Key需要合并成一個(gè)Parent Key,這時(shí)候就會(huì)丟掉這兩個(gè)Key,并將Parent Key塞入Buffer,為了避免某個(gè)key被重復(fù)塞入,通常會(huì)選擇在0號Child上進(jìn)行相應(yīng)處理。
buffer keyBufferOut { uvec2 u_SubdBufferOut []; };
uniform atomic_uint u_SubdBufferCounter ;
// write a key to the subdivision buffer
void writeKey ( uint key )
{
uint idx = atomicCounterIncrement ( u_SubdBufferCounter );
u_SubdBufferOut [ idx ] = key ;
}
// general routine to update the subdivision buffer
void updateSubdBuffer ( uint key , int targetLod )
{
// extract subdivision level associated to the key
int keyLod = findMSB ( key );
// update the key accordingly
if (/* subdivide ? */ keyLod < targetLod && ! isLeafKey ( key ))
{
uint children [2]; childrenKeys (key , children );
writeKey ( children [0]) ;
writeKey ( children [1]) ;
}
else if (/* keep ? */ keyLod == targetLod )
{
writeKey ( key);
}
else /* merge ? */
{
if (/* is root ? */ isRootKey ( key ))
{
writeKey ( key );
}
else if (/* is zero child ? */ isChildZeroKey ( key))
{
writeKey ( parentKey (key));
}
}
}
下面這段代碼用于檢測當(dāng)前節(jié)點(diǎn)是否屬于其Parent的0號子節(jié)點(diǎn)
bool isChildZeroKey (in uint key ) { return (key & 1u == 0u); }
下面這段代碼用于判斷對應(yīng)節(jié)點(diǎn)是葉子節(jié)點(diǎn)還是根節(jié)點(diǎn)
bool isRootKey (in uint key ) { return ( key == 1u); }
bool isLeafKey (in uint key ) { return findMSB (key) == 31; }
從現(xiàn)有的信息來看,當(dāng)前給出的算法可以很好的適應(yīng)GPU的特性,因此使得我們可以實(shí)現(xiàn)此前示意圖中所展示的部分劃分(Adaptive)方案。 不過需要注意的是,由于每輪迭代,我們只能對每個(gè)key執(zhí)行一次細(xì)化或者粗化處理,如果我們需要進(jìn)行更多次處理的話,就需要依賴多buffer的迭代方案,在原方案的渲染實(shí)現(xiàn)中,會(huì)在每幀開始之前就進(jìn)行一次單buffer的迭代(這是因?yàn)橹辽傩枰{(diào)用一次迭代嗎?)
1.2.4 Conversion to Explicit Geometry
經(jīng)過前面的處理,我們拿到了代表不同尺寸與細(xì)分結(jié)果的keys,下一步要做的就是將key轉(zhuǎn)換成渲染所使用的三角面片數(shù)據(jù)。這里是通過GPU Instancing來完成的。

對于subdivision buffer中的每個(gè)key,這里都會(huì)創(chuàng)建出一個(gè)對應(yīng)的三角面片,每個(gè)面片的頂點(diǎn)坐標(biāo)可以通過下面給出的GLSL代碼來計(jì)算,頂點(diǎn)上的其他屬性比如法線等也可以通過類似機(jī)制計(jì)算出來,出于闡述簡單考慮,這里就沒有展示了:
// Get 3D Coordinates According To Vector Weights
vec3 berp (in vec3 v[3] , in vec2 u)
{
return v [0] + u.x * (v [1] - v [0]) + u.y * (v [2] - v [0]) ;
}
// subdivision routine ( vertex position only )
void subd (in uint key , in vec3 v_in [3] , out vec3 v_out [3])
{
mat3 xf = keyToXform ( key);
vec2 u1 = (xf * vec3 (0, 0, 1)).xy;
vec2 u2 = (xf * vec3 (1, 0, 1)).xy;
vec2 u3 = (xf * vec3 (0, 1, 1)).xy;
v_out [0] = berp (v_in , u1);
v_out [1] = berp (v_in , u2);
v_out [2] = berp (v_in , u3);
}
1.3 Adaptive Subdivision on the GPU
1.3.1 Overview
前面介紹了implicit subdivision scheme,下面我們將介紹如何使用這個(gè)方案來對場景中的物件進(jìn)行曲面細(xì)分??偟膩碚f,就是為每個(gè)面片創(chuàng)建一個(gè)對應(yīng)的adaptive subdivision,從而控制其在屏幕空間的規(guī)模,避免文章開頭說到的sub-pixel projection問題(指的是通過將多個(gè)sub-pixel面片合并成一個(gè)大的面片,從而避免這個(gè)問題吧?)

上圖給出了本文算法在OpenGL中的實(shí)現(xiàn)流程,可以看到這是一個(gè)遞歸調(diào)用的過程,每一幀執(zhí)行一次調(diào)用,完成后再將subdBufferIn跟subdBufferOut互換。其中綠色表示的是GPU中的buffer數(shù)據(jù),紅色表示GPU中的stage,而灰色表示的是CPU中的stage??梢钥吹剑珿PU中的執(zhí)行stage主要有三個(gè)(使用OpenGL 4.5版本),這三個(gè)都是在Compute Shader中完成:
- LoadKernel,這個(gè)stage會(huì)使用之前介紹過的implicit subdivision scheme對CS中的subdivision buffer進(jìn)行更新,完成曲面細(xì)分或者合并;此外,為了減輕后面的工作量,還會(huì)觸發(fā)一個(gè)frustum culling調(diào)用,使用一個(gè)原子計(jì)數(shù)器對subdivision buffer中的key進(jìn)行裁剪,只將可見的keys填入到CulledSubdBuffer中。
- IndirectBatcherkernel,這個(gè)stage的目的是為下一次LoadKernel的調(diào)用做準(zhǔn)備,比如完成相應(yīng)數(shù)據(jù)(DispatchIndirectBuffer)的準(zhǔn)備工作,同時(shí)也完成下一Stage的數(shù)據(jù)(DrawIndirectBuffer)準(zhǔn)備工作。
- RenderKernel,調(diào)用IndirectDraw指令完成geometry數(shù)據(jù)到framebuffer的渲染過程。這個(gè)過程會(huì)創(chuàng)建一系列的triangle instance(InstancedGeometryBuffers),前面CulledSubdBuffer中的每個(gè)key分別對應(yīng)一個(gè)instance。
1.3.2 LOD Function
前面的implicit subdivision scheme介紹了如何細(xì)分與合并,這里來介紹在什么情況下面片需要細(xì)分,什么情況下需要合并。為了使得生成的面片對于后續(xù)的光柵化流程是友好的,因此使用到相機(jī)的距離作為判定準(zhǔn)則。在透視投影下,image plane(垂直于相機(jī)視角的2D平面)的尺寸s(用水平方向的寬度表征)與相機(jī)距離z之間的關(guān)系可以用如下公式來表征:
其中,對應(yīng)的是水平方向的FOV。根據(jù)這個(gè)關(guān)系,我們可以按照如下的算法來確定每個(gè)key的細(xì)分層級k:
float distanceToLod(float z)
{
// smaller the tmp, higher the subdivision level
// targetPixelSize refers to the pixels number that the final subdivided triangle covered
float tmp = s(z) * targetPixelSize / screenResolution;
return -log2(clamp(tmp, 0.0, 1.0));
}
函數(shù)中的參數(shù)z指的是從相機(jī)到key所對應(yīng)的subtriangle的距離,從代碼看來,其實(shí)現(xiàn)邏輯跟這里給出的不太一樣:

首先需要計(jì)算對應(yīng)三角形到相機(jī)的距離d,之后將d乘上一個(gè)lod常量(比如CPU給定的LOD偏移之類)得到三角形的lod距離,之后通過一個(gè)log函數(shù)就可以算出當(dāng)前三角形對應(yīng)的細(xì)分層級了。
下面給出LoadKernel的完整實(shí)現(xiàn):
buffer VertexBuffer { vec3 u_VertexBuffer []; };
buffer IndexBuffer { uint u_IndexBuffer []; };
buffer SubdBufferIn { uvec2 u_SubdBufferIn []; };
void main ()
{
// get threadID ( each key is associated to a thread )
int threadID = gl_GlobalInvocationID .x;
// get coarse triangle associated to the key
uint primID = u_SubdBufferIn [ threadID ].y;
vec3 v_in [3] = vec3 [3](
u_VertexBuffer [ u_IndexBuffer [ primID * 3 ]],
u_VertexBuffer [ u_IndexBuffer [ primID * 3 + 1]] ,
u_VertexBuffer [ u_IndexBuffer [ primID * 3 + 2]] ,
);
// compute distance - based LOD
uint key = u_SubdBufferIn [ threadID ].x;
vec3 v [3];
// what does this process do? Get The subdivision triangle's coordinates
subd (key , v_in , v);
float z = distance ((v [1] + v [2]) / 2.0 , camPos );
int targetLod = int( distanceToLod (z));
// write to u_SubdBufferOut
updateSubdBuffer (key , targetLod );
}
1.3.3 T-Junction Removal

跟其他的自適應(yīng)網(wǎng)格細(xì)分算法一樣,本文所給出的算法也是會(huì)出現(xiàn)T-junctions問題的,如下圖紅色三角形跟綠色三角形交界位置所示,T-junctions在頂點(diǎn)高度調(diào)整后會(huì)出現(xiàn)裂縫問題。不過如果能夠保證相鄰兩個(gè)Child Triangle的細(xì)分級別相差不大于1,那么就有希望消除T-junctions問題,如圖中藍(lán)色三角形與綠色三角形的交界處所示(但是即使將紅色三角形繼續(xù)細(xì)分一級,其與綠色三角形就能達(dá)到相差1級的標(biāo)準(zhǔn),但這種情況下還是會(huì)存在T-junctions???下面會(huì)說,當(dāng)細(xì)分標(biāo)準(zhǔn)以斜邊中心點(diǎn)到相機(jī)的距離為判斷標(biāo)準(zhǔn),那么紅色三角形就還會(huì)繼續(xù)拆分,從而保證可以消除T-Junctions)
為了實(shí)現(xiàn)T-junctions的消除,首先需要保證相鄰兩個(gè)Child Triangle的key level相差不大于1,這個(gè)是如何做到的呢,首先需要先檢測出會(huì)出現(xiàn)T-Junctions的三角形,這個(gè)是通過對當(dāng)前三角形的爺爺?shù)男值艿腖OD與當(dāng)前三角形的LOD做比對完成的,如下圖中0101的爺爺就是01,其兄弟就是00,比對兩者的LOD就可以知道是否存在T-junctions。
另外還需要以三角形的斜邊(hypotenuse)中心點(diǎn)到相機(jī)的距離作為三角形是否細(xì)分的判定依據(jù),在這兩個(gè)標(biāo)準(zhǔn)的處理下,下圖紅色三角形就會(huì)被進(jìn)行兩次細(xì)分(或者對綠色三角形進(jìn)行合并),從而避免T-junctions。



buffer VertexBuffer { vec3 u_VertexBuffer []; };
buffer IndexBuffer { uint u_IndexBuffer []; };
in vec2 i_InstancedVertex ;
in uvec2 i_PerInstanceKey ;
void main ()
{
// get coarse triangle associated to the key
uint primID = i_PerInstanceKey .y;
vec3 v_in [3] = vec3 [3](
u_VertexBuffer [ u_IndexBuffer [ primID * 3 ]],
u_VertexBuffer [ u_IndexBuffer [ primID * 3 + 1]] ,
u_VertexBuffer [ u_IndexBuffer [ primID * 3 + 2]] ,
);
// compute vertex location
uint key = i_PerInstanceKey .x;
vec3 v [3];
subd (key , v_in , v);
//berp, Get The World Coorindates of Vertex
vec3 finalVertex = berp (v, i_InstancedVertex );
// displace , deform , project , etc .
}
這里給出的是RenderKernel Stage中的VS代碼。
1.3.4 Results
為了驗(yàn)證實(shí)現(xiàn)效果,原文作者給出了一個(gè)使用displacement-mapping算法實(shí)現(xiàn)的地形渲染demo,鏈接中給出了對應(yīng)的git地址,感興趣的同學(xué)可以自行下載,下面是渲染的表現(xiàn):

下面給出的是在Intel i7-8700k CPU, 3.70 GHz,NVidia GTX1080 GPU,8GiB顯存配置下使用1080p分辨率繪制時(shí)的時(shí)間消耗,相機(jī)上使用的是固定相機(jī)朝向,但是允許縮放的配置,這里的時(shí)間消耗數(shù)據(jù)的每一行分別對應(yīng)三個(gè)處理步驟的時(shí)間消耗,另外帶有stdev后綴的指的是隨著相機(jī)不斷縮進(jìn)(放大)過程中5000幀的一個(gè)標(biāo)準(zhǔn)差(standard deviation)。

從數(shù)據(jù)上來看,性能消耗很低,而且十分穩(wěn)定(不會(huì)出現(xiàn)尖刺之類的問題),因?yàn)榈匦武秩鞠膶τ趕hading會(huì)有比較大的依賴,因此為了重點(diǎn)驗(yàn)證細(xì)分算法,這里使用的是constant color來進(jìn)行著色處理,因此上面的消耗基本上就可以理解為地形渲染中頂點(diǎn)處理與subdivision算法的時(shí)間消耗。
1.4 Discussion
本文給出了一個(gè)通過Compute Shader完成的,細(xì)分層級上限不受限制的地形網(wǎng)格細(xì)分方案,在后續(xù)的工作中,可能會(huì)嘗試一些更為復(fù)雜的細(xì)分scheme,比如Catmull-Clark Scheme,此外這里還給出了一些本文實(shí)現(xiàn)中的一些細(xì)節(jié)考慮:
需要為包含subdivision keys的buffer分配多大的內(nèi)存空間?
這個(gè)取決于屏幕空間的三角面片密度,一個(gè)三角形作為一個(gè)節(jié)點(diǎn),且最高的劃分層級為max_level的話,那么最少需要分配3 * max_level + 1個(gè)節(jié)點(diǎn),這個(gè)數(shù)值對應(yīng)的是之前介紹過的adaptive subdivision中相鄰兩個(gè)Child Triangle層級相差不超過1的情況,最大需要分配個(gè)節(jié)點(diǎn),這個(gè)數(shù)值對應(yīng)的是完全細(xì)分的情況(uniform split)。
本文算法是否會(huì)受到浮點(diǎn)數(shù)精度的限制?
implicit subdivision scheme是使用整數(shù)表示的,本身不存在問題,但是前面通過多次累乘輸出變換矩陣上會(huì)存在一定的風(fēng)險(xiǎn),這邊給出的結(jié)論是使用31級或者低于31級的情況不會(huì)有問題,但是如果最大細(xì)分級別高于31的話,是可能會(huì)因?yàn)楦↑c(diǎn)精度不足導(dǎo)致表現(xiàn)問題的。這里給出的一個(gè)解決方案是使用雙精度浮點(diǎn)數(shù)(OpenGL4.0+之后的硬件支持),雖然不能完全消除這個(gè)問題,但是在出問題之前的最大細(xì)分級別應(yīng)該是足以滿足所有需要了。
是否能夠?qū)⑦@個(gè)算法用在tessellation shader中來消除tessellation shader 的限制呢?
原文作者在github的demo-isubd-terrain demo中給出了實(shí)現(xiàn)源碼,開發(fā)者可以根據(jù)軟硬件條件決定使用哪種方案更好。
要實(shí)現(xiàn)對地形網(wǎng)格的細(xì)分有兩種方案,分別對應(yīng)本文的subdivision scheme以及傳統(tǒng)的LOD(如CDLOD)方案,這兩種方案優(yōu)劣對比是怎樣的?
原文作者認(rèn)為表現(xiàn)取決于具體的平臺,之前的github源碼給出了instanced triangle grid進(jìn)行tessellation的工具,因此可以很好的比對兩種方案,下圖給出了在NVidia GTX1080上不同sublevel下的per-instance性能消耗,可以看到,隨著sublevel的增加,每個(gè)instance繪制的時(shí)間消耗是逐漸降低的(不論是LOD Stage還是Render Stage,不過這種對比貌似也不能說明什么問題,因?yàn)殡m然per-instance消耗降低了,但是instance數(shù)目增加了)

本文方法是否可以用于除地形網(wǎng)格細(xì)化之外的其他區(qū)域,比如模型細(xì)分?
implicit subdivision scheme提供了跟tessellation shader相同的功能,因此凡是tessellation shader使用的地方都可以使用。原文作者使用此方法分別應(yīng)用[Vlachos et al. 01] 中的PN-triangles算法以及[Boubekeur and Alexa 08] 的Phong Tessellation算法完成了對模型的面片優(yōu)化,結(jié)果見下圖:

參考文獻(xiàn)
[1] Adaptive GPU Tessellation with Compute Shaders
[2] GPU Tessellation with Compute Shaders