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

前言

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

這一章綜合性很強,在學習本章以前須要先了解以下內容:node

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

學習目標:git

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

DirectX11 With Windows SDK完整目錄github

Github項目源碼web

歡迎加入QQ羣: 727623616 能夠一塊兒探討DX11,以及有什麼問題也能夠在這裏彙報。算法

DirectCompute 內存模型

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

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

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

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文檔。像素着色階段也可使用這些函數,容許它跨資源同步(注意像素着色器不支持組內共享內存)。

順序無關透明度

如今讓咱們再回顧一下正確的透明計算法。對每個像素來講,若當前的背景色爲\(c_0\),而後待渲染的透明像素片元按深度值從大到小排序爲\(c_1, c_2, ..., c_n\),透明度爲\(a_1, a_2, ..., a_n\)則最終的像素顏色爲:
\[ 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的限制。所以要保證渲染效率,最好是可以減小透明物體的複雜程度、場景中透明物體的數目,必要時甚至是避免透明混合。

練習題

  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,以及有什麼問題也能夠在這裏彙報。

相關文章
相關標籤/搜索