DirectX11 With Windows SDK--33 曲面細分階段(Tessellation)

X_Jun發表於2020-07-12

前言

曲面細分是Direct3D 11帶來的其中一項重要的新功能。它引入了兩個可程式設計著色器階段以及一個固定的鑲嵌處理過程。簡單來說,曲面細分技術可以將幾何體細分為更小的三角形,並以某種方式把這些新生成的頂點偏移到合適的位置,從而以增加三角形數量的方式豐富網格細節。但為什麼不在建立網格之初就直接賦予它高模(high-poly,高面數多邊形)的細節呢?以下是使用曲面細分的3個理由:

  1. 基於GPU實現動態LOD(Level of Detail,細節級別)。可以根據網格與攝像機的距離或依據其他因素來調整其細節。比如說,若網格離攝像機過遠,則按高模的規格對它進行渲染將是一種浪費,因為在那個距離我們根本看不清網格的所有細節。隨著物體與攝像機之間距離的拉緊,我們就能連續地對它鑲嵌細分,以增加物體的細節。
  2. 物理模擬與動畫特效。我們可以在低模(low-poly,低面數多邊形)網格上執行物理模擬與動畫特效的相關計算,再以鑲嵌畫處理手段來獲取細節上更加豐富的網格。這種降低物理模擬與動畫特效計算量的做法能夠節省不少的計算資源。
  3. 節約記憶體。我們可以在各種儲存器(磁碟、RAM、VRAM)中儲存低模網格,再根據需求用GPU動態地對網格進行鑲嵌細分。

曲面細分技術涉及到的三個階段都是可選的,但如果要使用曲面細分,這三個階段都是必須要經歷的。

學習目標:

  1. 瞭解曲面細分所用的面片圖元型別。
  2. 理解曲面細分階段中的每個步驟都做了什麼,它們所需的輸入及輸出又分別是哪種資料
  3. 通過編寫外殼著色器與域著色器程式來對幾何圖形進行鑲嵌化細分
  4. 熟悉不同的細分策略,以便於在鑲嵌化處理的時候選擇出最適當的方案。除此之外,還需要了解硬體曲面細分的效能
  5. 學習貝塞爾曲線與貝塞爾曲面的數學描述,並在曲面細分階段將它們予以實現

DirectX11 With Windows SDK完整目錄

Github專案原始碼

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。

曲面細分的圖元型別

在進行曲面細分時,我們並不向IA(輸入裝配)階段提交三角形,而是提交具有若干控制點面片。Direct3D支援具有1~32個控制點的面片,並以下列圖元型別進行描述:

D3D_PRIMITIVE_TOPOLOGY_1_CONTROL_POINT_PATCHLIST	= 33,
D3D_PRIMITIVE_TOPOLOGY_2_CONTROL_POINT_PATCHLIST	= 34,
D3D_PRIMITIVE_TOPOLOGY_3_CONTROL_POINT_PATCHLIST	= 35,
...
D3D_PRIMITIVE_TOPOLOGY_31_CONTROL_POINT_PATCHLIST	= 63,
D3D_PRIMITIVE_TOPOLOGY_32_CONTROL_POINT_PATCHLIST	= 64,

由於可以將三角形看作是擁有3個控制點的三角形面片(D3D_PRIMITIVE_TOPOLOGY_3_CONTROL_POINT_PATCHLIST),所以我們依然可以提交需要鑲嵌化處理的普通三角形網格。對於簡單的四邊形面片而言,則只需要提交4個控制點的面片(D3D_PRIMITIVE_4_CONTROL_POINT_PATCH)即可。這些面片最終也會在曲面細分階段經過鑲嵌化處理而分解為多個三角形。

注意:D3D_PRIMITIVE_TOPOLOGY列舉項描述輸入裝配階段中的頂點型別,而D3D_PRIMITIVE列舉項則描述的是外殼著色器的輸入圖元型別。

