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

5、用DirectComputer完成GIF幀預處理

  當看到這一小節的標題時,可能會有點蒙,爲什麼不把解析出來的幀直接當成紋理用?還需要“處理”什麼?其實仔細想想這不是那麼簡單的。前面已經囉嗦了很多關於GIF以及幀的屬性讀取的內容,並在其中介紹了不少關於GIF優化存儲策略方面的知識,從這些知識就可以想明白爲什麼不能直接把幀畫面當成紋理用了。因爲根據原理,GIF中的幀很可能是不完整的,也即GIF幀往往是隻存儲了變換部分圖像內容的一個子畫面而已,最終要呈現一幅完整的幀,至少還需要與背景或前一幀進行Alpha混色操作(也就是判斷最終畫面裏前一幀中某個像素要不要被當前幀覆蓋,或者說當前幀的某個像素是不是透明的,用不用顯示),這個過程纔是真正的完成了對GIF完整的一幀畫面的“解碼”。而“解碼”運算後的最終圖片,纔是完整的一副圖像,這樣顯示之後整個GIF動畫纔是正確的。

  如果明白了這個過程,這時來思考一個重要的問題,就是GIF幀究竟怎樣繪製到紋理上去,注意本章示例的終極目標就是把GIF用作紋理,而不是最終在屏幕(或渲染目標)上渲染GIF就完事。默默想兩分鐘這個問題,思考清楚問題的癥結,尤其是與一般的圖片甚至法線圖、Cube Map等等用作紋理,這裏究竟有什麼不同?

  其實重要的區別就是,一般的紋理無論內容究竟存儲的是什麼,也不論是不是有Mipmap或者Image Array等,基本都是“靜態的”,使用方法就是簡單的解碼之後通過“兩次Copy”操作,上傳到顯存中的默認堆上即可。而GIF幀作爲紋理時,它是“動態”的,主要體現在兩個方面,一方面就是剛纔描述的必須還要用幀的畫面繪製到背景(或者前一幀完整畫面)上形成一副完整的畫面然後才能用於紋理;另一方面就是必須要按照GIF幀要求的延時值順序顯示每一幀,從而形成GIF動畫效果。第二個方面,不是很複雜,無非就是反覆加載不同的紋理並渲染即可,在示例中也就是兩個Texture的不斷創建、銷燬以及對應的兩次Copy上傳操作而已,這個大家已經輕車熟路了,也就不再囉嗦。而第一個方面的問題,就是本章的重點了,也就是動態紋理的預計算問題,或者叫紋理預處理問題,這是一個很重要的基礎技術。

  首先可以想象一下,因爲在渲染管線中有一個Blend混合階段,貌似可以用來處理GIF幀繪製的問題,最多就是再結合下之前已經掌握的渲染到紋理的技術以及一般的基本UI渲染技術(偏移矩形框)即可。其實這樣的做法也可以,但是仔細一想會發現這樣做有點殺雞用牛刀的嫌疑,因爲渲染管線實在是太笨重了,只是解算一幀的畫面就需要動用整個管線,或者說至少要動用基本的VS、PS兩個Shader階段,還要根據需要打開Blend State,然後還有一堆的資源狀態要來回切換,所以這貌似不是最“簡潔”的方式。

  此時,不要忘了在D3D12中,還有一個DirectComputer管線可以加以利用,相較於渲染管線來說,它就簡單的多了,說白了它裏面純粹就是按照Computer Shader計算數據而已,沒有複雜的狀態需要管理或切換,也不用管什麼渲染目標之類的資源。而其實前述的關於GIF幀作爲”動態“紋理的預處理過程,表面上看是一個小矩形中的圖片繪製到一個大的背景上的問題,而實質上其實就是兩個二維數組的組合運算(一般是Alpha混色)操作而已!而這個計算過程完全可以放到DirectComputer管線中去處理。(注意前面講DirectComputer時提出而沒有回答的問題。)

  另外根據之前教程中所一再強調的那樣,現代的GPU架構一般都是多引擎架構,而且這些引擎間是可以完全並行執行的!這當中,除了重要的3D引擎外,就數計算引擎最重要了,而要使用計算引擎,就需要通過DirectComputer來操控計算引擎。

5.1、Computer Shader中以數組方式訪問紋理完成幀預處理

  在本章核心示例的Computer Shader中,爲了完成GIF幀畫面的預處理,就需要在Compter Shader中操作相應圖片畫面中的每個像素。

  而在一般的Shader中,要操作圖片,都是加載成紋理,然後使用採樣器來操作,也就是常說的紋理採樣操作,紋理座標都是歸一化的在[0-1]之間,這對於紋理的採樣以及顯示等操作來說是非常合適的選擇,這樣不但不用區別大小不同的紋理,同時正好方便線性插值等操作。

  但是對於本例中需要精確訪問每個像素並進行計算的GIF幀預處理操作來說,則是個很麻煩的事情。幸運的是,在Computer Shader中就可以以整數下標並以二維數組的方式來訪問紋理。具體的可以先像下面這樣來定義被應用的紋理:

// 幀紋理,尺寸就是當前幀畫面的大小,注意這個往往不是整個GIF的大小
Texture2D			g_tGIFFrame : register(t0);
// 最終代表整個GIF畫板紋理,大小就是整個GIF的大小
RWTexture2D<float4> g_tPaint	: register(u0);

  首先,在代碼中要先聲明從GIF中解析出來的一幀畫面對應的紋理,從代碼中可以看出,這與在其他的Shader中聲明一副紋理沒什麼區別。都是使用Texture2D數據類型,然後寄存器都是texture(t0)。後面會看到對它的訪問就與其它Shader中不一樣了。

  其次,第二行代碼中出現了一個新的數據類型RWTexture2D。如果之前的例子看明白了的話,這個數據類型就好理解了,它就是一副可以讀寫訪問的2D紋理。那麼它跟普通的只讀紋理唯一的區別其實就是可以寫入。並且要注意它放在了unorder access buffer(u0)類型的寄存器中,即無序訪問的緩衝。當然這裏說成是紋理更合適,以體現它是2D形式的緩衝的本質。這句代碼實質上就等於是定義了GIF的背景,像素大小就是整個GIF的大小,然後GIF的幀不斷的繪製在其上就形成了最終完整的GIF幀畫面。

  定義好了“畫板”(g_tPaint),以及將要被“複製”的GIF幀畫面(g_tGIFFrame),接着還需要將GIF中定義的怎麼“複製”畫面的操作(Offset、SubHW、Disposal)等參數傳入到Computer Shader中,這時只需要像在其他Shader中那樣定義一個常量緩衝區來接收這些參數即可,在本例中,定義了像下面這樣的常量緩衝:

cbuffer ST_GRS_GIF_FRAME_PARAM : register(b0)
{
	float4	m_c4BkColor;		
	uint2	m_nLeftTop;
	uint2	m_nFrameWH;
	uint	m_nFrame;
	uint	m_nDisposal;
};

  這個定義方式與在其它的Shader中定義常量緩衝是一樣的。其中m_c4BkColor變量中存儲的就是從GIF調色板中解析出的全局背景色。m_nLeftTop相當於一個有兩個元素的UINT類型的數組,其中存放的就是GIF子幀畫面相對於整個GIF左上角的偏移位置,相當於將GIF左上角座標看成原點時,子幀畫面左上角的偏移座標,示意圖如下:
GIF畫面與子幀畫面座標原理
  上圖中幾個關於GIF及GIF幀的座標變量的含義就一目瞭然了。接着m_nFrame參數就是GIF幀的序號,這個序號是從0開始的,因此第n幀的序號就是n-1。最後m_nDisposal就是從GIF中解析出的幀的處理手法,也就是當前幀要怎麼處理GIF的背景,這在用WIC解析GIF部分已經有說明了。這裏直接上Shader代碼來看具體含義是指什麼:

[numthreads(1, 1, 1)]
void ShowGIFCS( uint3 n3ThdID : SV_DispatchThreadID)
{
	if ( 0 == m_nFrame )
	{// 第一幀時用背景色整個繪製一下先,相當於一個Clear操作
		g_tPaint[n3ThdID.xy] = m_c4BkColor;
	}

	// 注意畫板的像素座標需要偏移,畫板座標是:m_nLeftTop.xy + n3ThdID.xy
	// m_nLeftTop.xy就是當前幀相對於整個畫板左上角的偏移值
	// n3ThdID.xy就是當前幀中對應要繪製的像素點座標

	if ( 0 == m_nDisposal )
	{//DM_NONE 不清理背景
		g_tPaint[m_nLeftTop.xy + n3ThdID.xy]
			= g_tGIFFrame[n3ThdID.xy].w ? g_tGIFFrame[n3ThdID.xy] : g_tPaint[m_nLeftTop.xy + n3ThdID.xy];
	}
	else if (1 == m_nDisposal)
	{//DM_UNDEFINED 直接在原來畫面基礎上進行Alpha混色繪製
		// 注意g_tGIFFrame[n3ThdID.xy].w只是簡單的0或1值,所以沒必要進行真正的Alpha混色計算
		// 但這裏也沒有使用IF Else判斷,而是用了開銷更小的?:三元表達式來進行計算
		g_tPaint[ m_nLeftTop.xy + n3ThdID.xy ] 
			= g_tGIFFrame[n3ThdID.xy].w ? g_tGIFFrame[n3ThdID.xy] : g_tPaint[m_nLeftTop.xy + n3ThdID.xy];
	}
	else if ( 2 == m_nDisposal )
	{// DM_BACKGROUND 用背景色填充畫板,然後繪製,相當於用背景色進行Alpha混色,過程同上
		g_tPaint[m_nLeftTop.xy + n3ThdID.xy] = g_tGIFFrame[n3ThdID.xy].w ? g_tGIFFrame[n3ThdID.xy] : m_c4BkColor;
		//g_tPaint[m_nLeftTop.xy + n3ThdID.xy] = m_c4BkColor;
	}
	else if( 3 == m_nDisposal )
	{// DM_PREVIOUS 保留前一幀
		g_tPaint[ m_nLeftTop.xy + n3ThdID.xy ] = g_tPaint[n3ThdID.xy + m_nLeftTop.xy];
	}
	else
	{// Disposal 是其它任何值時,都採用與背景Alpha混合的操作
		g_tPaint[m_nLeftTop.xy + n3ThdID.xy]
			= g_tGIFFrame[n3ThdID.xy].w ? g_tGIFFrame[n3ThdID.xy] : g_tPaint[m_nLeftTop.xy + n3ThdID.xy];
	}
}

  第一、Shader代碼中numthreads語義說明的所有參數都是1,根據前面的知識,立刻就可以知道,這裏的線程盒中只有一個線程項。這主要是因爲就當前的繪製GIF幀這個問題來說,細粒度的數據項其實就是像素點而已,而像素點在這裏就是一個4D向量,而之前我們說過,在GPU中ALU都是被設計成了能夠同時處理4個分量的向量計算器,所以就可以在一個線程項中並行的處理4D向量的所有分量,因此我們不必像之前那個爲了解釋線程盒、線程組等概念時舉得例子,也即不需要將4個分量放到4個線程裏去處理。最終我們只需要一個線程項來處理一個像素即可。

  第二、Computer Shader的主函數的參數列表中只引用了SV_DispatchThreadID語義參數,也是因爲當前的線程盒中只有一個線程項的原因,這時其實SV_DispatchThreadID和SV_GroupID語義參數是一樣的。(想想爲什麼?)而具體的此處的SV_DispatchThreadID語義參數也只有前兩個維度是有意義的,它的上限即GIF子幀畫面的大小,因爲在這裏,實質上只需要處理GIF子幀畫面中的元素即可,而不需要處理整個GIF畫面。

  第三、代碼中第一個if判斷目的就是把整個GIF畫面用背景色填充一下。當在第一幀時,一般幀畫面大小就是整個GIF畫面的大小,所以直接使用n3ThdID.xy來索引像素點,併爲像素點賦值爲背景色即可。這裏需要注意的就是在Computer Shader中索引一個2D的數組或紋理時,我們可以直接寫成g_tPaint[n3ThdID.xy]這種形式,而不必像c++中需要兩個中括號[]的表達式。

  第四、代碼中後續的大的if…else if…else判斷分支語句就是根據GIF幀的Disposal(處理手法)屬性進行與背景的混合。而之前已經說過雖然GIF幀已經被轉換成了R8G8B8A8的形式,但其Alpha分量其實只有0或1兩個值,即表示當前背景像素點要不要被覆蓋而已,所以代碼中關於混色操作就用了更高效的?問號三元表達式,而沒有寫成經典的Alpha混色表達式。

  第五、要注意代碼中關於像素點偏移座標的計算。因爲GIF子幀的偏移對於一幀畫面的顯示來說都是固定的所以將子幀偏移值用常量傳遞了進來。最後用它與SV_DispatchThreadID語義參數相加,即m_nLeftTop.xy + n3ThdID.xy,就得到了子幀像素相對於整個GIF左上角偏移的像素點座標,這是一個最簡單的2D圖像偏移計算,座標系就是類似於屏幕的座標系,x軸向右,y軸方向垂直向下。這與一般的D3D座標系是不一樣的,與我們之前講解過的UI座標系是一致的。也等價於一般的紋理2D座標系。

  第六、最後大的條件分支中之所以有個else分支,完全是因爲我在測試中發現有些GIF中幀的Disposal值居然會大於3,無奈我也沒有搜到相關資料和說法,大多數講GIF顯示文章中,關於這個Disposal參數解釋也是含糊不清的,但基本都只說了小於等於3的這4種情況而已,所以我就用else來處理這種情況,也按照一般的像素是否透明的方法來顯示,目前測試沒有發現其它的問題。如果各位在查看運行代碼的過程中試出了其它的問題,請將對應的GIF圖片也發給我測試下,不勝感激!

5.2、Dispatch啓動GIF幀繪製

  處理GIF子幀畫面的Computer Shader準備好之後,就是編譯然後創建計算管線的根簽名和PSO對象,這部分沒什麼特別不容易理解的,我就不囉嗦多說了。

  最後在代碼中就是調用Dispatch發起一幀畫面的繪製了,代碼如下:

pICSList->Dispatch(stGIFFrame.m_nFrameWH[0], stGIFFrame.m_nFrameWH[1],1);

  代碼中m_nFrameWH成員變量中存儲的就是GIF子幀畫面的寬和高,當然爲了方便的向Computer Shader中傳遞這個數據,就定義成與Computer Shader相同的形式,只是在Shader中數據類型是uint2,而代碼中是UINT類型的2維數組,二者在字節大小及順序上保持一致即可。

  這樣我們就根據子幀畫面的像素大小啓動了同樣多的線程盒來處理其中的每個像素。

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