ECS 真的是「未来主流」的架构吗?

ECS 真的是「未来主流」的架构吗?

因为说ECS好话的比较多,就不重复了。所以我来说说ECS的反面:

ECS怎么样被滥用/误用的

-ECS真的为你带来性能优势了吗?

众所周知,ECS带来的两大性能优势,就是cache友好,以及易于做多线程并行。

事实上,绝大部分的unity项目,首要的性能热点都不是游戏逻辑本身,而是unity引擎本身的各种坑带来的性能消耗。比如mmo类游戏普遍的ui性能问题(ugui主要背锅)、GC(遥遥无期的mono SGEN垃圾回收机制)、渲染、移动(transform update的性能问题)、寻路、物理这些计算密集的大户,不少项目用的又是Unity内部的方案。

另外,鉴于手游现在普遍为了热更新而重度使用lua/ILRuntime等方案去写逻辑,那么在这些方案上应用ECS,就不要想着对性能有什么实际的贡献:这些方案本身就是非常的cache不友好(hash table的乱序存储、vm执行过程中大量读写逻辑以外的数据),lua不支持单vm多线程并行(ILRuntime没深入看过,但目测也是不支持单vm并行)。

那么,就算我真的只考虑在C#层面优化逻辑本身的性能,ECS是不是一定就是最适合的方案呢?接着看。

-ECS让你的代码cache友好了吗?

事实上,对于开发游戏逻辑这个特定范畴,在很多情况下,设计cache友好的代码,非常困难。

因为游戏逻辑并不是全部都是路径单一且数据密集的情况。

如果你的游戏,像unity里的ECS demo那样,模拟成千上万的单位做一样的简单逻辑事情,就一样地移动、放相同的技能,那么你可以很容易就写出几个for循环,每个循环内用很少的数据,就可以完成整个逻辑。

又或者你的项目像守望先锋那样,可以触及到底层,能够对数据密集的代码进行优化(寻路、碰撞、移动等等),那么这些地方也很适合通过for循环来高效遍历。

然而对于大部分游戏逻辑,特别是不能触碰底层的unity项目,要符合这个条件并不容易。

做一次伤害结算,你需要读取敌我双方的各种属性、状态,有些复杂的情况甚至还要计算双方的距离、甚至牵连到AI状态。然后一次伤害又可能产生各种附带的事件,比如触发一个buff、触发了死亡、等等等需要处理的逻辑。

你需要对数据精心设计和拆分,才可能做到数据连续读写。稍有不注意,可能某个人加了一些功能就会破坏原来连续的存储访问,导致丧失cache友好的优势。

Cache友好的编程方式真正牛逼的地方是用于解决数据单一而密集的问题,比如frostbite在做culling的时候就分享过放弃对场景做树状划分,直接暴力遍历,反而因为cache友好而获得更好的性能以及更简单的代码。这是因为culling时候数据结构单一,逻辑也单一,才获得了好的效果。

目前来说,移动逻辑、寻路逻辑,这些相对容易简化的密集运算,才真正适合cache友好的写法。而AI、技能则要视乎项目,往往多数项目在这块需求极其复杂,几乎无法构建出真正cache友好的数据结构。

-ECS真的让你的程序更清晰了吗?

使用ECS也会经常提及到解耦的问题。

ECS是一种反OOP的设计,其中最大的一点就是ECS没有提供天然的多态支持。多态必须通过为entity装配不同的component来实现,那这样component的设计就会变得很折腾,例如如果你有几十个AI节点或者几十种技能效果,你可能要设计很多很多的component去对应。

但是,如果你想用多态来做逻辑,为什么还要用ECS呢?因为多态本身是反Cache友好的。不同逻辑之间使用的数据都不相同,自然也就破坏了数据的连续读写。

这就是为什么用ECS做ui、做复杂技能特性,往往会束手束脚。

ECS还有另一点就是,没有什么很约定俗成的夸模块耦合方案。

不同的框架有不同的思路。有些框架提供立即触发的事件调用其他的system。守望先锋提倡共享代码抽到util,大的side effect抽到一个单独的system通过延迟事件解决。

这里要重点说一下,立即触发和延迟触发的事件,对ECS的意义,是不同的。

立即触发的事件,本质上相当于在循环里直接调用另一个system。这首先是反Cache友好,因为我在一个循环里又去尝试访问其他system关注的数据,同时他大大地增加了一个循环内部的逻辑复杂度。然后这也是反多线程友好的,因为多线程必须清晰知道每个system对component的读写关系,而事件隐藏了这块的关系,很容易会造成框架对读写关系判断错误而产生线程同步问题。

然后就是延迟事件,这种方式,可以解决掉上面说的两个问题。但是这不是一个万能的方案:延迟事件本身需要将事件参数进行存储,这带来了存储的消耗以及需要增加这部分的代码。大量地使用,也会让你觉得十分折腾,有时很简单的一个side effect都要加一个处理延迟事件的system逻辑来解决。而且延迟事件还要考虑清楚,延迟这个事情本身会不会产生问题。

所以,不好说这两种方案的优劣,但是如果system之间如果存在大量的逻辑耦合,要么就是system不是一个好的拆分,要么就是不应该选择ECS这种模式去开发。

最后说明,这个答案不是对ECS妖魔化。ECS绝对是提高游戏开发中数据密集运算的性能以及解决网络预测回滚的很好的手段,并且在某些情况下,他真的能够比OOP或者EC更好地解耦和简化代码。

这里是为了充分说明,一个框架,是怎么被误解或者过分使用的(典型的还有MVC)。每个框架需要真正深入思考过,才能真正用好这些框架。

另外,可以预见unity应该会加强推动ECS,因为ECS确实解决了unity很多的性能坑,比如getcomponent会产生GC,遍历component效率不高,天然方便支持job system等,社区关注度也比较高,所以在易用性做得足够好的情况下,甚至有可能会作为默认框架使用。