標籤:

從零開始手敲次世代遊戲引擎(二十六)

接上一篇,我們來設計編寫解析各種資源所需要的文件格式解析器。

遊戲總的來說屬於互動式多媒體,遊戲引擎的runtime實際上也可以被看做一個多媒體播放器。通常情況下,遊戲當中所包含的媒體文件可以大致劃分為如下幾個類型∶

  1. 靜態圖片(Image)。在一個2D的遊戲當中,靜態圖片是構成遊戲畫面的主要媒體內容。在3D遊戲當中,靜態圖片則往往被作為貼圖使用。歷史上靜態圖片發展出了很多種格式(參考引用1),大部分格式的出現是為了實現圖片的設備(或者應用)無關性和存儲(或者網路傳輸)尺寸的問題,相對的,在圖片的載入速度方面作出了一定的犧牲。因此,在大多數當代的遊戲引擎當中,大都採用平台專有的格式對圖片進行存儲,而不是採用(參考引用1)當中的這些通用格式。然而,由於圖片素材大多來自於美術的DCC製作工具,因此遊戲引擎runtime,或者是遊戲引擎的資源導入工具需要支持至少一種通用格式;
  2. 動態圖像,也稱為視頻。這類素材的典型應用是遊戲的過場動畫,也有作為貼圖進行應用的時候。比如在遊戲場景當中包括一個播放節目的電子廣告屏或者電視,那麼其貼圖就很可能是一個視頻。當用作貼圖時,在遊戲引擎的runtime當中,由於其自身是以一定的fps刷新畫面的,所以視頻是被視為一系列按描繪順序排列的靜止圖片,在每一幀當中繪製過程與靜止圖片並沒有什麼差別;而當作為過場動畫進行播放的時候,因為還需要同步處理音頻數據,往往是將渲染緩衝區直接臨時託管給視頻播放器進行畫面的輸出;
  3. 音頻文件。最為基本的是LPCM,也就是wave文件。這個文件當中包括了聲音信號經過A/D採樣轉換之後的樣本數據。其它的音頻文件格式基本上就是對於這個樣本的不同壓縮演算法。音頻的播放一般比較獨立,在一個專門的模塊當中進行處理。在硬體層面也往往是一個獨立的模塊。當今大多數設備都能在休眠模式或者極低功耗的情況下播放音樂,就是因為音頻處理有單獨的硬體完成。也因為比較獨立,這部分主要的挑戰是對於播放時間點的掌握,如何與畫面以及用戶輸入保持精準的同步。這裡面需要考慮到其它處理帶來的額外壓力所造成的延時。比如我們在玩很多遊戲的時候,當遊戲處於載入畫面的時候往往會出現聲音不連續的情況,這就是因為大量的資源載入擠佔了硬碟的讀取隊列,或者是因為相關線程被同步文件I/O暫時卡死了所導致的。
  4. 3D場景文件。這裡所說的3D場景文件包括我們在文章二十四當中提到的場景地圖以及場景物體和場景物體的組織結構,還包括掛載在場景物體上的各種組件。這部分相對上面來說是最沒有得到標準化的部分。市面上大多數商業遊戲引擎都使用了自己專有的格式,而DCC工具在這一部分也是使用著五花八門的格式。因此這部分需要我們編寫相當程度的代碼去進行各種格式的轉換和導入。

在文章二十五當中我們實現了文件的基本I/O。在將文件讀取進內存之後,我們需要根據其格式規範對其進行解析,把其中我們感興趣的數據提取出來並在內存上以一種方便我們引擎使用的方式進行展開。在這裡我們會用到我們之前編寫的內存管理模塊,也會用到我們之前編寫的數學庫。

首先作為演示,讓我們來寫一個BMP文件的解析器。BMP文件的格式規範請參考(參考引用2)。

首先,我們需要在Framework/Common下面新建一個Image.hpp文件,在其中定義適用於我們的引擎的靜止圖片在內存上的結構:

#pragma oncen#include "geommath.hpp"nnnamespace My {nn typedef struct _Image {n uint32_t Width;n uint32_t Height;n R8G8B8A8Unorm* data;n uint32_t bitcount;n uint32_t pitch;n size_t data_size;n } Image;nn}n