那麼,具有更多控制點的面片又有什麼用處呢?控制點的概念來自於特定種類數學角度上特定曲線或曲面的構造過程。如果在類似於Adobe Illustrator這樣的繪圖程式中使用過貝塞爾曲線工具,那讀者一定會知道要通過控制點才能描繪出曲線形狀。在數學上,可以利用貝塞爾曲線來生成貝塞爾曲面。舉個例子,我們可以用9個控制點或16個控制點來建立一個貝塞爾四邊形面片,所用的控制點越多,我們對面片形狀的控制也就越隨心所欲。因此,這一切圖元控制型別都是為了給這些不同種類的曲線、曲面的繪製提供支援。

曲面細分與頂點著色器

在我們向渲染管線提交了面片的控制點後,它們就會被推送至頂點著色器。這樣一來,在開始曲面細分的時候,頂點著色器就徹底淪為“處理控制點的著色器”。正因為如此,我們還能在曲面細分開始之前,對控制點進行一些調整。一般來說,動畫與物理模擬的計算工作都會在對幾何體進行鑲嵌化處理之前的頂點著色器中以較低的頻次進行(鑲嵌化處理之後,頂點增多,處理的頻次也將隨之增加)。

外殼著色器

外殼著色器是由兩種著色器共同組成的:常量外殼著色器(Constant Hull Shader)控制點外殼著色器(Control Point Hull Shader)

常量外殼著色器

常量外殼著色器會針對每個面片統一進行處理(即每處理一個面片就被呼叫一次)。它的任務是輸出當前網格曲面細分因子,而且必須要輸出。曲面細分因子指示了在曲面細分階段中將面片鑲嵌處理後的份數,以及怎麼進行細分。它由兩個輸出系統值所表示:SV_TessFactorSV_InsideTessFactor,這兩個系統值屬於float或float陣列的型別,具體取決於輸入裝配階段定義的圖元型別。常量外殼著色器的輸出被限制在128個標量(如32個4D單精度浮點向量),這意味著除了系統值,你還可以額外新增輸出資訊供每個面片所使用。下面是一個具有3個控制點的四邊形面片示例,我們通過常量緩衝區來為其設定各個方面的細分程度:

struct QuadPatchTess
{
    float EdgeTess[4] : SV_TessFactor;
    float InsideTess[2] : SV_InsideTessFactor;
    
    // 可以在下面為每個面片附加所需的額外資訊
};

QuadPatchTess QuadConstantHS(InputPatch<VertexOut, 4> patch, uint patchID : SV_PrimitiveID)
{
    QuadPatchTess pt;
    
    pt.EdgeTess[0] = g_QuadEdgeTess[0];			// 四邊形面片的左側邊緣
    pt.EdgeTess[1] = g_QuadEdgeTess[1];			// 四邊形面片的上側邊緣
    pt.EdgeTess[2] = g_QuadEdgeTess[2];			// 四邊形面片的右側邊緣
    pt.EdgeTess[3] = g_QuadEdgeTess[3];			// 四邊形面片的下冊邊緣
    pt.InsideTess[0] = g_QuadInsideTess[0];		// u軸(四邊形內部細分的列數)
    pt.InsideTess[1] = g_QuadInsideTess[1];		// v軸(四邊形內部細分的行數)
    
    return pt;
}

其中InputPatch<VertexOut, 4>定義了控制點的數目和資訊。前面提到,控制點首先會傳至頂點著色器,因此它們的型別由頂點著色器的輸出型別VertexOut來確定。在此例中,我們的面片擁有4個控制點,所以就將InputPatch模板第二個引數指定為4。系統還通過SV_PrimitiveID語義提供了面片的ID值,此ID唯一地標識了繪製呼叫過程中的各個面片,我們可以根據具體的需求來運用它。

但按左上右下的順序來控制邊緣細分是建立在使用下面的頂點擺放順序而言的:

XMFLOAT3 quadVertices[4] = {
    XMFLOAT3(-0.54f, 0.72f, 0.0f),	// 左上角
    XMFLOAT3(0.54f, 0.72f, 0.0f),	// 右上角
    XMFLOAT3(-0.54f, -0.72f, 0.0f),	// 左下角
    XMFLOAT3(0.54f, -0.72f, 0.0f)	// 右下角
};

