ECS的一些理解

我不认为ECS是专门为了解决游戏逻辑而存在的架构思想,而是一种全新的,一般的,普适的建模方法。游戏领域更容易暴露出传统面向对象体系下的各种问题而prefer ECS,但这并不是将这种架构思想限制于游戏的理由。ECS我认为是一种比面向对象更加一般,更加优秀的建模方法。

在面向对象中,当我们描述世界中某个概念,某个实体,就会写新的class,或者继承某些已有的class,我要继承某个对象,往往为的是往上面添加某些新的行为/方法,或者数据。继承结构是编译期确定的,在运行时,每一种类的能力都不能发生变化。

但ECS中,我们的做法是,组合一些component,或者添加新的component类型并组合,组合的结果是entity。任何概念和实体都是entity。原先用类描述一个物体,现在我们用entity。如果我要给某个entity添加新的数据/能力,那么我就添加它需要的component。相比面向对象,由于entity只是component的组合,所以任何物体,在运行时可以动态增加/删除 行为和能力,这个相比编译期的类非常非常灵活。面向对象的类,其能力和行为都是通过静态的继承树/图(多继承)完成的,不可变的。但在ECS中,一个实体,具体有什么能力/行为,这个东西本身就被数据化了,数据化的结果就是可以让我们在运行时能够添加修改删除查询任意实体的能力和行为。我想这是一种建模思想和建模能力的飞跃。

我想,很多架构上的改进,或者说设计模式,其实本质上就是将原先写死在代码里的逻辑,数据化,使得原先不灵活的东西,变成灵活的可操控的东西,以应对需求变化/演进,甚至你可以针对数据化的逻辑写代码。举一些例子比如观察者模式,如果没有观察者模式,那么对象之间的依赖,通信,都只能是写死在各个类上的方法,这个类call另一个类,在代码里写死调用,写死依赖。而观察者模式本质上把这种依赖数据化了,原先写死的方法调用依赖,变成统一装载在eventdispatcher里回调函数,变成了一个function的array,每一个function都是原先的依赖,数据化的结果就是对象直接可以动态的建立联系,on off emit,而不是写死在代码里。 又比如我们实现的rendergraph框架,也是把整个复杂的多变的渲染流程描述变成了数据,正因为完成了数据化,我们才能实现针对这种数据的逻辑,即自动依赖分析等。所以我一直觉得,架构改进,就是数据化的过程,而数据化的终极形态,就是ecs,ecs里我们都不会为了世界中的实体写类了,任何东西都是组合出来的,任何东西都是数据的组合。everthing is about data.

在ecs中,你的所有东西,或者说你的世界,你的场景,你的业务数据,事实上全部都在一个database中。entity就是这个database的index,你可以想想一个巨大的table,每一列是一种component类型,每一行是entity,里边单元格装着component的实例。 你的everything都在这里边,无比的simple,整齐,统一,只有数据。这是EC

那么数据都在这里整齐的放置着,我的业务逻辑呢,这就是S, System。逻辑其实就是处理数据的过程,system就是一个处理过程的单元,具体怎么处理数据呢,很简单,我们有个database,那么你在这个database上运行某些查询条件,读取你关心的数据,做处理,再写入你指定的地方。仿佛就是写web后端一样,模式高度简单统一。我们实际的逻辑其实比较复杂,应该拆分成若干小的sysytem,这些小的system就实现了处理逻辑的复用(对应的数据字段的复用体现在component组合上)。每一个system可能依赖其他system,那么事实上整个system构成了有向无环图,和rendergraph一样。这下,事实上正如同我们解决渲染pass间的依赖关系一样,逻辑之间的依赖关系也被自动解决了。。更amazing的是,由于依赖关系变成数据(图),多线程瞬间就支持了,systemgraph的执行器完全可以搞threadpool发任务。我们知道多线程很恶心数据竞争的问题,事实上这个也瞬间被解决了,因为你声明system的时候,system要运行的EC数据库查询条件,无论是写还是读,都是确定的,所以执行器完全可以判断某个时刻EC数据库的哪些部分正在被哪些sysytem进行访问,避免同时写就ok了。更amazing的是由于ec的存储基本都是array,稍加考虑缓存命中都相对随机访问很好,性能会有额外的好处,在我看到的ecs的实现中,有的甚至有在budget时间内整理内存的接口来优化这个事情。