注意我們包含了geommath.hpp這個之前我們寫的數學庫,並使用其中的R8G8B8A8Unorm類型來保存靜止圖片當中的像素顏色數據。R8G8B8A8Unorm這個類型在文章二十三當中並沒有出現。它是我在寫這篇文章的時候新加入到geommath.hpp當中的,定義如下:

typedef Vector4Type<uint8_t> R8G8B8A8Unorm;n

可以看到它就是一個數據類型為uint8_t的Vector4Type(4維向量)。unorm的意思是Unsigned Normalized Integer(參考引用3),而R8G8B8A8表示數據包括RGBA四個顏色通道,每個通道是8個bit。它代表了一種在內存當中存儲顏色的格式。

結構體當中的Width指貼圖的寬度,而Height指貼圖的高度。他們的單位都是像素。bitcount指一個像素在內存上占的尺寸(bit數),而pitch是指圖形的一行在內存上的尺寸(byte數)。這兩個值與點陣圖本身的質量、在內存上的壓縮格式以及內存對齊方式有關。

data_size則是data所指向的數據區域的尺寸。注意這個尺寸應該是(pitch * Height)而不是(Width * Height * bitcount/8),原因就是內存區域有對齊的問題,貼圖每行的數據尺寸如果不滿足內存對齊的要求在行尾會有padding。這是為了滿足GPU定址方面的要求。

好了,一個基本的Image結構我們定義好了,接下來我們定義ImageParser這個介面,來抽象化不同圖片格式的解析過程:

#pragma oncen#include "Interface.hpp"n#include "Image.hpp"n#include "Buffer.hpp"nnnamespace My {n interface ImageParsern {n public:n virtual Image Parse(const Buffer& buf) = 0;n };n}n

這個介面很簡單,傳一個Buffer進去,得到一個Image。唯一需要解釋的是,我們的返回值是一個Image類型。也就是說,對於Image的內存分配是在這個介面內完成的。這似乎與我們之前所說的誰分配內存誰釋放的原則不符。

首先,當然,我們可以把這個介面定義為

virtual int Parse(const Buffer& buf, Image* img) = 0;n

這樣就要求在實際調用Parse之前,先分配好Image對象。但是問題是,在我們實際解析文件內容之前,我們也不知道到底需要為Image的data成員分配多少內存。

在一些第三方庫或者Win32 API當中,我們有時候會見到這麼一種設計:

int size = Parse(buf, nullptr);nImage Img;nImg.data = new uint8_t[size];nParse(buf, &Img);n

就是首先帶入nullptr,來告訴函數我們需要知道需要多少內存。函數通過返回值返回所需要的內存大小之後,創建好用於保存數據的對象,再次調用這個函數進行數據填充的操作。這個方法符合誰分配誰釋放的原則,但是很不自然,效率也很低。

之所以出現這種設計,或者說要求遵守誰分配誰釋放的原則,是因為截止到C++11之前,C++當中沒有顯式指定移動語義的方法。除非使用指針進行傳遞,當我們採用按值返回的時候,對象的拷貝構造函數會被調用。也就是說,如果我們將Parse定義為:

virtual Image Parse(const Buffer& buf) = 0;n

並且如下進行調用

Image img = Parse(buf);n

那麼從C++語義上,會發生一次對象的生成(等號左邊變數,也就是左值分配內存),拷貝(從等號右邊拷貝到左邊),以及一次對象的析構(內存的釋放)。但是從C++11開始,通過定義移動語義的構造函數,可以顯式的規避這種拷貝。如下,帶一個&的是左值拷貝構造函數和賦值重載,帶兩個&的是右值拷貝構造函數和賦值重載。在右值的版本當中,我們直接接管相關的buffer而不進行賦值。

