1. 程式人生 > >【圖形學與遊戲程式設計】開發筆記-入門篇3:圖形繪製

【圖形學與遊戲程式設計】開發筆記-入門篇3:圖形繪製

(本系列文章由pancy12138編寫,轉載請註明出處:http://blog.csdn.net/pancy12138)

這篇文章將會開始講解最基本的圖形繪製方法,也就是說。這一次的教程將為大家展示一個3D圖形是怎麼被一步步的處理並最終顯示出來的。當然,大家應該還記得入門篇所提到的渲染管線以及數學,程式等基礎知識吧,這一節就是要教會大家利用這些知識來構建3D場景。

首先,我們來回憶一下渲染管線的第一步,很明顯第一步應該是先描述出一個幾何體出來,也就是要告訴計算機我們希望它畫什麼,那麼如何才能把一個幾何體描述出來呢,在入門篇教程中我們已經解釋過了,圖形學是用一個個的網格來描述一個幾何體。當然,作為剛開始的教程,我們先來描述一個比較簡單的網格,也就是一個簡單的立方體。對於一個立方體而言,大家都很清楚他的頂點一共只有八個。然後面的話有六個四邊面,每個四邊面由兩個三角形組成,也就是說最終有12個面。我們描述一個三角形需要3個點,也就是說需要36個點來描述著六個面。下面將給出一個立方體在記憶體中的儲存程式碼,注意這個程式碼裡面立方體的頂點並不是我們剛剛說的8個,這是因為這個立方體擁有紋理座標,因此有些點雖然位置一樣(比如都是(0,0,1)),但是紋理座標不一樣,必須要拆成兩個點。關於神馬是紋理我們會在之後講,這節課主要是講幾何體的繪製,這些東西可以先放一放。大家知道有些點是重複的就好了:

struct pancy_point
{
	XMFLOAT3 position;
	XMFLOAT3 normal;
	XMFLOAT2 tex;
};
pancy_point square_test[]=
	{
		{XMFLOAT3(-1.0f,1.0f,-1.0f), XMFLOAT3(-1.0f,1.0f,-1.0f), XMFLOAT2(0.0f,0.0f)},
		{XMFLOAT3(1.0f,1.0f,-1.0f),  XMFLOAT3(1.0f,1.0f,-1.0f),  XMFLOAT2(1.0f,0.0f)},
		{XMFLOAT3(1.0f,-1.0f,-1.0f), XMFLOAT3(1.0f,-1.0f,-1.0f), XMFLOAT2(1.0f,1.0f)},
		{XMFLOAT3(-1.0f,-1.0f,-1.0f),XMFLOAT3(-1.0f,-1.0f,-1.0f),XMFLOAT2(0.0f,1.0f)},
		{XMFLOAT3(1.0f,1.0f,1.0f),   XMFLOAT3(1.0f,1.0f,1.0f),   XMFLOAT2(0.0f,0.0f)},
		{XMFLOAT3(-1.0f,1.0f,1.0f),  XMFLOAT3(-1.0f,1.0f,1.0f),  XMFLOAT2(1.0f,0.0f)},
		{XMFLOAT3(-1.0f,-1.0f,1.0f), XMFLOAT3(-1.0f,-1.0f,1.0f), XMFLOAT2(1.0f,1.0f)},
		{XMFLOAT3(1.0f,-1.0f,1.0f),  XMFLOAT3(1.0f,-1.0f,1.0f),  XMFLOAT2(0.0f,1.0f)},
		{XMFLOAT3(-1.0f,1.0f,1.0f),  XMFLOAT3(-1.0f,1.0f,1.0f),  XMFLOAT2(0.0f,0.0f)},
		{XMFLOAT3(1.0f,1.0f,1.0f),   XMFLOAT3(1.0f,1.0f,1.0f),   XMFLOAT2(1.0f,0.0f)},
		{XMFLOAT3(1.0f,1.0f,-1.0f),  XMFLOAT3(1.0f,1.0f,-1.0f),  XMFLOAT2(1.0f,1.0f)},
		{XMFLOAT3(-1.0f,1.0f,-1.0f), XMFLOAT3(-1.0f,1.0f,-1.0f), XMFLOAT2(0.0f,1.0f)},
		{XMFLOAT3(-1.0f,-1.0f,-1.0f),XMFLOAT3(-1.0f,-1.0f,-1.0f),XMFLOAT2(0.0f,0.0f)},
		{XMFLOAT3(1.0f,-1.0f,-1.0f), XMFLOAT3(1.0f,-1.0f,-1.0f), XMFLOAT2(1.0f,0.0f)},
		{XMFLOAT3(1.0f,-1.0f,1.0f),  XMFLOAT3(1.0f,-1.0f,1.0f),  XMFLOAT2(1.0f,1.0f)},
		{XMFLOAT3(-1.0f,-1.0f,1.0f), XMFLOAT3(-1.0f,-1.0f,1.0f), XMFLOAT2(0.0f,1.0f)},
		{XMFLOAT3(-1.0f,1.0f,1.0f),  XMFLOAT3(-1.0f,1.0f,1.0f),  XMFLOAT2(0.0f,0.0f)},
		{XMFLOAT3(-1.0f,1.0f,-1.0f), XMFLOAT3(-1.0f,1.0f,-1.0f), XMFLOAT2(1.0f,0.0f)},
		{XMFLOAT3(-1.0f,-1.0f,-1.0f),XMFLOAT3(-1.0f,-1.0f,-1.0f),XMFLOAT2(1.0f,1.0f)},
		{XMFLOAT3(-1.0f,-1.0f,1.0f), XMFLOAT3(-1.0f,-1.0f,1.0f), XMFLOAT2(0.0f,1.0f)},
		{XMFLOAT3(1.0f,1.0f,-1.0f),  XMFLOAT3(1.0f,1.0f,-1.0f),  XMFLOAT2(0.0f,0.0f)},
		{XMFLOAT3(1.0f,1.0f,1.0f),   XMFLOAT3(1.0f,1.0f,1.0f),   XMFLOAT2(1.0f,0.0f)},
		{XMFLOAT3(1.0f,-1.0f,1.0f),  XMFLOAT3(1.0f,-1.0f,1.0f),  XMFLOAT2(1.0f,1.0f)},
		{XMFLOAT3(1.0f,-1.0f,-1.0f), XMFLOAT3(1.0f,-1.0f,-1.0f), XMFLOAT2(0.0f,1.0f)},
	};
	//建立索引陣列。
	num_vertex = sizeof(square_test) / sizeof(pancy_point);
	for(int i = 0; i < num_vertex; ++i)
	{
		vertex[i] = square_test[i];
	}
	UINT indices[] = {0,1,2, 0,2,3, 4,5,6, 4,6,7, 8,9,10, 8,10,11, 12,13,14, 12,14,15, 16,17,18, 16,18,19, 20,21,22, 20,22,23};
上面的程式碼展示了在CPU上建立兩個陣列來儲存一個幾何體網格的頂點以及索引的方法,首先我們來看頂點,頂點是一個網格最主要的資訊儲存方式,我們不僅僅需要在這個資料結構裡面儲存每個點的位置,相應的一些別的屬性,例如法向量,紋理座標,切線等我們都需要儲存在這個結構體裡面。之前我們提過,整個圖形學程式的基礎是線性代數,也就是說我們的大部分變數不再是大家以前寫C/C++/java這些語言的時候所用的int啊,float啊這些簡單的變數,而是 換做float3啊,float4啊,matrix啊這些變數。至於原因大家也很容易看的出來,比如說這裡我們要描述“座標”這個概念,這個概念肯定是一個三維的變數,至少得三個float才能存的下來。注意我們這裡頂點是用一個"pancy_point"結構體來儲存的。這個結構是我自己定義的,大家也可以隨便定義一個結構來描述一個點,只不過內部所用的變數都是一些XMFLOAT的directx內建向量型別而不是int啊float啊這些型別。當然啦,肯定有人會問難道我這裡隨便定義一個結構體,directx都能認得出來我定義的點嗎?答案顯然是肯定的,因為你這裡定義的點,最終渲染的時候還是你自己用,其實directx是不管的,這個待會大家學完這一篇就知道了。然後我們來看索引,索引就很簡單了,三個數字代表一個三角形,比如前三個0,1,2意思就是第0個點,第一個點,第二個點組成了一個三角形,這裡的第幾個點指的就是之前的那個頂點緩衝區的點。當然,索引是用於光柵化三角面的,這個型別固定的就是UINT,然後最終渲染的時候就不歸大家管了,屬於不可程式設計單元。這裡我們提一下,依靠索引號來標識三角面並不是唯一的方法,像曲面細分就不依靠這種辦法來標識三角面,當然啦,曲面細分的光柵化過程也屬於不可程式設計的單元。這個我們回頭再講。

上面的程式碼相信大家一眼都能看懂,因為這就是普通的陣列操作,跟我們平常寫的黑框框一樣,甚至都沒用到神馬API。下面我們再講點重要的,很明顯,大家建立的這兩個陣列現在還是在CPU上的,我們下一步就是要想辦法把這個CPU上的網格(也就是兩個陣列),送到GPU裡面,這樣我們才能對他進行變換啊,著色啊,繪製啊這些高運算量的操作。而directx的作用這個時候就體現出來了。首先,我用最通俗的語言來講接下來的操作,directx可以在GPU上開陣列資源,然後把資料複製過去,並且保留一個CPU上的指標來控制這個資源。通過這種方法,我們就可以靈活的控制GPU上的資源。那麼現在回過頭來我們看這個網格,很明顯這個網格需要在GPU上開兩個陣列存起來,因此我們建立兩個directx的資源指標,然後把資料通過這兩個指標拷貝到GPU的視訊記憶體裡面:

D3D11_BUFFER_DESC point_buffer;
	point_buffer.Usage = D3D11_USAGE_IMMUTABLE;            //頂點是gpu只讀型
	point_buffer.BindFlags = D3D11_BIND_VERTEX_BUFFER;         //快取型別為頂點快取
	point_buffer.ByteWidth = all_vertex * sizeof(T); //頂點快取的大小
	point_buffer.CPUAccessFlags = 0;
	point_buffer.MiscFlags = 0;
	point_buffer.StructureByteStride = 0;
	D3D11_SUBRESOURCE_DATA resource_vertex;
	resource_vertex.pSysMem = vertex;//指定頂點資料的地址
										  //建立頂點緩衝區
	HRESULT hr = device_pancy->CreateBuffer(&point_buffer, &resource_vertex, &vertex_need);
	if (FAILED(hr))
	{
		MessageBox(0, L"init point error", L"tip", MB_OK);
		return hr;
	}
	//建立索引緩衝區
	D3D11_BUFFER_DESC index_buffer;
	index_buffer.ByteWidth = all_index*sizeof(UINT);
	index_buffer.BindFlags = D3D11_BIND_INDEX_BUFFER;
	index_buffer.Usage = D3D11_USAGE_IMMUTABLE;
	index_buffer.CPUAccessFlags = 0;
	index_buffer.MiscFlags = 0;
	index_buffer.StructureByteStride = 0;
	//然後給出資料
	D3D11_SUBRESOURCE_DATA resource_index = { 0 };
	resource_index.pSysMem = index;
	//根據描述和資料建立索引快取
	hr = device_pancy->CreateBuffer(&index_buffer, &resource_index, &index_need);
	if (FAILED(hr))
	{
		MessageBox(0, L"init point error", L"tip", MB_OK);
		return hr;
	}
上面展示瞭如何建立GPU上的資源,首先對於buffer型的陣列,directx提供了一種ID3D11Buffer*的指標(如果大家瞭解C++的基礎的話這裡我提一下,其實所有的類似GPU資源指標的類都是繼承於一個叫resource的類,這個buffer類以及之後的texture類都是一樣的),也就是上面的vertex_need與index_need這兩個指標來指向GPU上的資源(當然這根CPU上的指標指向不是一個概念,不過這裡這麼講比較形象),注意GPU上的資源陣列比CPU上的陣列要嚴格的多,因此我們在建立這個資源的時候要指定資源的格式,這裡格式包括一些用途,繫結用途,大小啊,CPU可讀性啊這些,這些格式將決定這個陣列的大小,是否能讀寫,是否可以map到cpu上等等一系列的屬性。大家以後將會經常用到,這裡就不細講了。我們可以看到藉助d3d裝置的CreateBuffer函式,我們就可以輕易的建立一片GPU上的陣列,這裡的三個引數分別是(陣列格式,CPU上的資源指標,GPU上的資源指標),注意這個CPU上的資源指標不能直接用之前建立陣列的那個指標,要先建立一個D3D11_SUBRESOURCE_DATA的指標,然後把之前那個CPU上的陣列指標賦給這個指標的pSysMem變數。用這個新建的指標來作為CPU上的資源指標。這裡大家也許發現了,這個GPU上的資源在建立的時候就必須要確定每個變數的值了,而不像CPU上的陣列一般先開空間,在後面隨意改值。這裡我解釋一下,之前我說directx通過一個buffer指標來控制GPU上的變數,而實際上,它並不能算是“控制”變數,也就是說這個指標不足以訪問GPU上的資料的具體的值,他只是“巨集觀”的控制這個變數,至於怎麼個巨集觀法大家之後寫多了程式就能體驗到了。如果大家想要改變這個GPU上的陣列也是可以的,只不過這需要進行常說的map操作把陣列從GPU上拷貝回CPU,這種操作非常的耗時間,大家能不改儘量不要改。也就是說在程式正式渲染之前,最好能把資源都確定好了然後全部存在GPU上。

通過上面的方法,我們就已經成功的在GPU的視訊記憶體裡面儲存了一個幾何體。通封裝過這種方法,我們可以很輕鬆的在GPU上把場景中的所有幾何體都變成兩個兩個的陣列儲存起來。接下來我們要講的就是當我們把幾何體儲存完畢了之後,如何將這些幾何體繪製出來了。我們千辛萬苦的把幾何體描述出來,然後又拷貝到GPU裡面,為的就是把它能快速的渲染出來。渲染管線的基礎知識我們在之前的入門篇講過了,大家應該也有一個基本的認識了,這裡呢我就再重複一遍給大家加深一下印象敲打。要想把它繪製出來,首先我們要做的就是先把它在3D空間的位置給定下來,雖然之前我們在陣列中已經指定了這個立方體的每個點的座標,但是一般我們要根據情況把這個立方體擺放到合適的3D空間中,這就需要對這個幾何體進行最基本的幾何變換,也就是大家通常所瞭解的平移,旋轉,縮放。那麼如何才能達到這個效果呢,肯定有人就想到寫一個函數了。當然這個我們也在入門篇提到了,線性代數的優點就是利用矩陣乘法來代替函式。因此這裡我們其實是使用三個基本的矩陣(平移,旋轉,縮放)來代替函式做這些操作的。當我們通過點和矩陣相乘得到了變換後的幾何體後,我們就可以進行下一步的操作了,也就是投影。這個操作將所有的三維點,通過一個矩陣,直接轉換成二維的座標(範圍是(-1,-1)->(1,1)),這個投影矩陣的推導還是比較複雜的,不過因為directx預設永遠在座標原點朝著z軸正方形投影,因此,推導過程被化簡了很多。當然,有人就問了,你這directx預設的投影方向就只有一個,那我想換視角怎麼辦,我想看場景的另一個方向怎麼破?嘿嘿,科學家們當初想到了一個非常巧妙的方法解決了這個問題,並且給這個方法起了個名字叫“取景變換”,簡單的來說,如果你想看場景的後面,你也不需要動,讓整個場景轉過來把背面朝著你就可以了。具體的方法我們在之後講三維攝像機的時候會再提,這裡大家先把他放過去,等講到的時候再說生氣。當投影完畢之後,我們就得到了一個二維的向量圖,這也同時標誌著3D層面的工作到此結束,之後的工作都是二維層面上的工作了。接下來的緊接著的工作就是光柵化了,這個工作就是把投影之後的二維向量圖轉換成二維的光柵圖,說的通俗一點,就是把一個個用頂點表示的三角形(比如(1,0),(-1,0),(0,1)這種的),通過索引號以及螢幕畫素點的位置來轉換成畫素點標識的三角形。我們知道向量三角形一般依靠三個頂點來表示,而畫素化的三角形就得用很多很多畫素點來標識了,至於用多少畫素點就看這個三角形有多大了,它能包住多少螢幕畫素點就需要多少個畫素點來標識。那麼至於如何把一個個二維的向量三角形轉換成光柵化的三角形,這是光柵化硬體做的工作,也就是說我們程式設計師不需要管,大家如果有興趣的話可以看看一些圖形學課本,上面會提及一些光柵化所使用的填充演算法以及掃描演算法等等。我們接下來要關注的是光柵化完畢了之後我們要幹神馬,注意之前我們只提到了物體的形狀怎麼表示,怎麼投影以及怎麼轉化為二維光柵圖,但是並沒有提及光柵化之後每個三角面的顏色應該是神馬。也就是說接下來的操作就是為這些光柵化之後的三角形進行著色操作。這個時候有人就有問題了,因為之前我們在入門篇說過圖形學的顏色都是依據光照原理得到的,也就是說根據物體和光源在3D空間之中的關係得到的,那麼我們現在整個3D圖形都已經經過了投影+光柵化成為了一個圖片了,這圖片怎麼能有3D資訊,怎麼計算光照和顏色呢?這個問題其實很簡單,在我們投影+光柵化的時候,每個頂點雖然被投影變換矩陣變成了二維的向量點,然後又根據填充演算法變成了一個個的光柵點。但是我們仍然可以為這些點保留它以前的資訊,原因就是這些點是由“結構體”來表示的,而這個“結構體”是我們制定的,所以想保留多少資訊就可以保留多少資訊,我們只需要在這個過程中把結構體中代表座標的部分進行變換就可以了,其餘的完全可以保留到最後一步再進行計算。當然這個時候有人又會有疑問了,你投影的時候保留資訊我相信,因為就是三個點變成三個點,但是你光柵化保留資訊是神馬鬼,一個三個點的三角形光柵化之後一般都會變成成千上萬個畫素點的啊,那除了三個頂點的資訊以外,三角形裡面的畫素點的資訊難道也是保留來的,哪來的資料給他們保留啊?那麼這個問題就比較有深意了,其實裡面的點的資訊,也是保留下來的,我們知道光柵化是根據三個頂點的位置為三角形決定包住了多少個畫素點。但是,這一步其實遠不是那麼簡單,事實上他不僅僅把三角形向量轉換成了三角形畫素點,他還為每個畫素點通過“插值”演算法得到了這個點所繼承的結構體資料。那麼神馬是插值演算法呢?很簡單,比如說我們著色的時候需要用到頂點結構體裡面法向量的資訊,現在向量三角形的三個頂點是a,b,c。現在我們想得到三角形的中心點的法向量,那麼先進行線性插值得到兩條邊的中點的法向量資訊n1 = (a+b)/2和n2 = (a+c) / 2。然後根據這兩個邊的中點的資訊再得到中心點的資訊(n1+n2) / 2。這樣就插值得到了中點的法向量,這種演算法就叫雙線性插值演算法。


當然,插值演算法有很多種,並且這一步是不需要程式設計師們插手的,也就是說他會在光柵化的時候自動的為每一個畫素點根據之前的結構體資訊插值得到新的資訊。這裡就不得不提一下我們為什麼要在光柵化之後著色了,注意有些人可能想到了在頂點還沒有被投影的時候給每個頂點先把顏色算出來,然後再進行光柵化,這樣豈不是不需要再記錄神馬原始資訊了,光柵化的時候會根據頂點的顏色把其他點的顏色插值出來。這種想法在過去是可以的,比如Gouraud著色法就是這麼幹的。不過大家一定要記住,這種“插值”的演算法只對類似於“位置”一類的東西有效果,比如3D座標,3D向量都是可以進行插值的。但是對於“顏色”“距離”這種東西是效果極其差的,我舉個例子。比如說距離,三角形的兩條邊大家都知道比垂線要長吧,但是如果使用這種插值演算法進行長度插值的話就會得到垂線長度等於兩邊長度之和的平均這種一眼就能看出是錯誤的例子。再比如說顏色,光源如果正對著三角形,很明顯三角形的中點是最亮的,但是根據這種插值演算法肯定會得到三個頂點亮度的平均值,這就問題很嚴重了。所以說大家在著色的時候儘量都在光柵化之後進行著色運算,這樣才能得到正確的結果。

OK,上面所說的就是所有的基礎知識了,相信大家有了這些基礎知識,再來看下面的程式碼實現就會更容易一些。下面我們具體的開始講解GPU上如何實現上面我們說的這些演算法。注意上面的演算法大致分為三類,頂點層次的,光柵化的,以及光柵化之後的著色的。我們之前說了,光柵化的部分由硬體包辦了,也就是說我們現在只需要實現兩個部分就可以了,其一是頂點層次的,也就是3D級別的演算法,我們稱之為頂點著色器(vertex shader)。其二是光柵化之後的畫素級別的,我們稱之為畫素著色器(pixel shader)或者OpenGL裡面稱之為片段著色器(fragment shader)。也就是說我們的GPU上的程式並不像CPU上的語言那樣一路寫到尾的,而是分開來的。這種程式的寫法有點像topcoder的比賽一樣,我們來設計函式就可以了,輸入和輸出都是固定的。而這裡我們除了要設計函式以外,就是指定我們需要的輸入輸出的格式,也就是輸入的頂點格式結構體長什麼樣子,以及我們希望光柵化的時候所保留的結構體資料長什麼樣子。當然有人就說了,臥槽,你這是逗我呢,我設計個GPU的程式還要束手束腳的,我就想一路走到黑,全部GPU程式由我控制怎麼破?那很簡單啊,如果你很擅長並行程式設計的話,你可以用通用計算(compute shader)去設計自己的所有流程,但是很明顯,人家通過硬體封裝的光柵化流程肯定比你自己寫的任何軟體演算法都要快很多。而之所以把GPU程式拆成這麼三段就是為了方便並行處理,能在不同的階段最大化的利用GPU多核多執行緒的特點去更快的解決問題,如果把幾千個執行緒一股腦的交給你其實你還真不一定能把程式設計的有多快,一個最簡單的例子就是geometry shader與曲面細分,前者因為不好把細分過程並行化,最終還是得改成後者的硬體整合模式才能更好地進行細分曲面的工作。當然通用計算在一些圖形演算法中也是很重要的,比如HDR的pass1,但是那也只是藉助其特性而已,並不算是代替傳統的光柵渲染管線,這個我們後面講到的時候會細說。那麼廢話不多說,下面來看今天我們的一個最簡單的著色器:

float4x4         final_matrix;     //總變換
struct Vertex_IN
{
	float3	pos 	: POSITION;     //頂點位置
	float3	normal 	: NORMAL;       //頂點法向量
	float2	tex     : TEXCOORD;      //頂點切向量
};
struct VertexOut
{
	float4 position      : SV_POSITION;    //變換後的頂點座標
	float3 color         : NORMAL;    //變換後的頂點座標
};
VertexOut VS(Vertex_IN vin)
{
	VertexOut vout;
	vout.position = mul(float4(vin.pos, 1.0f), final_matrix);
	if (vin.normal.r < 0.0f && vin.normal.g < 0.0f && vin.normal.b < 0.0f)
	{
		vin.normal.r = 1.0f;
		vin.normal.b = 1.0f;
	}
	if (vin.normal.r < 0.0f)
	{
		vin.normal.r = 0.0f;
	}
	if (vin.normal.g < 0.0f)
	{
		vin.normal.g = 0.0f;
	}
	if (vin.normal.b < 0.0f)
	{
		vin.normal.b = 0.0f;
	}vout.color = vin.normal.xyz;
	return vout;
}
float4 PS(VertexOut pin) :SV_TARGET
{
	return float4(pin.color,1.0f);
}
technique11 colorTech
{
	pass P0
	{
		SetVertexShader(CompileShader(vs_5_0, VS()));
		SetGeometryShader(NULL);
		SetPixelShader(CompileShader(ps_5_0, PS()));
	}
}
上述就是一個全套的著色器(fx型別的),fx是微軟將自己的著色器封裝的一種格式,這樣大家就更容易切換和管理著色器了,而OpenGL是沒有這種東西的,所以大家得自己管理自己的每一段著色程式碼。這個著色器呢很簡單,輸入的頂點結構體只有(位置,法向量,紋理座標),我們指定的光柵化的時候保留的結構更是隻有兩個(位置,顏色)。這裡為了儘量使得著色器簡單,我們先不扯光照,直接把法向量當做顏色輸出去。整個程式就非常簡單,先把頂點進行變換,投影,然後光柵化之後把每個點的法向量當作這個點的顏色輸出出去就行了。注意,無論你想保留多少變數在結構體裡面都是可以的,但是在頂點著色器的輸出裡面一定要有一個SV_POSITION代表投影后的二維向量,這個是光柵化必須要用的。然後在畫素著色器裡面最終的輸出一定是這個畫素的顏色。基本上這兩個程式的用途就是這樣,注意頂點著色器部分的函式是每個頂點都會跑一次的,然後畫素著色器部分的函式是每個光柵化之後的三角形裡面的每個畫素點都會跑一次的,後者的工作量一般都比前者大一些。然後這裡的語法是HLSL的語法,和c語言幾乎是一毛一樣的。大家也很容易應該就能看懂,注意這裡的頂點輸入格式一定要和之前建立的幾何體的頂點格式一樣,不然的話就很容易出問題。還有一點,大家看到那個final_matrix只是定義就被後面用了,可能會有一些疑惑,其實這個變數我們稱之為外部變數,也就是說他不由GPU賦值,是由CPU賦值的,也就是說這個矩陣我們每一幀計算一次,然後把它賦值給GPU上的這個程式的。之所以這麼做是因為大部分這種變數其實每一幀都在變化,當然對於大型的變數我們可不能這麼做,只能通過資源繫結的方法進行訪問,這個我們回頭講到再說。

那麼上面我們講完了GPU上的程式怎麼設計,接下來我們就要說最後一步了,當我們在視訊記憶體裡面儲存了一個幾何體,並且怎麼渲染幾何體的演算法也已經寫好了之後。如何才能告訴計算機呼叫這個演算法去繪製這個幾何體呢?這就是directx要乾的事情了。其實這個也很簡單,首先我們要做的就是把那些GPU上的演算法先編譯了,因為畢竟是兩種硬體上的語言,只能先編譯好一個,然後交給directx。這個調directx編譯的程式碼意義不大,大家看看就好了,反正對於每一個shader而言都是一樣的而且和我們講的圖形學關係不大。注意vs2015是可以在編譯階段對fx一類的HLSL程式碼進行預編譯的,編譯成二進位制程式碼之後,我們其實就是呼叫directx來得到一個shader指標就好了,就不需要在程式執行的時候再編譯了。

HRESULT shader_basic::combile_shader(LPCWSTR filename)
{
	//建立shader
	UINT flag_need(0);
	flag_need |= D3D10_SHADER_SKIP_OPTIMIZATION;
#if defined(DEBUG) || defined(_DEBUG)
	flag_need |= D3D10_SHADER_DEBUG;
#endif
	//兩個ID3D10Blob用來存放編譯好的shader及錯誤訊息
	ID3D10Blob	*shader(NULL);
	ID3D10Blob	*errMsg(NULL);
	//編譯effect
	std::ifstream fin(filename, std::ios::binary);
	if (fin.fail()) 
	{
		MessageBox(0, L"open shader file error", L"tip", MB_OK);
		return E_FAIL;
	}
	fin.seekg(0, std::ios_base::end);
	int size = (int)fin.tellg();
	fin.seekg(0, std::ios_base::beg);
	std::vector<char> compiledShader(size);
	fin.read(&compiledShader[0], size);
	fin.close();
	HRESULT hr = D3DX11CreateEffectFromMemory(&compiledShader[0], size,0,device_pancy,&fx_need);
	if(FAILED(hr))
	{
		MessageBox(NULL,L"CreateEffectFromMemory錯誤!",L"錯誤",MB_OK);
		return E_FAIL;
	}
	safe_release(shader);
	//建立輸入頂點格式
	return S_OK;
}

上述程式碼的最終的目的呢,就是把我們之前寫的fx程式碼編譯成一個ID3DX11Effect*的指標,就是這麼簡單的一段程式碼,有了這個指標之後,我們就可以根據這個指標來幹兩件事情,第一是通過這個指標給shader的外部變數賦值,賦值的方法很簡單,先定義一個外部變數指標,然後把這個外部變數指標註冊到這個ID3DX11Effect*裡面,然後就可以隨意的操控外部變數指標來複制了。下面展示如何進行賦值操作:
ID3DX11EffectMatrixVariable           *project_matrix_handle;      //全套幾何變換控制代碼
project_matrix_handle = fx_need->GetVariableByName("final_matrix")->AsMatrix();

HRESULT shader_basic::set_matrix(ID3DX11EffectMatrixVariable *mat_handle, XMFLOAT4X4 *mat_need)
{
	XMMATRIX rec_mat = XMLoadFloat4x4(mat_need);
	HRESULT hr;
	hr = mat_handle->SetMatrix(reinterpret_cast<float*>(&rec_mat));
	return hr;
}
其中fx_need就是我們所說的之前編譯的ID3DX11Effect*型別的指標。接下來我們說這個指標的第二個用處,那就是可以呼叫這個指標來獲取相應的渲染路徑來渲染幾何體,神馬是渲染路徑呢,這個是effect格式所獨有的,也就是說指定究竟使用fx檔案裡面哪個vertex shader以及哪個pixel shader來渲染物體,路徑的名字大家可以在之前的GPU程式碼上看到,就是technique11 colorTech那一行,colorTech就是我們所寫的渲染路徑,指定的vs和ps在那行程式碼的下面就可以看到。下面我們來看看怎麼渲染物體:
void color_shader::set_inputpoint_desc(D3D11_INPUT_ELEMENT_DESC *member_point, UINT *num_member)
{
	//設定頂點宣告
	D3D11_INPUT_ELEMENT_DESC rec[] =
	{
		//語義名    語義索引      資料格式          輸入槽 起始地址     輸入槽的格式 
		{ "POSITION",0  ,DXGI_FORMAT_R32G32B32_FLOAT   ,0    ,0  ,D3D11_INPUT_PER_VERTEX_DATA  ,0 },
		{ "NORMAL"  ,0  ,DXGI_FORMAT_R32G32B32_FLOAT   ,0    ,12 ,D3D11_INPUT_PER_VERTEX_DATA  ,0 },
		{ "TEXCOORD",0  ,DXGI_FORMAT_R32G32_FLOAT      ,0    ,24 ,D3D11_INPUT_PER_VERTEX_DATA  ,0 }
	};
	*num_member = sizeof(rec) / sizeof(D3D11_INPUT_ELEMENT_DESC);
	for (UINT i = 0; i < *num_member; ++i)
	{
		member_point[i] = rec[i];
	}
}
HRESULT shader_basic::get_technique(ID3DX11EffectTechnique** tech_need,LPCSTR tech_name)
{
	D3D11_INPUT_ELEMENT_DESC member_point[30];
	UINT num_member;
	set_inputpoint_desc(member_point,&num_member);
	*tech_need = fx_need->GetTechniqueByName(tech_name);
	D3DX11_PASS_DESC pass_shade;
	HRESULT hr2;
	hr2 = (*tech_need)->GetPassByIndex(0)->GetDesc(&pass_shade);
	HRESULT hr = device_pancy->CreateInputLayout(member_point,num_member,pass_shade.pIAInputSignature,pass_shade.IAInputSignatureSize,&input_need);
	if(FAILED(hr))
	{
		MessageBox(NULL, L"CreateInputLayout錯誤!", L"錯誤", MB_OK);
		return hr;
	}
	contex_pancy->IASetInputLayout(input_need);
	input_need->Release();
	input_need = NULL;
	return S_OK;
}
void Geometry<T>::show_mesh()
{
	ID3DX11EffectTechnique teque_pancy;
	color_need->get_technique(&teque_pancy, "colorTech");

	UINT stride_need = sizeof(T);     //頂點結構的位寬
	UINT offset_need = 0;                       //頂點結構的首地址偏移
												//頂點快取,索引快取,繪圖格式
	contex_pancy->IASetVertexBuffers(0, 1, &vertex_need, &stride_need, &offset_need);
	contex_pancy->IASetIndexBuffer(index_need, DXGI_FORMAT_R32_UINT, 0);
	contex_pancy->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
	//選定繪製路徑
	D3DX11_TECHNIQUE_DESC techDesc;
	teque_pancy->GetDesc(&techDesc);
	for (UINT i = 0; i < techDesc.Passes; ++i)
	{
		teque_pancy->GetPassByIndex(i)->Apply(0, contex_pancy);
		contex_pancy->DrawIndexed(all_index, 0, 0);
	}
}

上面的三個函式就是呼叫繪製的函式,而這三個函式就是最終繪製的時候每一幀需要執行的函式。第一個函式的作用就是指定頂點的型別,之前我們說過結構體大家可以自己定義,這裡就是通過這個函式來告知GPU最終的結構體的格式長什麼樣子的。注意大家之前看GPU程式碼的時候肯定發現頂點格式結構體裡面,定義方式和CPU的結構體有那麼一絲區別,就是後面會有POSITION啊SV_POSITION啊這些修飾詞,這就是用來標識變數作用的,這裡也是依靠這些修飾詞來把GPU上的格式和CPU上的結構體格式一一對應起來的。第二個函式就很明顯了,根據我們需要的渲染路徑的名字,從之前編譯的著色器指標裡面找到對應的著色用的technique指標。然後最後一步就是利用這個指標來告知directx要進行渲染了。也就是第三個函式所做的工作。這裡的程式碼層層遞進,大家應該很容易就能看懂。

如果大家所有的操作的完成無誤的話,最終就會得到一個如下的旋轉彩色立方體。這也就標誌著大家這一節課所學的知識都完成了:


大家也許發現了這篇文章大部分的篇幅其實都在講解一些原理的知識,而程式碼方面的介紹相比之下就顯得不是很詳細了,這個我要在這裡宣告一下,其實大家學到後面就會發現這些程式碼方面的知識對於新學習這方面知識的人來說,一開始可能會顯得很難,但是隨著大家的熟悉程度增加,這些知識其實都是很簡單的,而一些原理的理解到那時會顯得很重要,因為一點點的原理理解錯誤都有可能導致一些嚴重的bug。所以,大家在學習的時候也是一樣的,我的準則是,大家寧可是看懂所有的原理之後把程式碼複製到自己計算機上跑出結果之後多改效果多看,也不要光是一步步的照著教程的程式碼按部就班的寫下來而不知其所以然敲打。好了,下面是這節課的最終的程式碼地址,大家可以靠這個來更好的理解上述的知識:

原始碼下載