DirectX11 With Windows SDK–34 位移貼圖

前言

在前面的章節中,我們學到了法線貼圖和曲面細分。現在我們可以將這兩者進行結合以改善效果,因為法線貼圖僅僅只是改善了光照的細節,但它並沒有從根本上改善幾何體的細節。從某種意義上來說,法線貼圖只是一個光照的小把戲。接下來我們將會學習如何使用位移貼圖來改善網格細節。

在此之前你需要了解如下章節:

章節
25 法線貼圖
33 曲面細分階段(Tessellation)

學習目標:

  1. 了解位移貼圖
  2. 熟悉如何用曲面細分來改善網格細節

DirectX11 With Windows SDK完整目錄

Github項目源碼

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

位移貼圖(Displacement Mapping)

位移貼圖的想法是利用一個額外的貼圖,稱作高度圖,它描述了一個表面的凸起和縫隙。換句話說,法線貼圖有三個顏色通道來為每個像素產生法線向量(x, y, z),而高度圖僅僅由一個顏色通道來為每個像素產生高度值h。從視覺上來看,高度圖僅僅是一張灰度圖(因為灰度圖只有一個顏色通道),每個像素可以解釋成一個高度值,它基本上是一個2D標量場的離散表示h = f(x, z)。當我們對網格進行曲面細分時,我們在域著色器對高度圖進行取樣,然後利用法線的方向來對頂點進行偏移,以此來增加網格的幾何體細節。

儘管我們通過鑲嵌來對幾何體增加三角形,但是它並沒有增加其本身的細節。那是因為如果你對三角形進行多次細分,你只是獲得了更多的和原來的三角形同屬於一個平面的三角形。為了增加細節(如凸起和縫隙),你需要以某種方式來偏移這些經過鑲嵌後得到的頂點。高度圖是其中一個座位輸入的紋理資源,它可以用來對鑲嵌後的頂點進行偏移。通常情況下,我們會用到下面的公式,為此我們還需要用到法線貼圖取樣出來的法向量來確定偏移的方向:

\[\mathbf{p’}=\mathbf{p}+s(h-1)\mathbf{n}
\]

其中標量h∈[0, 1]是從高度圖得到的高度值。我們對高度值減1來讓區間[0, 1]→[-1, 0]。因為表面的法向量通常是面向網格的外部,這意味著我們以向內偏移的方式來替代向外偏移。一般將幾何體彈入會比將幾何體拉出更為方便一些。標量s則是用來控制在世界空間的塌陷程度。這樣高度值的將從[0, 1]→[-s, 0],即高度值最大的時候將不會有向內的偏移,而高度值最小的時候將會產生最大的向內偏移。通常我們會將高度圖存放在法線貼圖中的alpha通道。