Buffer(const Buffer& rhs) {n m_pData = reinterpret_cast<uint8_t*>(g_pMemoryManager->Allocate(rhs.m_szSize, rhs.m_szAlignment));n memcpy(m_pData, rhs.m_pData, rhs.m_szSize);n m_szSize = rhs.m_szSize;n m_szAlignment = rhs.m_szAlignment;n }nn Buffer(Buffer&& rhs) {n m_pData = rhs.m_pData;n m_szSize = rhs.m_szSize;n m_szAlignment = rhs.m_szAlignment;n rhs.m_pData = nullptr;n rhs.m_szSize = 0;n rhs.m_szAlignment = 4;n }nn Buffer& operator = (const Buffer& rhs) {n if (m_szSize >= rhs.m_szSize && m_szAlignment == rhs.m_szAlignment) {n memcpy(m_pData, rhs.m_pData, rhs.m_szSize);n }n else {n if (m_pData) g_pMemoryManager->Free(m_pData, m_szSize);n m_pData = reinterpret_cast<uint8_t*>(g_pMemoryManager->Allocate(rhs.m_szSize, rhs.m_szAlignment));n memcpy(m_pData, rhs.m_pData, rhs.m_szSize);n m_szSize = rhs.m_szSize;n m_szAlignment = rhs.m_szAlignment;n }n return *this;n }nn Buffer& operator = (Buffer&& rhs) {n if (m_pData) g_pMemoryManager->Free(m_pData, m_szSize);n m_pData = rhs.m_pData;n m_szSize = rhs.m_szSize;n m_szAlignment = rhs.m_szAlignment;n rhs.m_pData = nullptr;n rhs.m_szSize = 0;n rhs.m_szAlignment = 4;n return *this;n }n

注意我上面強調了「C++語義上」。因為實際上即使我們不顯式地進行這樣的指定,當代的大部分C++編譯器在對於按值返回的時候會默認地進行類似的優化。但是編譯器自動進行的操作有的時候是不那麼容易理解或者不見得是我們想要的結果,所以我們通過上面的方法進行顯式的指定。

好了,接下來我們來寫BMP文件的解析器。我們在Framework/之下創建一個新目錄,名為Codec,然後從ImageParser派生出BmpParser類,來實現對於BMP文件的解析:

#pragma oncen#include <iostream>n#include "ImageParser.hpp"nnnamespace My {n#pragma pack(push, 1)n typedef struct _BITMAP_FILEHEADER {n uint16_t Signature;n uint32_t Size;n uint32_t Reserved;n uint32_t BitsOffset;n } BITMAP_FILEHEADER;nn#define BITMAP_FILEHEADER_SIZE 14nn typedef struct _BITMAP_HEADER {n uint32_t HeaderSize;n int32_t Width;n int32_t Height;n uint16_t Planes;n uint16_t BitCount;n uint32_t Compression;n uint32_t SizeImage;n int32_t PelsPerMeterX;n int32_t PelsPerMeterY;n uint32_t ClrUsed;n uint32_t ClrImportant;n } BITMAP_HEADER;n#pragma pack(pop)nn class BmpParser : implements ImageParsern {n public:n virtual Image Parse(const Buffer& buf)n {n Image img;n BITMAP_FILEHEADER* pFileHeader = reinterpret_cast<BITMAP_FILEHEADER*>(buf.m_pData);n BITMAP_HEADER* pBmpHeader = reinterpret_cast<BITMAP_HEADER*>(buf.m_pData + BITMAP_FILEHEADER_SIZE);n if (pFileHeader->Signature == 0x4D42 /* BM */) {n std::cout << "Asset is Windows BMP file" << std::endl;n std::cout << "BMP Header" << std::endl;n std::cout << "----------------------------" << std::endl;n std::cout << "File Size: " << pFileHeader->Size << std::endl;n std::cout << "Data Offset: " << pFileHeader->BitsOffset << std::endl;n std::cout << "Image Width: " << pBmpHeader->Width << std::endl;n std::cout << "Image Height: " << pBmpHeader->Height << std::endl;n std::cout << "Image Planes: " << pBmpHeader->Planes << std::endl;n std::cout << "Image BitCount: " << pBmpHeader->BitCount << std::endl;n std::cout << "Image Compression: " << pBmpHeader->Compression << std::endl;n std::cout << "Image Size: " << pBmpHeader->SizeImage << std::endl;nn img.Width = pBmpHeader->Width;n img.Height = pBmpHeader->Height;n img.bitcount = 32;n img.pitch = ((img.Width * img.bitcount >> 3) + 3) & ~3;n img.data_size = img.pitch * img.Height;n img.data = reinterpret_cast<R8G8B8A8Unorm*>(g_pMemoryManager->Allocate(img.data_size));n if (img.bitcount < 24) {n std::cout << "Sorry, only true color BMP is supported at now." << std::endl;n } else {n uint8_t* pSourceData = buf.m_pData + pFileHeader->BitsOffset;n for (int32_t y = img.Height - 1; y >= 0; y--) {n for (uint32_t x = 0; x < img.Width; x++) {n (img.data + img.Width * (img.Height - y - 1) + x)->bgra = *reinterpret_cast<R8G8B8A8Unorm*>(pSourceData + img.pitch * y + x * (imng.bitcount >> 3));n }n }n }n }nn return img;n }n };n}n