四邊形面片(quad)進行鑲嵌化處理的過程由兩個構成:

  1. 4個邊緣曲面細分因子控制著對應邊緣鑲嵌後的份數
  2. 兩個內部曲面細分因子指示瞭如何來對該四邊形面片的內部進行鑲嵌化處理(其中一個針對四邊形的橫向維度,另一個則作用於四邊形的縱向維度)

三角形面片(tri)進行鑲嵌化處理的過程同樣分為兩部分:

  1. 3個邊緣曲面細分因子控制著對應邊緣鑲嵌後的份數
  2. 一個內部曲面細分因子指示著三角形面片內部的鑲嵌份數。

等值線(isoline)進行鑲嵌化處理的過程如下:

  1. 2個邊緣細分因子控制著等值線如何進行鑲嵌。第一個值暫時不知道作用(忽略),第二個用於控制兩個相鄰控制點之間分成多少段。

Direct3D 11硬體所支援的最大麴面細分因子為64(D3D11_TESSELLATOR_MAX_TESSELLATION_FACTOR).如果把所有的曲面細分因子都設定為0,則該面片會被後續的處理階段所丟棄。這就使得我們能夠以每個面片為基準來實現如視錐體剔除與背面剔除這類優化。

  1. 如果面片根本沒有出現在視錐體範圍內,那麼就能將它從後續的處理中丟棄(倘若已經對該面片進行了鑲嵌化處理,那麼其細分後的各三角形將在三角形裁剪期間被拋棄)
  2. 如果面片是背面朝向的,那麼就能將其從後面的處理過程中丟棄(如果該面片已經過了鑲嵌化處理,則其細分後的所有三角形會在光柵化階段的背面剔除過程中被拋棄)

一個問題自然而然地復現出來:到底應該執行幾次鑲嵌化處理才合適?前面提到,曲面細分的基本想法就是為了豐富網格的細節。但是,如果使用者對此無感,我們就不需要對它增添細節了。以下是一些確定鑲嵌次數的常用衡量標準。

  1. 根據與攝像機之間的距離:物體與攝像機的距離越遠,能分辨的細節就越少。因此,我們在兩者距離較遠的時候渲染物體的低模版本,並隨著兩者逐漸接近而逐步對物體進行更加細緻的鑲嵌化細分。
  2. 根據佔用螢幕的範圍:可以先估算出物體覆蓋螢幕的畫素個數。如果數量比較少,則渲染物體的低模版本。隨著物體佔用螢幕範圍的增加,我們便可以逐漸增大鑲嵌化細分因子。
  3. 根據三角形的朝向:三角形相對於觀察者的朝向也被納入考慮的範疇之中。位於物體輪廓邊線上的三角形勢必比其他位置的三角形擁有更多的細節。
  4. 根據粗糙程度:粗糙不平的表面較光滑的表面需要進行更為細緻的曲面細分處理。通過對錶面紋理進行檢測可以預算出相應的粗糙度資料,繼而來決定鑲嵌化處理的次數。

[Story10(可點選)]給出了以下幾點關於效能的建議。

  1. 如果曲面細分因子為1(這個數字意味著該面片不必細分),那麼就考慮在渲染此面片時不對它進行細分處理;否則,便會在曲面細分階段白白浪費GPU資源,因為在此階段並不對其執行任何操作。
  2. 考慮到效能又涉及GPU對曲面細分的具體實現,所以不要對小於8個畫素這種過小的三角形進行鑲嵌化處理。
  3. 使用曲面細分技術時要採用批繪製呼叫(batch draw call,即儘量將曲面細分任務集中執行)(在繪製呼叫之間往復開啟、關閉曲面細分功能的代價極其高昂)。

控制點外殼著色器