下面,我简单设想一下如果将ecs作为场景层架构,具体的形态可能是怎么样的:

matrix, worldMatrix, modelViewMatrix,或者说,所有的uniformLike的数据 全部都是独立的component,这样,只有拥有这些component的物体才会被更新这些数据,而不是像现在一样统一更新(毕竟可能有些物体的绘制不需要matrix)同理,任意一种uniformlike的数据,也都是按需被组合在entity上,会有具体的system完成他们的更新,不会有额外的数据,不会有额外的逻辑。

层次结构: 层次结构是个component,具体的数据是parent和children的entityID.

如何完成变化监测:我能想到有两种思路: 1 事实上任意一种component,我们可以统一支持其额外的state,就是自上一次同步前,哪些change,add,delete,sysytem在查询数据库时,可以为某些component的查询附加额外的查询条件,比如我只想查询所有上次同步前所有新加的的 A component ,另一种思路2,是component对应的change信息也是数据,也是component,我们提供一个封装良好的结构,可以让用户包装某些component,使其实际变成多个component并实现change的功能

场景更新流程:每一种场景内容相关的component,都应当实现上面提到的watchable的装饰。所以更新的系统们可以完整知道整个场景数据的change情况,便可以执行更新逻辑。以我们现有的更新逻辑同步举例:

step0 业务逻辑完成场景更新

这部分是业务逻辑,不属于引擎范畴,如果业务逻辑也是ecs,那么这里放的是业务逻辑的sysytem

step1 完成层次结构的更新(matrix,visible等受层次结构影响的数据)

查询所有 (有层次结构组件,所有可能有(任意受层次结构影响 且 发生变更)的若干组件),
system逻辑触发所有子tree change,change标记在层次结构组件上
forach所有发生变更的组件,完成更新,其中,各种component各自从parent更新的方法使用一个统一的特殊trait约束,其中更新方法对数据需求都是optional的,因为更新的组件这时候也是optional的

step2 完成GPU数据更新

查询所有ubo,vao,vbo,ibo,texture,sample,所有的GPU资源的变更组件,各种sysytem完成更新操作

step3 drawcalllist 生成和剔除

查询所有renderobject(drawcall)这个也是component,但这个component里其实是其他component的ID,表现上看起来是entity)且具有Bounding的component。走剔除system得到rawlist多pass,每个节点依赖的camera是不一样的,所以这个system会被调用多次,生成的list本身也是component。其他drawcalllist 过滤,分叉,变换都是各个实现好的sysytem,读写listcomponent数据

step4 rendergraph execution

因为rendergraph和system dispatch graph本质是一样的,所以新的系统会使用一套实现,你可以理解成,整个ECS系统的dispatch部分,就是rendergraph。 每个pass是system,每个pass前的小更新操作也是system。整个引擎使用一整套Systemgraph作为流程抽象和逻辑组织,整个场景使用一个Entity Component数据库存储所有绘制信息

最后我对ecs还有一些疑虑之处比如:

前文我提到某些component事实上有些数据是其他的component的reference(ID)。所以事实上我们是通过typed id来完成引用关系的数据化,那么数据删除的话,id失效怎么办,难道runtime check and panic吗。所以我们针对这种reference的data,需要提供引用计数回收吗? 如果你认为entity本身也是一个component,事实上为什么不呢?所以最后的概念模型就是我有一堆的component,这些component以id的形式相互引用,这感觉就像是一堆heap一样,每个id就是某个heap内的指针(地址),感觉就是自己维护了堆。。如果从这个角度想,那要真的正确回收component,引用计数能work,似乎再搞个garbage collection 也没什么问题?

将数据拆分成多个component,我的另一个疑虑是是否会增加额外的遍历次数和成本,在没有ecs的情况下,我要的数据的访问往往可以集中在一次循环中完成,而且不需要动态的根据条件来过滤component组合,这其实也是额外的开销。当然我也知道基于原型的ecs可以解决这个问题,但也会引入其他问题,总觉得还是有tradeoff存在