DirectX12(D3D12)基礎教程(十四)——使用WIC、Computer Shader顯示GIF動畫紋理(中)

4、Direct Computer基礎知識

  使用WIC完成了GIF幀的加載、解碼、轉換等操作之後,剩下的核心任務就是使用Direct Computer計算管線來進一步完成GIF幀預處理任務了。這裏有必要先了解一下Direct Computer的一些相關基礎知識。

4.1、Direct Computer簡介

  DirectCompute是一組通過COM形式封裝的API,可以使Windows平臺上的程序利用GPU進行並行化的通用計算。DirectCompute是DirectX的一部分。雖然DirectCompute最初在DirectX 11 API中得以實現,但支持DX10的GPU可以利用此API的一個子集進行通用計算,支持DX11的GPU則可以使用完整的DirectCompute功能。DirectX12中繼續保留並支持DirectComputer接口,並做了相應的功能擴展與性能提升。

  Direct Computer利用GPU上的並行計算能力,並且結合HLSL語言,實現了稱爲Computer Shader的通用計算爲目的的着色器。因此通過Direct Computer COM接口,以及Computer Shader就可以利用GPU獨立的完成一些通用計算爲目的的應用場合。這樣就把過去依託於傳統渲染管線的所謂GPGPU方式的一些計算任務,移植到專門的以通用計算爲目的的輕量級的Direct Computer通用計算管線上。這樣就可以獨立的使用Direct Computer。並且也可以和DirectX中的其它部分無縫融合。

4.2、Direct Computer編程基本框架

  在D3D12中Direct Computer的基本編程過程如下:

1、創建D3D12設備(與D3D無縫集成的基礎);
2、編寫並編譯Computer Shader,創建Direct Computer管線的PSO及根簽名;
3、創建數據緩衝(輸入及輸出緩衝);
4、創建數據緩衝視圖/描述符及描述符堆(主要用於操作數據);
5、錄製計算管線的命令列表,重點記錄Dispatch命令函數,並調用ExecuteCommandLists運行Computer Shader對數據進行計算;
6、獲取計算結果;
7、釋放資源;

  其中創建3D設備、準備數據緩衝、創建描述符及描述堆、還有創建PSO對象等操作與D3D12圖形渲染管線中的對應過程大同小異,就不多囉嗦了。之後的重點將放在Computer Shader和Disptch上面,因爲這是理解整個DirectComputer的核心知識。

4.3、Computer Shader簡介

  Computer Shader是使用HLSL語言編寫的用於計算數據(通用計算)的着色器程序。HLSL語言採用類似C/C++語法設計,但不支持指針,細節上則跟C/C++差別較大。Computer Shader傳承HLSL語法的精髓,並且爲通用計算做了很多必要的改進,所以在一些細節語法上與其它Shader有一定區別。HLSL語言是嚴格區分大小寫的,註釋同C/C++註釋。

4.4、Computer Shader示例

Computer Shader 示例程序
  上面的例子代碼中#include命令含義與C/C++中的#include命令含義相同,就是包含一個頭文件,是HLSL中的預處理命令之一。

  StructuredBuffer可以理解爲是一個Computer Shader內置的模版類,<ST_BASEDATA>就是這個模版類的實例化參數,整句意思就是實例化一個由ST_BASEDATA結構體類型爲元素的結構化動態數組(緩衝/Buffer)。BaseDataBuffer就是上面模版類經實例化後自定義的對象名。

  register(t0)就是HLSL中的語義文法,用以說明冒號:前的對象(主要是數據/緩衝等)所在寄存器類型爲t(t = Texture)並且序號爲0。關於語義文法的目的,在本教程第十二篇中已經做了詳細描述,它其實就是爲了標明寄存器的使用策略,從而使的Shader編譯器可以將整個函數的調用過程全部從編譯結果中完整”拆除“,從而使HLSL中所有的函數最終都成爲”內聯“展開形式的。

  用寄存器直接標明變量之後,無論函數定義在哪裏(某個Shader文件中),或者在哪裏(另一個Shader文件中)被調用,編譯器(或者說鏈接器更確切)都可以輕鬆的知道某個數據其實就是一直在某個寄存器中,或者說某個寄存器就一直是某個數據,從而不用去互相之間引用源代碼。

  類似的,RWStructuredBuffer就是一個可讀寫的結構化緩衝的模版類,其餘要素與前述普通結構化緩衝(StructuredBuffer)相同。主要區別是,普通緩衝(StructuredBuffer)只能讀取,而RWStructuredBuffer可以讀寫,所以它通常被用來當作輸出緩衝。

  [numthreads(1, 1, 1)]是HLSL中函數的擴展語義文法說明,用以說明組織計算的GPU線程塊(Thread Box)的最小單元(後面詳述)。這也是Computer Shader中最重要最核心的一個函數語義文法(也被稱爲函數屬性文法,懂編譯原理的話就知道語義可以在抽象語法樹中用擴展屬性文法來描述,因此往往對二者不做詳細區分,即說語義也可以,說屬性也可以),是區別於別的Shader的重要標誌。
  void CSMain( uint3 DTid : SV_DispatchThreadID ){…}是Computer Shader的主函數。作用與C/C++中的main函數類似,是計算着色器過程開始的地方。

