1. 程式人生 > >自制軟3D渲染程式 之一 3D起草程式

自制軟3D渲染程式 之一 3D起草程式

自制軟3D渲染程式 

0.介紹

很久之前就開始寫CPU 3D渲染程式了。一開始的打算是使用EGE(Easy Graphics Engine)或者EasyX,

因為接觸比較多,並且也使用這兩個繪圖工具做了一個斜45度偽3D遊戲引擎( 自制45度2D引擎之座標轉換),

這個雖然是網頁版本的(Github),但是後來抽空將它移植到了EGE上,點選這兒Github

EasyX版本暫時還沒有做,因為EasyX和EGE在IMAGE的記憶體操作、以及圖片讀取和縮放上有很大不同。

有了這些經驗,可以說做3D是順其自然,得心應手。首先看一下成果,點這兒Github或者Gitee

製作過程持續了3-4年,期間斷斷續續,忙於工作和找工作(笑)。目前還在不斷完善中。

最後的效果不是很理想,雖然我做了很多優化工作,但是最後在三角形面數過多的時候會比較卡。

主要原因還是沒有使用GPU,純CPU渲染,因此在效能上肯定不如當前流行的Vulkan、OpenGL或者DirectX。

雖然如此,但是對於我這個初學者來說,能通過編寫和使用這個程式來了解3D的世界,也還是不錯的。

謹以此文獻給出入3D之門初學者。

目前正在研究CUDA,打算將資料結構移植到CUDA上。如果成功,想測試一下效能怎麼樣。

可能往CUDA上移植的時候,因為GPU架構的關係,資料結構會有很大改變。

但是現在只介紹一下CPU下面的資料結構,以及隨之衍生出來的一些效果。

1.資料結構

具體的資料結構還是沿用之前自己寫的一個

雙向迴圈型多連結連結串列

雙向迴圈連結串列是最完備的連結串列。而我在此基礎上加上了多連線。

也就是它不僅是一條連結串列,而是有可能成為多條連結串列,而不用再去申請多餘的記憶體。

這個資料結構在自己寫作業系統(一步步寫作業系統)上應用與記憶體管理、任務管理,並且測試沒有任何問題。

該資料結構一共四個版本:

Javascript版本:這個忘記說了,是最早的版本,使用js的prototype來進行類管理。應用在網頁版斜45度偽3D遊戲引擎。

純C版本:主要用於作業系統等沒有基礎庫的地方。

C++版本:使用類的建構函式和解構函式,簡化了很多操作,但是不適合底層。使用在斜45度偽3D遊戲引擎中。

Java版本:這是從網頁版斜45度偽3D遊戲引擎移植到Android版本時,對Javascript版本的資料結構進行的移植。(

Github)

另外一提,純C版本的也被應用到我的另一個專案,倒敘詞典裡面(Github),因為使用JNI所以使用純C而不是使用基礎庫。

支援>200000詞彙的載入,時間大概為30秒。

在本3D渲染程式中也繼續使用這個資料結構。

這個資料結構很原理很簡單,就是在原來的連結串列裡面,將prev和next擴充套件成了prev[]和next[],

然後外面套一層管理器,管理器裡面在構造的時候指定了使用的下標,對元素進行遍歷、增刪時

使用對應的下標對prev[]和next[]進行操作。


上圖每一條線代表一個管理器管理的prev和next連結,指定下標為0的管理器只對prev[0]和next[0]進行操作。

對於3D程式來說,有很多點,三角形物體,都可以使用這個結構進行管理和渲染。使用下標0進行管理,

使用下標1建立另一個管理器,在渲染時,可以對所有三角形進行裁剪、背面消隱等,

這時管理連結就會跳過被忽略的三角形(如圖中彎曲的線所示),而不會對下標0的管理器有任何影響。

並且從我的後面的一些程式碼編寫中還發現,多連結連結串列很適合在多執行緒下進行操作。

如果要多個執行緒對所有物體進行操作,那麼將執行緒id作為下標建立管理器,就可以互不影響,是不是很方便?

有了管理器結構,後面就省事了,我按照從小到大、由基本到具體的順序介紹其他資料結構。

可以參考我的3D渲染起草專案,這裡面只有基礎資料結構,和一個簡單的渲染器, 點這裡Gitee

2.點

