DirectX11 With Windows SDK--29 計算着色器:內存模型、線程同步;實現順序無關透明度(OIT)

前言

由於透明混合在不同的繪製順序下結果會不同,這就要求繪製前要對物體進行排序,然後再從後往前渲染。但即便是僅渲染一個物體(如上一章的水波),也會出現透明繪製順序不對的情況,普通的繪製是無法避免的。如果要追求正確的效果,就需要對每個像素位置對所有的像素按深度值進行排序。本章將介紹一種僅DirectX11及更高版本才能實現的順序無關的透明度(Order-Independent Transparency,OIT),雖然它是用像素着色器來實現的,但是用到了計算着色器裏面的一些相關知識。

這一章綜合性很強,在學習本章之前需要先了解如下內容:

章節內容
11 混合狀態
12 深度/模板狀態、平面鏡反射繪製(僅深度/模板狀態)
14 深度測試
24 Render-To-Texture(RTT)技術的應用
28 計算着色器:波浪(水波)
深入理解與使用緩衝區資源(結構化緩衝區、字節地址緩衝區)

學習目標:

  1. 熟悉內存模型、線程同步
  2. 熟悉順序無關透明度

DirectX11 With Windows SDK完整目錄

Github項目源碼

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

DirectCompute 內存模型

DirectCompute提供了三種內存模型:基於寄存器的內存設備內存組內共享內存。不同的內存模型在內存大小、速度、訪問方式等地方有所區別。