控制點外殼著色器以大量的控制點作為輸入與輸出,頂點著色器每輸出一個控制點,此著色器都會被呼叫一次。控制點外殼著色器的應用之一是改變曲面的表示方式,比如把一個普通的三角形(向渲染管線提交的3個控制點)轉換為3次貝塞爾三角形面片。例如,假設我們像平常那樣利用三角形對網格進行建模,就可以通過控制點外殼著色器,把這些三角形轉換為具有10個控制點的高階三次貝塞爾三角形面片。新增的控制點不僅會帶來更加豐富的細節,而且能將三角形面片鑲嵌細分為使用者所期望的份數。這一策略被稱之為N-patches方法(法線—面片方法,normal-patches scheme)或PN三角形方法(即(曲面)點—法線三角形方法,point-normal triangles,簡寫為PN triangles scheme)[Vlachos]。由於這種方案只需用曲面細分技術來改進存在的三角形網格,且無需改動美術製作流程,所以實現起來比較方便。對於本章前面兩個演示案例來說,控制點外殼著色器僅充當一個簡單的傳遞著色器,它不會對控制點進行任何的修改。

注意:驅動程式可能會對傳遞著色器進行檢測與優化。

struct VertexOut
{
    float3 PosL : POSITION;
};

typedef VertexOut HullOut;

// Tessellation_Quad_Integer_HS.hlsl

[domain("quad")]
[partitioning("integer")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(4)]
[patchconstantfunc("QuadConstantHS")]
[maxtessfactor(64.0f)]
float3 HS(InputPatch<VertexOut, 4> patch, uint i : SV_OutputControlPointID, uint patchId : SV_PrimitiveID) : POSITION
{
    return patch[i].PosL;
}

通過InputPatch引數可以將面片的所有控制點都傳至外殼著色器中。系統值SV_OutputControlPointID索引的是正在被外殼著色器處理的輸出控制點。值得注意的是,輸入的控制點數量與輸出的控制點數量未必相同。例如,輸入的面片可能僅含有4個控制點,而輸出的面片卻能夠擁有16個控制點;這些多出來的控制點可以由輸入的4個控制點所衍生。

上面的控制點外殼著色器還用到了以下幾種屬性。

  1. domain:面片的型別。可選用的引數有tri(三角形面片)、quad(四邊形面片)或isoline(等值線)

  2. partioning:指定了曲面細分的細分模式。

    • integer:新頂點的新增或移除依據的是上取整的函式。例如我們將細分值設為3.25f時,實際上它將會細分為4份。這樣一來,在網格隨著曲面細分級別而改變時,會容易發生明顯的躍變。
    • 非整型曲面細分(fractional_even/fractional_odd):新頂點的增加或移除取決於曲面細分因子的整數部分,但是細微的漸變“過渡”調整就要根據細分因子的小數部分。當我們希望將粗糙的網格經曲面細分而平滑地過渡到具有更加細節的網格時,該引數就派上用場了。
    • pow2:目前測試的時候行為和integer一致,不知道什麼原因。這裡暫時不講述。
  3. outputtopology:通過細分所創的三角形的繞序

    • triangle_cw:順時針方向的繞序
    • triangle_ccw:逆時針方向的繞序
    • line:針對線段的曲面細分
  4. outputcontrolpoints:外殼著色器執行的次數,每次執行都輸出1個控制點。系統值SV_OutputControlPointID給出的索引標明瞭當前正在工作的外殼著色器所輸出的控制點。

  5. patchconstantfunc:指定常量外殼著色器函式名稱的字串

  6. maxtessfactor:告知驅動程式,使用者在著色器中所用的曲面細分因子的最大值。如果硬體知道了此上限,就可以瞭解曲面細分所需的資源,繼而在後臺對此進行優化。Direct3D 11硬體支援的曲面細分因子最大值為64

鑲嵌器階段

程式設計師無法對鑲嵌器這一階段進行任何控制,因為這一步的操作全權交給硬體處理。此環節會基於常量外殼著色器程式所輸出的曲面細分因子,對面片進行鑲嵌化處理。

四邊形面片的曲面細分示例

  1. integer模式

  1. fractional_odd模式:

  1. fractional_even模式:

三角形面片的曲面細分示例

域著色器

