當前位置: 華文問答 > 遊戲

如何評價【守望先鋒】架構設計?

2018-04-02遊戲

@猴與花果山童鞋已經闡述了ECS的主要概念。此文主要從技術和工程角度簡單探討遊戲行業中設計模式的演變歷史和ECS的意義。

綜述

設計模式產生的動機,往往源於嘗試解決一些存在的問題。遊戲開發領域技術架構的宏觀目標,大體包括以下目標:

  • 適合快速叠代。 無論是上線前的敏捷開發流程還是上線後根據市場反饋的調整,叠代都是基本需求。
  • 易於保證產品品質。 良好的架構,可以降低 Bug 和 Crash 出現機率。
  • 開發效率。 重要性不必多說。即使是更重視遊戲品質的公司,越高的開發效率也有助於更短的時間打造出品質更高的遊戲。
  • 執行效率。 大部份遊戲對即時響應和執行流暢度都有很高的要求,同時遊戲中又存在大量吃效能的模組(比如渲染、物理、AI等)。
  • 協作擴充套件性。 能夠在開發團隊擴張時盡可能無痛,同時方便支持美術、策劃、音效等非程式同事的開發需求。
  • 現代 Entity Component System 的概念,以及對遊戲開發領域的意義:

  • Entity: 代表遊戲中的實體,是 Component 的容器。本身並無數據和邏輯。
  • Component: 代表實體「有什麽」,一個或多個 Component 組成了遊戲中的邏輯實體。只有數據,不涉及邏輯。
  • System: 對 Component 集中進行邏輯操作的部份。一個 System 可以操作一類或多類 Component。同一個 Component 在不同的 System 中,可以關聯不同的邏輯。
  • ECS 並非【守望先鋒】所獨有和原創,事實上近年來以 ECS 為基礎架構逐漸成為國際遊戲開發領域的主流趨勢。

    采用 ECS 的範式進行開發,思路上跟傳統的開發模式有較大的差別:

  • Entity 是個抽象的概念,並不直接對映為具體的事物:比如可以不存在 Player 類,而是由多個相關 Component 所組成的 Entity 代表了 Player。如 Entity { PositionComponent, RenderComponent, StateMachineComponent, ... } 等等。
  • 行為透過對 Component 實施操作來表達。比如簡單重力系統的實作,可以遍歷所有 Position Component 實施位移,而不是遍歷所有 玩家、怪物、場景物件,或者它們統一的基礎類別。
  • 剝離數據和行為,數據儲存於 Component 中,而 Component 的相關行為,和涉及多個 Component 的互動和耦合,由 System 進行實施。
  • ECS 框架,至少有以下優點:

  • 模式簡單 。如果還是覺得復雜,推薦看看 GoF 的【設計模式】。
  • 概念統一。 不再需要龐大臃腫的 OOP 繼承體系和大量中間抽象,有助於迅速把握系統全貌。同時,統一的概念也有利於實作**數據驅動**(後面會提到)。
  • 結構清晰。 Component 即數據,System 即行為。Component 扁平的表達有助於實作 Component 間的正交。而封裝數據和行為的做法,不仔細設計就會導致 Component 越來越臃腫。
  • 容易組合,高度復用。 Component 具有高度可插拔、可復用的特性。而 System 主要關心的是 Component 而不是 Entity,透過 Component 來組裝新的 Entity,對 System 來說是無痛的。
  • 擴充套件性強。 增加 Component 和 System,不需要對原有程式碼框架進行改動。
  • 利於實作面向數據編程(DOP)。 對於遊戲開發領域來說,面向數據編程是個很重要的思路。天然親和數據驅動的開發模式,有助於實作以編輯器為核心的工作流程。
  • 效能更好最佳化。 接上條,相比 OOP 來說,DOP 有更大的效能最佳化空間。(詳見後面章節)
  • 若要了解為何會出現 ECS 這樣的模式,以及它所試圖解決的問題,需要考慮一下歷史行程:

    演化路徑

    簡單粗暴的上個世紀開發模式

    註重於實作相關演算法、功能和邏輯,程式碼只要能實作功能就行,怎麽直觀怎麽來。比如

    class Player { int hp ; Model * model ; void move (); void attack (); };

    類似這樣完全沒有或很少架構設計的程式碼,在計畫規模增大後,很快變得臃腫、難以擴充套件和維護。

    OOP 設計模式的泛濫 案例:OGRE

    設計模式是語言表達能力不足的產物。 —— 某程式設計師

    那麽,作為他山之石,GoF 基於 Java 提出的設計模式,能否有效解決遊戲開發領域的問題?

    大家還記得當年國內風靡一時的遊戲引擎 OGRE 麽?

    OGRE中用到的設計模式 - 逍遙劍客 - 部落格頻道 - CSDN.NET

    @逍遙劍客

    OGRE 總有那麽些學院派的味道,試圖透過設計模式的廣泛使用,來提高程式碼的可維護性和可延伸性。

    然而,個人對遊戲開發領域大規模使用 OOP 設計模式的看法:

  • 設計模式的六大原則大部份仍值得遵循。
  • 基於 Java 實作的設計模式,未必適合其它語言和領域。想想 C# 的 event、delegate、lambda 可以簡化或者消除多少種 GoF 的模式,再想想 Golang 的隱式介面。
  • C++ 是遊戲開發領域最主要的語言,可以 OOP 但並不那麽 OO,比如缺少語言層面純粹的 interface,也缺少 GC、反射等特性。照抄 Java 的設計模式未免有些東施尿頻,而且難以實作 C++ 所推崇的零代價抽象。(template 笑而不語)
  • 局部使用 OOP 設計模式來實作模組,並暴露簡單介面,是可以起到提升程式碼品質和逼格的效果。然而在架構層面濫用,往往只是把邏輯中的復雜度轉移到架構復雜度上。
  • 濫用設計模式導致的復雜架構,並不對可讀性和可維護性有幫助。比如原本 c style 只要一個檔順序讀下來就能了解清楚的模組,濫用設計模式的 OOP 實作,閱讀程式碼時有可能需要在十幾個檔中來回跳轉,還需要人腦去正確保證閱讀程式碼的上下文...
  • 過多的抽象導致過多的中間層次,卻只是把耦合一層一層傳遞。直到最後結合反射 + IoC框架 + 數據驅動,才算有了靠譜的解決方案。然而一提到反射,C++表示我的蛋蛋有點疼。
  • 那麽,有沒有辦法簡化和沈澱出遊戲開發領域較通用的模式?

    未脫離 OO 思想的 Entity Component 模式 案例:Unity3D

    Unity3D 是個使用了 Entity Component 模式的成功的商業引擎。

    相信使用過 Unity3D 的童鞋,都知道 Unity3D 的 Entity Component 模式是怎麽回事。(在Unity3D 中,Entity 叫 GameObject)。

    其優點:

  • 元件復用。 體現了 ECS 的基本思想之一,Entity 由 Component 組成,而不是具體邏輯物件。設計得好的 Component 是可以高度復用的。
  • 數據驅動。 場景建立、遊戲實體建立,主要源於數據而不是寫死。以此為基礎,引擎實作了以編輯器為中心的開發模式。
  • 編輯器為中心。 使用者可在編輯器中視覺化地編輯和配置 Entity 和 Component 的關系,修改內容和配置數據。在有成熟 Component 集合的情況下,新的關卡和玩法的開發,都可以完全不需要改動程式碼,由策劃透過編輯器實作。
  • 看起來,Unity3D 已經在很大程度上解決了遊戲設計領域通用模式的問題。然而,其 Entity Component 模式仍然存在一些問題:Component 仍然延續了一些 OOP 的思路。比如:

  • Component 是數據和行為的封裝。 雖然此概念容易導致的問題可以透過其它方式避免,但以不加思考照著最自然的方式去做,往往會造成 Component 後期的膨脹。比如 Component 需要支持不同的行為就定義了不同的函式和相關變量;Component 之間有互相依賴的話邏輯該寫在哪個 Component 中;多個 Component 邏輯上互相依賴之後,就難以實作單個 Component 級別的復用,最後的參照鏈有可能都涉及了程式碼庫中大部份 Component 等等。
  • Component 是支持多型的參照語意。 這意味著單個 Component 需要單獨在堆上分配,難以實作下文所提到的,對同型別多個 Component 進行數據局部性友好的儲存方式。這樣的儲存方式好處在於,批次處理可以減少 cache miss 和記憶體換頁的情況。
  • 當前主流的 Entity Component System 架構 案例:EntityX

    那麽,綜合以上所說的各種問題,一個基於 C++ 的現代 Entity Component System,應該是什麽樣子?

    具體案例,可以參考 [EntityX](https:// github.com/alecthomas/e ntityx ),一個開源的 C++ ECS 框架。

    一一實作了前述現代 ECS 的各種概念:Entity 只是個 ID,Component 儲存數據,System 實作關聯多個 Component 的行為。

    程式碼味道:

    struct Position { Position ( float x = 0.0f , float y = 0.0f ) : x ( x ), y ( y ) {} float x , y ; }; struct Direction { Direction ( float x = 0.0f , float y = 0.0f ) : x ( x ), y ( y ) {} float x , y ; }; struct MovementSystem : public System < MovementSystem > { void update ( entityx :: EntityManager & es , entityx :: EventManager & events , TimeDelta dt ) override { es . each < Position , Direction > ([ dt ]( Entity entity , Position & position , Direction & direction ) { position . x += direction . x * dt ; position . y += direction . y * dt ; }); }; };

    如上,實作了兩類 Component:Position 和 Direction。

    MovementSystem 只關心同時具有兩類 Component 的 Entity。

    一些值得說的特點:

  • 低抽象代價。 C++ 的樣版特性,便於把不少在其他語言中難以避免的執行時開銷,轉移到編譯時。
  • 同類的多個 Component 實作了緊湊連續的記憶體布局。 這個特性為什麽重要?請參考[這個問題](https://www. zhihu.com/question/2027 5578 ) @Milo Yip 的回答。同時這也是 Unity3D 的 Entity Component 模式難以做到的。當遍歷同類 Component 時,數據儲存於連續的記憶體空間中,可以大大提高緩存命中率。
  • Component 只有數據,行為是 System 的事。 這樣的模式,避免了上一節提到的 Unity3D 中容易出現的問題。Component 沒有邏輯上的互相參照,Component 的耦合和依賴由 System 處理。此外,由 System 進行統一的狀態修改,也有利於定位和隔離問題。
  • System 間的解耦,主要透過事件回呼。 System 之間不提倡互相參照,透過 Signal 來實作 publish / subscribe 進行處理。【守望先鋒】也提到了關於 System 間發生了耦合的麻煩情況通常用 Singleton 模式和把共用程式碼放進 Utils 解決。
  • 2017/07/27 追加

    ECS 的進一步最佳化

    除了可以提高緩存命中率外,新世代的 ECS 還可以透過分析數據依賴和讀寫關系,來實作 System 間的並列。比如更新時, System A 需要讀 元件1,System B 需要讀 元件1、寫元件2,System C 需要寫 元件1,那麽排程時可以把 System A 和 System B 分配到不同執行緒處理,之後再處理 System C。原貼中也一筆帶過提到了這方面的最佳化。然而對於復雜的 C++ 遊戲來說,這個目標在實踐上的可行性具有比較大的障礙:難以確保團隊中的熊孩子不小心寫出非執行緒安全的程式碼。

    不過,Rust 給這個問題帶來了解決方案。可以參考 Rust 實作的並列 ECS 框架:slide-rs/specs

    Rust 的語言特性在編譯期保證了執行緒安全,只需聲明一下 System 對 Component 的存取許可權如:

    type SystemData = ( ReadStorage < 'a , Velocity > , WriteStorage < 'a , Position > );

    這樣,即可安全地獲得多執行緒帶來的效能提升。