« ECS 中的消息发布订阅机制 | 返回首页 | 不适合采用 System 的一种情况 »

ECS 中的概念缺失

经过长时间的思考和实践,最近一个多月,我们的 ECS 框架做了较大的调整。其中一部分工作已经在前一篇消息发布订阅机制中介绍,另一部分工作其实开展的更早,但因为我想多沉淀一段时间再写。到本周基本基本改动完毕,可以总结一下了。

ECS 框架几乎只在游戏开发领域提出,我认为这主要是因为目前只有在游戏领域,周期性的大量对象的状态变换才是主流行为。而在其它人机交互领域,响应外部事件才是主流。这是为何 System 在游戏领域如此重要的原因。

但另一方面,ECS 框架对过去流行的面向对象框架的反思,主要集中在面向数据/数据驱动。这是 Component 概念被如此看重的原因。Component 是行为被拆分出来的对象,重要的是数据本身。对于框架来说,要解决的是更方便的组合、有完善的自省机制,这才能针对数据集本身来编程。因为游戏开发,程序员的工作仅占很少的部分,大部分的工作是策划和美术围绕开发工具给数据添砖加瓦的。

因为大部分游戏领域的底层框架都基于 C++ 开发,所以现在大部分关于 ECS 的演讲、文章、开源代码,几乎都是围绕在 C++ 语言中的具体实现手段。它们解决的很多问题,其实是 C++ 本身的局限,而非 ECS 的设计问题。例如 C++ 的类不支持 mixin ,提倡继承,动态构造数据类型的能力不足,缺乏原生的反射……

当我们用动态语言(例如用 lua )来构建 ECS 框架时,会发现很多在 C++ 框架重点考量的东西不值一提:例如,GDC 2018 上守望先锋团队所做的关于 ECS 框架的著名演讲中,花了很多时间描述他们在 Singleton 问题上走过的弯路。这在我们用 lua 设计框架时极大的误导了我,其实 Lua 天生就能很好的创建沙盒,根本不会犯全局唯一实例这样的失误;我们并不需要刻意的在 ECS 框架中制造出一个 Singleton 的概念。

但那个演讲有一点讲的很对:采用 ECS 框架,重点是为了解耦。至于性能,只是附带的产物。我们应考虑框架如何把问题分解的更清晰,相互独立。这是拆分 Component 和 System 的关键。

在一年多的实践中,我们发现,Component 本质上是用来描述对象的一个方面 (aspect) ,Aspect Oriented Programming(AOP) 的概念并不新鲜,在 web 开发领域早已有之,具体想解决的问题和手法和游戏领域的 ECS 大相径庭。但我认为究其本质,还是希望把业务逻辑能分解到独立的模块中,方便组合和重用,提供灵活度。AOP 中叫 concern ,ECS 中叫 System ,想达到的目的(解耦业务)是一样的。只不过 AOP 偏重的是给现有流程扩展额外行为,ECS 强调的是按需组合已实现的行为。

在我们的实践中,我们将对象可能存在的数据拆分为一个个的 Component ,例如为了实现会动的物件,就需要有蒙皮、骨骼、网格、变换矩阵等多个 Component ,它们可能在实现别的特性时被复用;同时会有很多的 System 分别对 Component 的数据进行处理。有时,一个 Component 会被一个 System 处理,有时有多个 System 处理同一个 Component ,还有时一个 System 会处理多个 Component 。

最终拆分结果就是,一个一般的渲染流程,通常会引入数十个 System ,每个可渲染对象由数十个 Component 构成。如果直接面向 Component 和 System 搭建业务,几乎没有人能够不漏掉点什么,也无法保证没引入不必要的冗余。

如果框架导致开发的复杂度增加,肯定出了问题。ECS 成功解耦了不同的功能模块,却让使用它的地方变得难用,那肯定是不对的。

所以,我们决定引入一个新的概念,叫做 Policy ,用来表达某个特定的功能模块。

我们在创建 Entity 时,描述这个 Entity 拥有哪些 Policy ,而不是描述它由哪些 Component 构成。Policy 的个数一定比 Component 更精简,同时还描述了 Component 组合的初始化流程。因为我们是面向数据编程,所以 Entity 是由数据构造出来的,一部分数据直接是持久化的结果,最初的数据源于人在编辑器中的创造;而另一部分数据却是由其它 Component 构造出来的。

例如,持久化的网格数据,可能仅仅是一个数据文件的 URI ,但是运行时却需要把文件加载到内存数据结构中;动画数据可能需要根据骨骼和蒙皮数据联合起来才得得到……

数据的初始化是一个复杂的过程,可以看作是一系列操作针对单个 Entity 联合作用的结果。我觉得可以把这个过程的每个部分看作是一个变换,从一个数据源变换到另一组数据上。我们只要定义出变换的输入和输出,就能正确的组建出这个流程。

那么我们就在 Policy 中定义出这些,Policy 解决了引入哪些 Component 和附加其上的初始化变换操作的流程,使用者只要引入一个 Policy 就可以解决正确构造对象的这个特性所需组件的问题。

第二,Policy 还应该引入哪些相关 System 的问题,最好,还可以校验 System 间是否有冲突。

当 System 很多时,它们在更新时的执行次序就相当重要。

