« December 2019 | Main

January 19, 2020

不适合采用 System 的一种情况

ECS 框架中,System 通常是对某种 Component 的集中处理。大部分情况下,一次性对大量对象中的简单的数据结构做简单的处理,比每次对一个对象的复杂数据结构(通常是简单数据结构的组合)做复杂处理,依次处理所有的对象要更好。

凡事总有例外。我们最近就发现有一类情况不适合这样做。那就是骨骼动画的骨骼计算。

骨骼计算会分为若干步骤,有骨骼数据的展开,骨骼姿态的混合,IK 计算等等。一开始,我们遵循着 ECS 的设计原则,把这些步骤分到不同 System 种。例如并非所有的对象都需要做 IK 计算,这样 IK System 就只用遍历一个子集;同样,也并非所有的动画都需要做多个姿态的混合,等等。

但我们在实现时却发现了很大的问题。

骨骼相关运算是需要在一个临时短生命期的 Buffer 上进行的。骨骼本身的持久化数据结构占内存不大,但是如果需要做中间运算,就需要把紧凑的数据展开到一个中间 Buffer 中,有关混合、IK 这些运算都是基于这个中间 Buffer 的,而当这些运算完毕,到了蒙皮阶段,中间 Buffer 又没有存在的必要了。

如果遵循 ECS 的原则,我们把这些步骤分离,每个步骤都遍历所有的对象各做一次,就会生成大量的中间 Buffer ,徒耗内存。其实本质上,一个线程只需要一个中间 Buffer 就够了。因为对一个对象做完处理,这个 Buffer 就不再使用,可以被下一组运算复用。

所以,我们近期做的调整就是把这一系列关于骨骼的算法全部集中在一个 System 中处理。

下面再谈谈 Lua 封装代码的处理。

如果整个过程是在 C/C++ 层面完成,那么,这个中间 Buffer 完全可以开在 stack 上。等一系列操作完成后,就会自动回收。但是将 C 函数封装为 Lua 函数后, C stackframe 无法保留任何私有数据。如果你把中间 Buffer 封装成一个 userdata 扔回 Lua ,就面临被滥用的可能。一旦滥用,对 gc 带来额外负担。

我们的做法是把它封装成 userdata 后,隐藏在 C api 的 upvalue 里面,应用层无法直接访问。这一系列针对骨骼数据的 API 就好像 opengl 的 api 那样,有个内在的状态维持在 vm 中。每组调用都从一个显式的调用开始:对于骨骼运算,就是将骨骼数据展开到临时 Buffer 中。

相比昂贵的骨骼运算操作步骤,一次 Lua 到 C 的调用开销算不得什么,所以我们没有将它封装成 [数学库] (https://blog.codingnow.com/2019/03/improved_math3d.html) 的样子(允许单次数学库调用进行足够多的数学运算),还是保留成一个个独立的 Lua API 调用。


我认为以后会有很多类似的情况不适合分 System 分步处理,值得注意。

总结一下,当针对单个对象进行的一系列操作,需要的中间临时资源相对昂贵时,不应该拆分成多步 System 。昂贵的资源可能是内存,也可能是显卡资源。

January 13, 2020

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 。