點是最基礎的資料結構。除了基礎的+-*/運算子過載外,還需要一些其他函式,比如

自身角度旋轉、繞點旋轉、歸一化、是否在矩形內等等函式,這些函式都比較基本,不再贅述。

3.頂點

頂點的基本型別就是含有(x, y, z, w)的四元矩陣。為了簡化編碼書寫,需要定義一些基本運算子過載函式。

class Vert3D{
DOUBLE x, y, z, w;
}    

之所以要使用DOUBLE型別是因為FLOAT精度不足,到後期會看到光線追蹤渲染時噪點會比較多。您可以定義巨集DOUBLE。

為了支援向量操作,一些操作符已經失去了原來的含義,分別定義如下:

*操作: 為向量叉乘,用於計算面法線。所得結果是一個頂點,但解釋為原點在(0, 0, 0, 0)處的向量,

            方向使用右手法則判定。一般計算了面法線以後,需要使用normalize()對其長度進行歸一化。

^操作:為向量點乘,結果為a*b=|a||b|cos<a,b>,其結果因為包含cos<a,b>,所以可以用於在Phong模型中

            使用法線和光線夾角計算光照係數,但一般不用求解出具體的<a,b>角度值。

&操作:同^操作,不過結果為cos<a,b>,即出去了|a||b|部分,當需要使用準確的cos值時使用。

            但因為有除法運算,效率大大降低。計算Phone模型的照度係數時,一般使用該函式,而不用含有干擾引數

            |a||b|的^操作。

另外一些運算子過載涉及到矩陣。

4.矩陣

設有一個頂點v,做一個變換,就是乘以一個矩陣M,


如圖,令M第一列為mx,第二列為my,第三列為mz,第四列為mw,則頂點變換後就是

|  x*mx.x + y*mx.y+ z*mx.z + w*mx.w      |T

|  x*my.x+ y*my.y + z *my.z+ w*my.w     |

|  x*mz.x + y*mz.y+ z*mz.z + w*mz.w      |

|  x*mw.x + y*mx.y+ z*mw.z + w*mw.w  |

因為矩陣乘積結果是一維橫向矩陣,為了便於書寫為豎向,所以加上了"T"代表轉置。

這樣,就可以將資料結構分解成這樣的形式:

class Mat {
DOUBLE x, y, z, w;
};
class Mat3D {
MAT mx, my, mz, mw;
}

另外,我們已知有矩陣M=[mx, my, mz, mw]T和頂點V=[x, y, z, w]T,以及另一些變換矩陣,

如M1,M2...。我們知道頂點V為頂點的初始座標,這個座標是以(0, 0, 0, 0)為原點的原點座標,

假設M為頂點V的世界座標變換,Mn為頂點V的後續變換(比如要將V變換到相機座標或者

逆變換回世界座標或原點座標),則有

M*M1*M2*V = (M*M1*M2)*V

加個括號有設麼用?對了,可以將矩陣合併,令MM=M*M1*M2,那麼上述變換可以寫成

M*M1*M2*V =MM*V

不過如果同時又平移和縮放以及旋轉變換,根據想象您也應該能猜到,應該先進行旋轉或縮放,

然後最後進行平移,否則先平移,再按照原點來旋轉就成了將物件繞圓周運動,而不是單純的轉向動作。

那麼根據上述MM*V又可以得到什麼?對了,就是優化。

通過計算出MM並儲存起來,以後只要沒有涉及到矩陣變化,都可以直接使用MM*V來得到變換後的座標。

另外,矩陣操作無外乎旋轉、縮放、平移,令旋轉矩陣為Mr,縮放矩陣為Ms,平移矩陣為Mm,

則對於一個已經進行MM變換的頂點V來說,再進行旋轉縮放平移變換,就是在其MM上左乘旋轉縮放矩陣

或者右乘平移矩陣,即:

Ms*Mr*MM*Mm = MM'

對於這個新矩陣,可以直接替代原來的MM矩陣,直接使用MM*V來得到變換後的座標。

矩陣的運算子過載,一般只希望在Mat3D上操作,而不是Mat上,Mat上只有賦值=和+-,

以及*一個浮點數的操作。

Mat3D上的運算子過載:

*操作:即上面說到的矩陣乘法。矩陣乘法比較複雜,對於矩陣