DataBuffer[DTid.x].m_dbAmount
    = BaseDataBuffer[DataBuffer[DTid.x].m_iType-1].m_dbPrice * DataBuffer[DTid.x].m_dbCount;

  是Computer Shader程序中的語句。例子中是簡單的數組下標表達式語句。語義與C/C++中等價的數組下標表達式類似;

  在例子中所有冒號:之後的語法部分以及類似CSMain函數開頭的以方括號[]包圍的部分就是之前已經提到過的語義文法說明了。它的核心目的就是爲了方便Shader編譯器把所有的HLSL程序中的函數都徹底變成“內聯展開”形式,以適應GPU運行的特殊架構要求。

  本質上這樣的語法一般是一門語言中比較低級的語法部分,這裏的低級的說法類似於說彙編語言相較於高級語言如C++、Java等是低級語言時的含義,表示更貼近硬件,或者說保留了一些硬件要求的特徵在裏面的意思。類似的語法設計還有C/C++中的指針相關語法等,這也是語言中比較低級的部分,或者說更貼近硬件特性的部分,因爲指針表示的就是內存中的地址。這些語法特性的加入,使得我們要想徹底掌握這些語法,就必須要瞭解相當多的硬件知識。這也是爲什麼在本系列教程中,一直反覆的講解很多必要的GPU相關硬件知識的原因。否則,對於這些特殊的語法要素,學習掌握就會不徹底,只知其然,不知其所以然。

  進一步的,擴展來說,我把多線程的一些相關知識,包括GPU線程的知識也歸結爲此類。總之,就是說要徹底的掌握關於Shader編程的所有方面,就必須要對CPU、GPU等等的硬件知識有相當的瞭解。因此在本系列教程中,會不斷的穿插講解很多必要的相關的硬件知識,當然要想進一步瞭解,就建議大家再擴展閱讀很多這方面其它的相關資料。

4.5、[numthreads(x, y, z)]語義文法詳解

  [numthreads(x,y,z)]屬性語句是理解Computer Shader並行計算的核心。該語句定義了可執行的線程塊(也稱線程盒,Thread Box)線程數量,其中每個線程可以被稱爲一個Thread Item(線程項)。當一個Computer Shader被分派執行的時候。線程盒可以理解爲一個由x,y,z座標爲參數的線程項組成的線程盒(立方體,threads box/threads cube)。一個線程盒中的線程總數就是xyzx*y*z

  在理解上,首先numthreads中的x、y、z參數指定了其修飾的Computer Shader主函數(入口函數/入口點)所代表的線程項組成的線程盒中,或者說是線程三維數組中,每個維度下標的上限。

  其次要理解的是,這裏雖然可以將線程盒理解爲線程三維數組,但是不能指定某個上限最小值爲0,即不能指定類似numthreads[0,y,z]或numthreadsp[x,0,z]等形式的語義說明,也就是說任何一維最小上限只能指定爲1,表示該維度最少有”一片“線程被執行。這與指定一般的數組時一樣,即不能定義0維度大小的數組。因此當只需要一個線程項執行的時候就需要指定語義爲numthreads[1,1,1],這也是一種較常用的形式(後面還會詳細解釋)。

  又次,當指定某個維度的上限爲1時,就相當於忽略這個維度。比如當只需要2維線程盒來參與計算時,可以指定屬性爲numthreads[x,y,1]這種形式,其中x,y可以根據算法需要指定一個大於0的整數。

  再次要理解的就是在線程盒中,也就是在線程三維數組中,任何一個元素(Thread Item)都是一個執行被語義修飾的Computer Shader主函數的GPU子線程。

  最後需要理解的就是,Computer Shader主函數及子函數中一般以這個語義指定的維度大小來索引一組數據(常常是數組)。方法就是將某個uint3(HLSL中的向量數據類型)類型入口參數用SV_GroupThreadID語義修飾後用作數組元素的下標索引。比如:

[numthreads(4, 4, 2)]
void CSMain( uint3 n3ThreadBoxIndex : SV_GroupThreadID)
{
    ...
    anydata[n3ThreadBoxIndex.xyz] = ...;
    ...
}

  代碼中anydata[n3ThreadBoxIndex.xyz]的含義類似於c++代碼中:

anydata[n3ThreadBoxIndex.x][n3ThreadBoxIndex.y][n3ThreadBoxIndex.z]

  這樣數組下標的上限就分別對應是4-1=3、4-1=3、2-1=1,索引範圍分別是[0-3]、[0-3]、[0-1]。而在代碼中之所以用xyz來代用的意思就是說GPU在具體執行時會總共調用:
442=32 4*4*2=32
個線程,而每個CSMain函數入口參數n3ThreadBoxIndex的xyz分量都被指定了一個對應的“座標”。下圖更形象的解釋了這個含義:
Computer Shader Thread Box
  從圖中就可以明白的看出一個線程盒或者說線程三維數組的結構原理了,也就明白了SV_GroupThreadID參數的含義,它就是一個線程盒內部的具體每個線程項的座標,可以直接被用來索引一個三維的數組,或者索引小於三維的二維或一維數組。

  這樣當一個並行算法,比如固定維度的4D顏色值的簡單混色乘法,需要一個維度的下標量索引的時候,就可以使用這個方式來索引,此時可以定義numthreads的第一個分量上限爲1,代碼上基本就可以套用公式如下來寫了:

StructuredBuffer<float4> colorA;
StructuredBuffer<float4> colorB;
RWStructuredBuffer<float4> colorC;
[numthreads(4, 1, 1)]
void CS_Color_mul( uint3 n3ThreadBoxIndex : SV_GroupThreadID)
{
    ...
    colorC[n3ThreadBoxIndex.x] = colorA[n3ThreadBoxIndex.x]*colorB[n3ThreadBoxIndex.x];
    ...
}

  上面示例代碼中,colorC、colorA、colorB是4D顏色向量的數組,因爲線程盒函數屬性中只有第一維不爲0,所以就可以忽略y、z兩個維度的索引,只保留使用x索引即可,這是n3ThreadBoxIndex.x的值範圍就是0-3,正好用來索引每個color數組元素的4個分量。

  最終在運行時爲每個color數組元素啓動一個具有411=44*1*1=4個線程的線程盒,而這個線程盒中的所有線程項都是並行執行的。綜合起來,線程項運行以及索引數據的示意圖如下所示:
線程盒線程項索引數據示意圖
  從圖中可以看出第i個線程盒中的每個線程項都並行運行,並且每一個都索引到了一個顏色分量數據。當然實際中,4D的顏色分量完全可以在一個線程項中用rgba下標來一次性操作,因爲最終GPU上的ALU是按照操作最小4個分量的向量來設計的,所以在一個線程項中完全可以一次性操作一個4D向量的所有分量,甚至可以操作4*4大小的矩陣,這本來就是3D圖形計算中的基本要求。這裏之所以示例這樣,完全是爲了演示一個線程盒中不同的線程項可以操作具體一個數據項的不同分量而設計的,並且可以根據數據項(或複雜結構體數組元素)的維度大小來定義具體線程盒的大小,這樣就可以一個線程盒作爲操作一個多維數據項的最小單元,其內部維度根據數據項的維度來確定,這樣在一些有精細結構的數據項中,就可以利用GPU的線程盒來並行操作每一個最小的數據元。這也是一般的使用Computer Shader操作數據時的思路。