鑲嵌器階段會輸出新建的所有頂點與三角形,在此階段所建立的頂點,都會逐一呼叫域著色器進行後續處理。隨著曲面細分功能的開啟,頂點著色器便化身為“處理每個控制點的頂點著色器”,而外殼著色器的本質則為“針對已經鑲嵌化的面片進行處理的頂點著色器”。特別是,我們可以在此將經過鑲嵌化處理的面片頂點投射到齊次裁剪空間。

首先是三角形面片,域著色器以曲面細分因子(還有一些來自常量外殼著色器所輸出的每個面片的附加資訊)、控制點外殼著色器所輸出的所有面片控制點、鑲嵌化處理後的頂點位置引數(以重心座標系(alpha, beta, gamma)的形式表示)作為輸入。注意,域著色器給出的並不是鑲嵌化處理後的實際頂點位置,而是這些點位於面片域空間內的引數座標。是否利用這些引數座標及控制點來求取真正的3D頂點位置,完全取決於使用者自己。下面展示了前面的例子顯示的三角形所用到的域著色器程式碼:

struct VertexOut
{
    float3 PosL : POSITION;
};

typedef VertexOut HullOut;

// Tessellation_Triangle_DS.hlsl

[domain("tri")]
float4 DS(TriPatchTess patchTess,
    float3 weights : SV_DomainLocation,
    const OutputPatch<HullOut, 3> tri) : SV_POSITION
{
    // 重心座標系插值
    float3 pos = tri[0].PosL * weights[0] +
        tri[1].PosL * weights[1] +
        tri[2].PosL * weights[2];
    
    return float4(pos, 1.0f);
}

將三角形面片以重心座標系作為輸出的原因,很可能是因為貝塞爾三角形面片都是用重心座標來定義所導致的。

而四邊形面片的頂點位置引數以(u, v)的形式表示,前面例子的四邊形所用的域著色器程式碼如下:

struct VertexOut
{
    float3 PosL : POSITION;
};

typedef VertexOut HullOut;

// Tessellation_Quad_DS.hlsl

[domain("quad")]
float4 DS(QuadPatchTess patchTess,
    float2 uv : SV_DomainLocation,
    const OutputPatch<HullOut, 4> quad) : SV_POSITION
{
    // 雙線性插值
    float3 v1 = lerp(quad[0].PosL, quad[1].PosL, uv.x);
    float3 v2 = lerp(quad[2].PosL, quad[3].PosL, uv.x);
    float3 p = lerp(v1, v2, uv.y);
    
    return float4(p, 1.0f);
}

貝塞爾曲線(Bézier Curves)

這部分借用GAMES101來說明。並且這裡講的貝塞爾曲線所用的演算法是由Pierre Bézier和Paul de Casteljau所提出的。

現在我們先從二階貝塞爾曲線開始,下面有3個非共線的控制點b0b1b2

接下來我們用線性插值的方式,從b0b1方向的線段使用引數t來確定其中一點,記為\(\mathbf{b_{0}^{1}}\)

然後從b1b2方向的線段使用同樣的引數t來確定另一點,記為\(\mathbf{b_{1}^{1}}\)

\(b_{0}^{1}\)\(b_{1}^{1}\)連線起來,問題降級為一階貝塞爾曲線(直線)。我們對其再使用一次引數t的線性插值即可得到在引數t下該貝塞爾曲線的對應一點\(\mathbf{b_{2}^{0}}\)

我們將t[0, 1]的所有情況都求出來,就可以得到一條光滑的貝塞爾曲線。

三階的貝塞爾曲線需要用到4個控制點,但做法也是類似的。首先求出b0b1b1b2b2到b3的線段在t時刻下的插值點\(\mathbf{b_{0}^{1}}, \mathbf{b_{1}^{1}}, \mathbf{b_{2}^{1}}\),此時問題就被轉化成了這三個控制點下的二階貝塞爾曲線。不斷降階最終算出目標點即可。

可以看到,三階貝塞爾曲線需要進行6次插值運算,二階貝塞爾曲線則需要進行3次插值運算。以此類推,我們可以知道n階貝塞爾曲線需要n(n+1)/2次運算

計算過程

下圖闡述了二階貝塞爾曲線的計算過程

