技术头条 - 一个快速在微博传播文章的方式     搜索本站
您现在的位置首页 --> 系统架构 --> ECS 中的概念缺失

ECS 中的概念缺失

浏览:1025次  出处信息

   经过长时间的思考和实践,最近一个多月,我们的 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 。

建议继续学习:

  1. 2015年版阿里云ECS服务器使用总结(与aws比较)    (阅读:2507)
  2. android开发入门2:概念建立    (阅读:2312)
  3. 浅谈《守望先锋》中的 ECS 构架    (阅读:1610)
  4. ECS 中的消息发布订阅机制    (阅读:1101)
QQ技术交流群:445447336,欢迎加入!
扫一扫订阅我的微信号:IT技术博客大学习
© 2009 - 2024 by blogread.cn 微博:@IT技术博客大学习

京ICP备15002552号-1