4.6、通過Dispatch來批量發起GPU線程盒

  有了上面這個最簡單的操作顏色分量的線程盒的Computer Shader的示意性例子,那麼接着需要做的就是在程序的C++代碼中調用DirectComputer接口函數Dispatch來啓動它們。這有點像在程序中最終通過調用Draw Call來啓動渲染過程一樣。Dispatch函數現在是圖形命令列表(ID3D12GraphicsCommandList)接口的一個函數,原型如下:

void ID3D12GraphicsCommandList::Dispatch(
  UINT ThreadGroupCountX,
  UINT ThreadGroupCountY,
  UINT ThreadGroupCountZ
);

  通過這個函數的聲明,應該立即發現一個新的名詞Thread Group,這又是什麼意思呢?貌似它也有X、Y、Z三個座標,同時作爲函數的參數。其實這就是說Dispatch按照X、Y、Z三個座標,告訴GPU將線程盒再組織成一個三維線程組的形式。形象的可以將每個線程盒想象成一個集裝箱,而Dispatch的意思就是以怎樣的三維形式擺放這些集裝箱。最終Dispatch和線程盒中的numthreads語義說明共同構成了兩個層面的三維線程項結構。舉個形象的例子來理解就是線程盒是一套房子,線程項是這套房子中的一個房間,而Dispatch則將這些房子組合成了一棟樓!

  接着以前面的那個操作顏色分量的線程盒的例子,那麼假設需要操作1024個4D顏色組成的數組,這時Dispatch可以如下調用:

pICommandList->Dispatch(1024,1,1);

  當這些線程盒運行後的情景就可以如下圖所示:
Dispatch Thread Box示意圖
  圖中最下面一行表示color數組,原本應該有三個分別表示colorA、colorB、colorC三個數組,這裏爲了更簡化,方便大家理解,所以只畫了一個來表示。這些顏色數組都有1024個元素,每個元素都有4個分量。對應到每個線程盒就操作數組中的一個對應元素。同樣例子中僅展示了Dispatch第一維爲1024個線程盒的情況,這樣線程盒就排成了一維的結構,這樣安排是爲了方便大家形象的先理解低維的情況,畢竟理解3維數組有時候都比較麻煩,更何況這裏的3維線程盒還嵌套兩層3D結構。所以這裏大家先理解兩個一維結構所表示的意思,也爲理解更高維的情況打下基礎。

  在此基礎上,大家可以把每個4D顏色分量想象成是2D圖片上的一個像素,也就是4D顏色數據組成了2D數組,接着還是我們的操作4個不同顏色分量的線程盒來完成顏色混合操作,那麼Dispatch應該怎樣調用呢?這個問題就留給大家先思考了,後面我們在介紹如何操作GIF幀的時候就會明白具體要怎麼弄了。當然如果你已經明白了,那就給自己點個贊先!

4.7、SV_GroupID、SV_DispatchThreadID、SV_GroupIndex語義

  在理解了線程盒以及用Dispath發起線程盒的運行之後,接着讓我們再來看下其它的幾個重要的語義,以及用它們所修飾的參數的具體含義。

  理解了之前的例子之後,讓我們來思考一個問題,假如將線程盒想象成了集裝箱,並且用Dispatch將它們按照3維方式擺放在了一起,那麼在每個線程盒內部怎麼知道它所在的集裝箱整體上在什麼位置呢?其實這等同於在Thread Box內接受Dispatch設定的維度上限範圍內的具體線程盒的三維索引。這時SV_GroupID語義就派上用場了,也就是在Computer Shader主函數的參數列表中,再加入一個uint3類型的參數,並用SV_GroupID語義修飾說明,具體如下所示:

[numthreads(4, 1, 1)]
void CS_Color_mul( uint3 n3GroupID : SV_GroupID,uint3 n3ThreadBoxIndex : SV_GroupThreadID)
{
    ...
}

  如上代碼中聲明瞭這個參數之後,那麼在Computer Shader主函數中就可以引用其x,y,z分量來明確知道當前線程盒所在的具體索引位置。與SV_GroupThreadID語義參數類似,通常可以使用這個參數來索引全局的大的數據項,比如索引全局的結構化緩衝中的大的數據項,或者索引一個3D Texture中的具體某個像素。而SV_GroupID語義參數的上限就是調用Dispatch函數中指定的值。即SV_GroupID語義參數的索引範圍是從0到Dispatch函數對應參數的X-1,Y-1,Z-1。因此當Dispatch某個參數爲1時,即當前這個維度只有一個索引,那麼就可以在Computer Shader的線程盒函數中忽略它,或者可以不用明確指定它。

  另外在Computer Shader線程盒函數中還可以使用SV_DispatchThreadID語義參數和SV_GroupIndex語義參數。

  SV_DispatchThreadID語義參數就相當於將線程盒內的一個線程項(Thread Item)的局部座標(SV_GroupThreadID語義參數),偏移到由Dispatch發起的整體的全部線程盒所組成的線程組的全局座標系中,具體計算公式如下所示:

uint3 n3GlobalThreadItem:SV_DispatchThreadID;
uint3 n3ThreadItem:SV_GroupThreadID;
uint3 n3Group;
[numthreads(X,Y,Z)];

n3GlobalThreadItem.xyzn3Group.xyzuint3(X,Y,Z)+n3ThreadItem.xyz; n3GlobalThreadItem.xyz \equiv n3Group.xyz * uint3(X,Y,Z) + n3ThreadItem.xyz;

  注意公式中uint3(X,Y,Z)uint3(X,Y,Z)表示numthreads語義說明中的三個上限,即線程盒(Thread Box)的大小,其中的乘法是指簡單的分量相乘,不是向量內積也不是外積,而是類似顏色的分量相乘。整個公式的含義就是將整個線程組按照線程盒大小放大,然後在加上具體的線程項的座標,就得到了線程項在整個大的線程組中的索引位置。
 而SV_GourpIndex語義參數類型是uint,就是將線程項複雜的全局3維座標計算爲等價的1維座標偏移,這與在C++代碼中計算高維數組的一維等價座標的方法是一樣的,具體的公式如下所示:
nGroupIndex=n3ThreadItem.z(numthreads.xnumthread.y)+n3ThreadItem.ynumthreads.x+n3ThreadItem.z nGroupIndex = n3ThreadItem.z*(numthreads.x*numthread.y) + n3ThreadItem.y*numthreads.x + n3ThreadItem.z
  上述最後兩個語義參數一般並不常用。因爲在Computer Shader中通常都是使用結構化數組來描述數據,所以使用SV_GroupID來索引數組元素,而使用SV_GroupThreadID來索引一個數組元素中的子數據項即可。這也是使用Computer Shader進行數據的並行通用計算時的一般思路。

  在MSDN中用了一副圖來說明這四個語義參數的含義,在這裏引用一下:
threadgroupids
  這裏要注意的是MSDN中是將本文中說的Thread Box稱之爲Thread Group,而Dispatch形成的線程組稱之爲Thread Groups,同時將線程項(Thread Item)稱之爲Thread。爲了區分CPU線程和GPU線程(或者稱之爲Computer Shader線程),同時因爲中文詞法中沒有類似英文中的單數複數的說法,所以本文特意都轉義成了線程組(或者線程盒組更確切)、線程盒、線程項這樣的說法,方便大家理解和記憶。當然這是非官方非正式的說法,純屬一種習慣。當然我是參考了一下OpenCL中的一些叫法。終極目的就是爲了方便大家的理解。畢竟理解了才能應用。而我認爲MSDN中的叫法,以及這四個語義的命名都是畢竟令人迷惑的說法,所以就大膽的轉義一下,或者用現在流行的說法就是“說人話”。

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