基於寄存器的內存:它的訪問速度非常快,但是寄存器不僅有數目限制,寄存器指向的資源內部也有大小限制。如紋理寄存器(t#),常量緩衝區寄存器(b#),無序訪問視圖寄存器(u#),臨時寄存器(r#或x#)等。而且我們在使用寄存器時也不是直接指定某一個寄存器來使用,而是通過着色器對象(例如tbuffer,它是在GPU內部的內存,因此訪問速度特別快)對應到某一寄存器,然後使用該着色器對象來間接使用該寄存器的。而且這些寄存器是隨着着色器編譯出來後定死了的,因此寄存器的可用情況取決於當前使用的着色器代碼。

下面的代碼展示瞭如何聲明一個基於寄存器的內存:

tbuffer tb : register(t0)
{
	float weight[256];		// 可以從CPU更新,只讀
}

設備內存:通常指的是D3D設備創建出來的資源(如紋理、緩衝區),這些資源可以長期存在,只要引用計數不爲0。你可以給這些資源創建很大的內存空間來使用,並且你還可以將它們作爲着色器資源或者無序訪問資源綁定到寄存器中供使用。當然,這種作爲着色器資源的訪問速度還是沒有直接在寄存器上創建的內存對象來得快,因爲它是存儲在GPU外部的顯存中。儘管這些內存可以通過非常高的內部帶寬來訪問,但是在請求值和返回值之間也有一個相對較高的延遲。儘管無序訪問視圖可以用於在設備內存中實現與基於寄存器的內存相同的操作,但當執行頻繁的讀寫操作時,性能將會收到嚴重影響。此外,由於每個線程都可以通過無序訪問視圖讀取或寫入資源中的任何位置,這需要手動同步對資源的訪問,也可以使用原子操作,又或者定義一個合理的訪問方式避免出現多個線程訪問到設備內存的同一個數據。

組內共享內存:前面兩種內存模型是所有可編程着色階段都可使用的,但是group shared memory只能在計算着色器使用。它的訪問速度比設備內存資源快些,比寄存器慢,但是也有明顯的內存限制——每個線程組最多隻能分配32KB內存,供內部所有線程使用。組內共享的內存必須確定線程將如何與內存交互和使用內存,因此它還必須同步對該內存的訪問。這將取決於正在實現的算法,但它通常涉及到前面描述的線程尋址。

這三種類型的內存提供了不同的訪問速度和可用的大小,使得它們可以用於與其能力相匹配的不同情況,這也給計算着色器提供了更大的內存操作靈活性。下表則是對內存模型的總結:

內存模型 訪問速度 可用內存 使用方式
基於寄存器的內存 很快 聲明寄存器內存對象、全局變量
設備內存 通過特定視圖綁定到渲染管線
組內共享內存 較快 較小 僅支持計算着色器,在全局變量前面加groupshared

線程同步

由於大量線程同時運行,並且線程能夠通過組內共享內存或通過無序訪問視圖對應的資源進行交互,因此需要能夠同步線程之間的內存訪問。與傳統的多線程編程一樣,許多線程可用讀取和寫入相同的內存位置,存在寫後讀(Read After Write,簡稱RAW)導致內存損壞的危險。如何在不損失GPU並行性帶來的性能的情況下還能夠高效地同步這麼多線程?幸運的是,有幾種不同的機制可用用於同步線程組內的線程。

內存屏障(Memory Barriers)

這是一種最高級的同步技術。HLSL提供了許多內置函數,可用於同步線程組中所有線程的內存訪問。需要注意的是,它只同步線程組中的線程,而不是整個調度。這些函數有兩個不同的屬性。第一個是調用函數時線程正在同步的內存類別(設備內存、組內共享內存,還是兩者都有),第二個則指定給定線程組中的所有線程是否同步到其執行過程中的同一處。根據這兩個屬性,衍生出了下面這些不同版本的內置函數:

不帶組內同步 帶組內同步
GroupMemoryBarrier GroupMemoryBarrierWithGroupSync
DeviceMemoryBarrier DeviceMemoryBarrierWithGroupSync
AllMemoryBarrier AllMemoryBarrierWithGroupSync

這些函數中都會阻止線程繼續,直到滿足該函數的特定條件位置。其中第一個函數GroupMemoryBarrior()阻塞線程的執行,直到線程組中的所有線程對組內共享內存的所有寫入都完成。這用於確保當線程在組內共享內存中彼此共享數據時,所需的值在被其他線程讀取之前有機會寫入組內共享內存。這裏有一個很重要的區別,即着色器核心執行一個寫指令,而那個指令實際上是由GPU的內存系統執行的,並且寫入內存中,然後在內存中它將再次對其他線程可用。從開始寫入值到完成寫入到目標位置有一個可變的時間量,這取決於硬件實現。通過執行阻塞操作,直到這些寫操作被保證已經完成,開發人員可以確定不會有任何寫後讀錯誤引發的問題。

不過話說了那麼多,總得實踐一下。個人將雙調排序項目中BitonicSort_CS.hlsl第15行的GroupMemoryBarrierWithGroupSync()修改爲GroupMemoryBarrier(),執行後發現多次運行程序會出現一例排序結果不一致的情況。因此可以這樣判斷:GroupMemoryBarrier()僅在線程組內的所有線程組存在線程寫入操作時阻塞,因此可能會出現阻塞結束時絕大多數線程完成了共享數據寫入,但仍有少量線程甚至還沒開始寫入共享數據。因此實際上很少能夠見到他出場的機會。

然後是GroupMemoryBarriorWithGroupSync()函數,相比上一個函數,他還阻止那些先到該函數的線程執行,直到所有的線程都到達該函數才能繼續。很明顯,在所有組內共享內存都加載之前,我們不希望任何線程前進,這使它成爲完美的同步方法。

而第二對同步函數也執行類似的操作,只不過它們是在設備內存池上操作。這意味着在繼續執行着色器程序前,可以同步通過無序訪問視圖寫入資源的所有掛起內存的寫入操作。這對於同步更大數目的內存更爲有用,如果所需的共享存儲器的大小太大不適合用組內共享內存,則可以將數據存在更大的設備內存的資源中。

第三對同步函數則是一起執行前面兩種類型的同步,用於同時存在共享內存和設備內存的訪問和同步上。

原子操作

內存屏障對於同步線程中的所有線程非常有用。然而,在許多情況下,還需要較小規模的同步,可能一次只需要幾個線程。在其他情況下,線程應該同步的位置可能在同一個執行點,也可能不在同一個執行點(例如,當線程組中的不同線程執行異構任務時)。Shader Model 5引入了許多新的原子操作,可以在線程之間提供更細力度的同步。這樣在多線程訪問共享資源時,能夠確保所有其他線程都不能在統一時間訪問相同資源。原子操作保證該操作一旦開始,就一直運行到結束:

原子操作
InterlockedAdd
InterlockedMin
InterlockedMax
InterlockedOr
InterlockedAnd
InterlockedXor
InterlockedCompareStore
InterlockedCompareExchange
InterlockedExchange

原子操作也可以用於組內共享內存和資源內存。這裏舉個使用的例子,如果計算着色器程序希望保留遇到特定數據值的線程數的計數,那麼總計數可以初始化爲0,並且每個線程可以在組內共享內存(以最快的訪問速度)或資源(在調度調用之間持續存在)上執行InterLockedAdd函數。這些原子操作確保總計數正確遞增,而不會被不同線程重寫中間值。

每個函數都有其獨特的輸入要求和操作,因此在選擇合適的函數時應參考Direct3D 11文檔。像素着色階段也可以使用這些函數,允許它跨資源同步(注意像素着色器不支持組內共享內存)。

順序無關透明度

現在讓我們再回顧一下正確的透明計算法。對每一個像素來說,若當前的背景色爲c0c_0,然後待渲染的透明像素片元按深度值從大到小排序爲c1,c2,...,cnc_1, c_2, ..., c_n,透明度爲a1,a2,...,ana_1, a_2, ..., a_n則最終的像素顏色爲:
c=[ancn+(1an)...[a2c2+(1a2)[a1c1+(1a1)c0]...] c=[a_n c_n + (1 - a_n)...[a_2 c_2 + (1 - a_2)[a_1 c_1 + (1 - a_1)c_0]...]
在以往的繪製方式,我們無法控制透明像素片元的繪製順序,運氣好的話還能正確呈現,一旦換了視角就會出現問題。要是場景裏各種透明物體交錯在一起,基本上無論你怎麼換視角都無法呈現正確的混合效果。因此爲了實現順序無關透明度,我們需要預先收集這些像素,然後再進行深度排序,最後再計算出正確的像素顏色。

逐像素使用鏈表(Per-Pixel Linked Lists)

Direct3D 11硬件爲許多新的渲染算法打開了大門,尤其是對PS寫入UAV、附着在Buffer的原子計數器的支持,爲Per-Pixel Linked Lists帶來了可能,它可以實現諸如OIT,間接陰影,動態場景的光線追蹤等。

但是,由於着色器只有按值傳遞,沒有指針和引用,在GPU是做不到使用基於指針或引用的鏈表的。爲此,我們使用的數據結構是靜態鏈表,它可以在數組中實現,原本作爲next的指針則變成了下一個元素的索引值。

因爲數組是一個連續的內存區域,我們還可以在一個數組中,存放多條靜態鏈表(只要空間足夠大)。基於這個思想,我們可以爲每個像素創建一個鏈表,用來收集對應屏幕像素位置的待渲染的所有像素片元。

該算法需要歷經兩個步驟:

  1. 創建靜態鏈表。通過像素着色器,利用類似頭插法的思想在一個大數組中逐漸形成靜態鏈表。
  2. 利用靜態鏈表渲染。通過計算着色器,取出當前像素對應的鏈表元素,進行排序,然後將計算結果寫入到渲染目標。

創建靜態鏈表

首先需要強調的是,這一步需要的是像素着色器5.0而不是計算着色器5.0。因爲這一步實際上是把原本要繪製到渲染目標的這些像素片元給攔截下來,放到靜態鏈表當中。而要寫入Buffer就需要允許像素着色器支持無序訪問視圖(UAV),只有5.0及更高的版本才能這樣做。

此外,我們還需要原子操作的支持,這也需要使用着色器模型5.0。

最後我們還需要創建兩個支持讀/寫的緩衝區,用於綁定到無序訪問視圖:

  1. 片元/鏈接緩衝區:該緩衝區存放的是片元數據和指向下一個元素的索引(即鏈接),並且由於它需要承擔所有像素的靜態鏈表,需要預留足夠大的空間來存放這些片元(元素數目通常爲渲染目標像素總數的數倍)。因此該算法的一個主要開銷是GPU內存空間。其次,片元/鏈接緩衝區必須要使用結構化緩衝區,而不是多個有類型的緩衝區,因爲只有RWStructuredBuffer才能夠開啓隱藏的計數器,而這個計數器又是實現靜態鏈表必不可少的一部分,它用於統計緩衝區已經存放的鏈表節點數目。
  2. 首節點偏移緩衝區:該緩衝區的寬度與高度渲染目標的一致,存放的元素是對應渲染目標像素在片元/鏈接緩衝區對應的靜態鏈表的首節點偏移。而且由於採用的是頭插法,指向的通常是當前像素最後一個寫入的片元位置。在使用之前我們需要定義-1(若是uint則爲0xFFFFFFFF)爲達到鏈表末端,因此每次使用之前都需要初始化鏈接值爲-1.該緩衝區使用的是RWByteAddressBuffer,因爲它能夠支持原子操作

下圖展示了通過像素着色器創建靜態鏈表的過程:

看完這個動圖後其實應該基本上能理解了,可能你的腦海裏已經有了初步的代碼構造,但現在還是需要跟着現有的代碼學習才能實現。

首先放出實現該效果需要用到的常量緩衝區、結構體和函數:

// OIT.hlsli

cbuffer CBFrame : register(b6)
{
    uint g_FrameWidth;		// 幀像素寬度
    uint g_FrameHeight;		// 幀像素高度
    uint2 g_Pad2;
}

struct FragmentData
{
    uint Color;				// 打包爲R8G8B8A8的像素顏色
    float Depth;			// 深度值
};

struct FLStaticNode
{
    FragmentData Data;		// 像素片元數據
    uint Next;				// 下一個節點的索引
};

// 打包顏色
uint PackColorFromFloat4(float4 color)
{
    uint4 colorUInt4 = (uint4) (color * 255.0f);
    return colorUInt4.r | (colorUInt4.g << 8) | (colorUInt4.b << 16) | (colorUInt4.a << 24);
}

// 解包顏色
float4 UnpackColorFromUInt(uint color)
{
    uint4 colorUInt4 = uint4(color, color >> 8, color >> 16, color >> 24) & (0x000000FF);
    return (float4) colorUInt4 / 255.0f;
}

一個像素顏色的類型爲float4,要是用它作爲數據存儲到緩衝區會特別消耗顯存,因爲最終顯示到後備緩衝區的類型爲R8G8B8A8_UNORMB8G8R8A8_UNORM,要是能夠將其打包成uint型,就可以節省這部分內存到原來的1/4。

當然,更狠的做法是,如果已知所有透明物體的Alpha值相同(都爲0.5),那我們又可以將顏色壓縮成R5G6B5_UNORM,然後再把深度值壓縮成16爲規格化浮點數,這樣一個像素只需要一半的內存空間就能夠表達了,當然代價爲:顏色和深度都是有損的。

接下來是用於存儲像素片元的着色器:

#include "Basic.hlsli"
#include "OIT.hlsli"

RWStructuredBuffer<FLStaticNode> g_FLBuffer : register(u1);
RWByteAddressBuffer g_StartOffsetBuffer : register(u2);

// 靜態鏈表創建
// 提前開啓深度/模板測試,避免產生不符合深度的像素片元的節點
[earlydepthstencil]
void PS(VertexPosHWNormalTex pIn)
{
    // 省略常規的光照部分,最終計算得到的光照顏色爲litColor
    // ...
    
    // 取得當前像素數目並自遞增計數器
    uint pixelCount = g_FLBuffer.IncrementCounter();
    
    // 在StartOffsetBuffer實現值交換
    uint2 vPos = (uint2) pIn.PosH.xy;  
    uint startOffsetAddress = 4 * (g_FrameWidth * vPos.y + vPos.x);
    uint oldStartOffset;
    g_StartOffsetBuffer.InterlockedExchange(
        startOffsetAddress, pixelCount, oldStartOffset);
    
    // 向片元/鏈接緩衝區添加新的節點
    FLStaticNode node;
    // 壓縮顏色爲R8G8B8A8
    node.Data.Color = PackColorFromFloat4(litColor);
    node.Data.Depth = pIn.PosH.z;
    node.Next = oldStartOffset;
    
    g_FLBuffer[pixelCount] = node;
}

這裏面多了許多有趣的部分,需要逐一仔細講解一番。

首先是UAV寄存器,這裏要先留個印象,寄存器索引初值不能從0開始,具體的原因要留到講C++的某個API時才能說的明白。

來到PS,我們也可以給像素着色器添加屬性,就像上面的[earlydepthstencil]那樣。因爲在繪製透明物體之前我們已經繪製了不透明的物體,而不透明的物體會阻擋它後面的透明像素片元。雖然一般情況下深度測試是在像素着色器之後,但也希望能拒絕掉那些被遮擋的像素片元寫入到片元/鏈接緩衝區種。因此我們可以使用屬性[earlydepthstencil],把深度/模板測試提前到光柵化後,像素着色階段之前,這樣就可以有效剔除被遮擋的像素,既減小了性能開銷,又保證了渲染的正確。

然後是RWStructuredBuffer特有的方法IncrementCounter,它會返回當前的計數值,並給計數器+1.與之對應的逆操作爲DecrementCounter。它也屬於原子操作,因爲涉及到大量的線程要訪問一個計數器,必須要有相應的同步操作才能保證一個時刻只有一個線程訪問該計數器,從而確保安全性。

這裏又要再提一遍SV_POSITION,在作爲頂點着色器的輸出時,提供的是未經過透視除法的NDC座標;而作爲像素着色器的輸入時,它歷經了透視除法、視口變換,得到的是對應像素的座標值。比如說第233行,154列的像素對應的xy座標爲(232.5, 153.5),拋棄小數部分正好可以用作同寬高紋理相同位置的索引。

緊接着是RWByteAddressBufferInterlockedExchange方法:

void InterlockedExchange(
  in  uint dest,			// 目的地址
  in  uint value,			// 要交換的值
  out uint original_value	// 取出來的原值
);

你可以將其看作是一個寫入緩衝區的函數,同時它又吐出原來存儲的值。唯一要注意的是一切RWByteAddressBuffer的原子操作的地址值必須爲4的倍數,因爲它的讀寫單位都是32位的uint

實際渲染階段

現在我們需要讓片元/鏈接緩衝區和首節點偏移緩衝區都作爲着色器資源。因爲還需要準備一個存放渲染了場景中不透明物體的背景圖作爲混合初值,同時又還要將結果寫入到渲染目標,這樣的話我們還需要用到TextureRender類,存放與後備緩衝區等寬高的紋理,然後將場景中不透明的物體都渲染到此處。

對於頂點着色器來說,因爲是渲染整個窗口,可以直接傳頂點:

// OIT_Render_VS.hlsl
#include "OIT.hlsli"

// 頂點着色器
float4 VS(float3 vPos : POSITION) : SV_Position
{
    return float4(vPos, 1.0f);
}

而到了像素着色器,我們需要對當前像素對應的鏈表進行深度排序。由於訪問設備內存的效率相對較低,而且排序又涉及到頻繁的內存操作,在UAV進行鏈表排序的效率會很低。更好的做法是將所有像素拷貝到臨時寄存器數組,然後再做排序,這樣效率會更高,其實也就是在像素着色器開闢一個全局靜態數組來存放這些鏈表節點的元素。由於是靜態數組,數組元素固定,開闢較大的空間並不是一個比較好的選擇,這不僅涉及到排序的複雜程度,還涉及到顯存開銷。因此我們需要限制排序的像素片元數目,同時也意味着只需要讀取鏈表的前面幾個元素即可,這是一種比較折中的做法。

由於排序算法的好壞也會影響最終的效率,對於小規模的排序,可以使用插入排序,它不僅是原址操作,對於已經有序的序列不會有多餘的交換操作。又因爲是線程內的排序,不能使用雙調排序。

像素着色器的代碼如下:

// OIT_Render_PS.hlsl
#include "OIT.hlsli"

StructuredBuffer<FLStaticNode> g_FLBuffer : register(t0);
ByteAddressBuffer g_StartOffsetBuffer : register(t1);
Texture2D g_BackGround : register(t2);

#define MAX_SORTED_PIXELS 8

static FragmentData g_SortedPixels[MAX_SORTED_PIXELS];

// 使用插入排序,深度值從大到小
void SortPixelInPlace(int numPixels)
{
    FragmentData temp;
    for (int i = 1; i < numPixels; ++i)
    {
        for (int j = i - 1; j >= 0; --j)
        {
            if (g_SortedPixels[j].Depth < g_SortedPixels[j + 1].Depth)
            {
                temp = g_SortedPixels[j];
                g_SortedPixels[j] = g_SortedPixels[j + 1];
                g_SortedPixels[j + 1] = temp;
            }
            else
            {
                break;
            }
        }
    }
}



float4 PS(float4 posH : SV_Position) : SV_Target
{
    // 取出當前像素位置對應的背景色
    float4 currColor = g_BackGround.Load(int3(posH.xy, 0));
    
    // 取出當前像素位置鏈表長度
    uint2 vPos = (uint2) posH.xy;
    int startOffsetAddress = 4 * (g_FrameWidth * vPos.y + vPos.x);
    int numPixels = 0;
    uint offset = g_StartOffsetBuffer.Load(startOffsetAddress);
    
    FLStaticNode element;
    
    // 取出鏈表所有節點
    while (offset != 0xFFFFFFFF)
    {
        // 按當前索引取出像素
        element = g_FLBuffer[offset];
        // 將像素拷貝到臨時數組
        g_SortedPixels[numPixels++] = element.Data;
        // 取出下一個節點的索引,但最多隻取出前MAX_SORTED_PIXELS個
        offset = (numPixels >= MAX_SORTED_PIXELS) ?
            0xFFFFFFFF : element.Next;
    }
    
    // 對所有取出的像素片元按深度值從大到小排序
    SortPixelInPlace(numPixels);
    
    // 使用SrcAlpha-InvSrcAlpha混合
    for (int i = 0; i < numPixels; ++i)
    {
        // 將打包的顏色解包出來
        float4 pixelColor = UnpackColorFromUInt(g_SortedPixels[i].Color);
        // 進行混合
        currColor.xyz = lerp(currColor.xyz, pixelColor.xyz, pixelColor.w);
    }
    
    // 返回手工混合的顏色
    return currColor;
}

HLSL部分結束了,但C++端還有很多棘手的問題要處理。

OITRender類

在進行OIT像素收集時,需要通過替換像素着色器的手段來完成,因此它需要依附於BasicEffect,不好作爲一個獨立的Effect使用。在此先放出OITRender類的定義:

class OITRender
{
public:
	template<class T>
	using ComPtr = Microsoft::WRL::ComPtr<T>;

	OITRender() = default;
	~OITRender() = default;
	// 不允許拷貝,允許移動
	OITRender(const OITRender&) = delete;
	OITRender& operator=(const OITRender&) = delete;
	OITRender(OITRender&&) = default;
	OITRender& operator=(OITRender&&) = default;

	HRESULT InitResource(ID3D11Device* device, 
		UINT width,			// 幀寬度
		UINT height,		// 幀高度
		UINT multiple = 1); // 用多少倍於幀像素數的緩衝區存儲像素片元

	// 開始收集透明物體像素片元
	void BeginDefaultStore(ID3D11DeviceContext* deviceContext);
	// 結束收集,還原狀態
	void EndStore(ID3D11DeviceContext* deviceContext);
	
	// 將背景與透明物體像素片元混合完成最終渲染
	void Draw(ID3D11DeviceContext * deviceContext, ID3D11ShaderResourceView* background);

	void SetDebugObjectName(const std::string& name);

private:
	struct {
		int width;
		int height;
		int pad1;
		int pad2;
	} m_CBFrame;												// 對應OIT.hlsli的常量緩衝區
private:
	ComPtr<ID3D11InputLayout> m_pInputLayout;					// 繪製屏幕的頂點輸入佈局

	ComPtr<ID3D11Buffer> m_pFLBuffer;							// 片元/鏈接緩衝區
	ComPtr<ID3D11Buffer> m_pStartOffsetBuffer;					// 起始偏移緩衝區
	ComPtr<ID3D11Buffer> m_pVertexBuffer;						// 繪製背景用的頂點緩衝區
	ComPtr<ID3D11Buffer> m_pIndexBuffer;						// 繪製背景用的索引緩衝區
	ComPtr<ID3D11Buffer> m_pConstantBuffer;						// 常量緩衝區

	ComPtr<ID3D11ShaderResourceView> m_pFLBufferSRV;			// 片元/鏈接緩衝區的着色器資源視圖
	ComPtr<ID3D11ShaderResourceView> m_pStartOffsetBufferSRV;	// 起始偏移緩衝區的着色器資源視圖

	ComPtr<ID3D11UnorderedAccessView> m_pFLBufferUAV;			// 片元/鏈接緩衝區的無序訪問視圖
	ComPtr<ID3D11UnorderedAccessView> m_pStartOffsetBufferUAV;	// 起始偏移緩衝區的無序訪問視圖

	ComPtr<ID3D11VertexShader> m_pOITRenderVS;					// 透明混合渲染的頂點着色器
	ComPtr<ID3D11PixelShader> m_pOITRenderPS;					// 透明混合渲染的像素着色器
	ComPtr<ID3D11PixelShader> m_pOITStorePS;					// 用於存儲透明像素片元的像素着色器
	
	ComPtr<ID3D11PixelShader> m_pCachePS;						// 臨時緩存的像素着色器

	UINT m_FrameWidth;											// 幀像素寬度
	UINT m_FrameHeight;											// 幀像素高度
	UINT m_IndexCount;											// 繪製索引數
};

這裏不放出初始化的代碼,但在調用初始化的時候需要注意提供合理的幀像素的倍數,若設置的太低,則緩衝區可能不足以容納透明像素片元而渲染異常。

OITRender::BeginDefaultStore方法–在默認特效下收集像素片元

不管寫什麼渲染類,渲染狀態的管理是最複雜的,一處錯誤都會導致渲染結果的不理想。

該方法首先要解決兩個主要問題:UAV的初始化、綁定到像素着色階段。

ID3D11DeviceContext::ClearUnorderedAccessViewUint–使用特定值/向量設置UAV初始值

void ClearUnorderedAccessViewUint(
  ID3D11UnorderedAccessView *pUnorderedAccessView,	// [In]待清空UAV
  const UINT [4]            Values					// [In]清空值/向量
);

該方法對任何UAV都有效,它是以二進制位的形式來清空值。若爲DXGI特定類型,如R16G16_UNORM,則該方法會根據Values的前兩個元素取出各自的低16位分別複製到每個數組元素的x分量和y分量。若爲原始內存的視圖或結構化緩衝區的視圖,則只取Values的第一個元素來複制到緩衝區的每一個4字節內。

ID3D11DeviceContext::OMSetRenderTargetsAndUnorderedAccessViews–輸出合併階段設置渲染目標並設置UAV

既然像素着色器能夠使用UAV,一開始找了半天都沒找到ID3D11DeviceContext::PSSetUnorderedAccessViews,結果發現居然是在OM階段的函數提供UAV綁定。

void ID3D11DeviceContext::OMSetRenderTargetsAndUnorderedAccessViews(
  UINT                      NumRTVs,						// [In]渲染目標數
  ID3D11RenderTargetView    * const *ppRenderTargetViews,	// [In]渲染目標視圖數組
  ID3D11DepthStencilView    *pDepthStencilView,				// [In]深度/模板視圖
  UINT                      UAVStartSlot,					// [In]UAV起始槽
  UINT                      NumUAVs,						// [In]UAV數目
  ID3D11UnorderedAccessView * const *ppUnorderedAccessViews,	// [In]無序訪問視圖數組
  const UINT                *pUAVInitialCounts					// [In]各個無序訪問視圖的計數器初始值
);

前三個參數和後三個參數應該都沒什麼問題,但中間的那個參數是一個大坑。對於像素着色器,UAVStartSlot應當等於已經綁定的渲染目標視圖數目。渲染目標和無序訪問視圖在寫入的時候共享相同的資源槽,這意味着必須爲UAV指定偏移量,以便於它們放在待綁定的渲染目標視圖之後的插槽中。因此在前面的HLSL代碼中,u寄存器需要從1開始就是這裏來的。

注意:RTV、DSV、UAV不能獨立設置,它們都需要同時設置。

兩個綁定了同一個子資源(也因此共享同一個紋理)的RTV,或者是兩個UAV,又或者是一個UAV和RTV,都會引發衝突。

OMSetRenderTargetsAndUnorderedAccessViews在以下情況才能運行正常:

NumRTVs != D3D11_KEEP_RENDER_TARGETS_AND_DEPTH_STENCILNumUAVs != D3D11_KEEP_UNORDERED_ACCESS_VIEWS時,需要滿足下面這些條件:

  • NumRTVs <= 8
  • UAVStartSlot >= NumRTVs
  • UAVStartSlot + NumUAVs <= 8
  • 所有設置的RTVs和UAVs不能有資源衝突
  • DSV的紋理必須匹配RTV的紋理(但不是相同)

NumRTVs == D3D11_KEEP_RENDER_TARGETS_AND_DEPTH_STENCIL時,說明OMSetRenderTargetsAndUnorderedAccessViews只綁定UAVs,需要滿足下面這些條件:

  • UAVStartSlot + NumUAVs <= 8
  • 所有設置的UAVs不能有資源衝突

它還會解除綁定下面這些東西:

  • 所有在slots >= UAVStartSlot的RTVs
  • 所有與待綁定的UAVs發生資源衝突的RTVs
  • 所有當前綁定的資源(SOTargets,CS UAVs, SRVs)衝突的UAVs

提供的深度/模板緩衝區會被忽略,並且已經綁定的深度/模板緩衝區並沒有被卸下。

NumUAVs == D3D11_KEEP_UNORDERED_ACCESS_VIEWS時,說明OMSetRenderTargetsAndUnorderedAccessViews只綁定RTVs和DSV,需要滿足下面這些條件

  • NumRTVs <= 8

  • 這些RTVs相互沒有資源衝突

  • DSV的紋理必須匹配RTV的紋理(但不是相同)

它還會解除綁定下面這些東西:

  • 所有在slots < NumRTVs的UAVs

  • 所有與待綁定的RTVs發生資源衝突的UAVs

  • 所有當前綁定的資源(SOTargets,CS UAVs, SRVs)衝突的RTVs

    提供的UAVStartSlot忽略。

現在可以把目光放回到OITRender::BeginDefaultStore上:

void OITRender::BeginDefaultStore(ID3D11DeviceContext* deviceContext)
{
	deviceContext->RSSetState(RenderStates::RSNoCull.Get());
	
	UINT numClassInstances = 0;
	deviceContext->PSGetShader(m_pCachePS.GetAddressOf(), nullptr, &numClassInstances);
	deviceContext->PSSetShader(m_pOITStorePS.Get(), nullptr, 0);

	// 初始化UAV
	UINT magicValue[1] = { 0xFFFFFFFF };
	deviceContext->ClearUnorderedAccessViewUint(m_pFLBufferUAV.Get(), magicValue);
	deviceContext->ClearUnorderedAccessViewUint(m_pStartOffsetBufferUAV.Get(), magicValue);
	// UAV綁定到像素着色階段
	ID3D11UnorderedAccessView* pUAVs[2] = { m_pFLBufferUAV.Get(), m_pStartOffsetBufferUAV.Get() };
	UINT initCounts[2] = { 0, 0 };
	deviceContext->OMSetRenderTargetsAndUnorderedAccessViews(D3D11_KEEP_RENDER_TARGETS_AND_DEPTH_STENCIL,
		nullptr, nullptr, 1, 2, pUAVs, initCounts);

	// 關閉深度寫入
	deviceContext->OMSetDepthStencilState(RenderStates::DSSNoDepthWrite.Get(), 0);
	// 設置常量緩衝區
	deviceContext->PSSetConstantBuffers(6, 1, m_pConstantBuffer.GetAddressOf());
}

上面的代碼有兩個點要特別注意:

  1. 因爲是透明物體,需要關閉背面消隱
  2. 因爲沒有產生實際繪製,需要關閉深度寫入

OITRender::EndStore方法–結束收集

方法如下:

void OITRender::EndStore(ID3D11DeviceContext* deviceContext)
{
	// 恢復渲染狀態
	deviceContext->PSSetShader(m_pCachePS.Get(), nullptr, 0);
	ComPtr<ID3D11RenderTargetView> currRTV;
	ComPtr<ID3D11DepthStencilView> currDSV;
	ID3D11UnorderedAccessView* pUAVs[2] = { nullptr, nullptr };
	deviceContext->OMSetRenderTargetsAndUnorderedAccessViews(D3D11_KEEP_RENDER_TARGETS_AND_DEPTH_STENCIL,
		nullptr, nullptr, 1, 2, pUAVs, nullptr);
	m_pCachePS.Reset();
}

OITRender::Draw方法–對透明像素片元進行排序混合並完成繪製

方法如下,到這一步其實已經沒那麼複雜了:

void OITRender::Draw(ID3D11DeviceContext* deviceContext, ID3D11ShaderResourceView* background)
{

	UINT strides[1] = { sizeof(VertexPos) };
	UINT offsets[1] = { 0 };
	deviceContext->IASetVertexBuffers(0, 1, m_pVertexBuffer.GetAddressOf(), strides, offsets);
	deviceContext->IASetIndexBuffer(m_pIndexBuffer.Get(), DXGI_FORMAT_R32_UINT, 0);

	deviceContext->IASetInputLayout(m_pInputLayout.Get());
	deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

	deviceContext->VSSetShader(m_pOITRenderVS.Get(), nullptr, 0);
	deviceContext->PSSetShader(m_pOITRenderPS.Get(), nullptr, 0);

	deviceContext->GSSetShader(nullptr, nullptr, 0);
	deviceContext->RSSetState(nullptr);

	ID3D11ShaderResourceView* pSRVs[3] = {
		m_pFLBufferSRV.Get(), m_pStartOffsetBufferSRV.Get(), background};
	deviceContext->PSSetShaderResources(0, 3, pSRVs);
	deviceContext->PSSetConstantBuffers(6, 1, m_pConstantBuffer.GetAddressOf());

	deviceContext->OMSetDepthStencilState(nullptr, 0);
	deviceContext->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF);

	deviceContext->DrawIndexed(m_IndexCount, 0, 0);

	// 繪製完成後卸下綁定的資源即可
	pSRVs[0] = pSRVs[1] = pSRVs[2] = nullptr;
	deviceContext->PSSetShaderResources(0, 3, pSRVs);

}

場景繪製

現在場景中除了山體、波浪,還有兩個透明相交的立方體。只考慮開啓OIT的GameApp::DrawScene方法如下:

void GameApp::DrawScene()
{
	assert(m_pd3dImmediateContext);
	assert(m_pSwapChain);

	m_pd3dImmediateContext->ClearRenderTargetView(m_pRenderTargetView.Get(), reinterpret_cast<const float*>(&Colors::Silver));
	m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
	
	// 渲染到臨時背景
	m_pTextureRender->Begin(m_pd3dImmediateContext.Get(), reinterpret_cast<const float*>(&Colors::Silver));
    {
        // ******************
		// 1. 繪製不透明對象
		//
		m_BasicEffect.SetRenderDefault(m_pd3dImmediateContext.Get(), BasicEffect::RenderObject);
		m_BasicEffect.SetTexTransformMatrix(XMMatrixIdentity());
		m_Land.Draw(m_pd3dImmediateContext.Get(), m_BasicEffect);
    
    	// ******************
		// 2. 存放透明物體的像素片元
		//
		m_pOITRender->BeginDefaultStore(m_pd3dImmediateContext.Get());
    	{
			m_RedBox.Draw(m_pd3dImmediateContext.Get(), m_BasicEffect);
			m_YellowBox.Draw(m_pd3dImmediateContext.Get(), m_BasicEffect);
			m_pGpuWavesRender->Draw(m_pd3dImmediateContext.Get(), m_BasicEffect);
    	}
        m_pOITRender->EndStore(m_pd3dImmediateContext.Get());
    }
	m_pTextureRender->End(m_pd3dImmediateContext.Get());
	
    // 渲染到後備緩衝區
	m_pOITRender->Draw(m_pd3dImmediateContext.Get(), m_pTextureRender->GetOutputTexture());

	// ******************
	// 繪製Direct2D部分
	//
	// ...

	HR(m_pSwapChain->Present(0, 0));
}

演示

下面演示了關閉OIT和深度寫入、關閉OIT但開啓深度寫入、開啓OIT下的場景渲染效果:

開啓OIT的平均幀數爲2700,而默認平均幀數爲4200。可見影響渲染性能的主要因素有:RTT的使用、場景中透明像素的複雜程度、排序算法的選擇和n的限制。因此要保證渲染效率,最好是能夠減少透明物體的複雜程度、場景中透明物體的數目,必要時甚至是避免透明混合。

調試問題

該項目無法圖形調試,就和DirectX SDK Samples中OIT樣例一樣,遇到了未知問題。如果要調試,需要把OIT相關的代碼撤走才能調試。

練習題

  1. 嘗試改動HLSL代碼,將顏色壓縮成R5G6B5_UNORM(規定透明物體Alpha統一爲0.5),然後再把深度值壓縮成16爲規格化浮點數。同時也要改動C++端代碼來適配。

參考資料

  1. DirectX SDK Samples中的OIT
  2. OIT-and-Indirect-Illumination-using-DX11-Linked-Lists 演示文件

DirectX11 With Windows SDK完整目錄

Github項目源碼

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章