生成高度圖是一項藝術性的工作,紋理藝術家可以繪製它們,或者使用工具來產生(例如:crazybump

位移貼圖的著色器程式碼

位移貼圖的程式碼主要在頂點著色器、外殼著色器和域著色器有所變化。像素著色器則和我們之前使用了法線貼圖的版本一樣無需改動。

圖元類型

為了將位移貼圖整合到我們的渲染當中,我們需要曲面細分的支援,這樣我們就可以細化我們的幾何解析度,使得他能夠與位移貼圖更好地匹配。接下來我們將使用圖元類型D3D11_PRIMITIVE_TOPOLOGY_3_CONTROL_POINT_PATCHLIST而不是D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST來繪製我們的網格三角形。通過這種方式,三角形的三個頂點將解釋成三角形面片的3個控制點,以允許我們來對每個三角形進行鑲嵌。

頂點著色器

當我們處理曲面細分的時候,我們必須決定每個三角形的細分程度。這裡我們將引入一個簡單的距離量來確定細分數目。若三角形離攝像機越近,它的細分程度越大。頂點著色器通過計算每個頂點和攝像機之間的距離來幫助我們計算出曲面細分因子,然後將其傳遞給外給著色器。

在常量緩衝區中,我們引入了下面這些數據來控制距離的計算。這些值的設置非常依賴於場景(你的場景有多大,以及你想要怎樣的細分程度):

cbuffer CBChangesEveryFrame
{
    // ...
    float g_HeightScale;
    float g_MaxTessDistance;
    float g_MinTessDistance;
    float g_MinTessFactor;
    float g_MaxTessFactor;
}
  1. g_MaxTessDistance:從攝像機到該頂點的距離拉近到某個閾值時,將會達到最大的曲面細分因子
  2. g_MinTessDistance:從攝像機到該頂點的距離拉遠到某個閾值時,將會達到最小的曲面細分因子
  3. g_MinTessFactor:曲面細分因子的最小值。比如說你想讓每個三角形無論距離攝像機多遠,都要讓它最少被鑲嵌成3份
  4. g_MaxTessFactor:曲面細分因子的最大值。比如說你想讓這些三角形無論距離攝像機多近,它最多的鑲嵌份數不超過6.此外,回想起上一章所提到的建議,鑲嵌後的三角形如果少於8個像素將會變得低效。

此外我們應該留意到g_MaxTessDistance < g_MinTessDistance,因為隨著頂點距離我們的攝像機越近,鑲嵌的份數將會越多。

使用這些變數,我們就可以創建一個關於距離的線性函數來決定如何根據距離來確定鑲嵌的份數。

// DisplacementMapObject_VS.hlsl
#include "Basic.hlsli"

// 頂點著色器
TessVertexOut VS(VertexPosNormalTangentTex vIn)
{
    TessVertexOut vOut;

    vOut.PosW = mul(float4(vIn.PosL, 1.0f), g_World).xyz;
    vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose);
    vOut.TangentW = mul(vIn.TangentL, g_World);
    vOut.Tex = vIn.Tex;
    
    float d = distance(vOut.PosW, g_EyePosW);
    
    // 標準化曲面細分因子
    // TessFactor = 
    //   0, d >= g_MinTessDistance
    //   (g_MinTessDistance - d) / (g_MinTessDistance - g_MaxTessDistance), g_MinTessDistance <= d <= g_MaxTessDistance
    //   1, d <= g_MaxTessDistance
    float tess = saturate((g_MinTessDistance - d) / (g_MinTessDistance - g_MaxTessDistance));
    
    // [0, 1] --> [g_MinTessFactor, g_MaxTessFactor]
    vOut.TessFactor = g_MinTessFactor + tess * (g_MaxTessFactor - g_MinTessFactor);
    
    return vOut;
}

// DisplacementMapInstance_VS.hlsl
#include "Basic.hlsli"

// 頂點著色器
TessVertexOut VS(InstancePosNormalTangentTex vIn)
{
    TessVertexOut vOut;
    
    vOut.PosW = mul(float4(vIn.PosL, 1.0f), vIn.World).xyz;
    vOut.NormalW = mul(vIn.NormalL, (float3x3) vIn.WorldInvTranspose);
    vOut.TangentW = mul(vIn.TangentL, vIn.World);
    vOut.Tex = vIn.Tex;
    
    float d = distance(vOut.PosW, g_EyePosW);
    
    // 標準化曲面細分因子
    // TessFactor = 
    //   0, d >= g_MinTessDistance
    //   (g_MinTessDistance - d) / (g_MinTessDistance - g_MaxTessDistance), g_MinTessDistance <= d <= g_MaxTessDistance
    //   1, d <= g_MaxTessDistance
    float tess = saturate((g_MinTessDistance - d) / (g_MinTessDistance - g_MaxTessDistance));
    
    // [0, 1] --> [g_MinTessFactor, g_MaxTessFactor]
    vOut.TessFactor = g_MinTessFactor + tess * (g_MaxTessFactor - g_MinTessFactor);
    
    return vOut;
}


外殼著色器

回想上一章說的,常量外殼著色器對每個面片進行計算,並且它的任務是要輸出該面片的曲面細分因子。曲面細分因子將告訴鑲嵌器階段對該面片以怎樣的程度來進行鑲嵌處理。曲面細分因子計算的大部分工作是由頂點著色器所完成的,但仍有一部分的工作需要交給常量外殼著色器處理。特別地,我們通過對頂點曲面細分因子進行求平均值的方式來得到邊緣的曲面細分因子。至於內部的曲面細分因子,我們就隨意挑選了第一條邊的曲面細分因子。

