影片遊戲設計/程式設計/框架/2D vs 3D/3D 引擎
如果遊戲需要 3D 環境,則表示它將使用 3D 檢視,其特點是使用基於多邊形的圖形。多邊形是扁平的形狀,在低數量(低解析度多邊形場景)中,圖形通常是角形的。
一個好的 3D 引擎應該以相當的速度執行,無論整個世界的規模如何;速度應該與實際可見的細節數量成正比。當然,如果速度只取決於你想要繪製的畫素數量,那會更好,但由於顯然還沒有人找到一個能做到這一點的演算法,我們只能嘗試改進過去的工作。
本節將嘗試描述 3D 引擎架構中的元件。3D 引擎包含許多概念。我們將盡力涵蓋使用 3D 引擎的其他系統(例如 AutoCAD、Blender、醫療程式等),但我們將重點關注遊戲中的 3D 引擎。
遊戲引擎是一個非常廣泛複雜的主題。它們有自己的架構和方法,針對特定遊戲或遊戲型別中必須具備的功能。一個引擎的工作方式不一定就是所有引擎的工作方式,甚至大多數引擎都不是,也就是說每個引擎都是獨特的創作。
世界的 3D 性質將要求大多數概念藝術在獲得批准後被掃描並建模到 3D 建模應用程式(如 Maya 或 3DsMax)中的平面中。
一些流行的 3D 圖形引擎:水晶空間、Irrlicht、Ogre3D
3D 建模和動畫示例:AutoDesk(以前稱為 Alias)Maya、3dbuzz、AutoDesk 3dsMax、Blender、TrueSpace(現在免費)
基本上,基於傳送門的引擎允許對通常構成虛擬世界的龐大資料集進行“空間索引”:換句話說,它可以是一種簡單的技術,可以避免在每幀都顯示所有這些資料,而是隻繪製相關的資料,甚至可以節省快速記憶體需求。
一個基本的傳送門引擎依賴於表示世界的 dataset。世界被細分為區域,我稱之為“扇區”。扇區透過“傳送門”連線,因此得名“傳送門引擎”。基於傳送門的引擎世界可以被認為是 邊界表示(或 B-rep)資料結構的一種變體,它對遊戲的世界的屬性進行了專門化。渲染過程從攝像機所在的扇區開始。它繪製當前扇區中的多邊形,當遇到傳送門時,就會進入相鄰的扇區,處理該扇區中的多邊形。當然,這仍然會繪製世界上所有的多邊形,假設所有扇區都以某種方式連線。但是,並非所有傳送門都是可見的。如果傳送門不可見,則它連結到的扇區不必繪製。這很合乎邏輯:一個房間只有當攝像機到該房間有一條視線且該視線沒有被牆壁遮擋時才可見。
所以現在我們有了我們想要的東西:如果一個傳送門不可見,跟蹤就會在那裡停止。如果那個傳送門後面有一個很大的世界,那個部分永遠不會被處理。因此,實際處理的多邊形數量幾乎完全等於可見的多邊形數量加上插入的傳送門多邊形。
現在應該也清楚了在世界中應該在哪些地方插入傳送門:傳送門的理想位置是門、走廊、窗戶等等。這也清楚地說明了為什麼傳送門引擎不適合戶外場景:在戶外場景中幾乎不可能選擇好的傳送門位置,每個扇區幾乎可以“看到”世界上其他所有扇區。但是,傳送門渲染可以與戶外引擎完美結合:如果用其他型別的引擎渲染你的景觀,你可以在洞穴、建築物等的入口處放置傳送門。當“普通”渲染器遇到一個傳送門時,你可以簡單地切換到傳送門渲染,以處理該傳送門後面的所有內容。這樣,傳送門引擎甚至可以很適合“太空模擬”……
有時現有的 3D 引擎無法滿足遊戲的要求、功能或許可。 3D 遊戲引擎是龐大的軟體專案。一個人確實可以編寫一個,但這不是一個一夜之間就能完成的過程。它可能會導致有超過幾兆位元組的原始碼。如果沒有足夠的決心完成它,嘗試就會失敗,甚至會危及整個遊戲專案。
在開始編寫引擎之前,需要進行一些計劃工作,就像遊戲本身一樣,以便確定將如何使用引擎或可以實現什麼。通常,建立 3D 引擎源於特定遊戲的需求,但它們本身也可以被視為產品。不要指望第一次就能編寫一個完整的引擎,如果必須構建引擎並且時間或知識不足,請讓合適的人來做,或者將工作重點放在一個更小的遊戲專案上,該專案對引擎的要求列表同樣很小。
不要直接開始編寫引擎,因為會出錯,並且需要多次重寫引擎的大部分內容才能新增效果和控制。對引擎設計進行一些預先思考可以節省時間和精力。
3D 引擎中的功能列表可以包括曲面、動態照明、體積霧、鏡子和門戶、天空盒、頂點著色器、粒子系統、靜態網格模型和動畫網格模型。如果一個人對所有這些東西的工作原理有很好的瞭解,並且具有實現它們的技術能力,他可能會將它們組合成自己的引擎。
在本節中,我們將討論 3D 引擎中通常存在的元素。這些本質上包括工具、控制檯、系統、渲染器、引擎核心、遊戲介面和遊戲本身。
讓我們看一下基本元件,以一個功能齊全的 3D 遊戲引擎可能的結構劃分為例。這尤其針對那些對 3D 比較瞭解的人,但需要了解遊戲引擎需要多少工作量和多少部分。
在開發過程中,將需要處理特定資料,不幸的是,這不像編寫一些定義立方體的文字檔案那麼簡單。至少需要處理由 3d 模型編輯器、關卡編輯器和圖形程式生成的資料。
建立原始資料的工具可以購買,也可以免費獲得。不幸的是,將需要進行特定於域的編輯和資料處理,這些編輯和資料處理特定於您的遊戲,這些工具可能還不存在,因此需要建立它們。
這可能是一個關卡編輯器,如果找不到滿足所需功能的編輯器。可能還需要編寫一些程式碼來將檔案打包到一個檔案中,因為處理和分發數百或數千個檔案可能會很麻煩。還需要編寫各種 3d 模型編輯器格式到您自己格式的轉換器或外掛,以及處理遊戲資料所需的所有工具,例如可見性計算或光照貼圖。
基本原則就是,您在工具方面的程式碼可能與實際遊戲程式碼一樣多,甚至更多。您可以找到可使用的格式和工具,但遲早您會意識到需要一些非常適合您的引擎的東西,最終您還是會自己編寫。
雖然可能急於完成工具以便能夠使用,但在編碼時也要注意。將來可能有人會嘗試使用、擴充套件或修改您的工具,尤其是如果您將引擎設為開源或可修改的。
您可能應該做好準備,在製作圖形、關卡、聲音、音樂和模型上花費與編寫遊戲、工具和引擎一樣多的時間。
系統是引擎與機器本身通訊的部分。如果引擎編寫得很好,系統是唯一在移植到不同平臺時需要進行重大修改的部分。系統內包含幾個子系統。圖形、輸入、聲音、計時器、配置。系統負責初始化、更新和關閉所有這些子系統。
圖形子系統非常簡單。如果它處理將東西放在螢幕上,它就歸這裡管。您最有可能使用 OpenGL、Direct3D、Glide 或軟體渲染編寫此元件。為了更花哨,您可以實現多個 API 介面,並在它們之上放置一個圖形層,為使用者提供更多選擇以及更多相容性和效能機會。但這有點難,尤其是因為並非所有 API 都有相同的特徵集。
輸入子系統應該接收所有輸入(鍵盤、滑鼠、遊戲手柄和操縱桿),並對其進行統一,並允許對控制進行抽象。例如,在遊戲邏輯中的某個地方,將需要檢視使用者是否要向前移動其位置。與其對每種型別的輸入進行多次檢查,不如對輸入子系統進行一次呼叫,它將透明地檢查所有裝置。這還允許使用者輕鬆配置,並輕鬆地讓多個輸入執行相同的操作。
聲音系統負責載入和播放聲音。非常簡單,但大多數當前遊戲都支援 3D 聲音,這會讓事情變得更復雜。
3d 遊戲引擎中的幾乎所有東西(我在這裡假設的是即時遊戲……)都基於時間。因此,您需要在計時器子系統中使用一些時間管理例程。這實際上很簡單,但由於任何移動的東西都將隨時間移動,因此一組不錯的例程可以防止您一遍又一遍地編寫相同的程式碼。
配置單元實際上位於所有其他子系統之上。它負責讀取配置檔案、命令列引數或使用的任何設定方法。其他子系統在初始化和執行時查詢此係統。這使得更改解析度、顏色深度、按鍵繫結、聲音支援選項甚至載入的遊戲變得很容易。使您的引擎可配置性很高,這使得測試變得更容易,並且允許使用者按照自己的喜好設定內容。
在這裡我們將討論諸如 Windows 下的螢幕訪問之類的事情。除此之外,還將提供一些關於雙緩衝、畫素繪製等方面的基本資訊。
在過去,有 DOS。在 DOS 中,有模式 13。一旦初始化,不知情的編碼人員就可以寫入影片記憶體,從一個固定地址開始,如果我沒記錯的話,是 A0000。所有這些都沒有阻塞計算機,並且在世界上幾乎所有 VGA 卡上都能正常工作。該模式允許輸出 256 色圖形,如果你做得好,這就可以了。
但後來發生了一件非常糟糕的事情。SVGA 卡開始出現在各個地方,更糟糕的是,計算機變得足夠快以至於可以進行真彩色圖形。從那一刻起,一切都不再確定。您有 15 點陣圖形(出於某種非常奇怪的原因,有人決定忽略一個完整的位),16 點陣圖形、24 位、32 位,以及這些位陣列中顏色元件的隨機排序。例如,您可以遇到一張做 RGB 的卡,但也可以做 BGR 或 RGBA……因此,VESA 標準被髮明出來。這使事情再次變得可以忍受。
如您所知,一旦出現標準,就會有其他人(微軟)認為可以做得更好。因此,Windows 被“展示”給世界(或者被強塞到我們喉嚨裡,您喜歡哪個)。從那一刻起,您永遠無法確定您的處理器在特定時刻在做什麼。它可能正在執行您的應用程式,但也可能在您演示的某個關鍵時刻切換到另一個“重要”應用程式……當您屈服於微軟並開始編寫“本機”應用程式時,情況會變得更糟:繪製到視窗是一場災難,需要“鎖定”,而且速度更慢。
好了,抱怨夠了。讓我們嘗試著接受現狀。我總是使用一個緩衝區,因為它看起來很像舊的模式 13。當我有這樣的緩衝區時,我會嘗試儘可能高效地將其傳送到一個視窗。這是一個絕對必要的但毫無趣味的步驟。我最喜歡的“工具包”是 MGL:一個來自 SciTech 的相當龐大的庫,SciTech 是 UNIVBE 的製作方,也就是“顯示醫生”。這個庫允許你相對容易地建立一個視窗,並獲取指向它的指標。但是,它仍然需要“鎖定”,這意味著你無法很好地除錯:在“鎖定”期間,沒有螢幕更新。這可以透過使用你自己的緩衝區並在完成操作後將其複製到視窗來部分解決。在這種情況下,鎖定僅在複製過程中需要,這通常不是什麼大問題。MGL 庫有一個小問題:它做的不僅僅是顯示視窗。例如,它還可以繪製線條和多邊形,並且包含 OpenGL 內容。由於這種不必要的負擔,你必須將一個 1.5 兆位元組的庫連結到你的程式,才能開啟一個視窗...... 所以,還有更簡單的方法,我想介紹給你。它被稱為“PTC”,是“Prometheus True Color”的縮寫,或者類似的東西。它是一個非常簡單的 Direct X 包裝器,允許你在各種位深度和解析度下進行全屏圖形輸出,僅此而已。因此,這個庫非常小。開啟顯示只需要幾個(易於理解和記憶的)命令。之後,你就可以完全訪問顯示器了。還有一個問題:可怕的鎖定。這也同樣可以透過緩衝區複製來解決。
我必須提到的 PTC 的一大優勢是它與平臺無關:PTC 也適用於 Linux、BeOS 和其他一些作業系統。因此,如果你的程式使用 PTC 進行圖形輸出,你只是在“使用”Windows,而不是被迫朝著它思考。這也讓將來遷移到其他作業系統變得更加容易,因為你的程式碼只需稍作修改即可再次執行。
所以,給你留一點家庭作業:訪問 http://www.gaffer.org/,並獲取 PTC 庫。看看一些示例程式,你會發現它們非常簡單。完成之後,我們就可以繼續了。
這是一個可以玩的小 PTC 應用程式
#include "ptc.h"
#include <stdlib.h>
int APIENTRY WinMain (HINSTANCE hInst, HINSTANCE hPrevInst, LPSTR lpCmdLine, int nCmdShow){
try{
Format format(32,0x00FF0000,0x0000ff00,0x000000ff);
Console console;
console.open("demo",320,200,format);
Surface surface(320,200,format);
int32* pixels=(int32*)new int32[320*200];
while (!console.key()){
int32* surf=(int32*)surface.lock();
for (int i=0; i<(320*200); i++){
*(surf+i)=*(pixels+i);
}
surface.unlock();
surface.copy(console);
console.update();
}
return 0;
}
catch (Error &error){
error.report();
}
}
解釋:這裡的大部分內容可能都比較清楚。我在緩衝區部分添加了幾行:首先,我聲明瞭一個與 PTC 顯示器大小相同的緩衝區,該緩衝區在每次“鎖定”期間都會被複制到 PTC 表面。這使表面實際鎖定的時間儘可能短,防止了錯誤程式碼導致的掛起。
注意:在本系列的剩餘部分,我們將完全忽略 Windows 部分。假設你已經設法獲得了用於繪製的緩衝區,以便我們可以專注於有趣的東西:硬核 3D。
現在我們已經完成了惱人的緩衝區和視窗部分,就到了真正的主題:向量數學。矩陣很酷。它們也相當難以完全理解,所以如果你想了解有關它們的一切,現在就是尋找一個關於它們的優秀網站或書籍的時候了(最好是一個網站,別忘了將連結提交到這個網站!)。
首先我想談論另一件事:收集知識。我經常收到人們的電子郵件,詢問我如何學習我的所有知識。“你能推薦什麼書?”“你從哪裡開始?”,類似這樣的問題。我對此的通常反應是和我的妻子聊聊天,談論“自學成才”:你必須自己學習東西。不要依賴他人的知識,自己收集資訊。更重要的是:嘗試。你不會透過讀書變得優秀,你會透過犯錯變得優秀。透過瀏覽網路收集資訊,這是世界上最大的資訊來源。而且它通常比書店更及時。使用這種方法,可以培養出其他人沒有的知識:你以獨特的方式將收集到的知識組合在一起,使你能夠做其他人沒有做過的事情,嘗試別人沒有想到的事情。這就是我獲得(可疑的)知識的方式。這有點難以解釋,但我希望你明白了我的意思。:)
好的,回到矩陣。矩陣可以用來定義 3D 空間中的旋轉和平移。因此,我通常使用 4x4 矩陣:3x3 定義任意旋轉,最後一列定義移動,也就是平移。然後可以透過將座標(向量)放入矩陣中來計算旋轉後的座標。
假設你將矩陣定義為一個浮點數陣列:四行四列,例如名為 cell[4][4]。你可以使用以下公式“放入”一個向量
x_rotated = cell[0][0] * x + cell[1][0] * y + cell[2][0] * z + cell[3][0]
y_rotated = cell[0][1] * x + cell[1][1] * y + cell[2][1] * z + cell[3][1]
z_rotated = cell[0][2] * x + cell[1][2] * y + cell[2][2] * z + cell[3][2]
注意最後一排沒有使用,所以理論上你甚至不必儲存它。
那麼我們如何建立矩陣本身呢?我們從單位矩陣開始
1 0 0 0
0 1 0 0
0 0 1 0
0 0 0 1
這個矩陣具有獨一無二的特性,它對 3D 座標沒有任何影響:它完美地保留了它們。然後我們獲取圍繞 x 軸的所需旋轉,這裡我將其稱為“rx”。此旋轉的矩陣如下所示
1 0 0 0
0 cos(rx) sin(rx) 0
0 -sin(rx) cos(rx) 0
0 0 0 1
所以,如果你只想圍繞 x 軸旋轉你的 3D 座標,你可以使用這個矩陣。但是,如果你還想圍繞 y 和 z 軸旋轉,你必須將 x 旋轉矩陣與 y 和 z 旋轉矩陣“連線”。它們如下所示
y
cos(ry) 0 -sin(ry) 0
0 1 0 0
sin(ry) 0 cos(ry) 0
0 0 0 1
z
cos(rz) sin(rz) 0 0
-sin(rz) cos(rz) 0 0
0 0 1 0
0 0 0 1
好的,那麼我們如何連線矩陣呢?方法如下:連線後的矩陣的 Cell [x][y] 等於行乘以列的總和。一些 C 程式碼解釋了我的意思
for (c=0; c<4; c++) for (r=0; r<4; r++)
conc[c][r] = m1[0][r] * m2[c][0] +
m1[1][r] * m2[c][1] +
m1[2][r] * m2[c][2] +
m1[3][r] * m2[c][3];
注意連線矩陣的順序很重要!這是合乎邏輯的:如果你先圍繞 x 軸旋轉 90 度,然後圍繞 z 軸旋轉 45 度,你會得到與你以相反順序執行這些旋轉不同的旋轉。
這些是基本知識。以下是如何在實踐中應用它們。想象你有一個立方體。它位於 3D 空間中的 (100,100,100) 處,應該圍繞該座標旋轉。它的旋轉是 (rx, ry, rz),當然我們會在一個漂亮的迴圈中改變 rx、ry 和 rz,以便有一個旋轉的立方體。立方體的頂點可以定義如下
1: -50, -50, -50
2: 50, -50, -50
3: 50, 50, -50
4: -50, 50, -50
5: -50, -50, 50
6: 50, -50, 50
7: 50, 50, 50
8: -50, 50, 50
注意立方體最初是在 3D 原點定義的。這是因為我們希望旋轉它:如果你旋轉一個以 (100,100,100) 為中心的立方體,它也會圍繞 3D 原點旋轉,導致在 3D 空間中進行大範圍的掃描。立方體的中心,也就是它的平移,應該是 (100,100,100)。因此,我們首先使用 rx 建立一個矩陣。我們為 ry 建立另一個矩陣,並將這兩個矩陣連線起來。然後我們為 rz 建立另一個矩陣,並將它連線起來。最後新增平移,這可以透過直接更改相關的矩陣元素 cell[3][0]、cell[3][1] 和 cell[3][2] 來完成(分別對應 x、y 和 z)。然後我們將頂點放入最終矩陣中,瞧!當然需要處理旋轉後的座標,才能在螢幕上顯示它們。你可以使用以下公式來實現
screen_x = rot_x * 500 / rot_z + 160;
screen_y = rot_y * 500 / rot_z + 100;
注意我在這裡使用 160 和 100 作為 320x200 緩衝區的中心。只要生成的 2D 座標不在螢幕外,就可以直接在顯示器上繪製它們。
好的,這些是基本知識。這裡還有一些內容來激發你的胃口:你不需要在連線 rx、ry 和 rz 後就停止。例如,如果你有兩個立方體,其中一個圍繞另一個立方體旋轉,你可以為第二個立方體定義一個只有圍繞 x 軸旋轉的旋轉矩陣。如果你將該矩陣連線到“父”物件的矩陣,那麼旋轉的立方體將被第一個立方體的矩陣加上它自己的矩陣旋轉。這使得構建一個“層次結構”的物件系統成為可能:例如一個機器人,它的手臂相對於機器人的身體旋轉(將手臂矩陣連線到機器人矩陣),手相對於手臂旋轉(再次連線)。
你還可以使用矩陣做一些其他很酷的事情,比如反轉它們。這可以用在快速表面剔除中,我以後會詳細介紹,這篇文章已經有點長了。
這是一種幾乎被引擎中所有其他系統使用的系統。這包括你所有的數學例程(向量、平面、矩陣等)、記憶體管理器、檔案載入器、容器(如果你不自己編寫,可以使用 STL)。這些東西非常基礎,你參與的幾乎所有專案都可能用到。
啊,是的,每個人都喜歡渲染 3D 圖形。由於渲染 3D 世界的方法有很多,幾乎不可能給出適用於所有情況的圖形管道的描述。
無論你如何實現渲染器,最重要的是使你的渲染器成為基於元件的,並且乾淨。確保你為不同的事情建立了不同的模組。我將渲染器分成以下子部分:可見性、碰撞檢測和響應、相機、靜態幾何體、動態幾何體、粒子系統、貼圖、網格、天空盒、燈光、霧化、頂點著色和輸出。
這些部分中的每一個都需要有一個介面,以便輕鬆地更改設定、位置、方向或與系統相關的任何其他設定。
要注意的一個主要陷阱是功能膨脹。在設計階段決定你將要實現什麼功能。如果你沒有新增新功能,那麼開始變得難以做到,並且解決方案會變得笨拙。
一個好且方便的方法是讓所有三角形(或面)最終透過渲染管道的同一個點。(不是一次一個三角形,我指的是三角形列表、扇形、條帶等)。將所有內容轉換成可以透過相同的燈光、霧化和著色程式碼處理的格式需要更多工作,但最終你會很高興,因為只要更改其材質/紋理 ID,你就可以對遊戲中任何多邊形執行的任何效果都可以在任何多邊形上執行。
從多個點繪製東西並沒有什麼壞處,但如果你不小心,會導致冗餘程式碼。
你最終會發現,你想要實現的所有那些酷炫的效果只佔你最終編寫的程式碼的 15% 或更少。沒錯,遊戲引擎的大部分內容都不是圖形。
我正在開發一個名為 **Focus** 的輕量級 3D 引擎實驗,我只是用它來嘗試一些小東西,比如雙線性插值、陰影和一般的快速軟體渲染。當然,這個小引擎不是我的第一個引擎,因此我想談談 3D 資料結構。我發現放棄一個引擎並從頭開始的主要原因是“糟糕的設計”。當然,重寫不是問題:從頭開始通常可以改進程式碼,因為你可以快速地重寫以前引擎中痛苦地編寫的程式碼,而且通常程式碼更簡潔、更高效。早期的引擎經常有一些限制,我只能透過進行重大修改或重寫才能擺脫這些限制。這些限制中的大多數僅僅存在於資料結構的限制。以下是一些例子。
我的第一個 3D 引擎之一(E3Dengine)完全用 Pascal 編寫。多邊形在內部儲存為四邊形凸多邊形,因為我的世界只包含正方形,所以一開始這似乎還不錯。經過幾個月的編碼,我開發了一個室內渲染引擎,具有 6DOF(順便說一下,這是在 Quake 釋出之前),我遇到了第一個重大問題:與 Z=0 平面進行裁剪。對於室內引擎來說,這是必要的,因為多邊形通常部分位於你的身後(與物件渲染引擎不同,物件渲染引擎通常整個物件都在你的面前)。當將透視應用於相機後面的頂點時,這些頂點會發生奇怪的事情:請記住上一篇文章中提到的透視公式,其中旋轉後的 x 和 y 座標除以它們的深度。一旦 x 和 y 座標超過 z=0,它們就會被此公式取反,而在 z=0 上,該公式會導致“除以零”。從多邊形中裁剪某些東西的問題在於,多邊形會獲得多餘的或更少的頂點。我的結構可以處理 3 個頂點,但不能處理 5 個頂點…… 在這種情況下,我可以將所有陣列中的空間增加到每個多邊形 5 個頂點,但這會弄亂我的彙編例程,因為它們期望某些資料位於記憶體中的固定位置。
經驗教訓
- 在引擎開發的早期階段,不要使用匯程式設計序。在 Pascal 中,有時仍然需要使用它,但最新的 C++ 編譯器真正將彙編程式變成了只在最後最佳化步驟中使用的工具。
- 不要使用固定值來表示諸如多邊形中頂點的數量之類的東西。更一般地說,使資料結構可擴充套件。
最近的引擎有其他限制:物件記錄無法處理分層物件系統,多邊形記錄無法輕鬆擴充套件以包含凹凸資料,等等。所以我想你可能從我的傷疤中學到了一些東西……
一個現代的 3D 引擎需要相當多的資料,而且這些資料存在於不同的層次。以下是我認為你需要的內容(自上而下)
1. 世界結構 2. 扇區(區域性)結構 3. 物件結構 4. 多邊形結構
在理想情況下,所有這些都應該儘可能通用,以避免過度重寫,從而導致潛在計程車氣低落,並讓有才華的新程式設計師離開 3D 領域。:) 這是一個不好的事情。
好的,讓我們繼續討論細節。在頂層(世界結構)上,你應該考慮如何組織一個世界,如果你要構建一個需要類似東西的引擎。我的意思是,如果你是一個初學者,你可能會像我一樣從一個物件渲染器開始。所以,將這個物件視為一個“世界”可能很誘人,因為無論如何沒有其他資料要處理。你可能只是宣告一個頂點陣列,旋轉它們,並將它們轉儲到螢幕上。很好。但想想這個:如果你的頂點陣列儲存在一個更大的結構中,稱為“物件”,你可能會(很可能)嘗試讓你的引擎使用兩個或更多個物件。以下是一些你在確定適合自己的世界結構時需要考慮的因素。
一個世界可能太大了,無法在每一幀中顯示或甚至處理。因此,你需要一些結構來允許部分處理。最好不要考慮看不見的世界部分和物件。
這意味著世界和其中的物件之間必須存在某種聯絡。否則,你將渲染所有物件,加上世界可見的部分,這仍然可能太繁重了。
你可能還想有一個或多或少的動態世界。這再次需要不同的資料結構。以下是我如何實現頂層。
class World
{
SectorList* sectors;
};
以下是扇區的樣子。
class Sector
{
PolygonList* polygons;
ObjectList* objects;
};
大多數引擎的工作原理都是如此:即使是 BSP 引擎也是如此。這樣,只要物件每次移動時都儲存在正確的扇區中,物件就會直接連結到世界。因此,物件可見性問題現在直接與世界可見性問題相關聯。
你應該考慮的下一個層次(或者如果你只做物件引擎,那麼是第一個層次)是物件資料結構。以下是一個示例結構。
class Object
{
PolygonList* polies;
ObjectList* childs;
Matrix matrix;
};
這個結構中有一些有趣的地方。首先,我總是將指向此結構中物件列表的指標放在這個結構中。這樣做是有充分理由的:許多物件以某種方式連結在一起。如果它們是連結在一起的,它們的矩陣通常以增量的方式確定:例如,手臂相對於軀幹旋轉,因此它需要自己的矩陣和其父節點(軀幹)的矩陣。軀幹本身可能相對於世界旋轉:因此,我也在 Object 類中放置了一個矩陣。
好的,再往下一個層次,多邊形層次。以下是一個基本的多邊形類。
class Polygon
{
VertexList* vertices;
Texture* texture;
Sector* sector;
Plane plane;
};
以及頂點類。
class Vertex
{
Coordinate* position;
float U, V;
};
最後是座標類。
class Coordinate
{
Vertex original;
Vertex rotated;
boolean processed;
};
這是有趣的東西,因為即使是初學者也會遇到這種情況。讓我們從多邊形類開始:在本例中,我使用了 VertexList 類來儲存我的頂點。這使得在多邊形中擁有任意數量的頂點成為可能,但也有一個缺點:每個列表條目也具有一個“next”指標(假設它是一個連結串列)。因此,每個頂點比你預期的多佔用 4 個位元組。如果這不是一個大問題,請考慮以下情況:另一種選擇是陣列。陣列是按順序儲存的,通常對快取更好。但陣列的尺寸是固定的…… 在 Focus 中,我最終選擇了陣列方法。因此,你必須在例項化多邊形時指定頂點的數量。我的多邊形類如下所示。
class Polygon
{
Vertex** vertices;
int vertices;
Texture* texture;
Sector* sector;
Plane plane;
};
因此,有一個指向記憶體塊的單個指標,該記憶體塊儲存指向頂點的指標。請注意,我仍然使用額外的記憶體:一個用於頂點數量的整數(因為列表末尾不再有空指標),以及指向指標塊的指標。
在多邊形類中要注意的另一件事是扇區指標。它用於門戶多邊形:由於門戶連結了兩個扇區,因此這是你應該儲存此資訊的地方。
最後,還有一個平面結構。如果你想重複使用平面(例如,如果你有很多位於同一平面的多邊形),你也可以將它設定為平面指標。如果你不知道如何處理平面:我會在以後的文章中詳細介紹。
在 Vertex 類中儲存座標指標也是迴圈利用的原因。立方體有八個頂點,但六個面都有 4 個頂點,總共 24 個頂點。在立方體的情況下,很明顯,只有八個頂點的 3D 位置是唯一的。然而,紋理座標不需要由同一位置的兩個多邊形頂點共享。因此,頂點類應該包含 U/V 資訊(紋理座標),以及指向 3D 位置的指標。這節省了空間和處理時間,因為旋轉後的頂點不必再次旋轉。這讓我想到座標類。
在座標類中,可能比你預期的要多一些東西。首先,它既包含一個原始向量,也包含一個旋轉後的向量。你需要保留原始向量以確保最大精度:即使是高精度的浮點算術也會在反覆旋轉座標時快速引入數值不穩定性。另一個有趣的是“processed”欄位:它指示頂點是否已針對當前幀旋轉。這消除了重複旋轉。
以下是針對更高階程式設計師的一條小建議:可以使用整數而不是布林值。如果你將當前“幀 ID”儲存在這個整數中(只需在每一幀中增加它),你就不用在每一幀中重置所有旋轉頂點的“processed”標誌。當很難在繪製過程結束時確定巨大的頂點集中哪些頂點已旋轉時,這特別有用。
好的,這就是我想說關於 3D 資料結構的內容。我的目的是讓你在早期就開始考慮這個問題:你設定資料的方式可能是在設計引擎時做出的最重要的決定。簡而言之,實際的建議是:使用指標和列表,儘可能少使用陣列。陣列最初看起來很簡單,但它們很快就會讓你的引擎變得更復雜,迫使你做一些 C++ 本身不支援的醜陋的事情。
編碼線框立方體的示例
[edit | edit source]我可以就 PTC 和 Windows 程式設計、資料結構等等喋喋不休,但最終沒有什麼比一個好的例子更好了。嗯,我剛剛編寫了一個非常小的程式(實際上只有 54 行程式碼),它只顯示了一個線框立方體,這可能正是你需要的。以下是程式碼。
#include "ptc.h"
#include "math.h"
float angle, x[8], y[8], z[8], rx[8], ry[8], rz[8], scrx[8], scry[8];
void line (unsigned short* buf, float x1, float y1, float x2, float y2)
{ double hl=fabs(x2-x1), vl=fabs(y2-y1), length=(hl>vl)?hl:vl;
float deltax=(x2-x1)/(float)length, deltay=(y2-y1)/(float)length;
for (int i=0; i<(int) length; i++)
{ unsigned long x=(int)(x1+=deltax), y=(int)(y1+=deltay);
if ((x<640)&&(y<480)) *(buf+x+y*640)=65535;
}
}
void render (unsigned short* buf, float xa, float ya, float za)
{ float mat[4][4]; // Determine rotation matrix
float xdeg=xa*3.1416f/180, ydeg=ya*3.1416f/180, zdeg=za*3.1416f/180;
float sx=(float)sin(xdeg), sy=(float)sin(ydeg), sz=(float)sin(zdeg);
float cx=(float)cos(xdeg), cy=(float)cos(ydeg), cz=(float)cos(zdeg);
mat[0][0]=cx*cz+sx*sy*sz, mat[1][0]=-cx*sz+cz*sx*sy, mat[2][0]=cy*sx;
mat[0][1]=cy*sz, mat[1][1]=cy*cz, mat[2][1]=-sy;
mat[0][2]=-cz*sx+cx*sy*sz, mat[1][2]=sx*sz+cx*cz*sy, mat[2][2]=cx*cy;
for (int i=0; i<8; i++) // Rotate and apply perspective
{ rx[i]=x[i]*mat[0][0]+y[i]*mat[1][0]+z[i]*mat[2][0];
ry[i]=x[i]*mat[0][1]+y[i]*mat[1][1]+z[i]*mat[2][1];
rz[i]=x[i]*mat[0][2]+y[i]*mat[1][2]+z[i]*mat[2][2]+300;
scrx[i]=(rx[i]*500)/rz[i]+320, scry[i]=(ry[i]*500)/rz[i]+240;
}
for (i=0; i<4; i++) // Actual drawing
{ line (buf, scrx[i], scry[i], scrx[i+4], scry[i+4]);
line (buf, scrx[i], scry[i], scrx[(i+1)%4], scry[(i+1)%4]);
line (buf, scrx[i+4], scry[i+4], scrx[((i+1)%4)+4], scry[((i+1)%4)+4]);
}
}
int APIENTRY WinMain (HINSTANCE hInst, HINSTANCE hPrevInst, LPSTR lpCmdLine, int nCmdShow)
{ for (int i=0; i<8; i++) // Define the cube
{ x[i]=(float)(50-100*(((i+1)/2)%2));
y[i]=(float)(50-100*((i/2)%2)), z[i]=(float)(50-100*((i/4)%2));
}
Console console; // Initialize PTC and start rendering
Format format (16, 31<<11, 63<<5, 31);
console.open ("3D", 640, 480, format);
Surface surface (640, 480, format);
while (!console.key ())
{ unsigned short* buf=(unsigned short*) surface.lock ();
memset (buf, 0, 640*480*2);
render (buf, angle, 360-angle, 0);
angle+=0.2f; if (angle==360) angle=0;
surface.unlock();
surface.copy (console);
console.update();
}
return 0;
}
請花點時間讓這些東西執行起來。使用 VC5 或 VC6,建立一個沒有檔案的新的 Win32 應用程式。將 PTC 庫檔案新增到專案中(使用“新增檔案”),以及 ptc.h 標頭檔案,當然還有我剛剛展示的原始碼。接下來,你必須停用兩個預設庫的包含(在“連結器輸入設定”下):LIBC 和 LIBCD。現在程式應該可以正常編譯。你可以嘗試透過將在一行中執行多個變數初始化的程式碼行拆分來使其更具可讀性。:)
那麼這個程式做了什麼?讓我們從程式入口開始,在本例中是 WinMain 函式。這裡發生的第一件事是立方體的定義。立方體有八個頂點,可以按如下方式初始化。
1. x: -50 y: -50 z: -50
2. x: 50 y: -50 z: -50
3. x: 50 y: 50 z: -50
4. x: -50 y: 50 z: -50
5. x: -50 y: -50 z: 50
6. x: 50 y: -50 z: 50
7. x: 50 y: 50 z: 50
8. x: -50 y: 50 z: 50
使用模運算子的奇怪結構正是這樣做的(只是更短:)。如果你花點時間視覺化這些資料,你會發現這些是圍繞 (0, 0, 0) 的 8 個頂點,這很方便,因為圍繞 3D 空間的原點進行旋轉很容易。
之後,我們需要設定 PTC。在本例中,我使用了 16 位顯示器,解析度為 640x480 畫素。這種影片模式應該適用於大多數計算機。
在主迴圈中,呼叫函式 'render',並使用指向 PTC 緩衝區的指標和圍繞三個軸的旋轉作為輸入。請注意,旋轉以度為單位傳遞。
函式 'render' 稍微有趣一些。讓我們看看它需要做什麼:最終它應該在旋轉的頂點之間繪製線條,並且這些線條應該位於螢幕中心附近。旋轉是使用矩陣完成的。如果你忘記了它是如何工作的,請回到討論它們的這篇文章。如你所知,可以透過計算圍繞每個軸的旋轉矩陣,然後將它們連線起來,來執行圍繞三個軸的旋轉。在本例中,我已經為你完成了連線:矩陣 'mat' 同時填充了正弦和餘弦值。我建議你修改程式碼,以便透過連線各個矩陣來計算最終矩陣,這樣你也可以以不同的順序圍繞軸旋轉。
旋轉後的頂點仍然以原點為中心。由於透視計算會對 z 座標進行除法,因此我們需要將物體移離相機。這是透過將 300 新增到旋轉後的 z 來完成的。請注意,你也可以向 x 和 y 新增一些內容:這就是你在除了原點之外的其他地方旋轉物體的方式。在本例中,物體實際上是圍繞 (0, 0, 300) 旋轉的。
最後,計算透視。請注意,透過將 320 新增到螢幕 x 座標,將 240 新增到螢幕 y 座標,物體也在螢幕上居中。
現在可以將線條繪製到螢幕上。我包含的線條函式非常短,這是它的唯一優點。如果你需要快速程式碼,請放棄此函式,幷包含你自己的組合語言 bresenham 程式碼。關於此程式碼的一些評論
它首先確定需要繪製多少畫素。如果線條的垂直範圍大於水平範圍,它會繪製 abs(y2-y1) 個畫素,否則繪製 abs(x2-x1) 個畫素。這可以防止出現間隙。
繪製畫素時,可以透過將某些內容新增到第一個 x 座標 (x1) 和第一個 y 座標 (y2) 來計算每個後續螢幕位置。這個 '某些內容' 實際上是 x 或 y 範圍除以要繪製的畫素總數。當你仔細想想,在將 'n' 次新增一個位到 x 和 y 之後,邏輯上就會到達 (x2,y2),其中 'n' 是計算的畫素數量。還要注意,'delta-x' 或 'delta-y' 恰好為 1。
如果你想用這段程式碼玩一玩,以下是一些建議
- 編者注:請隨時提交你所做的任何修改後的程式版本,我可能會在這裡釋出它,並附上你的姓名。
修改線條繪製程式碼,使其接受其他顏色。目前顏色始終為 65535,在 16 位顏色模式下為純白色。這種顏色由紅色、綠色和藍色組成:紅色為 5 位,綠色為 6 位,藍色為 5 位。最終顏色使用以下公式計算:red*2048+green*32+blue。請注意,紅色應該在 0 到 31 之間的整數,藍色也是如此。綠色是在 0 到 63 之間的整數。
稍微調整一下物體的位置。它也可以部分超出螢幕,線條繪製程式碼不會崩潰。
嘗試建立除立方體以外的其他物體。使用此程式碼,你可以在壯麗的 3D 中設計你自己的姓名。
擴充套件資料結構,以便不再硬編碼頂點之間的連線。例如,你可以定義邊,它應該包含一個起始頂點和一個結束頂點。然後渲染程式碼應該繪製所有邊,如果你有很多邊,這將使程式碼變得更加美觀。你也可以引入 '多邊形',它包含兩個以上的頂點。
新增一個立方體,並使用矩陣文件中的內容使第二個立方體圍繞第一個立方體旋轉。為了使操作正確,在 (100,0,0) 處構建第二個立方體,以便旋轉使它圍繞第一個立方體 '擺動'。
光線
[edit | edit source]霧
[edit | edit source]液體
[edit | edit source]如果要追求真實感,模擬水或其他液體可能會非常困難。水的細節會隨著觀察者的距離而改變,它可能會有泡沫、透明度、扭曲和鏡面反射。水面的反射性、隨機性和複雜性極高,難以模仿,尤其是當玩家可以與水進行互動或物理屬性可變時,例如模擬碎片飛濺或包含液體的環境的變化。
控制檯
[edit | edit source]我知道每個人都喜歡隨大流,擁有像 Quake 這樣的控制檯。但這確實是一個好主意。使用控制檯變數和函式,你可以更改遊戲和引擎中的設定,而無需重新啟動它。在開發過程中,這對於輸出除錯資訊非常有用。通常情況下,你需要檢查一些變數,將它們輸出到控制檯比執行偵錯程式更快,有時也更好。一旦你的引擎執行起來,如果發生錯誤,你就不必退出應用程式;你可以優雅地處理它,只需列印一條錯誤訊息。如果你不希望你的終端使用者看到或使用控制檯,可以很容易地停用它,沒有人會知道它仍然存在。
遊戲介面
[edit | edit source]3D 遊戲引擎最重要的部分是它是一個遊戲引擎。它不是遊戲。實際的遊戲永遠不應該包含放入遊戲引擎中的元件。在引擎和遊戲之間設定一層薄薄的層可以使程式碼更乾淨,更易於使用。它只是一些額外的程式碼,但也使遊戲引擎非常可重用,並且使用指令碼語言進行遊戲邏輯或將遊戲程式碼放在庫中變得更容易。如果你將任何遊戲邏輯嵌入引擎本身,不要打算在不遇到大量問題和修改的情況下重新使用它。
所以你可能想知道引擎和遊戲之間這層提供了什麼。答案是控制。對於引擎中具有任何動態屬性的每個部分,引擎/遊戲層都提供了一個修改它的介面。此類別中的一些內容包括相機、模型屬性、燈光、粒子系統物理、播放聲音、播放音樂、處理輸入、更改關卡、碰撞檢測和響應以及用於抬頭顯示、標題螢幕或任何其他內容的 2D 圖形的放置。基本上,如果你想讓你的遊戲能夠做到這一點,那麼必須有一個介面進入引擎才能做到這一點。