M=[mx, my, mz, mw]和M1=[mx1, my1, mz1, mw1]有:


+-*操作:對mx、my、mz、mw分別進行+-*操作,其中*操作只針對一個浮點數,如果小於1則實際為除操作。

另外一些線性代數上常用的操作比如轉置、單位對角矩陣化等操作,

現在我可以向您打包票,這些都不需要,看,是不是簡單?

對於頂點,有了矩陣以後當然需要增加矩陣操作,上面的操作是針對兩個矩陣之間的,

下面則是對於頂點資料結構Vert3D的:

*操作:對頂點進行矩陣變換的操作。參照4.矩陣一開始給出的圖片。

最後,在整個引擎的設計中,我們需要將上述操作(縮放、渲染、平移)儲存起來,並針對具體操作進行更新,

同時儲存和更新其逆變換,因此產生了Matrix3D這個類。

這個類裡面包含多個Mat3D,分別為:

M/Mm/Ms/Mrx/Mry/Mrz:正變換操作矩陣,其中M為所有變換的乘積,而Ms為縮放變換,Mr為繞三個軸方向的旋轉變換,Mm為平移變換。

M_1/Mm_1/Ms_1/Mrx_1/Mry_1/Mrz_1:逆變換操作矩陣,記錄上述所有操作的逆變換,當需要對變換後的物體變換回來取其材質座標時,就可以直接使用。

當進行任意操作時,首先對Ms/Mrx/Mry/Mrz/Mm進行計算,然後對Ms_1/Mrx_1/Mry_1/Mrz_1/Mm_1

進行相應的逆變換計算,最後重新整理全變換矩陣M和全逆變換矩陣M_1。

之所以要把Mr分解為Mrx、Mry和Mrz是因為考慮到旋轉軸的問題。前面也提到,旋轉以後才進行平移是因為,

不這樣的話,平移之後,再按軸旋轉是圓周旋轉而不是自身旋轉。對於旋轉自身也是一樣,

繞xyz軸哪個先哪個後會影響到最終的效果,所以將他們分開,以便之後進行設定。

5.物體

在3D引擎中,一般是由一系列的頂點構成三角形,然後一些列三角形構成一個物體。

這種三角形有各種不同的構成方法。本引擎中使用了一下三種構成:

三種不同的三角形構成,加入的頂點為紅色,遍歷方式為下面的->號。圓圈箭頭代表點的順序,

可以根據右手法則找到法線的方向。

第一種,Triangle Loop,這是不在任何現有流行的3D引擎裡面使用的,我稱之為懶惰模式,

也就是簡單按照加入的頂點的順序首尾相接進行遍歷,但第二個[2 3 4]進行遍歷時,法線相反,

如果進行了背面消隱,則看不到這個三角形,因此需要在加入頂點的時候指定法線是否反向,

所以途中圓圈箭頭旁邊出現了一個-1。也就是說,在編寫模型頂點時,需要考慮法線方向是否一致,

如果不一致,需要明確法線反向。所以我稱之為懶惰模式。

第二種,Triangle Strip,這是和現有3D引擎裡面一致的,就是按照奇正偶反的規則計算遍歷頂點的。

對於當前遍歷的第n個點,

如果n為偶數,則使用第[n-1 n-2 n]個頂點構成三角形,比如n=4,則使用[3 2 4]構成三角形,稱為偶反。

如果n為奇數,則使用第[n-2 n-1 n]個頂點構成三角形,比如n=5,則使用[3 4 5]構成三角形,稱為奇正。

第三種,Triangle。這是不管重複點,只管加入頂點即可。如圖紅色字型中234都會被重複加入三角形中。

但是因為指定了確定的三個頂點作為三角形,所以法線固定,因此也不存在遍歷順序的問題。

在一些匯出檔案如3ds或者obj檔案中,所有的模型頂點都是按照Triangle的模式來進行匯出的,

所以讀取解析它們也需要使用Triangle模式進行頂點加入。

6.連結串列

前面提到使用雙迴圈多連結連結串列,但是文至此還沒有出現過這個相關。現在就要正式加入連結串列了。

要對定點進行管理,首先定義一個類來作為連結串列的元素:

class VObj {
    VOBJ() {
        initialize();
    }
// for multilinklist
#define MAX_VOBJ_LINK	4
	void initialize() {
		for (INT i = 0; i < MAX_VOBJ_LINK; i++)
		{
			this->prev[i] = NULL;
			this->next[i] = NULL;
		}
	}
	INT uniqueID;
	VObj * prev[MAX_VOBJ_LINK];
	VObj * next[MAX_VOBJ_LINK];
	void operator delete(void * _ptr){
		if (_ptr == NULL)
		{
			return;
		}
		for (INT i = 0; i < MAX_VOBJ_LINK; i++)
		{
			if (((VObj*)_ptr)->prev[i] != NULL || ((VObj*)_ptr)->next[i] != NULL)
			{
				return;
			}
		}
		delete(_ptr);
	}
}

這個是作為多連結連結串列元素的必要結構,包含構造、初始化和刪除。對於底層如何操作,

感興趣的可以從這裡下載原始碼Github

之後所有的多連結連結串列結構都使用這些元素,比如之後的燈光、相機,當然也包括物體

本身也是多連結連結串列結構,用於更上層管理。連結串列元素新增完成以後,就可以隨意新增資料元素了,

不用去管連結串列怎麼使用和操作,很是方便。因為是使用的C++版本,所以對於VObj來說,只要繼承

自Vert3D就可以擁有頂點的所有資料。

我們想要對定點進行更高效和優化的管理,最好是對頂點的一些座標進行快取,而不是按照上面說的方式,

每次需要某個變換的座標都進行一次矩陣運算。最好的方式是,有使用者操作時,如果有物體的操作,

則對每個頂點的矩陣進行變換,然後通過變換計算出世界座標、相機座標、法線座標,

甚至其他一些座標,比如AABB包圍盒座標,並將這些存起來。

當沒有物體操作時,比如只是轉動攝像機,則只計算物體的相機座標並更新儲存。

當頂點連結串列元素定義好以後,就可以定義物體連結串列元素了。

物體管理者頂點,所以需要有幾個多連結連結串列管理器,就是

MultiLinkList<VObj> verts;
MultiLinkList<VObj> verts_r;
MultiLinkList<VObj> verts_f;

其中verts儲存所有的頂點,verts_r儲存當前的渲染頂點,verts_f儲存所有的反射頂點。

渲染頂點在每次使用者操作以後,都對每個頂點進行變換,變換過程中,通過相機的投影函式,可以得到

頂點是否剪下,而在後續變換中,可以得到頂點的法線,並且根據法線和相機的位置關係標記是否背面消隱。

如果上述都完成,則在渲染頂點中,因此將它鍵入到verts_r中。

反射頂點為什麼要單獨拿出來,因為反射不能在正常位置上去做,而是要將攝像機切換到反射面的映象

的攝像機位置,對所有反射頂點做變換,然後繪製反射頂點,最後回到原來的攝像機位置。


如圖,右下角的攝像機在視域範圍內是看不到球背面的紅色點的,但是鏡面反射時,將攝像機移動到其

映象位置,然後就能觀察到紅色點,並且到點的距離和原攝像機到鏡面再到點的距離是一致的,所以虛擬

攝像機渲染的影象就是鏡面反射的影象。對於反射物體,上面的每個三角形法線可能各不一樣,因此就

需要將反射頂點單獨拿出來對每個三角形進行這種變換,渲染三角形上面的影象,最後合成到最終渲染圖中。

這種方法因為對每個三角形都涉及到座標變換,所以實際做出來效果是,對於只含兩個三角形的平面,可以

實時渲染,對於多個三角形的平面,效率降低了幾個檔次。然而對於含多個三角形的球面來說,幀率小於1,

不能做到實時渲染。但是總體來說,效果已經出來了。

另外有一個特別重要的函式,就是渲染函式。雖然不能叫渲染,因為通過這個函式並不能把物體直接顯示到

螢幕上,只是按照上面說的,做一些內部的處理,比如變換、儲存等。

渲染函式首先利用自身的變換矩陣M和相機的變換矩陣cam->M計算出物體的變換矩陣CM,

然後對每個頂點做V*CM的變換,變換到相機座標,然後通過相機的引數進行裁剪,並通過相機的投影矩陣

進行投影變換。

7.相機

在瞭解相機之前,首先要確保瞭解了矩陣運算。如果沒有問題,那麼可以往下繼續閱讀。

