《遊戲引擎架構》筆記六

資源及文件系統程序員

載入及管理多種媒體,是遊戲引擎必須具有的能力。多數引擎會採用某種類型的資源(或資產)管理器,載入並管理遊戲所需的資源,並確保在同一時間每一個媒體文件只可載入一份。每一個資源管理器都會大量使用文件系統。本文將介紹現代三維遊戲引擎中的各類文件系統API,再分析典型資源管理器的運做方式。數據庫

文件系統網絡

文件名和路徑數據結構

關於文件和文件夾路徑的概念,絕對路徑和相對路徑的概念,它們在各類操做系統之間的區別,屬於常識範疇,此處不贅述。異步

關於搜尋路徑,是指含若干個路徑(以特殊字符分隔)的字符串,尋找文件時會從這些路徑逐個尋找,PATH環境變量就是一種搜尋路徑。在運行期搜尋資產是費時的作法,而一般資產路徑會在運行期以前就得知,因此應該徹底避免搜尋資產。數據庫設計

關於路徑API,通常用於對路徑進行多種操做,如分離「目錄/文件名/擴展名」、使路徑規範化、絕對和相對路徑互轉等等。遊戲引擎一般會實現或封裝輕量化的路徑處理API,以便實現跨平臺,從各類特殊的儲存媒體(如記憶棒、DVD盤、網絡文件系統等等)中存取數據,以及提供操做系統API未能提供的功能,如串流(即在遊戲運行中同時載入數據)。ide

基本文件I/O

許多遊戲引擎都會把文件I/O API封裝成自定義的API,這樣至少有三個好處:函數

保證I/O API在全部目標平臺上均有相同行爲;工具

API能夠簡化到只剩下實際須要的函數,使維護開支維持最小限度;佈局

可提供延伸功能,如處理各類特殊的儲存媒體(同自定義路徑處理API)。

每次調用輸入/輸出,都須要稱爲緩衝區的數據區塊,以供程序和磁盤之間傳送字節。當API負責管理數據緩衝,就稱之爲有緩衝功能的API,不然爲無緩衝。C標準程序庫中,以f開頭的文件API是帶緩衝的,如fread(),沒有f開頭是無緩衝的,如read()。有時自行管理緩衝區是有必要的。例如往日誌寫數據可能會顯著下降性能,能夠先把數據累積在內存緩衝,滿溢後才寫進盤內,甚至把緩衝輸出函數置於另外一線程裏,以免令主遊戲循環發生流水線停頓。

同步與異步

C標準庫的兩種文件I/O庫都是同步的,即程序發出I/O請求之後,必須等待讀/寫數據完畢,程序才能繼續運行。

串流是指在背景載入數據,而主程序同時繼續運行。爲了支持串流,必須使用異步文件I/O庫。多數異步I/O庫允許主程序在請求發出後一段時間,等待I/O操做完成才繼續運行。有些異步I/O庫允許程序員取得某異步操做所需時間的估算,一些API也能夠爲請求設置時限,並設置請求超時的安排(例如取消請求、通知程序、繼續嘗試等)。

異步I/O操做常有不一樣的優先權,例如從硬盤中串流音頻,而且在串流其餘資源時播放音頻,顯然前者優先權高於後者。異步I/O系統必須能暫停較低優先權的請求,纔可讓較高優先權的I/O請求有機會在時限前完成。

異步文件I/O的實現原理,通常是利用另外一線程進行同步操做來實現。主線程調用異步函數時,會把請求放入一個隊列,並當即傳回。同時,I/O線程從隊列中取出請求,並以阻塞I/O函數處理這些請求。請求的工做完成後,就會調用主線程以前提供的回調函數告之該操做己完成。若主線程選擇等待完成I/O請求,就會使用信號量處理(每一個請求對應一個信號量,主線程把自身處於休眠狀態,等待I/O線程在完成請求工做後通知信號量)。

資源管理器

資源管理器由兩部分組成:一部分負責管理離線工具鏈,用來建立資產並把它們轉換成引擎可用的形式;另外一部分在執行期管理資源,確保資源在使用前已載入內存,不須要時從內存卸下。

離線資源管理與工具鏈

資產的版本控制

小型的遊戲項目中,遊戲資產的管理方式能夠是把組織不嚴謹的文件以項目特設的目錄結構置於公用網盤中;有些遊戲團隊使用源碼版本控制工具來管理資源。