PatchTess PatchHS(InputPatch<TessVertexOut, 3> patch,
                  uint patchID : SV_PrimitiveID)
{
    PatchTess pt;
	
    // 對每條邊的曲面細分因子求平均值,並選擇其中一條邊的作為其內部的
    // 曲面細分因子。基於邊的屬性來進行曲面細分因子的計算非常重要,這
    // 樣那些與多個三角形共享的邊將會擁有相同的曲面細分因子,否則會導
    // 致間隙的產生
    pt.EdgeTess[0] = 0.5f * (patch[1].TessFactor + patch[2].TessFactor);
    pt.EdgeTess[1] = 0.5f * (patch[2].TessFactor + patch[0].TessFactor);
    pt.EdgeTess[2] = 0.5f * (patch[0].TessFactor + patch[1].TessFactor);
    pt.InsideTess = pt.EdgeTess[0];
	
    return pt;
}

那些與多個三角形所共享的邊應當擁有相同的曲面細分因子,否則網格的裂紋可能會出現(見下圖)。舉個例子說下不計算曲面細分因子的情況,加入我們通過攝像機到三角形中心點的距離來計算內部曲面細分因子。然後我們將內部的曲面細分因子也設置到邊緣曲面細分因子上。如果兩個鄰接三角形擁有不同的內部曲面細分因子,它們的邊也將會擁有不同的曲面細分因子,從而導致在進行位移映射後會產生T型連接的裂紋效果。

可以看到,圖a展示了兩個三角形共享一條邊。圖b上面的三角形進行了一次邊緣細分,下面的三角形則沒有細分。圖c上面的三角形進行了一次內部細分,經過位移映射後,新產生的頂點被移走了(一般是向內移動),從而在兩個三角形之間產生了一條縫隙。

控制點外殼著色器以面片的控制點作為輸入,每次調用處理一個控制點並輸出。在本章示例項目中,控制點外殼著色器僅僅是將數據進行直傳:

// 外殼著色器
[domain("tri")]
[partitioning("fractional_odd")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(3)]
[patchconstantfunc("PatchHS")]
HullOut HS(InputPatch<TessVertexOut, 3> patch,
    uint i : SV_OutputControlPointID,
    uint patchId : SV_PrimitiveID)
{
    HullOut hOut;
	
	// 直傳
    hOut.PosW = patch[i].PosW;
    hOut.NormalW = patch[i].NormalW;
    hOut.TangentW = patch[i].TangentW;
    hOut.Tex = patch[i].Tex;
	
    return hOut;
}

域著色器

經過鑲嵌器創建出來的每個頂點都會有調用一次域著色器。在這裡我們將會對高度圖(即法線貼圖的Alpha通道部分)進行取樣,然後利用法向量對頂點偏移,從而完成整個位移映射的過程。

// DisplacementMap_DS.hlsl
#include "Basic.hlsli"

[domain("tri")]
VertexOutNormalMap DS(PatchTess patchTess,
             float3 bary : SV_DomainLocation,
             const OutputPatch<HullOut, 3> tri)
{
    VertexOutNormalMap dOut;
    
    // 對面片屬性進行插值以生成頂點
    dOut.PosW     = bary.x * tri[0].PosW     + bary.y * tri[1].PosW     + bary.z * tri[2].PosW;
    dOut.NormalW  = bary.x * tri[0].NormalW  + bary.y * tri[1].NormalW  + bary.z * tri[2].NormalW;
    dOut.TangentW = bary.x * tri[0].TangentW + bary.y * tri[1].TangentW + bary.z * tri[2].TangentW;
    dOut.Tex      = bary.x * tri[0].Tex      + bary.y * tri[1].Tex      + bary.z * tri[2].Tex;
    
    // 對插值後的法向量進行標準化
    dOut.NormalW = normalize(dOut.NormalW);
    
    //
    // 位移映射
    //
    
    // 基於攝像機到頂點的距離選取mipmap等級;特別地,對每個MipInterval單位選擇下一個mipLevel
    // 然後將mipLevel限制在[0, 6]
    const float MipInterval = 20.0f;
    float mipLevel = clamp((distance(dOut.PosW, g_EyePosW) - MipInterval) / MipInterval, 0.0f, 6.0f);
    
    // 對高度圖取樣(存在法線貼圖的alpha通道)
    float h = g_NormalMap.SampleLevel(g_Sam, dOut.Tex, mipLevel).a;
    
    // 沿著法向量進行偏移
    dOut.PosW += (g_HeightScale * (h - 1.0f)) * dOut.NormalW;
    
    // 生成投影紋理坐標
    dOut.ShadowPosH = mul(float4(dOut.PosW, 1.0f), g_ShadowTransform);
    
    // 投影到齊次裁減空間
    dOut.PosH = mul(float4(dOut.PosW, 1.0f), g_ViewProj);
    
    // 從NDC坐標[-1, 1]^2變換到紋理空間坐標[0, 1]^2
    // u = 0.5x + 0.5
    // v = -0.5y + 0.5
    // ((xw, yw, zw, w) + (w, w, 0, 0)) * (0.5, -0.5, 1, 1) = ((0.5x + 0.5)w, (-0.5y + 0.5)w, zw, w)
    //                                                      = (uw, vw, zw, w)
    //                                                      =>  (u, v, z, 1)
    dOut.SSAOPosH = (dOut.PosH + float4(dOut.PosH.ww, 0.0f, 0.0f)) * float4(0.5f, -0.5f, 1.0f, 1.0f);
    
    return dOut;
}