觀察b0b1b2的各項係數,可以發現它們滿足二項式定理。我們也可以用下面的一個金字塔來描述

從底端的這些控制點選取其中一個然後不斷往上走,最終走到頂端點的過程中,如果走過一段朝著右上方向的路徑,則給該控制點乘上因子(1-t),而朝著左上方向的路徑則給該控制點乘上因子t。比如從b0走到頂端的項為\(t^3 b_{0}\)。我們將這所有8條路徑都加起來就可以得到最後的結果:

\[\mathbf{b_0^3} = (1-t)^3\mathbf{b_0} + 3t(1-t)^2\mathbf{b_1} + 3t^2 (1-t)\mathbf{b_2} + t^3\mathbf{b_3} \]

更抽象的,我們可以用伯恩斯坦函式的形式來表示:

\[\mathbf{b^n}(t)=\sum_{i=0}^{n} C_{n}^{i}t^{i}(1-t)^{n-i}\cdot\mathbf{b_i} \]

對於三階貝塞爾曲線而言,曲線端點為:

\[\mathbf{b^3}(0)=\mathbf{b_0};\;\;\mathbf{b^3}(1)=\mathbf{b_3} \]

而三次伯恩斯坦函式的導數為:

\[\begin{align} {B_{0}^{3}}'(t)&=-3(1-t)^2\\ {B_{1}^{3}}'(t)&=3(1-t)^2-6t(1-t)\\ {B_{2}^{3}}'(t)&=6t(1-t)-3t^2\\ {B_{3}^{3}}'(t)&=3t^2\\ \end{align} \]

因此,對3次貝塞爾曲線求導的結果為:

\[\mathbf{{b^3}'(t)}=-3(1-t)^2\mathbf{b_0}+[3(1-t)^2-6t(1-t)]\mathbf{b_1}+[6t(1-t)-3t^2]\mathbf{b_2}+3t^2\mathbf{b_3} \]

通過這些導數就可以很方便地計算出曲線上某點處的切向量。

連續性

對於複雜曲線,如果我們使用高階曲線的話,觀察伯恩斯坦函式形式的頂點式會發現計算量呈平方級別增長。為此我們可以考慮將該曲線分段,然後這些曲線都使用較低階的貝塞爾曲線去擬合,以此來減少計算量。

假設我們現在有兩條三階貝塞爾曲線ab,那麼當曲線a的最後一個控制點與曲線b的第一個控制點位置相同時,我們稱這兩條曲線滿足C0連續。下圖的紅點為控制點,觀察中間的控制點處顯然滿足這一性質,但是可以看到左右兩端曲線的過渡並不是平緩的。

而如果在滿足C0連續的基礎上,曲線a在最後一個控制點處的導數還與曲線b在第一個控制點處的導數相等,則此時我們說這兩條曲線滿足C1連續。

而滿足C1連續的控制點必然滿足:

\[\mathbf{a_n}=\mathbf{b_0}=\frac{1}{2}(\mathbf{a_{n-1}+b_{1}}) \]

即曲線ab的連線點為曲線a倒數第二個控制點與曲線b第二個控制點的中點。

當然還有更高階別的C2連續,即滿足二階導數相等,這裡不再深入討論。

HLSL程式碼實現

繪製貝塞爾曲線的外殼著色器和域著色器程式碼如下:

// Tessellation.hlsli

float4 BernsteinBasis(float t)
{
    float invT = 1.0f - t;
    
    return float4(
        invT * invT * invT,         // B_{0}^{3}(t)= (1-t)^3
        3.0f * t * invT * invT,     // B_{1}^{3}(t)= 3t(1-t)^2
        3.0f * t * t * invT,        // B_{2}^{3}(t)= 3t^2(1-t)
        t * t * t);                 // B_{3}^{3}(t)= t^3
}

float4 dBernsteinBasis(float t)
{
    float invT = 1.0f - t;
    
    return float4(
        -3 * invT * invT,                   // B_{0}^{3}'(t)= -3(1-t)^2
        3.0f * invT * invT - 6 * t * invT,  // B_{1}^{3}'(t)= 3(1-t)^2 - 6t(1-t)
        6 * t * invT - 3 * t * t,           // B_{2}^{3}'(t)= 6t(1-t) - 3t^2
        3 * t * t);                         // B_{3}^{3}'(t)= 3t^2
}
// Tessellation_Isoline_HS.hlsl
IsolinePatchTess IsolineConstantHS(InputPatch<VertexOut, 4> patch, uint patchID : SV_PrimitiveID)
{
    IsolinePatchTess pt;
    
    pt.EdgeTess[0] = g_IsolineEdgeTess[0];  // 未知
    pt.EdgeTess[1] = g_IsolineEdgeTess[1];  // 段數
    
    return pt;
}

[domain("isoline")]
[partitioning("integer")]
[outputtopology("line")]
[outputcontrolpoints(4)]
[patchconstantfunc("IsolineConstantHS")]
[maxtessfactor(64.0f)]
float3 HS(InputPatch<VertexOut, 4> patch, uint i : SV_OutputControlPointID, uint patchId : SV_PrimitiveID) : POSITION
{
    return patch[i].PosL;
}

// Tessellation_BezierCurve_DS.hlsl
#include "Tessellation.hlsli"

[domain("isoline")]
float4 DS(IsolinePatchTess patchTess,
    float t : SV_DomainLocation,
    const OutputPatch<HullOut, 4> bezPatch) : SV_POSITION
{
    float4 basisU = BernsteinBasis(t);
    
    // 貝塞爾曲線插值
    float3 sum = basisU.x * bezPatch[0].PosL +
        basisU.y * bezPatch[1].PosL +
        basisU.z * bezPatch[2].PosL +
        basisU.w * bezPatch[3].PosL;
    
    float4 posH = mul(float4(sum, 1.0f), g_WorldViewProj);
    
    return posH;
}

貝塞爾曲線示例

下面的動圖展示了貝塞爾曲線的控制和細分

貝塞爾曲面

三階貝塞爾曲線有4個控制點,而三階貝塞爾曲面自然就有4x4控制點了。我們可以將其看做4條三次貝塞爾曲線。

這裡就不再列出複雜的公式來暈人了。在一維情況下,貝塞爾曲線使用範圍在[0, 1]的引數t來表示曲線上一點。那麼在二維情況下,我們可以使用範圍在[0, 1]的引數(u, v)來表示曲面上一點。

首先對於這4條橫向的貝塞爾曲線,我們分別使用引數u代入來求得四個點,這四個頂點按列順序構成新的控制點,然後問題就轉化成了在這四個控制點構成的貝塞爾曲線中求其中一點。然後我們再用引數v代入就可以求得最終在曲面上的一點。

HLSL程式碼實現

貝塞爾曲面的著色器程式碼實現如下:

// Tessellation.hlsli

// 計算以4x4控制點為基礎的三階貝塞爾曲面在(u, v)下的一點
float3 CubicBezierSum(const OutputPatch<HullOut, 16> bezPatch,
    float4 basisU, float4 basisV)
{
    float3 sum = float3(0.0f, 0.0f, 0.0f);
    sum = basisV.x * (basisU.x * bezPatch[0].PosL +
        basisU.y * bezPatch[1].PosL +
        basisU.z * bezPatch[2].PosL +
        basisU.w * bezPatch[3].PosL);
    
    sum += basisV.y * (basisU.x * bezPatch[4].PosL +
        basisU.y * bezPatch[5].PosL +
        basisU.z * bezPatch[6].PosL +
        basisU.w * bezPatch[7].PosL);
    
    sum += basisV.z * (basisU.x * bezPatch[8].PosL +
        basisU.y * bezPatch[9].PosL +
        basisU.z * bezPatch[10].PosL +
        basisU.w * bezPatch[11].PosL);
    
    sum += basisV.w * (basisU.x * bezPatch[12].PosL +
        basisU.y * bezPatch[13].PosL +
        basisU.z * bezPatch[14].PosL +
        basisU.w * bezPatch[15].PosL);
    
    return sum;
}


上面的函式不僅能用來計算\(\mathbf{p}(u, v)\),還能夠求它的偏導數:

float4 basisU = BernsteinBasis(uv.x);
float4 basisV = BernsteinBasis(uv.y);
    
// p(u, v)
float3 p = CubicBezierSum(bezPatch, basisU, basisV);


float4 dBasisU = dBernsteinBasis(uv.x);
float4 dBasisV = dBernsteinBasis(uv.y);
// p(u, v)對u的偏導
float3 dpdu = CubicBezierSum(bezPatch, dbasisU, basisV);
// p(u, v)對v的偏導
float3 dpdv = CubicBezierSum(bezPatch, basisU, dbasisV);

注意:可以發現,我們把基函式的計算結果傳入了CubicBezierSum函式。由於p(u, v)與其偏導數的求和形式相同,僅基函式不同,因此CubicBezierSum函式不僅能用來計算p(u, v),還能用於求其偏導數。

// Tessellation_BezierSurface_HS.hlsl
#include "Tessellation.hlsli"

[domain("quad")]
[partitioning("integer")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(16)]
[patchconstantfunc("QuadPatchConstantHS")]
[maxtessfactor(64.0f)]
float3 HS(InputPatch<VertexOut, 16> patch, uint i : SV_OutputControlPointID, uint patchId : SV_PrimitiveID) : POSITION
{
    return patch[i].PosL;
}

// Tessellation_BezierSurface_DS.hlsl
#include "Tessellation.hlsli"

[domain("quad")]
float4 DS(QuadPatchTess patchTess,
    float2 uv : SV_DomainLocation,
    const OutputPatch<HullOut, 16> bezPatch) : SV_POSITION
{
    float4 basisU = BernsteinBasis(uv.x);
    float4 basisV = BernsteinBasis(uv.y);
    
    // 貝塞爾曲面插值
    float3 p = CubicBezierSum(bezPatch, basisU, basisV);
    
    float4 posH = mul(float4(p, 1.0f), g_WorldViewProj);
    
    return posH;
}

幾何體定義

下面的程式碼定義了16個控制點:

XMFLOAT3 surfaceVertices[16] = {
// 行 0
XMFLOAT3(-10.0f, -10.0f, +15.0f),
XMFLOAT3(-5.0f,  0.0f, +15.0f),
XMFLOAT3(+5.0f,  0.0f, +15.0f),
XMFLOAT3(+10.0f, 0.0f, +15.0f),

// 行 1
XMFLOAT3(-15.0f, 0.0f, +5.0f),
XMFLOAT3(-5.0f,  0.0f, +5.0f),
XMFLOAT3(+5.0f,  20.0f, +5.0f),
XMFLOAT3(+15.0f, 0.0f, +5.0f),

// 行 2
XMFLOAT3(-15.0f, 0.0f, -5.0f),
XMFLOAT3(-5.0f,  0.0f, -5.0f),
XMFLOAT3(+5.0f,  0.0f, -5.0f),
XMFLOAT3(+15.0f, 0.0f, -5.0f),

// 行 3
XMFLOAT3(-10.0f, 10.0f, -15.0f),
XMFLOAT3(-5.0f,  0.0f, -15.0f),
XMFLOAT3(+5.0f,  0.0f, -15.0f),
XMFLOAT3(+25.0f, 10.0f, -15.0f)
};

注意:這裡並沒有嚴格地限定控制點一定要按等距排列為均勻的網格。

貝塞爾曲面示例

下圖展示了貝塞爾曲面

練習題

  1. 參考16章,只用6個頂點構成的八面體,並根據其與觀察點的距離關係將其鑲嵌細分為一個球體(距離越近,鑲嵌程度越大)
  2. 嘗試修改本演示程式中的控制點來改變其中的貝塞爾曲面
  3. 修改本演示程式,利用光照使得其中的貝塞爾曲面表現出明暗變化。為此,我們需要在域著色器中計算頂點法線。而位於頂點處的法線可以用此曲面點處座標的偏導數的叉積求得。

DirectX11 With Windows SDK完整目錄

Github專案原始碼

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。

相關文章