前面說到相機和燈光以及物體都是連結串列元素,因此相機也有連結串列元素應有的成員資料結構,這裡不再贅述。

主要說一下相機的投影矩陣。這裡涉及到的投影都是透視投影,而不涉及正交投影。

這裡也使用上面說到的方法,記錄一個投影矩陣和投影逆矩陣。投影矩陣用於將相機座標投影,

而投影逆矩陣用於將投影逆變換到相機座標。在網路上都可以找到投影矩陣的公式,但是投影逆矩陣並沒有

涉及到。本文將做一個投影逆矩陣的推導。

投影矩陣:


如圖,推導不再贅述。下式是在投影在原點中心對稱的情況下的化簡。

投影逆矩陣:


首先新增單位對角矩陣,進行逆矩陣求解:

第一步,每行分別除以2n/w、2n/h、-(f+n)/(f-n)

第二步,對第4列和第8列進行交換

第三步,第3列加上第4列

那麼可以得到如圖最下右邊四列就是逆矩陣。

在進行投影時,首先將頂點V=[x y z w]和投影矩陣相乘。另外,標準3D解決方案中還有一個叫做

規範視域體的概念,也就是從(-1, -1, 0)到(1, 1, 1)的盒子。如果所有的物體都投影到這個盒子中,

則由於z軸是從0~1的,所以很方便的就可以對z進行深度測試。因此將投影后的座標全部除以z得到

Vp=[xp/zp, yp/zp, 1, wp/zp]。

那xy可能超出(-1,-1)到(1,1)嗎?標準的3D中是有一個裁剪的,就是對於超過視椎體的三角形部分進行剪下。

但是我做的只做一些簡單的操作,將超過的xyz座標強制改變到far-near和width和height中。

但無論用裁剪還是強制改變,都可以確保投影以後除以z落在規範視域體內。

最後需要做的就是在規範視域體座標中,對x和y進行遍歷,如果物體落在這裡,那麼在螢幕上渲染出物體。

8.燈光

前面已經提到,在計算好物體的變換後,所有的引數都是儲存在物體以及物體上每一個頂點的資料結構上的。

因此,在渲染時,對渲染頂點進行遍歷迴圈中,不僅可以得到其在標準視域體中的投影,進行渲染,

還可以得到頂點在世界座標下的頂點座標和線座標,此時如果新增光線處理,可以說是非常容易的。

光線處理使用Phong模型,即

I=KaIa + KdId(N*L) + KsIs(R*V)^2

其中a表示環境光,d表示漫反射,s表示鏡面反射

Ka表示環境光係數,Ia表示全域性照明光顏色

Kd表示漫反射係數,Id表示漫反射顏色,N就是上面說的頂點的法線,而L則是光線,也就是連線

光源點和頂點的的向量。

Ks表示鏡面反射係數,V表示頂點法線,R表示鏡面反射,其方程為

2*(L*N)*N-L

因此,如果對於擁有固定顏色的頂點可以通過光線處理,得到光線係數

f=ka + Kd(N*L) + Ks(R*V)^2

然後用這個係數和顏色進行乘積處理,就能得到渲染畫素的顏色值。

9.顏色合成

顏色合成分為顏色疊加和顏色系數乘。

顏色疊加:通過rgb和RGB,以及混色系數s進行顏色混合疊加

r' = max(min(r * s + R *(1-s), 255), 0);

g' = max(min(g*s + G*(1-s), 255), 0);

b' = max(min(b*s + B*(1-s), 255), 0);

顏色系數乘:通過係數s對rgb進行進行加深或減淡處理。適合於陰影。

r' = max(min(r *s, 255), 0);

g' = max(min(g * s, 255), 0);

b' = max(min(b * s, 255), 0);

10.螢幕顯示

本來是要在EGE/EasyX上做的,但為了圖簡單和通用,先在WindowsGDI上起草。因此,對於顯示

庫的平臺支撐性要足夠好。這裡使用了陣列進行快取。分別定義了幾個DWORD的陣列,大小為

當前視窗的幾個大小,用於儲存渲染的結果。然後通過這個矩陣,對不同的庫平臺進行繪製。

11.效果圖


12.接下來

接下來將會看到:如何使用投影逆變換將標準視域體的任意點還原到相機座標,

以及如何依賴這一特性,在三角形渲染中,獲取材質貼圖顏色。