這裡值得注意的是,我們需要在域著色器中自行選擇mipmap等級。像素著色器中的方法Texture2D::Sample在域著色器中是不能使用的,所以我們必須使用Texture2D::SampleLevel方法並手工指定mipmap等級。

如果我們只是學了法線貼圖的話,到這裡基本上就了解的差不多了。但如果學了陰影映射和SSAO的話,那麼這裡就又多了兩個坑要填了。如果我們用了位移映射來繪製,那麼在繪製陰影的時候,也一樣要走一遍位移映射;對於SSAO來說也更是如此,如果不對SSAO寫入深度值的過程加入位移映射,那麼在正式繪製場景的時候就會因為像素深度值不一致而被剔除,從而導致了在運行龍書的SSAO Demo時,在開啟了位移映射之後,那些擁有法線貼圖的物體都沒有被畫出來的現象:

所以接下來做的事情就是體力活了,把DisplacementMap從頂點著色器到域著色器的實現原理也要搬運到繪製陰影圖的過程,以及在SSAO繪製法向量/深度緩衝區順便寫入深度/模板緩衝區的過程中。因為程式碼上高度相似,這裡我就只是列出本章新增的著色器文件列表:

BasicEffect SSAOEffect ShadowEffect
DisplacementMapObject_VS SSAO_NormalDepth_ObjectTess_VS ShadowObjectTess_VS
DisplacementMapInstance_VS SSAO_NormalDepth_InstanceTess_VS ShadowInstanceTess_VS
DisplacementMap_HS SSAO_NormalDepth_HS Shadow_DS
DisplacementMap_DS SSAO_NormalDepth_DS Shadow_HS

C++端程式碼實現

在本章中,與位移映射直接相關的類有BasicEffectSSAOEffectShadowEffect類,都是在前面的基礎上作的修改。然後GameObjectDraw也為此有所修改。GameApp類承擔了實現過程,和SSAO的相比繪製框架的變動比較小。這裡就不放出修改的部分了,讀者可以自行瀏覽。

網格細節問題

首先要注意的是,我們是對頂點進行位移映射。如果網格的三角形比較大,比如說只有4個頂點的地板,經過曲面細分後能生成的新頂點也比較有限,位移映射的效果就不明顯。為此,我們需要增大網格模型的頂點密集程度,意味著我們增大了高度圖的取樣點數目,以讓我們能夠逼近真實的地形。如果我們不走曲面細分,那我們就需要提前準備三角形密集的網格數據,這樣需要佔用比較多的顯示記憶體或記憶體。但即便是用了曲面細分,我們要權衡初始網格的頂點密集程度,以及經過曲面細分後的頂點密集程度如何。

在本章的示例中,我們的地面不再使用Geometry::CreatePlain,而是用Geometry::CreateTerrain來創建出更加精細的地面網格。由於一開始寫的Geometry::CreateCylinder它的側面三角形比較大,曲面細分後的頂點數目也不夠密集,為此我已經修改了它的實現,讓側面能夠支援分層的三角形。

演示

下面的動圖展示了基礎繪製、法線貼圖繪製、位移貼圖繪製下的區別,以及曲面細分前後網格的區別。

而下面的動圖則展示了不同的HeightScale下位移映射的效果。

DirectX11 With Windows SDK完整目錄

Github項目源碼

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