可是,藝術資產一般有極大的數據量,直接從中央版本庫複製到本地每每是低效的。如下是一些參考解決方案:

  • 使用如Alienbrain這種特別針對極大量數據的商業版本控制系統
  • 在版本控制工具上設計一套系統,保證用戶只會取得其真正所需的文件到本地
  • 頑皮狗開發了一款私有工具。用戶擁有資產版本庫的完整本地視圖,只要文件未簽出,本地就一直是UNIX的符號連接(Windows可使用junction實現)以消除數據複製。當簽出文件時則移除符號連接,更換爲本地副本,簽入時則相反。

資源數據庫

遊戲引擎不會使用多數資產本來的格式,而是須要經過一些資產調節管道(ACP)將資產轉換爲引擎所需的格式,其中每一個資源須要有元數據描述如何對資源進行處理。例如描述壓縮紋理時,使用哪一種壓縮方法;描述導出動畫片斷時,導出哪一個範圍的幀。

爲了管理這類元數據便須要某種數據庫。不一樣的引擎差異巨大,有的是嵌入到資產源文件自己,有的是每一個資產源文件伴隨一個小文本文件,有的將元數據寫進XML文件中,有的使用真正的關係數據庫。它通常提供如下功能:

  • 能處理多種資源,最好是以一致的方式處理
  • 建立、刪除、查看、移動磁盤位置和修改資源
  • 資源交叉引用其餘資源,並維持數據庫內的引用完整性
  • 保存版本歷史,含完整日誌記錄、改動者及事由
  • 支持不一樣形式的搜索和查詢

一些成功的資源數據庫設計

  • 虛幻3:由萬用工具UnrealEd管理,它是引擎的一部分頑皮狗的《神祕海域》引擎
    • 優勢:建立資產後能當即看到資產在遊戲中運行的模樣;以單1、整合、一致的界面管理全部類型的資源;資產必須明確導入數據庫,製做初期即可檢查資源有效性
    • 缺點:全部資源存於少許的大型二進制包文件,不利於版本控制包合併;資源重命名或移動時,使用虛擬對象,即把舊資源映射到新名稱/位置,問題是虛擬對象會閒置、累積起來形成問題,尤爲是刪除資源時變得嚴重

用Perforce以提供版本控制,元數據改成XML。Builder管理演員(包含行爲的動態對象)和關卡(含靜態背景網格和關卡信息等)兩種類型的資源,動畫能夠組成名爲動畫包(buddle)的僞文件夾;引擎含一組基於命令行的工具,用於查詢數據庫,處理資源原生DCC文件,生成某演員或關卡。

    • 優勢:資源粒度小;Builder僅提供必需的特性;源文件映射顯而易見,用戶容易得知某資源由哪些資產而來;容易更改DCC數據的處處及處理方式;依賴系統會自動處理,生成資產很是容易
    • 缺點:欠缺預覽資產的可視化工具;各類類型的工具沒有徹底整合
  • OGRE:擁有一個頗完備、設計很是好的運行時資源管理器,經過一組簡單一致又有擴展性的接口就能載入任何類型的資源。缺點在於僅是運行時方案,自己提供的離線處理很弱
  • 微軟的XNA:經過VS IDE的項目管理及生成系統,把遊戲資產以一樣形式管理及生成

資產調節管道

資產調節管道用於將DCC原生格式文件轉換成引擎可用的形式,通常通過3個處理階段:

  1. 導出器:爲DCC工具編寫自定義插件,將數據導出爲某種中間格式。若是DCC不提供自定義方法,則應該把數據存成開放格式,或比較直觀的文本格式,或其餘可作反向工程的原生格式
  2. 資源編譯器:對DCC導出的數據進行必定處理,如把網格的三角形從新排列成三角形帶,或壓縮紋理。並不是全部數據都要編譯
  3. 資源連接器:將多個資源先結合成單個有用的包,如複雜的三維模型,而後才載入至遊戲引擎。並不是全部數據都要連接

如同程序的源文件,各資產之間也有依賴關係。這些依賴關係一般會影響資產在管道內的處理次序,也可告訴咱們,當某個源資產作出改動後,要從新生成哪些資產。生成依賴不單圍繞資產自己的改動,也關係到數據格式的改動。每一個資產調節管道都須要一組規則來描述資產間的依賴關係,並本身搭建系統或使用像make這樣的工具來以正確順序生成資產。必定要管理好資產間的依賴。

運行時資源管理

運行時資源管理器的責任

  • 確保任什麼時候候,同一個資源在內存中只有一份副本
  • 管理每一個資源的生命期
  • 處理複合資源的載入(如三維模型)
  • 維護引用完整性:包括單個資源內的交叉引用,以及資源間的交叉引用
  • 管理資源載入後的內存用量,確保資源儲存在內存中合適的地方
  • 允許按資源類型,載入資源後執行自定義的處理
  • 一般提供統一的易擴展的接口管理多種資源類型
  • 若引擎支持,則要處理串流

