前言
比起SIGGRAPH來說,GDC Vault裡雖然也有大量遊戲開發相關的知識分享,但由於主要是視頻為主,因此能被我拿來作為二手知識粗讀一遍的文稿類內容相對沒那麼多;另外,GDC提供的PPT原稿裡面是不附帶解說稿的,所以其實詳細程度是不如看原視頻的。在這之中,偶爾還是能翻出一些詳略合適的內容(比如之前也讀過的《HiFi-Rush》和《電馭叛客2077》)。
ECS是Entity Component System的縮寫,雖然從名稱看不出其對於內存連續性和並行執行的意義,但它已經是行業公認的代表面向數據編程的設計模式框架。文末會附一篇Games104介紹ECS部分的鏈接。
本人雖然沒有實際參與過ECS模式的內容開發,只是粗略體驗過Unity(DOTS)和Unreal(Mass)的示例,但這次難得看到一篇ECS系統在3A遊戲中實際應用,出於個人好奇還是帶大家一起讀一下。
這篇粗讀以翻譯原文稿的PPT頁內容為主, 打星號的部分則是我個人的補充說明。
演講人個人履歷
1 系統框架概述
NorthLight引擎
NorthLight是一個聚焦的、藝術家導向的遊戲引擎和工具套件——由Remedy Entertaiment開發,賦權給我們現在和未來開發的遊戲。
在創造工業化導向的遊戲視覺方面不斷進行技術演進:
- 《量子破碎》中頂尖的面部動畫
- 《控制》中(Remedy產品)首次使用的硬件光線追蹤
對於《心靈殺手2》,NorthLight有著多項提升:
- GPU驅動的管線
- 基於SDF的風系統
- 植被控制
- 更多可以參見remedy官方提供的鏈接
NL引擎中的新ECS框架
- 在2021年,新的ECS(Entity Component System)物體模組在NL中被啟用了。
- 它替代了之前使用的重度面向對象(OOP)實體組件框架,通過預定義的函數和事件圖來連接不同的實體之上的組件。
- ECS是一個軟件設計方案,它通過分離數據和行為來改進代碼的重用度。其中數據被以一個緩存友好的方式存儲,以提升性能。
- 一個緩存友好的數據分佈,和NL ECS的易於並行化都能提升性能。
- 更重要的是,它能使遊戲玩法的程序員更易於理解遊戲邏輯的執行流程。
NL引擎中的ECS
NL引擎的ECS有如下特點:
- 它有實體(entities)概念,它們是有唯一性的標識符。
- 有組件(components)概念,是純數據類型(的結構),不執行行為。
- 組件可以被動態增加或移除。
- 它有系統(system)概念,是由包含特定組件的實體匹配而成的函數功能。
- 系統訪問數據的讀寫操作定義可以在編譯時就進行驗證,而這是一個強項。
*之所以可以在編譯時驗證是一定程度放棄了“動態類型”的,後面看例子就能看出。
ECS框架概覽——組件
- 組件是純數據結構,用來表達一個狀態。
- 它們不應包含任何行為,而只有狀態數據。
- 如果你需要提供一個公用接口以處理組件數據(以某種方式),可以採用一個自由函數傳入這個組件(作為參數)的方式,而不是作為組件的函數方法(method)。
*PPT頁中可以看到血量計算函數的具體寫法。
ECS框架概覽——環境
- 環境保存遊戲中能被全局訪問的數據。
- 它們直接存在於內存中,而不與任何實體關聯。
- 任何環境類型只以單例的形式存在於遊戲中。
*PPT頁中可以看到以遊戲光標為例的一套寫法,包含基本結構、更新位置、繪製。
ECS框架概覽——實體
- 實體是一個概念上的物體,由被稱為組件的數據結構組成——每個組件代表了該物體的一類特定功能。
- 一個實體由其關聯的組件集合來定義。對於實體來說並不存在具體的“物體”概念。
- 一個實體能被一個32位的整型數標識(相當於ID)。
- 代碼中仍有能表達單一或多個實體,並訪問其組件的方式。
ECS框架概覽——訪問定義
- 訪問定義是一個模板化的數據結構,它允許定義一次數據訪問(的內容)。
- 它是用於使ECS系統理解你的依賴項的。
*可以看到PPT頁中的類似面向過程的語法,以及一些基礎的模板樣式。
ECS框架概覽——訪問的類型
定義鏈通常以一個特定的訪問範圍定義作為結尾(如果需要的話),以指明從一個實體或一個集合中讀取數據。
*PPT頁中的Group和Query都是對應集合類型。
ECS框架概覽——系統
- 一個ECS中的系統是一個自由函數,它能接收一些輸入、進行處理併產生一些輸出給組件。
- 系統也能添加或移除組件。
- 系統使用訪問和環境作為參數。
ECS框架概覽——系統(示例)
*PPT頁中引用了兩個實體:可受傷角色實體、傷害來源實體。而系統函數主要內容是基於一組傷害來源(查詢)進行定時傷害結算。
ECS框架概覽——系統註冊
為使一個系統生效,我們需要將其註冊到遊戲循環中。
我們可以將其註冊到不同的遊戲循環階段中——對應系統組的概念:
- 可變更新
- 固定更新
- 固定更新前的可變更新
當註冊系統時,添加依賴項來影響系統的執行順序是可行的。如果沒有指定順序,則系統會按訪問需求的順序執行。
ECS框架概覽——系統註冊(示例)
*這裡update模塊的細節作者沒有給出。
*由於遊戲中所有類型的執行邏輯都要放在各種系統函數中集中執行,可想而知這套框架不適合處理邏輯過於複雜並且耦合較重的遊戲邏輯。
2 案例解析——THE CASE BOARD
案件板
- (女主角)Saga蒐集證據的串聯圖板。
- 她能通過放置線索,並將其與遊戲中獲得的其它信息組合來推進調查進度。
- 它主要的功能是幫助玩家梳理故事線,並更加投入其中。
- 某些遊戲中的事件只能被案件板中線索的組合來觸發。
案件板的需求
玩家在遊戲中能蒐集不同類型的線索(通過交互、拾取、調查實景模型,以及腳本事件)。
案件板的需求
- 玩家通過拖動操作來將線索放置在板上,並與其它線索組合。
- 部分線索會依賴案件板自身的事件觸發來被放置或解鎖。
案件板的需求:調查
遊戲中會有多項進行中的調查,玩家可以在它們之間切換。
案件板的需求:編輯整理案件
案件信息的佈局是通過一個編輯工具定義的——它是由Remedy的主UI/UX設計師Riho Kroll開發的。它能允許敘事設計師以所見即所得的方式編輯案件。(*What you see is what you get)
案件佈局數據會被導出成一個配置文件(lua格式),並用於遊戲中。
案件板的架構
- 代碼以一組模塊作為組織結構。
- 模塊至少由一個頭文件和一個.cpp文件。(*C++語言)
- 每個模塊有一個或多個系統。
- 對於案件板的模塊,其中有一組模塊,各自以應對特定的功能特性。
- 也有一組與其它遊玩系統共享的模塊。
*簡單看看案件板模塊,其中有例如:案件板模塊、手模塊、案件繪製模塊、案件道具生成等。而共享模塊有:遊戲光標模塊、攝像機追拍模塊、動作模塊等。
案件板元素:線索條目
- 板上的每個案件是一顆線索條目的“樹”。
- 線索條目是玩家在案件板上操作的主要物體。
- 當一個線索條目執行了某些動作後(例如:放下、解鎖、組合),一個或更多的規則可能被觸發。
- 每個規則可能調用一些其他的動作(放置條目、解鎖至手上、移除條目等),以應對特定條件被滿足的場合(某些道具被放置、解鎖等)。
- 線索條目可以通過以下方式被放置到板上:被玩家從“手”上放下;通過觸發規則;顯式通過腳本調用。
案件板元素:線索條目
線索條目可能有如下類型:
- 案件——一個調查文件夾條目。是所有條目的父節點,被自動放置。
- 照片——開啟一個新調查分支。通過手部操作放置。
- 線索——拍立得照片、引用、手稿頁。由玩家在遊戲過程中收集,通過手來放置。當與問題成功組合後能被放置在板上。
- 推理——當所有線索被與問題組合後,會被自動放置在問題下方。
案件板環境
我們將所有案件(名稱、條目集合)、條目描述、狀態(放置、解鎖、掛載父節點)存儲在一個環境中。
從以下角度來看這(種設計)很有用:
- 很便於從配置文件中加載數據。
- 這是一個同步點。當我們想修改當前案件板的邏輯狀態時,特別是從腳本調用時,我們都需要通過案件板環境。
- 非常便於加載案件板的狀態。
案件板:通過線索條目執行動作
這種方案中,為一個線索條目執行任何動作會如下實現(圖中)。
這是一個自由C++函數,傳入itemId和一個案件板環境的可變的引用作為參數。
在這個函數中我們將執行如下步驟:
- 找到環境中某條目的一個可變指針。
- 修改(需要的)狀態。
- 執行動作特有的邏輯。
- 處理動作特有的規則。
*引用reference和指針pointer是一組C++概念。
案件板模塊
這一模塊與“真實世界”條目實體一起生效,主要負責:
- 更新條目狀態。
- 更新板上的條目的空間變換。
邏輯被拆分成不同的系統。這是非常必要的,因為計算和應用最終的空間變換不如它看上去那麼容易。
這也使我們能:
- 避免鎖定非必須的數據,以至於阻礙其它遊戲系統的執行(例如空間變換)。
- 在可行時並行執行系統。
- 確保特定的邏輯應用到所有需要的實體上——在執行下一步驟前。
案件板:計算空間變換
- 在(開發中)的很長時間裡,線索條目是能通過光標在板上隨意放置的。
- 當移動一個條目時,所有子條目需要保持其基於父條目的偏移值(一起移動)。
- 條目可以疊放並堆疊起來。
- 並且,它們的位置不能超過板的邊界。
案件板:變換系統
整套位置機制被拆分為多個系統,逐個執行:
- 更新狀態和偏移值,尤其是通過案件板環境設置的“真實世界”實體。
- 設置條目的XY位置和邊界——基於偏移值和案件板邊界。
- 檢驗條目的疊放,以確認其上下關係:基於層級、條目類型、條目索引、(是否)最後被操作等。
- 基於疊放信息對深度排序,以計算相關的Z值。
- 設置條目的最終空間變換值(或直接通過動畫的方式)。
案件板模塊
*系統執行順序是通過excuteAfter語法來確保的。
案件板模塊:狀態更新系統
- 我們定義了一個組件結構以存儲所有的itemId和狀態。
- 我們通過環境中存儲的條目狀態來比較組件的狀態,在必要時對組件做修改。
- 關鍵的訪問是對於條目的實體的,因此係統可以潛在的在一個實體層級上並行執行:對於每個實體,一個系統在其所述的線程中執行。
案件板模塊:狀態更新系統
*案件板item的具體數據結構及狀態函數。
案件板模塊:更新邊界
- 在更新了條目狀態,並讀取它的偏移值數據後,我們需要更新它(和板位置相關)的XY位置和邊界——把板的限制列入考慮。
- 為存儲這些值,我們特定了一個新的組件用於條目的XY邊界。(*提到的ItemBoundaries組件)
- 這一系統將從條目組件中讀取條目的狀態,或許網格組件的外延值,並寫入ItemBoundaries組件。
案件板模塊:更新邊界
*邊界處理——基於三個二維向量,中心、最小值、最大值。
案件板模塊:檢驗疊放
- 在我們更新了條目的中心和邊界後,我們可以檢測疊放在一起的條目。
- 這(計算的信息)被用於後續更新條目的深度。
- 系統的關鍵訪問是(特定的疊放物)實體,但我們也需要分別查詢所有其它條目。(*這句的意思可以參照後面的函數體)
- 如果我們想保持一個實體層級的並行執行,我們需要確保在一次查詢訪問時不讀寫同一實體。(*否則就要改串行或者考慮加鎖了)
- 然而,將疊放結果寫入一個單獨(分開)的組件中也是必要的。
案件板模塊:檢驗疊放
*前面提到的特定單個實體是OverlappedItemsEntity類型的實體,後續需要寫入疊放計算結果;查詢的是通過ItemBoundariesQuery以訪問其它Item實體,並通過itemsHaveOverlap和isItemBelow函數來確定上下層級;計算的結果寫入itemsBelow容器中。
案件板模塊:更新深度
- 在得到所有條目的疊放信息後,我們可以對Z座標進行排序。
- 為實現這一點,我們創造了“層”的概念——基於某一條目其下條目的數量。
- 為創造這些“層”,我們需要讀寫所有條目數據以處理全部依賴關係。(*讀寫的是不同的組件類型)
案件板模塊:更新深度
*這裡的hasDependencies主要是上下層的依賴關係,簡單來看就是通過對overlappdItems.itemBelow的整理和查詢進行itemDepth的計算,並最終反應到Z值(層厚度)上。
案件板模塊:更新空間變換
- 最終,在我們計算完XY和Z值後,我們為條目設置實際的空間變換。
- 將這一邏輯放在一個獨立的系統中,使我們能僅在實際需要時“鎖定”變化組件,而不會阻止其它系統在一幀中的早些階段來與其交互。
案件板模塊:更新空間變換
*分別計算了和板之間相對的變換boardRelativeTransform,以及世界座標系變換worldTransform——最終實際生效的是世界座標系變換。可以看到這裡有一個動畫的分支,後面會提到。
案件板模塊:動畫條目
- 實體的動畫(彈簧運動)是被一個不同的(可以用於其它遊玩特性的)系統來處理的。
- 為一個條目增加動畫,只用為其增加一個運動組件。
- 當動畫結束時,我們移除這一組件。
- 我們使用一個特定的輔助物體——指令緩衝來註冊這些組件的佈局,並在每一幀的最後被flush。
*flush是程序開發上從緩衝區把數據匯入數據流(或進行清理)的一個概念。這個詞直譯的意思是“沖洗”,但這個場合沒有合適的翻譯;而且這部分細節後面的代碼示例並沒有涉及。
案件板模塊:動畫條目
*這裡的動畫只展示了一個比較簡單的基於deltaTime的程序動畫示例,能實現類彈簧或擺盪的效果。具體的邏輯就是在達到目標變換值前一直tick動畫系統。
3 腳本調用
*引入腳本語言的意義有很多,最容易理解的就有比如:更易於編寫、不用等待編譯等。這裡不展開了。
Lua腳本
- 某些案件板功能需要開放給設計師,在腳本系統中使用。
- 我們使用Lua作為腳本語言。
- 我們並不在Lua端反射數據。設計師不需要考慮例如組件、訪問定義等這類概念。作為替代,他們操作函數,傳入實體ID作為參數。
- 我們提供了兩種從Lua端進行交互的方式:C++的Lua綁定;Lua事件。
- 我們的Lua腳本執行於一個單線程序列。當它們被執行時,所有組件的狀態都是同步過的,並且也沒有其它系統在執行。這樣,設計師們有不用考慮多線程相關的問題了。
腳本:Lua綁定
Lua綁定是被組織成Lua模塊的C++函數,能允許lua腳本調用C++代碼。
每個Lua綁定由三部分組成:
- 一個綁定函數——在C++和Lua虛擬機之間傳輸數據,並校驗輸入的參數的有效性。
- 文檔註釋——用於生成VS Code中彙集式的函數說明。
- 類型定義——被Lua的類型校驗器用於檢測腳本錯誤。類型定義信息是基於文檔註釋自動生成的。
一個綁定函數的主要結構(步驟)是:
- 從Lua參數堆棧獲取參數。
- 檢驗參數有效性,對於無效的類型(例如函數希望輸入一個string,但傳入了number)或值(例如超出範圍的值),在必要時進行報錯。
- 調用C++函數或類方法以執行實際的邏輯。
- 將返回值和返回值的數量推入Lua堆棧。
*Lua這一側是調用示例。實際的綁定函數內容在右側C++部分。
*以第一個函數為例,第一行註釋描述了輸入和返回的類型,各自就對應了checkString和pushBool調用步驟。
腳本:Lua事件
- Lua事件是C++向Lua腳本通信的方式。
- 我們使用環境(*這裡還是ECS概念)LuaEvents來收集一幀中的事件。
- 收集的事件會在下一幀開始被派遣調度。
- 每個發送了Lua事件的系統都需要是單線程的,因為需要對LuaEvents環境進行數據寫入。
腳本:Lua事件
*lua側在初始化時註冊了一個回調函數,而C++側通過broadcast進行事件廣播。
總結
- 通過ECS框架,gameplay程序員的思維模式被改變了。與處理對象不同,這個框架裡我們和數據打交道。
- 總的來說程序執行看起來很像和數據庫交互,因為我們(更關注於)查詢和修改數據。
- 動態增加和移除組件的能力使我們能在運行時輕鬆地為不同實體開關係統。
- 系統註冊允許我們顯式的指定訪問同一份數據的不同系統的執行順序。
- 為獲得更好的性能,更佳的方式就是把數據拆分成不同的組件,而不是放在一起。
- 我們可以構造一個便利和堅固(robust,直接翻成“魯棒”也行)腳本交互系統,它不需要設計師關心數據的分佈(結構)及並行化邏輯。
結語
以我個人的角度來看,Remedy在開發這個遊戲使使用ECS的必要性或許沒有那麼大。雖然ECS沒有太多程序上的弊端,但確實在開發過程中寫代碼構建複雜世界時不太利於大規模並行開發;總的來說這個遊戲可“遊玩”的內容確實有限,除了“走路模擬”“情景解密”之外的內容不算多,這麼看使用ECS是利大於弊的。
從理念角度來看,ECS更適合數據類型相對有限,但實體數量級相對會比較高的遊戲。例如大量單位的即時戰略遊戲,或是有著大量簡單行為單位的割草遊戲等。我能想到的非常適合使用ECS的一個類型就是“倖存者like”遊戲;而我也確實覺得幾乎不可能用ECS來開發一個開放世界遊戲。
另外,ECS的介紹資料其實很多,除了我提供的一些鏈接外有興趣可以自行搜索。
最後是資料鏈接:
B站Games104中講解面向數據編程的部分
“ECS in practice The case board of Alan Wake 2” 的PPT地址