« Lua 下的 ECS 框架 | 返回首页 | Lua 实现 ECS 框架的一些技巧 »

ECS 的 entity 集合维护

最近在基于 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 的有效性来判别。

Comments

>id 也永不复用
像是MMO这类移除新建比较频繁的游戏里会不会用光?id改成用{id},在移除的时候id[1]=无效值,这样让id能够再复用会不会好点呢?

感觉有点问题,System 每帧都会收到一个变更集合。如果一帧有多个system执行的话,内部组件的时序问题是不是无法保证。风大,不知道我理解的对不对

其实cocos2dx quick那个框架已经有点这个意思了。。。。很完善的lua 框架,模型实现就是组合的,本身lua想继承也没那么严谨的操作

Overwatch里面有个很重要的utils,是个事件全局入口层,整合E,C,S。

云风老师可以看一下苹果GameplayKit框架中的ECS

最近的3个月开发了一个moba游戏,用了ECS。有的系统entity比较少,有得系统entity较多,我的做法是每个System自己维护一个entity集合。System只关心组建,所以每次Entity的组建发生增删的时候就比较麻烦。World拥有所有的entity,entity发生组建增删的时候就通知system做修改。关于动态增删,system处理这些entity的时序也很关键,比如帧开始的时候统一处理。

这个思想就是现在前端的react +redux的思想么?

风哥,弱弱的问个问题,你写的那个pbc里面有句:local c = require "protobuf.c"。报找不到该文件的错误,是不是用的时候,有什么地方没用对。

ECS就是面向数据编程的思想

做网络同步的时候方便处理

剔除为毛要顺序遍历进行剔除?你这么多年代码白写了啊?不知道把有用的关键数据提取出来做索引用啊?查找符合条件的对象时只有顺序遍历这一种方法吗?我实在是无语

https://ibob.github.io/dynamix/
这是一个C++实现的mixin聚合类库,可以看看,不知道是不是适合在游戏开中使用。

暴雪的实现中有个tuple的概念,它是直接保存在System下的吗?每个System都有一个Array<tuple*>这样。另外它的Component里面有个c->Sibling<Component>()的方法,这意味着从A Component里面可以得到B Component这个又是怎么实现的呢?通过Enity?还是其他的办法?这块不知大家有什么好的想法?

我自己的 ECS 實現中(Swift),對 component 是採用的 pooling 技術來提升 locality,針對初始化/響應特定事件/迭代/命令幀(同暴雪實踐)的需求將系統劃分為了:

System <- Initialize System

System <- Implicit System <- Command Frame System

System <- Implicit System <- Regular System

System <- Reactive System

其中 Reactive System 專門響應 component slice(同暴雪實踐、或曰 "Tuple") 的添加、更新和刪除。這個是我從一個叫 Entitas 的 C# ECS 實現裡學的(當然這個實現的性能不理想,雲風先生肯定看不上 -_-! )。之後,Manager(暴雪實踐中的 EntityAdmin 等效)會自動將 component 成分(我稱之為 components signature)相同的 tuples 以 pooling 的形式來做一個 cache 以提升 locality。而在 Reactive System 中只會給出發生了事件的 tuple 和相應的事件,迭代則由 Manager 在 cache 中根據 coalescing 的策略來完成,寫入動作會被 proxy 到真正的 component pool 中。如果說還可以有什麼提高的話,我覺得還可以實現一套 ReactiveX 的 API 來增加程序員的舒適度。另外 components signature 是可以用 bit-string 來實現的,而我實現了一個根據 bit-string 長度在本地和 heap 內存自動切換的 bit-string 來提升性能。

我覺得雲風先生可以看看 Vittorio Romeo (一個義大利的大學生)的 ECS 實現,這個實現有這麼幾個特點:

1)通過給出 System 間的依賴解出了一個 DAG,以此實現了天然的 inter-system parallelism(當然他稱之為 outer parallelism,而 System 運行過程中的 parallelism 他成為 inner parallelism);

2)通過一個 mega array 來做的整體的 entity pooling。這個和我的實現是不同的;我認為:有些 systems 僅關心個別 components,所以這給出了 per-component pooling 實用的理由,而 Reactive System 關心的 tuples 則由 Manager 來單獨 pooling 來做一個 cache。

Vittorio Romeo 的實現在 Github 的地址是 SuperV1234/ecst,感覺裡面的 pdf 文檔非常值得研讀,我不知道這個留言本會不會過濾超鏈接,就不發全了。另外 Entitas-CSharp 感覺也是可以借鑑的,不過只有思想,實現還是算了。

Swift 才剛剛引入了 Law of Exclusivity,而 Ownership 應該在 Swift 5 才會看見一點影子,所以做迭代的時候也是 value copy 到 stack,雖然比引用計數可能便宜點,也實現了 Swift class 所不能實現的連續內存上分配,但是還是沒有 C++ 能直接拿到 reference 高效,可以說 Swift 還不是一門 ECS ready(或者說 data-oriented design ready?)的編程語言吧。整個框架的實現過程中也能感覺到 Apple 強烈的 object-oriented design taste。我個人拿 ECS 也就是做做實驗性的 iOS 框架,並沒有那麼工業級,若有幼稚之處見笑。

我是针对每种 component 建立一个链表来处理的,每个 system 根据某一类 component 来遍历,再根据拿到的 component 来取得 entity 和上面的其它 component。
后来改成直接针对 component 类型做一个 entity id 的数组,拿到 entity id 之后再去取需要的 component。
感觉不是很纯粹,纯粹的感觉应该是每个 system 拿到的是一个 entity 下的几个子 component 组成的一个切片对象,不过暂时想不到怎么高效的实现。

关于事件系统网上也看到很多争论,我暂时是用某些特定的 component 来表示事件,利用本身的 ECS 机制来处理了。

Post a comment

非这个主题相关的留言请到:留言本