資源文件及目錄組織

資源通常儲存爲磁盤上的文件,並位於使創做者方便而組織的樹狀目錄中。但引擎一般不會理會資源被放置於資源樹中的哪一個位置,引擎會把多個資源包裹爲單一文件。文件載入時間和尋道時間、開啓每一個文件的時間、從文件讀至內存的時間相關。這種方法能減小文件載入時間。

OGRE使用ZIP存檔資源,ZIP格式的好處:

  • ZIP是開放格式
  • 內部虛擬文件有相對路徑
  • 可被壓縮(載入數據後解壓所花的時間,一般比讀取無壓縮數據所花的時間少)
  • 並可視爲模塊(例如把須要本地化的資產打包,針對不一樣語言製做不一樣版本的ZIP)

虛幻3採起相似的手法,可是其全部資源都必須置於大型的pak自定義格式文件中,並不允許資源以盤上獨立文件出現。

資源文件格式

每類資源均可能有不一樣的文件格式。單一文件格式也可儲存多種不一樣類型的資產。許多引擎會自定義文件格式,由於引擎所需部分信息可能沒有標準格式能夠支持,以及對資源脫機處理,以讓其聽從某種內存佈局加速運行時載入。

資源全局統一標識符

全部資源都須要資源全局統一標識符(GUID)來識別,最多見就是使用資源的文件系統路徑。也有使用128位散列碼。虛幻3的GUID格式是包名和包內資源路徑串接而成,如《戰爭機器》的一個資源GUID爲Locust_Boomer.PhysicalMaterials.LocustBommerLeather

資源註冊表

資源管理器都含某種形式的資源註冊表,以保證在任什麼時候間,載入內存的每一個資源只會有一份副本。最簡單的實現方法是使用字典,鍵爲資源的GUID,而值是指向內存中資源的指針。資源載入內存時,加進資源註冊表字典。卸下資源時,就刪除其註冊表記錄。

若不能從表中找到請求的資源,最直覺的處理手法就是自動載入該資源。但這樣作可能會由於臨時從硬盤或光驅等緩慢設備讀取數據而嚴重拖慢遊戲幀率。

所以引擎可採起這兩種替代手法:

  • 遊戲進行中徹底禁止加載資源(遊戲關卡的全部資源在遊戲進行前所有加載,那時候一般是loading界面);
  • 資源以相對較難實現的異步形式加載,如玩關卡A時,關卡B的資源在後臺加載。

資源生命期

資源管理器的職責之一是自動管理資源生命期,或對遊戲提供所需API供手動管理。每一個資源對生命期有不一樣需求:

  • 資源的生命週期是遊戲持續的全部時間(如角色網格、紋理、動畫,HUD的紋理字形等等),被稱爲載入並駐留(load-and-stay-resident,LSR)資源;
  • 資源的生命週期是某一關卡的時間;
  • 資源的生命週期短於所在關卡的時間(如過場動畫);
  • 即時串流(如BGM、環境音效等),每一個字節只是短暫停留在內存中,可是整個文件持續很長時間。

某資源的載入時期一般在玩家第一次看見該資源便能決定,但什麼時候卸下資源歸還內存,就難以回答,由於可能存在多個關卡共享的資源。解決方案之一就是對資源引用計數,即載入新關卡時,遍歷所需資源並引用加1,再遍歷即將結束的關卡的資源,全部引用減1。當有引用計數減爲0是卸載,當有新的資源的引用計數由0變爲1時載入。

資源的內存管理

資源加載的內存位置可能不一樣,像紋理、頂點緩衝、着色器駐留在顯存,大部分資源駐留在主內存,但不一樣的資源可能須置於不一樣的地址範圍。設計遊戲引擎時,內存分配器和資源系統要相互配合。有時用已有的內存分配器來設計資源系統,有時則要讓內存分配器配合資源管理所需。

  • 基於堆的資源分配:忽略內存碎片,在我的筆記本上運行的遊戲能夠用該方法,由於操做系統支持高級的虛擬內存功能,能夠解決內存碎片問題。
  • 基於堆棧分配器:若遊戲是以線性關卡爲中心,且內存足夠容納各個完整關卡,則可用堆棧分配器。注意棧頂端先分配駐留資源(LSR,各關卡共享的資源),再分配關卡所需內存。
  • 基於池分配器:支持串流的引擎中,把資源數據以大小相同的組塊載入。可是,要注意設計資源數據時,必須避免大型連續數據結構,或允許資源能被切割成同等大小的塊。這種分配方式天生的問題就是文件內最後的組塊空間被浪費。雖然組塊大小較小能減小空間浪費,可是,這樣會極大的限制資源數據的佈局。典型的大小時數千字節。選擇組塊大小時,能夠考慮設爲操做系統I/O緩衝區大小的倍數,如512KB。
  • 資源組塊分配器:專爲解決上述組塊浪費內存而設的分配模式。只需管理一個鏈表,內含全部未用滿內存的組塊以及自由內存塊的位置及大小。用堆分配器或棧分配器管理這些自由內存塊。該方案有一個問題是卸下資源內存時,其「邊角」的組塊也會同時消失。解決方案是隻利用該種分配器分配和對應關卡生命期相同的內存,這須要獨立地管理每一個關卡的組塊,且用戶請求分配時指明從哪一個關卡分配內存。
  • 分段資源文件:將資源文件分爲若干段,每段分爲若干個組塊(與池分配器配合)。各段的做用不一樣,有的是爲主內存而設的數據,有的是僅在載入過程當中使用、載入後被棄置的臨時數據,有的是發行版本不會載入的調試信息

