前言
由於透明混合在不同的繪制順序下結果會不同,這就要求繪制前要對物體進行排序,然后再從后往前渲染。但即便是僅渲染一個物體(如上一章的水波),也會出現透明繪制順序不對的情況,普通的繪制是無法避免的。如果要追求正確的效果,就需要對每個像素位置對所有的像素按深度值進行排序。本章將介紹一種僅DirectX11及更高版本才能實現的順序無關的透明度(Order-Independent Transparency,OIT),雖然它是用像素着色器來實現的,但是用到了計算着色器里面的一些相關知識。
這一章綜合性很強,在學習本章之前需要先了解如下內容:
章節內容 |
---|
11 混合狀態 |
12 深度/模板狀態、平面鏡反射繪制(僅深度/模板狀態) |
14 深度測試 |
24 Render-To-Texture(RTT)技術的應用 |
28 計算着色器:波浪(水波) |
深入理解與使用緩沖區資源(結構化緩沖區、字節地址緩沖區) |
學習目標:
- 熟悉內存模型、線程同步
- 熟悉順序無關透明度
DirectX11 With Windows SDK完整目錄
歡迎加入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文檔。像素着色階段也可以使用這些函數,允許它跨資源同步(注意像素着色器不支持組內共享內存)。
順序無關透明度
現在讓我們再回顧一下正確的透明計算法。對每一個像素來說,若當前的背景色為\(c_0\),然后待渲染的透明像素片元按深度值從大到小排序為\(c_1, c_2, ..., c_n\),透明度為\(a_1, a_2, ..., a_n\)則最終的像素顏色為:
在以往的繪制方式,我們無法控制透明像素片元的繪制順序,運氣好的話還能正確呈現,一旦換了視角就會出現問題。要是場景里各種透明物體交錯在一起,基本上無論你怎么換視角都無法呈現正確的混合效果。因此為了實現順序無關透明度,我們需要預先收集這些像素,然后再進行深度排序,最后再計算出正確的像素顏色。
逐像素使用鏈表(Per-Pixel Linked Lists)
Direct3D 11硬件為許多新的渲染算法打開了大門,尤其是對PS寫入UAV、附着在Buffer的原子計數器的支持,為Per-Pixel Linked Lists帶來了可能,它可以實現諸如OIT,間接陰影,動態場景的光線追蹤等。
但是,由於着色器只有按值傳遞,沒有指針和引用,在GPU是做不到使用基於指針或引用的鏈表的。為此,我們使用的數據結構是靜態鏈表,它可以在數組中實現,原本作為next
的指針則變成了下一個元素的索引值。
因為數組是一個連續的內存區域,我們還可以在一個數組中,存放多條靜態鏈表(只要空間足夠大)。基於這個思想,我們可以為每個像素創建一個鏈表,用來收集對應屏幕像素位置的待渲染的所有像素片元。
該算法需要歷經兩個步驟:
- 創建靜態鏈表。通過像素着色器,利用類似頭插法的思想在一個大數組中逐漸形成靜態鏈表。
- 利用靜態鏈表渲染。通過計算着色器,取出當前像素對應的鏈表元素,進行排序,然后將計算結果寫入到渲染目標。
創建靜態鏈表
首先需要強調的是,這一步需要的是像素着色器5.0而不是計算着色器5.0。因為這一步實際上是把原本要繪制到渲染目標的這些像素片元給攔截下來,放到靜態鏈表當中。而要寫入Buffer就需要允許像素着色器支持無序訪問視圖(UAV),只有5.0及更高的版本才能這樣做。
此外,我們還需要原子操作的支持,這也需要使用着色器模型5.0。
最后我們還需要創建兩個支持讀/寫的緩沖區,用於綁定到無序訪問視圖:
- 片元/鏈接緩沖區:該緩沖區存放的是片元數據和指向下一個元素的索引(即鏈接),並且由於它需要承擔所有像素的靜態鏈表,需要預留足夠大的空間來存放這些片元(元素數目通常為渲染目標像素總數的數倍)。因此該算法的一個主要開銷是GPU內存空間。其次,片元/鏈接緩沖區必須要使用結構化緩沖區,而不是多個有類型的緩沖區,因為只有
RWStructuredBuffer
才能夠開啟隱藏的計數器,而這個計數器又是實現靜態鏈表必不可少的一部分,它用於統計緩沖區已經存放的鏈表節點數目。 - 首節點偏移緩沖區:該緩沖區的寬度與高度渲染目標的一致,存放的元素是對應渲染目標像素在片元/鏈接緩沖區對應的靜態鏈表的首節點偏移。而且由於采用的是頭插法,指向的通常是當前像素最后一個寫入的片元位置。在使用之前我們需要定義-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_UNORM
或B8G8R8A8_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)
,拋棄小數部分正好可以用作同寬高紋理相同位置的索引。
緊接着是RWByteAddressBuffer
的InterlockedExchange
方法:
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_STENCIL
且NumUAVs != 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());
}
上面的代碼有兩個點要特別注意:
- 因為是透明物體,需要關閉背面消隱
- 因為沒有產生實際繪制,需要關閉深度寫入
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相關的代碼撤走才能調試。
練習題
- 嘗試改動HLSL代碼,將顏色壓縮成
R5G6B5_UNORM
(規定透明物體Alpha統一為0.5),然后再把深度值壓縮成16為規格化浮點數。同時也要改動C++端代碼來適配。
參考資料
- DirectX SDK Samples中的OIT
- OIT-and-Indirect-Illumination-using-DX11-Linked-Lists 演示文件
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報。