代碼很簡單,但是很容易出問題。因為涉及到很多關於內存順序和對齊方面的考慮,這是在一些高級語言或者腳本語言編程的時候不太會接觸到的。

我們首先是根據BMP規範(參考引用2)定義了兩個結構體,與BMP文件頭保持相同的結構。這裡需要注意的就是下面這3個預編譯命令:

#pragma pack(push, 1)n#define BITMAP_FILEHEADER_SIZE 14n#pragma pack(pop)n

因為在預設狀態下,C/C++的結構體當中的成員是按照4位元組對齊(PC。其它硬體平台可能不同)的。注意我們第一個結構體當中的Signature是uint16_t類型,也就是2個位元組。如果我們不指定pack為1,那麼下一個Size成員會從第5個位元組開始,而不是第3個位元組開始。那麼我們讀取出來的BMP頭部數據就會不對。

同樣的,我們定義了 BITMAP_FILEHEADER_SIZE 14,而不是使用sizeof(BITMAP_FILEHEADER),這也是因為結構體會有一個對齊的問題。在PC上,sizeof(BITMAP_FILEHEADER)很可能是16,而不是14。

其次,請注意下面這行代碼:

if (pFileHeader->Signature == 0x4D42 /* BM */) {n

雖然注釋裡面寫的是BM,但是實際我們比較的值是0x4D42。0x4D是『M,而0x42是B,正好倒過來。這是因為我們將Signature定義為uint16_t,而x86是little endian,當按照字讀入的時候,兩個位元組會發生交換。

而如果是PS3,那麼因為PS3是big endian,那麼就應該是0x424D了。因為目前我們要支持的平台不包括big endian的機器,所以這個地方就是按照little endian來寫的。

第三個需要注意的地方是下面這裡:

for (int32_t y = img.Height - 1; y >= 0; y--) {n for (uint32_t x = 0; x < img.Width; x++) {n (img.data + img.Width * (img.Height - y - 1) + x)->bgra = *reinterpret_cast<R8G8B8A8Unorm*>(pSourceData + img.pitch * y + x * (imng.bitcount >> 3));n }n }n

這裡特別需要注意如何計算像素在兩邊的位置。BMP文件可能是24bit或者32bit的,需要注意這個差別。(BMP文件歷史上還支持24bit以下的,也就是帶調色板的格式,這個遊戲當中很少用到,為了代碼的簡潔性我們沒有進行支持)

另外上面這段代碼包括乘法運算,是比較低效的。事實上這段代碼完全可以避免乘法運算,並且可以用ispc並列化執行。這部分就放在後面進行吧。

好了,這樣一個簡單的BMP解析器就寫好了。為了測試代碼,我們需要寫一個簡單的測試應用來顯示讀入的貼圖。我們趁這個機會將在文章十的DirectX 2D的代碼整合進來。首先依然是在RHI目錄下新建D2d目錄,參照之前D3dRHI的方式將D2d的代碼作為D2dRHI整合進來,然後在PlatformWindows下新建test目錄,在其中創建TextureLoadTest.cpp進行貼圖載入的測試。篇幅原因,代碼就不在這裡貼了,感興趣的請參考Github article_26。

測試應用執行的結果如下,載入的貼圖放在項目根目錄下的Asset目錄當中。我們的AssetLoader會自動查找這個目錄。

參考引用

  1. Image file formats
  2. BMP file format - Wikipedia
  3. Normalized Integer
  4. msdn.microsoft.com/en-u

本作品採用知識共享署名 4.0 國際許可協議進行許可。


推薦閱讀:

陳灼:我在2K的八年(序)
準備玩到手殘么?200款遊戲,70家廠商,遊戲業決戰最後百天
VG解密:老Infinity Ward談《使命召喚4》的開發得失
Unity MemoryProfiler 的工作機制及可能的改進

TAG:游戏开发 |