複合資源及引用完整性

每一個資源文件可包含一個或多個數據對象,這些對象可能以不一樣的方式引用或依賴其餘對象,資源數據庫能夠表達爲相互依賴的數據對象所組成的有向圖。交叉引用能夠分爲內部(單個文件裏對象間的引用)和外部(引用另外一個文件的對象)。

處理資源內部引用

在C++中, 因爲指針的內存地址總會變,並且離開運行中的程序就失去意義,因此不能用指針來表示對象間的依賴。

將資源引用存爲包含全局惟一標識符(GUID)的字符串或散列碼,資源管理器要維護一個全局資源查找表,其中鍵爲GUID,值爲資源在內存中的地址。這樣每次經過全劇資源查找表就能夠將資源對象的GUID轉換爲指針。

儲存對象到二進制文件的另外一經常使用方法是,把指針轉換爲文件偏移值,並創建指針修正表。

下圖給出了儲存二進制文件以及將文件載入內存的指針修正示意圖,具體過程爲:

  • 把每一個對象的內存影響遍歷一次,順序寫至文件成爲連續映像;
  • 寫進文件的代碼,清楚知道對象的數據類型和類,也就知道每一個對象的指針在哪裏,把這些指針位置儲存到指針修正表並一同寫進文件;
  • 載入文件至內存時,映像內對象仍保持連續,並憑藉修正表修正全部指針。

從文件載入C++對象,建立對象時必須調用構造函數。這個問題有兩個常看法決方案:

  • 使用純C結構體來儲存數據或使用無虛函數、只含不作事情的平凡構造函數的C++ struct/class;
  • 把非PODS(plain old data structure)對象的偏移值組成一個表,表中記錄對象屬於哪一個類,並將它寫入二進制文件中。以後加載二進制映像時遍歷該表,並使用placement new語法調用構造函數。
void* pObject = ConvertOffsetToPointer(objectOffset);
::new(pObject) ClassName;  // placement new語法,ClassName爲對象所屬的類名

處理資源外部引用

要正確表示外部引用,除了指明偏移值或GUID,還要加上資源對象所屬文件的路徑。通常作法是:載入每一個資源文件時,掃描文件中的交叉引用表,並載入全部被外部引用但未載入的資源文件,當載入全部互相依賴的資源時,就用主查找表把全部指針轉換成真實的內存地址(經過GUID或文件偏移值)。

資源載入後初始化

有一些資源載入後須要一些處理才能供引擎使用,這種載入後的全部處理被稱爲載入後初始化。

  • 某些狀況下,沒法避免載入後初始化,例如三維網格的頂點和索引載入主內存後,幾乎老是要傳送至顯存,並且只能在運行時進行。
  • 其餘可能載入後初始化能夠避免,但爲了方便。

資源的載入後初始化和拆除,都有獨特的需求。在C中,可使用查找表,把每一個資源類型映射到一對函數指針,一個負責載入後初始化,一個負責拆除。在C++中,可使用構造函數和析構函數來處理載入後初始化和拆除。可是爲了方便多態,通常爲每一個類設置如Init()Destroy()的虛函數用於獨立初始化和銷燬工做。

載入後初始化和資源內存分配策略息息相關,有時初始化會在文件的數據上新增數據(如額外計算類中的成員數據),有時初始化的數據用來取代己載入的數據(如引擎載入過期格式的網格數據,自動轉換爲最新格式,以保證向後兼容)。能夠採用先載入到臨時內存區域,初始化完成後再把相關數據複製到內存最終位置(例如《迅雷賽艇》的引擎)。

相關文章
相關標籤/搜索