ECS 的 entity 集合維護

wohl7868 7年前發布 | 16K 次閱讀 面向對象編程 軟件架構 單例模式

最近在基于 ECS 模型做一些基礎工作。實際操作時有一個問題不太明白,那就是涉及對象 (entity) 集合本身的 System 到底應該怎樣處理才合適。

仔細閱讀了能找到的關于 ECS 的資料,網上能找到的大多是幾年前甚至 10 年前的。關于 ECS 的資料都不斷地強調一些基本原則:C 里面不可以有方法(純數據結構),S 里面不可以有狀態(純函數)。從這個角度看,Unity 其實只是一個 EC 系統,而不是 ECS 系統。從 Unity 中尋找關于 System 的設計模式恐怕并不合適。

重看了一遍暴雪在今年 GDC 上的演講 Overwatch Gameplay Architecture and Netcode —— 這可能是最新公開的采用 ECS 模式的成功(守望先鋒)實踐了—— 我想我碰到的基礎問題應該在里面都有答案。

從絕大多數資料看來,Entity-Component 是對 C++ 中對象模型的一個反思:基于組合,甚至是運行期組合,而不是繼承,去合成對象;System 是對 OO 面向對象設計方法的反思:把方法和數據結構分離,不要把一組方法綁定在對象上,即面向對象所主張的,由對象來處理針對它的不同行為;而是由 System 來處理不同的對象聚合。

采用 ECS 模型是因為過去的 OOP 模型耦合度太高,EC/System 的方式可以用來解耦。

把對象 Entity 拆分為更基礎的數據結構單元(Component),讓 System 直接作用于 Component 集合而不是對象,的確可以對大部分問題解耦。正如 Wikipedia 頁面上的舉例:假設有一個繪圖 System將迭代所有有物理組件和可視組件的 Entity ,它從可視組件中了解 Entity 的怎樣繪制,再從物理組件中了解 Entity 該在哪里繪制。而另一個 System 專門處理碰撞檢測,它迭代出所有有物理組件的 Entity ,處理他們的碰撞關系,負責產生碰撞事件,但這個 System 不用關心這些 Entity 是怎么繪制的,也不用知道 Entity 具體是什么東西,碰撞本身會有什么后果。再會有另一個 System 負責 Entity 的血量,血量組件記錄了 Entity 的 HP 數據,這個 System 處理碰撞事件,知道當子彈擊中怪物后,怪物需要扣血。

這樣看都很美好,只是,游戲引擎中,往往還有一個性能相關的需求:剔除。一個 System 需要處理的對象往往是全體對象的一個子集。如果子集遠小于全體的話,每幀按 Component 類型去迭代整體就有很大的性能開銷。

比如,渲染 System 通常會根據攝像機在場景中的位置,剔除掉場景中的大部分物件。如果我們編寫了這么一個剔除的 System ,那么在這個 System 運作之后,后續的其它 System 就不應該在整個世界中迭代可視的 Component ,而應該針對的是剔除后的集合了。

如果忽略 EC 這種基于組合的對象模型和傳統 C++ 中基于繼承的對象模型的實現上的差異,我們可以把 Component 僅看成是 Entity 身上的一種篩選標簽 Label ,ECS 模型其實是為 System 提供了按 Label 篩選出 Entity 集合的能力。那么,我們是不是應該提供一種不太影響處理效率的,動態貼標簽和撕標簽的能力?空間剔除器可以給 Entity 打上需要渲染的標簽,后續的渲染器可以迭代“需渲染”的組件集合。

我在 Overwatch Gameplay Architecture and Netcode 中看不到類似的設計,在我自己的實踐中,Label 的想法也有不少問題。所以想了另一種方案。

據說,在守望先鋒的引擎中,存在著大量的單件(singleton)組件。相關的 System 只會處理這一個組件,從里面讀取數據,或把數據放在其中。我認為、用于 System 間交換數據的事件隊列、剔除器的結果、這些都應該是存放在某個單件中。像渲染器這種 System 不必從 Entity 全集中迭代需要渲染的集合,而應該轉而從剔除器單件里迭代一個子集。

而剔除器的工作依賴對象本身的狀態變化。游戲場景中會有大量的物件,它們的狀態幾乎不會變化,每幀都迭代一遍是很低效的。最好是只在位置變化時才更新剔除器中的集合。

Wikipedia 頁面中也談到 system 間的通訊問題。某些對象狀態改變并不頻繁,所以處理需要利用觀察者模式來被動觸發:

The normal way to send data between systems is to store the data in components. For example, the position of an object can be updated regularly. This position is then used by other systems.

If there are a lot of different infrequent events, a lot of flags will be needed in one or more components. Systems will then have to monitor these flags every iteration, which can become inefficient. A solution could be to use the ovserver pattern. All systems that depend on an event subscribe to it. The action from the event will thus only be executed once, when it happens, and no polling is needed.

我自己在實現的時候給 ECS 框架增加這么一個設施:你可以讓一個 System 關注一類 Component 的變化事件。只有這類 Component 變化后,System 才運行 —— 而普通 System 是每幀都運行的。而 Component 的新建和刪除都會觸發這種變更事件,我還給框架增加了一個方法,可以主動設置一個 Component 變更。同一個 Component 的變更事件在同一幀內只會觸發一次。

btw, 如果你有留意 Overwatch Gameplay Architecture and Netcode 演講,大約在第 4 分鐘的時候,他展示了一張系統的結構圖,其中 System 有兩個方法,一個是 Update ,第二個叫 NotifyComponent ,參數是一個 Component 指針。演講中并未談及這個 NotifyComponent 是做什么用的,但我猜想就是做的類似工作。

在我的(基于 Lua 的)實現中,System 每幀都會收到一個變更集合,而不是每個 Component 調用一次 System 的對應函數。這是因為 Lua 中維護集合相對廉價,而函數調用相對昂貴。

這個集合每幀更新。一旦有新建 Component ,或是別的 System 主動觸發變更消息,都會添加。一開始,我打算在最后將同幀刪除了的 Entity 以及從 entity 中移除了 Component 的部分從集合中去掉,保證 System 遍歷集合中的 entity 都是有效的。

后來發現,這種做其實是多余的。因為 System 不僅要關心 component 的變化,更要關心 Component 的消失。比如對于空間管理器來說,一個對象從空間中移除也是重要事件。好在 Entity 我們都用唯一 Id 來引用,即使刪除,id 也永不復用。如果只需要在 Entity 刪除時也把 id 記在這個集合里,System 自己迭代時,發現一個 Entity id 已經無效了,就說明觸發了刪除事件。這比單獨再設計一個刪除事件要簡潔的多。

總結:

當 System 需要迭代一組 Entity 對他們中的 Component 做特定操作時,這個集合可以從 EntityAdmin 中用 Component 類別做篩選,也可以從 EntityAdmin 獲取一個單件,由單件維護這樣的集合。

一個單件中的 Entity 集合由特定的 System 來維護,這個 System 可以訂閱指定的 Component 的變更事件。

變更事件包含了 Component 的創建、移除和狀態變化,創建和移除事件會隨著 Entity 的構建和移除自動產生,狀態變化由專有 API 產生;同一幀內,一個 Component 最多只會產生一個變更事件。事件并不區分類別(創建、移除等),由 System 在迭代時自行檢查 Entity id 的有效性來判別。

 

來自:https://blog.codingnow.com/2017/12/ecs_entity_changeset.html

 

 本文由用戶 wohl7868 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!