光是渲染流程,对于现代图形引擎就相当复杂了。寒霜引擎在 GDC2017 介绍了他们使用的一个叫做 FrameGraph 的概念,用于管理日益复杂的渲染流程。我们虽然还没有去实现一个那么复杂的渲染流水线,但我们的引擎需要管理比渲染更多的事情(例如,如果做一个很多单位拥挤在一起的场景 ,单位之间不可大量重叠,我们可能希望解决单位拥塞的问题。这就是个渲染之外的业务,也未必基于现有的物理模块去做)。这就需要一个灵活的抽象。

现在我们把这个流程成为 Pipeline 流水线。流水线从名字上看是一个线性的流程,由若干的环节构成,每个环节完成一个步骤。但从实用角度,我们组织成一棵树。然后给每个节点都起上可读的名字。框架会把这棵树以先序遍历的方式平展为流水线,并依次执行每个节点。

而 System ,它可以将任意业务注册到任意名字节点上。如果不同的 System 注册到相同名字的节点,那么我们就认为这些业务是互不相关,可以以任意次序执行的。如果一个 System 分几个步骤,这些步骤密不可分,且必须在流水线不同步骤运行,那么它就应该分开注册到不同名字节点上。如果有的步骤可被替换(例如蒙皮步骤可以用 CPU 实现,也可以用 GPU 实现),那么就可以实现在不同的 System 中,用 不同的 Policy 组合起来。

每个项目只要配置好 Pipeline ,再引入所需的 Policy ,框架就能跑起来。

如果多个 System 间有次序依赖,有什么方法可以防止 Pipeline 配置错误呢?这里的错误包括缺少必要的 System ,或持续倒置。

和 FrameGraph 中用 GPU 资源的实体做输入输出标注不同,我们采用的方法是给 System 标注上虚拟的输入输出。它只是一个可选的东西,用一个名字标识。如果一个 System 需要一个叫做 Foobar 的输入,但最终搭建的 Pipeline 里没有哪个 System 输出 Foobar ,或是次序不对,就会报告错误。

用一个可选虚拟概念更加灵活,只用来防止出错,你可以偷懒不写,也可以真的有这么个对应实体用 Component 或 Event 去承载。等以后遇到和 FrameGraph 相同的需求时(管理复杂的 GPU 资源),我在考虑赋予它更多的意义。


最终我们的 ECS 框架面对使用方来说,不再需要知道 Component 和 System 的概念,只有扩展的人才关心这些;创建 Entity 提供的是 Policy 列表和初始化的数据集(通常是由编辑器产生的持久化数据表,在我们的引擎中,可以用普通的 Lua 表表示),创建 World 则需要提供可能用到的 Policy 及预定义好的 Pipeline 。

框架对业务层暴露出来的接口,大多数用消息机制完成;由特定的 Policy 引入的 System 捕获这些消息来创建、销毁、控制 Entity 。而我们会对这些消息做一些封装交给业务层使用,对一般的业务,不用关心 ECS 框架的细节就能方便地用起来;对特别的需求,在理解 ECS 框架的基础上,扩展新的 Policy 。

Comments

而Pipeline类似于JobSystem

unityECS框架里Policy的概念叫做Archetype,用来用业务的概念聚合一组Component.

只能说英雄所见略同...

无论lua、skynet还是ECS,云大研究的新东西都给我指明了方向。
好久没来看博客,来一次受益匪浅。

按游戏领域需求进行范式/限制适当宽松,管理功能剪裁或衍变,兼顾并发并行问题与机制的一个“实时关系式数据库”:)

c/c++ 用ECS很大的优势还是在于 cache localize

并且,我认为代码的执行逻辑被抽离,有点反DDD
麻烦风大大指导下,自己对这个点没有想明白

Pipeline和Policy就很像后端服务经常使用的业务流程编排,平台化开发的思路。
接入成本很低,但是代码会变得难以理解。是否合适需要仔细权衡

风大大棒棒哒

我们在很长时间都是用依赖关系来解决这里的复杂性问题的。

首先,Component 不存在依赖关系,它们本身就是对象的独立的 aspect ,拆分就是为了组合。当你引入 A 的时候,可能配套的是 B 也可能是 C ,A 不关心是和 B 组合在一起还是和 C 组合在一起。同样,B 可以和 A 组合,也能和 A' 组合。

一开始我们在 System 中加入了依赖性描述,因为 System 往往在做同一间事时有多个步骤。所以我们不仅定义了 A 依赖 B ,还允许定义 B 被 A 依赖: 即 A/B 之间依赖关系可以写在 A 上也可以写在 B 上,用来应对不同的解耦方法。

最终我们发现,无法处理很多实际遇到的更复杂的关系。这就是为什么我用 Policy 来表达不同的组合方案的原因。

以下为个人见解,
如果说游戏是特别的开发领域,它会是面向对象的并发actor模型,因此消息和响应是交互的关键
ECS最重要的应用是在配置上的,也即是编辑器一块,20多年前至今的美术编辑器应用了类似手段,至于它的性能则是附带的好处
component数量太多是否可以用依赖表示,用户只关心上层component就可以了

非常领会“Policy”的概念和动机:拆得太细的模块虽然解藕了但是琐碎难懂,而用户有高级用户和低级用户之分(不是指能力,而是指工作层次,有类似高级语言低级语言这样),低级用户喜欢零碎的东西便于扩展,但高级用户只想拿来就用少做修改,这时Policy就是最好的“拿来就用”,一组好的Policy一定是对常见情形的有效总结。

Post a comment

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