rustum 发表于 2022-1-15 22:08

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

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

ECS怎么样被滥用/误用的

<hr/>-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这种模式去开发。

<hr/>最后说明,这个答案不是对ECS妖魔化。ECS绝对是提高游戏开发中数据密集运算的性能以及解决网络预测回滚的很好的手段,并且在某些情况下,他真的能够比OOP或者EC更好地解耦和简化代码。
这里是为了充分说明,一个框架,是怎么被误解或者过分使用的(典型的还有MVC)。每个框架需要真正深入思考过,才能真正用好这些框架。
另外,可以预见unity应该会加强推动ECS,因为ECS确实解决了unity很多的性能坑,比如getcomponent会产生GC,遍历component效率不高,天然方便支持job system等,社区关注度也比较高,所以在易用性做得足够好的情况下,甚至有可能会作为默认框架使用。

JamesB 发表于 2022-1-15 22:09

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

ECS怎么样被滥用/误用的

<hr/>-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这种模式去开发。

<hr/>最后说明,这个答案不是对ECS妖魔化。ECS绝对是提高游戏开发中数据密集运算的性能以及解决网络预测回滚的很好的手段,并且在某些情况下,他真的能够比OOP或者EC更好地解耦和简化代码。
这里是为了充分说明,一个框架,是怎么被误解或者过分使用的(典型的还有MVC)。每个框架需要真正深入思考过,才能真正用好这些框架。
另外,可以预见unity应该会加强推动ECS,因为ECS确实解决了unity很多的性能坑,比如getcomponent会产生GC,遍历component效率不高,天然方便支持job system等,社区关注度也比较高,所以在易用性做得足够好的情况下,甚至有可能会作为默认框架使用。

JoshWindsor 发表于 2022-1-15 22:14

相信大多数人开始关注ECS是从去年《守望先锋》团队在GDC的演讲开始的,这个分享更多关注的是ECS架构带来的解耦。也是在去年,Unity开始主推ECS,他们在Unite Austin 2017演示的demo非常震撼,在中高端PC上实现了10万个单位的战斗。相对于《守望先锋》,Unity的ECS把性能放在了首要位置,但是这也直接导致了目前这套ECS架构写起来约束非常多,后面会谈到。
说到性能,我们就要明白要发挥当代计算机性能最重要的是什么。一个是利用当代cpu的多核特性,这个自不必多说,另外一个就是减少cache miss,这个可能是很多人会忽略的。自计算机诞生以来,内存和cpu的速度一直在提升,但是提升速率并不相同,所以cache miss,或者更准确点L2 cache miss带来的性能损失越来越大。因为cache miss,在实际工程中传统的时间复杂度分析可能会失效,比如从数组中移除一个元素真的不一定比链表慢;一些暴力算法会比使用一个理论上时间复杂度更优的复杂数据结构来更快,比如 @招招 回答中提到的frostbite做culling的例子。要减少cache miss的方法很简单,就是内存连续分配和连续访问,这正是ECS擅长的。而Unity推出的不只是ECS,而是一套组合拳。在ECS之上,他们还有一套Job系统让你更轻松的写出安全的多线程代码。一个专门针对Unity优化的名为Burst的编译器,能帮助你生成最优的机器码,特别是SIMD。甚至Unity还想解决浮点数的确定性问题,个人极其期待。我个人认为这是Untiy诞生以来最大的技术变革,Unity对此的推动力度非常大。自去年以来他们CTO每次演讲都是ECS相关内容,他在unity的论坛上也一直非常活跃地回答各种技术细节。甚至Unity还把业界泰斗,Insomniac Games的Engine Director,Mike Acton挖了过来。
虽然Unity的ECS还处于preview阶段,但是最近一个多月一直在使用,接下来就谈下具体感受。首先是优点,因为这套架构将性能发到了最高优先级,所以simulation部分的很多逻辑确实可以非常快,特别是一些诸如移动和寻路的逻辑,配合Job简直快到飞起。我自己的demo,对于几百个单位的混战,已经是GPU bound了。另外一个直观的的好处是ECS的部分可以不pooling了,这部分内存是非managed,GC自然是没有的,并且只要不在一帧内频繁创建和删除对象也不会有性能波动。关于性能确实没有太多好说的,需要自己去使用时才会有真实的感受。然后关于解耦的话,感触还不是特别深,就暂时不谈了。然后是缺点,首先从系统设计的层面上,很多传统的系统在ECS中很不好做,其他回答都或多或少提及。结合具体的经验的话来谈的话,behavior tree这种层级更新的结构和ECS就很不搭。我考虑过将每一个node抽象为一类components和对应的system。但是这也代表着每次update,一个树只能移动一个node,因为system在一次update中只执行一次。然后是消息驱动的模型不适合ECS,倒并不是ECS中不能使用消息,只是当消息发生时,回调可能会访问任何数据,这和ECS的哲学是不匹配的。所以关于AI和技能,我觉得很难找到纯ECS的方案,除非做出很多妥协,比如很多命令需要延时处理。Mike Acton在今年GDC演讲的QA环节被问到是否有系统不适合ECS,他给出的回答是literally everything。我目前对此存疑,希望未来Unity能展示更多的best practice吧。最后的问题是,目前Unity的ECS的Component只能是struct,成员必须是blittable类型,简单来说就是你不能有引用类型,写起来简直痛苦。他们最近才开始支持string和array,在我写这个回答时,还没发布...
最后总结一下,对于需要进行大量对象遍历和计算密集的项目,我认为ECS是非常值得尝试的架构。虽然有些系统在ECS中不好实现,但是碰到这个问题你可以保留EC,将S封装成OO的系统去处理,比如AI。而在遇到性能问题时,你手上会有一套极其强大的武器去解决。最后,一位naughty dog大佬和我说过,“做游戏就是优化性能和做工具,其他都是扯淡”。

BlaXuan 发表于 2022-1-15 22:23

正如楼上所说,软件开发没有银弹,用了 ECS ,有得必有失。但 ECS 作为一颗耀眼的新星,未来在游戏开发世界里必然有它的一席之地。
从我这几个月的实践来看,Unity 2018 的 ECS 用起来确实很舒服,可以大幅简化游戏逻辑的编写。极端地说,ECS 模式可以把一个耦合极端严重的、有 10000 行的文件完美地分成 50 个易于理解的、便于维护的、源文件只有 300 行的 system 类——是的,你没加错,ECS 写起来很啰嗦!
不夸张地说,ECS 是游戏开发的一种终极解耦方式。
但是,100% 的解耦是不存在的,至少指定某些 system 的更新顺序是必要的。所以,像“随便把 component 组合起来便能写出一个即插即用的 system”这种美梦是不现实的。每次增删一个 system,你都要再三检查有没有破坏全局的 system 更新顺序,以及新 system 的各种 component 需求能否得到满足;而对于 component,你需要检查是不是已经在合适的时机把这些 entity/component 加到 ECS 世界里,再在某个合适的时候把它们销毁。ECS 没有垃圾回收这个概念,一切 component 的生命周期都依赖于程序员的手动管理。
我觉得这些工作做起来也很繁琐,当你增删 component/system 时,那个之前完美运行的 ECS 黑盒子忽然不工作了,你不得不花费大量时间去看究竟是哪个 system 的前置没有被满足、system 是不是按照预期顺序更新。这些调试工作不比面向对象开发模式轻松。
最近我们面对的一个难题是,一个 entity 不能有多个相同类型的 componet,所以没法用 ECS 高效实现游戏里常见的 buff/debuff 机制。纠结再三,最终选择的解决方案是用 C++ 写一个原生插件,当然,编程模式是面向对象。(为什么不用 C#?因为游戏的战斗系统用了 Unity 2018 的另一个重磅功能:job system,这个东西不能调 C# managed 代码,只能调外部的 unmanaged/native 代码。)
另外,一个普遍的看法是 ECS 无法很好地用来开发 UI,UI 最自然、最高效的开发方式还是面向对象编程模型。
Unity 的 ECS 是完全用 C# 实现的,源码随便看。下个 Unity 2018,在 Package Manager 里面装上 entities 包即可。
最后,ECS 能提高性能的根本原因是数据连续存放从而 CPU 缓存友好。

APSchmidt 发表于 2022-1-15 22:30

ECS解决2个问题:
1)性能;
2)减少不必要的内存使用;
放一张图,之前写了demo测试,对于使用ecs,不使用ecs,做instancing优化3中情况下,性能的差别。


可以看到如果你渲染的object在500以内,ecs性能并没有显著提升,当超过1000后,ecs性能有显著的优势,在10000obj下,差不多100的性能差距。
所以对于200内obj的游戏,是不是用ecs差别不大。
另外ecs这是unity提出的一个系统化的方案和标准,我们自己也可以或多或少使用传统方法做出类似的结果,没必要非ecs不可。

demo是如下图(Instancing),根据自带的rotate demo完成对应的instancing和传统方法版本,这个demo是1000个cube,有一个sphere旋转,撞到cube后,cube会自转一段时间,逐渐停止,所以需要1001个物体不停的update:



=========补充公司内分享的完整文章:

不再需要MonoBehaviour、Component和GameObject

以前MonoBehaviour承载了游戏逻辑和数据两部分功能,我们通过创建GameObject然后添加MB(MonoBehaviour,下同)然后通过Update来更新游戏逻辑,往往我们Update里就是更新一些数据,而MB的实现非常复杂,有很多不需要的功能都一股脑的被继承下来,这导致我们一个非常简单的任务可能需要浪费很多内存去应付那些不需要的功能,如果再无节制的使用MB,那基本就是游戏运行效率的噩梦。
之前的Component是继承自MB,大部分时候Component的作用就是提供数据,但是通过Component组织的数组是对CPU cache不够友好的,因为它并没有把需要多次重复计算更新的数据组织到一起,使得CPU在做计算时可能cache miss,如果游戏逻辑需要大量对象需要更新数据,可能这部分消耗是非常大的。
同时MB不能很好的解决执行顺序问题,对于动态创建的GameObject,MB的更新顺序是不确定的,我们往往系统某些MB的Update和Destroy在某些MB之后,以保证相互的依赖关系,但Unity的MB设计上没有更好的解决这个问题,通过调整Script Execution Order既麻烦也没啥卵用(之前测试对于动态创建的MB毫无作用,只能用于场景中静态的MB)。
还有,如果游戏中存在大量的GameObject,上面绑定大量的Component,那么执行大量的Update是非常耗时的,而且这些Update只能运行在主线程,无法并行。
为此,Unity 2018.2 引入了全新的ECS系统,用于解决上面提到的问题。
全数据化驱动

ECS的核心设计目标就是去掉传统的MB,GameObject,Component对象结构,而改为全数据化驱动的代码组织方式,通过这样的改变,可以解决2个问题:
1)将数据更有效的组织,提高CPU cache利用率;
2)并行化。
先简单看一个例子,这个是ECS sample自带的例子:



这个例子可以看到,虽然画面里有超过1000个物体,但并没有对应1000个GameObject,当球体碰到方块的时候,会产生自转后衰减,同时可以保持在300-600的fps运行帧率。这在以前,我们要实现类似的效果需要创建1000个GameObject,然后有1000个MB负责更新GameObject的transform信息,我按照这样的方法来实现,那么这个demo大概只有1/3的fps。



注意到上图会创建更多的GameObject,fps大概100-200fps之间,当然这么实现并不是最优化的,我们还可以使用Instancing优化drawcall,为了对比我又实现了Instancing的版本,对比如下:




fps大概150-300fps,可以看到instancing大概提高了1倍的fps,在1000 objs测试下,不同实现方法之间差别大概1-2倍,貌似差别不是很大,于是我又测试了更高obj数量的fps,在更高的objs测试下,得到如下图表:



可以看到在更高的obj数量下,ECS方法的优势就体现出来了,在10000 obj下,ECS任然可以做到350的高fps,而即便Instance优化,也只剩下4fps,差距几乎100倍。
现在我们回到开头,ECS解决了如下2个问题:
1)将数据更有效的组织,提高CPU cache利用率;
2)并行化。
但是如何解决的呢?
将数据更有效的组织,提高CPU cache利用率

传统的方法是将gameobject的数据放在Components上,比如可能是这样(参考1):
using Unity.Mathematics;
class PlayerData// 48 bytes
{
public string public int[]
public quaternion
public float3
string name;       // 8 bytes
someValues; // 8 bytes
}
PlayerData players[];
他的内存布局是这样:


而这样的设计对cpu cache是极度不友好的,当你试图批量更新float3数据时,cpu cache预测总是失败的,因为他们被分配到了内存中不连续的区域,cpu需要几次寻址跳转才能找到对应的数据并操作,如果我们能够将需要批量更新的数据连续的存放到一起,这将大大提高cpu cache的命中率,提升计算速度。
按ECS设计规范,就是将批量的更新的数据抽取出来,在内存中连续排列,从而在cache预读中能够将后续的数据一次性读取进来,提高cpu操作的效率,如图(参考1):



在实际计算的时候,通过间接索引,来更新全部entity的各个类型的数据,而不是更新每个entity的全部数据,如图:


可以看到这里,最大的变化是:
在一个system(相当于以前的component)更新计算中,是将所有的entity的position放在一起批量更新的,因为这些position的float3数据在内中是连续的,cpu操作起来是最快的。
并行化

将数据单独提取出来后,接下来的任务就是将这些数据计算并行化,充分利用多核。在以前,几乎逻辑代码都是跑在主线程,当然也有项目组意识到了这个问题,会将一些和显示无关的逻辑放入多线程中并行,但都没有彻底的在数据上抽象形成一套完整的开发框架,而ECS解决了这个问题。
ECS开放了底层的job system系统,在上层提供了c# job system,它是一套多线程调度系统。如果不同的数据是无相互依赖的,仅需要将这些数据通过c# job system放入多个线程并行化计算就可以了,如:
public class RotationSpeedSystem : JobComponentSystem
    {
      struct RotationSpeedRotation : IJobProcessComponentData<Rotation, RotationSpeed>
      {
            public float dt;

            public void Execute(ref Rotation rotation, ref RotationSpeed speed)
            {
                rotation.Value = math.mul(math.normalize(rotation.Value), quaternion.axisAngle(math.up(), speed.Value * dt));
            }
      }

      protected override JobHandle OnUpdate(JobHandle inputDeps)
      {
            var job = new RotationSpeedRotation() { dt = Time.deltaTime };
            return job.Schedule(this, 64, inputDeps);
      }
    }
如果不同的数据有依赖,需要其他的数据计算完才能完整计算,则可以设置任务依赖,c# job system会自动完成这样任务的调用和依赖关系排序。
混合模式

目前已经存在大量的传统方式开发的代码,如果想享受到ECS带来的高效,势必要将现有代码大幅改造,有没有相对简单的方法既能保持现有代码没有太大变动,又可以提高效率呢,答案是ECS提供一种兼容的混合模式。
例如如下代码(参考2):
using Unity.Entities;using UnityEngine;
class Rotator : MonoBehaviour{
// The data - editable in the inspector public float Speed;
}
class RotatorSystem : ComponentSystem{
struct Group
    {
// Define what components are required for this// ComponentSystem to handle them. public Transform Transform;
public Rotator   Rotator;
    }

override protected void OnUpdate()
{
float deltaTime = Time.deltaTime;

// ComponentSystem.GetEntities<Group>// lets us efficiently iterate over all GameObjects // that have both a Transform & Rotator component// (as defined above in Group struct). foreach (var e in GetEntities<Group>())
      {
            e.Transform.rotation *= Quaternion.AngleAxis(e.Rotator.Speed * deltaTime, Vector3.up);
      }
    }
}
主要的修改是把MB的Update函数移到了ComponentSystem的OnUpdate函数中,同时增加了一个Group的struct,用于在MB和ComponentSystem之间交换数据,同时在原GameObject上添加一个GameObjectEntity组件,这个组件的用途是将GameObject其他全部的Component抽取出来并创建一个Entity,这样就可以通过GetEntities函数在ComponentSystem中遍历对应的GameObject了。
Unity会在启动的时候,把所有挂载了GameObjectEntity组件的GameObject,都创建对应ComponentSystem,这样你任然可是使用以前的GameObject.Instantiate方法来创建GameObject。只是原本MB的Update函数被替换到了ComponentSystem的OnUpdate函数中。
通过这样的修改,你可以混合ECS的部分能力又相对保持原本的代码结果,总结来说:
混合模式可以分离数据和行为,可以批量化更新对象的数据,避免每个对象的virtual方法调用(抽象到了一个函数中),任然可以继续使用inspector来观察GameObject的属性和数据。
但是这样修改并没有彻底改善什么,创建、加载的时间没有改善,数据的访问任然是cache不友好的,并没有把数据在内存中连续排布,也没有并行化。
只能说这样修改是为了进一步靠近纯ECS做的阶段性代码重构,最终还是要完全使用纯ECS才能获得最大的性能提升。
相关参考

1)Unity 官方分享ppt,ECS & Job System。
2)https://github.com/Unity-Technologies/EntityComponentSystemSamples/

kyuskoj 发表于 2022-1-15 22:31

点赞再看,养成习惯对于很多人来说,ECS只是一个可以提升性能的架构,但是我觉得ECS更强大的地方在于可以降低代码复杂度。
在游戏项目开发的过程中,一般会使用OOP的设计方式让GameObject处理自身的业务,然后框架去管理GameObject的集合。但是使用OOP的思想进行框架设计的难点在于一开始就要构建出一个清晰类层次结构。而且在开发过程中需要改动类层次结构的可能性非常大,越到开发后期对类层次结构的改动就会越困难。
经过一段时间的开发,总会在某个时间点开始引入多重继承。实现一个又可工作、又易理解、又易维护的多重继承类层次结构的难度通常超过其得益。因此多数游戏工作室禁止或严格限制在类层次结构中使用多重继承。若非要使用多重继承,要求一个类只能多重继承一些 简单且无父类的类(min-in class),例如Shape和Animator。


也就是说在大型游戏项目中,OOP并不适用于框架设计。但是也不用完全抛弃OOP,只是在很大程度上,代码中的类不再具体地对应现实世界中的具体物件,ECS中类的语义变得更加抽象了。
ECS有一个很重要的思想:数据都放在一边,需要的时候就去用,不需要的时候不要动。ECS 的本质就是数据和操作分离。传统OOP思想常常会面临一种情况,A打了B,那么到底是A主动打了B还是B被A打了,这个函数该放在哪里。但是ECS不用纠结这个问题,数据存放到Component种,逻辑直接由System接管。借着这个思想,我们可以大幅度减少函数调用的层次,进而缩短数据流传递的深度。
基本概念

Entity由多个Component组成,Component由数据组成,System由逻辑组成。
Component(组件)

Component是数据的集合,只有变量,没有函数,但可以有getter和setter函数。Component之间不可以直接通信。
struct Component{
        //子类将会有大量变量,以供System利用
}Entity(实体)

Entity用来代表游戏世界中任意类型的游戏对象,宏观上Entity是一个Component实例的集合,且拥有一个全局唯一的EntityID,用于标识Entity本身。
class Entity{
        Int32 ID;
        List<Component> components;
      //通过观察者模式将自己注册到System可以提升System遍历的速度,因为只需要遍历已经注册的entity
}Entity需要遵循立即创建和延迟销毁原则,销毁放在帧末执行。因为可能会出现这样的情况:systemA提出要在entityA所在位置创建一个特效,然后systemB认为需要销毁entityA。如果systemB直接销毁了entityA,那么稍后FxSystem就会拿不到entityA的位置导致特效播放失败(你可能会问为什么不直接把entityA的位置记录下来,这样就不会有问题了。这里只是简单举个例子,不要太深究(●''●))。理想的表现效果应该是,播放特效后消失。
System(系统)

System用来制定游戏的运行规则,只有函数,没有变量。System之间执行顺序需要严格制定。System之间不可以直接通信。
一个 System只关心某一个固定的Component组合,这个组合集合称为tuple。
各个System的Update顺序要根据具体情况设置好,System在Update时都会遍历所有的Entity,如果一个Entity拥有该System的tuple中指定的所有Component实例,则对该Entity进行处理。
class System{
        public abstract void Update();
}

class ASystem:System{
        Tuple tuple;

        public override void Update(){
                for(Entity entity in World.entitys){
                        if(entity.components中有tuple指定的所有Component实例){
                                //do something for Components
                        }
                }
        }
}一个Component会被不同System区别对待,因为每个System用到的数据可能只有其中一部分,且不一定相同。
World(世界)

World代表整个游戏世界,游戏会视情况来创建一个或两个World。通常情况下只有一个,但是守望先锋为了做死亡回放,有两个World,分别是liveGame和replyGame。World下面会包含所有的System实例和Entity实例。
class World{
    List<System> systems;                   //所有System
    dictionary<Int32, Entity> entitys;      //所有Entity,Int32是Entity.ID

    //由引擎帧循环驱动
    void Update(){
      for(System sys in systems)
            sys.Update();
    }
}由ECS架构出来的游戏世界就像是一个数据库表,每个Entity对应一行,每个Component对应一列,打了代表Entity拥有Component。
Component1Component2...ComponentNEntityId1EntityId2...EntityIdN单例Component

在定义一个Component时最好先搞清楚它的数据是System数据还是Entity数据。如果是System的数据,一般设计成单例Component。例如存放玩家键盘输入的 Component ,全局只需要一个,很多 System 都需要去读这个唯一的 Component 中的数据。单例Component顾名思义就是只有一个实例的Component,它只能用来存储某些System状态。单例Component在整个架构中的占比通常会很高,据说在守望先锋中占比高达40%。其实换一个角度来看,单例Component可以看成是只有一个Component的匿名Entity单例,但可以通过GetSingletonIns接口来直接访问,而不用通过EntityID。
例子

守望先锋种有一个根据输入状态来决定是不是要把长期不产生输入的对象踢下线的AFKSystem,该System需要对象同时具备连接Component、输入Component等,然后AFKSystem遍历所有符合要求的对象,根据最近输入事件产生的时间,把长期没有输入事件的对象通知下线。
设计需要遵循的原则


[*]设计并不是从Entity开始的,而是应该从System抽象出Component,最后组装到Entity中。
2. 设计的过程中尽量确保每个System都依赖很多Component去运行,也就是说System和Component并不是一对一的关系,而是一对多的关系。所以xxxCOM不一定有xxxSys,xxxSys不一定有xxxCOM。

[*]System和Component的划分很难在一开始就确定好,一般都是在实现的过程中看情况一步一步地去划分System和Component。而且最终划分出来的System和Component一般都是比较抽象的,也就是说通常不会对应显示世界中的具体物件,可以参考下图守望先锋System和Component划分的例子。


3. System尽量不改变Component的数据。

[*]可以读数据完成的功能就不要写数据来完成。因为写数据会影响到使用了这些数据的模块,如果对于其它模块不熟悉的话,就会产生Bug。如果只是读数据来增加功能的话,即使出Bug也只局限于新功能中,而不会影响其它模块。这样容易管理复杂度,而且给并行处理留下了优化空间。
使用心得

我在一个游戏demo里尝试使用ECS去进行设计,最大的感受是所有游戏逻辑都变得那么的合理,应对改动、扩展也变得那么的轻松。加班变少了,也不再焦虑。在开始使用ECS来架构业务层之前,我对ECS还是存有一丝疑虑的。担心会不会因为规矩太多了,导致有些功能写不出来。中途也确实因为ECS的种种规矩,导致有些功能不好写出来,需要用到一些奇技淫巧,剑走偏锋。但这些技术最终造就了一个可持续维护的、解耦合的、简洁易读的代码系统。据说守望团队在将整个游戏转成ECS之前也不确定ECS是不是真的好使。现在他们说ECS可以管理快速增长的代码复杂性,也是事后诸葛亮。
引擎层的System比较好定义,因为引擎相关层级划分比较明确。但是游戏业务逻辑层可能会出现各种奇奇怪怪的System,因为业务层的需求千变万化,有时没有办法划分出一个对应具体业务的System。例如我曾经在业务层定义过DamageHitSystem、PointForceSys。
推迟技术:不是非常必要马上执行的内容可以推迟到合适的时再执行,这样可以将副作用集中到一处,易于做优化。例如游戏可能会在某个瞬间产生大量的贴花,利用延迟技术可以将这些需要产生的贴花数据保存下来,稍后可以将部分重叠的贴花删除,再依据性能情况分到多个帧中去创建,可以有效平滑性能毛刺。
如果不知道该如何去划分System,而导致System之间一定要相互通信才能完成功能,可以通过将数据放在中的一个队列里延迟处理。比如SystemA在执行Update的时候,需要执行SystemB中的逻辑。但是这个时候还没轮到SystemB执行Update,只能先将需要执行的内容保存到一个地方。但是System本身又没有数据,所以SystemA只好将需要执行的内容保存到单例Component中的一个队列里,等轮到SystemB执行Update的时候再从队列里拿出数据来执行逻辑。
但是System之间通过单例Component有个缺点。如果向单例Component中添加太多需要延迟处理的数据,一旦出现bug就不好查了。因为这类数据是一段时间之前添加进来的,到后面才出问题的话,不好定位是何处、何时、基于什么情况添加进来的。解决方案是给每一条需要延迟处理的数据加上调用堆栈信息、时间戳、一个用于描述为什么添加进来的字符串。
各个System都用到的公共函数可以定义在全局,也可以作为对应System的静态函数,这类函数叫做Utility函数。Utility函数涉及的Component最好尽可能少,不然需要作为参数传进函数Component会很多,导致函数调用不太雅观。Utility函数最好是无副作用的纯函数,不对Component的数据做任何写操作,只读取数据,最后返回计算结果。要改Component的数据的话,也要交给System来改。
函数调用堆栈的层次变浅了,因为逻辑被摊开到各个System,而System之间又禁止直接访问。代码变得扁平化,扁平化意味的函数封装少了,所以阅读、修改、扩展也很轻松。
如果可以把整个游戏世界都抽象成数据,存档/读档功能的实现也变得容易了。存档时只需要将所有Component数据保存下来,读档时只需要将所有Component数据加载进来,然后System照常运行。想想就觉得强大,这就是DOP的魅力。
优点

模式简单
结清晰
通过组合高度复用。用组合代替继承,可以像拼积木一样将任意Component组装到任意Entity中。
扩展性强。Component和System可以随意增删。因为Component之间不可以直接访问,System之间也不可以直接访问,也就是说Component之间不存在耦合,System之间也不存在耦合。System和Component在设计原则上也不存在耦合。对于System来说,Component只是放在一边的数据,Component提供的数据足够就update,数据不够就不update。所以随时增删任意Component和System都不会导致游戏崩溃报错。
天然与DOP(data-oriented processing)亲和。数据都被统一存放到各种各样的Component中,System直接对这些数据进行处理。函数调用堆栈深度大幅度降低,流程被弱化。
易优化性能。因为数据都被统一存放到Component中,所以如果能够在内存中以合理的方式将所有Component聚合到连续的内存中,这样可以大幅度提升cpu cache命中率。cpu cache命中良好的情况下,Entity的遍历速度可以提升50倍,游戏对象越多,性能提升越明显。ECS的这项特性给大部分人留下了深刻印象,但是大部分人也认为这就是ECS的全部。我觉得可能是被Unity的官方演示带歪的。
易实现多线程。由于System之间不可以直接访问,已经完全解耦,所以理论上可以为每个System分配一个线程来运行。需要注意的是,部分System的执行顺序需要严格制定,为这部分System分配线程时需要注意一下执行先后顺序。
缺点

在充满限制的情况下写代码,有时速度会慢一些。但是习惯之后,后期开发速度会越来越快。
优化

一个entity就是一个ID,所有组成这个entity的component将会被这个ID给标记。因为不用创建entity类,可以降低内存的消耗。如果通过以下方式来组织架构,还可以提升cpu cache命中率。
//数组下标代表entity的ID
ComponentA[] componentAs;
ComponentB[] componentBs;
ComponentC[] componentCs;
ComponentD[] componentDs;
...参考资料


[*]《守望先锋》架构设计与网络同步 -- GDC2017 精品分享实录
[*]http://gamadu.com/artemis/
[*]http://gameprogrammingpatterns.com/component.html
[*]http://t-machine.org/index.php/2014/03/08/data-structures-for-entity-systems-contiguous-memory/
[*]http://blog.lmorchard.com/2013/11/27/entity-component-system/
[*]浅谈《守望先锋》中的 ECS 构架
<hr/>这里整理了一份多年收集回来的优质学习资料,涵盖游戏开发的方方面面,免费送给大家


链接:https://pan.baidu.com/s/1C-9TE9ES9xrySqW7PfpjyQ 提取码:cqmd

感谢各位人才的点赞、收藏、关注
微信搜一搜「三年游戏人」关注一个有情怀的游戏人

Ilingis 发表于 2022-1-15 22:36

ECS用的越多越发现这个问题难以回答,下面是我们完全用ECS的思路设计的游戏。
一款类似CrimsonLand(血腥大陆)的游戏,设计了几个基本的系统:
1. 子弹
2. 玩家
3. 怪
4. 特效
5. 武器
这5个系统是非常模式化的,采用ECS的方式构建,但是其它的比如怪的生成逻辑,总共写下来也就不到500行代码直接用一个update解决。


https://www.zhihu.com/video/1035290048530345984
(更新:2018/10/15)
另外一个游戏模拟生存类游戏,里面构建了一个模拟鱼类运动的系统,这样只要给游戏中的其它比如蝌蚪/蛇加上这个组件它就能像鱼一样游动。这个游戏的系统:
1. 鱼类模拟系统
2. 水波纹系统
3. 鱼类AI系统
4. 泡泡系统
5. 食物系统
值得一提的是使用这种思路的时候根本都不会产生继承的想法,比如波纹系统本质上是把一个圆形的波纹放大10-100,在这个系统中只是维护了一组半径数据,系统更新波纹的半径而已。


https://www.zhihu.com/video/1005971403836837888
现在越来越发现ECS和OOP边界的模糊,尤其是在gameplay代码上有时候很难确定该怎么写,现在的方案是,对于大量的可能复用的逻辑采用ECS系统实现,这样有利于内存管理和并行化,其它的代码爱怎么写怎写。

TheLudGamer 发表于 2022-1-15 22:46

ECS近年来已然成为游戏开发中比较热门的一种架构模式,最近被大家所熟识并热烈讨论,还是源于GDC2017,《守望先锋》针对它们的ECS架构进行的一次技术分享。针对FPS,MOBA这类的竞技游戏,ECS架构有着得天独厚的优势。下面我们先简单地介绍一下什么是ECS。

[*]E -- Entity 实体,本质上是存放组件的容器
[*]C -- Component 组件,游戏所需的所有数据结构
[*]S -- System 系统,根据组件数据处理逻辑状态的管理器
这里需要强调一下,Componet组件只能存放数据,不能实现任何处理状态相关的函数,而System系统不可以自己去记录维护任何状态。说的通俗点,就是组件放数据,系统来处理。这么做的好处,就是为了尽可能地让数据与逻辑进行解耦。与此同时,一个良好的数据结构设计,也会以增加CPU缓存命中的形式来提升性能表现。但我认为,推动ECS流行的更深层次原因,是它的解耦理念,性能上还只是其次。
试想一下,你在开发一款多人实时在线竞技游戏,无论是吃鸡也好,农药也罢,日益堆积的变动需求与越来越多的状态维护肯定是开发人员所需面对的主旋律。也许游戏设计之初,策划会信誓旦旦地跟你说,咱们要整个5v5对战的MOBA游戏,就跟王者农药差不多,不过咱们牛逼的地方在于英雄可以变成汽车,就像变形金刚那样:


假设你用面向对象设计中的”类-继承“模式,肯定会优先设计出一个“英雄”类来,它可能会有不同的技能和武器,当然这需要通过策划来配置,我们则根据这些不同的配置数据,来实例化出一个个独立的英雄。同时,这个类还要包含“变身”的功能,变成汽车后,“人”的一些行为会被限制,比如无法开枪射击,但是移动速度会大大增加,物理耐性提升等等。
当你辛辛苦苦设计好满意的游戏架构后,策划又兴冲冲地跑过来,阐述他的新点子:英雄变成汽车状态后,应该有不同的形态表现,有的2个轮子的、4个轮子的,也有履带式的,最好还能支持没有轮子的概念车!在策划看来,这种仅仅调整一些配置性的东西,实现起来难度应该不大。但是对于开发者来说,移动的动作,车轮的印记特效等等,都需要重新考虑一番,好好地重构。
没过几天,策划又鬼魅般地出现,提出了新的需求:我体验了下游戏原型,只是变身汽车的话游戏画面不够立体,打击感也不强,你看能不能再加个变身飞机的机制,最好能海陆空全方位战斗,就像这样婶儿的:


没办法,还得继续扩展这个英雄类,让它的功能越来越“强大”,即使我们的内心几近崩溃的边缘。


又过了几天,策划再次出现,还未说话,卒。


当然,上面举的例子有很多玩笑的成分在里面,但如果你也是位游戏开发者的话,是不是会产生共鸣,有种似曾相识的感觉?策划的专业程度或许是一方面,但游戏的版本迭代与不断试错都是客观存在的事实,是无法逃避的。随着需求的不断累加,最初的那个“英雄类”会越来越臃肿。倘若你的项目中有多个程序员进行协作开发,那么恭喜你,代码的维护成本会指数级的增加!每个人都必须对英雄的方方面面了若指掌,否则一个不当的改动就可能造成毁灭性的灾难。
这个时候,ECS架构就体现出了它的优势。


与传统的“类-继承”奉行的“我是什么”不同,基于组件化的ECS架构更强调的是“我有什么”,是一种组合优先的编程模式。使用组合而非继承,会使你的代码更具灵活性。还是上面的例子,针对游戏的玩法,我们会构建出一个英雄的Entity实体类,它更像一个空盒子,可以在创建英雄Entity实例的时候赋予它一个ID作为唯一标识。当我们将这个实体放到world下,也许什么也看不见,什么也做不了,这是因为它现在还什么数据都没有。此时就需要根据游戏的需求,来设计出不同的组件填充到这个实体当中。注意,应尽可能地保证组件设计上的扁平化,会让你的模块结构更加清晰,也大大增加了CPU缓存命中的概率。
举个例子,常见的组件包括而不仅限于

[*]渲染组件 :英雄的顶点、材质等数据,保证我们能正确地渲染到world中
[*]位置组件 :记录着实体在这个world的真实位置
[*]特效组件 :不同的时机,可能会需要播放不同的粒子特效以增强视觉感受
此外,根据策划的各种奇葩需求,还可以衍生出不同的功能性组件,本质上都是数据的集合,之后会交由 System 来进行各种状态修改与逻辑计算。比如,想要一个英雄既能变成汽车又能变成飞机,我们可以设计出 Wheel 和 Wing 两个组件,存储数据的同时也表明不同实体的对应功能或身份。当然对应着的是处理该组件的System,一个 FlightSystem 可以去关注那些持有 Wing 的实体。确切点说,FlightSystem 其实只需要关注 Wing 组件就足够了,它不应该关心是哪个实体持有这个组件,只要能修改 Wing 的状态就足矣。
这样,就实现了我们经常说的解耦。
将复杂的游戏拆解成不同的逻辑处理单元 (System) ,而每个逻辑处理单元只关心那些向它注册监听的数据,其他数据一概不管。并且最主要的是,System 是不保存状态的,Component 才是状态的真正持有者。刚开始的时候,也许会很不适应,总想着在 System 里加点什么标识,好方便地进行状态回溯或者复用。这时应该警惕起来,你所设计的   System 职责是否单一,组件持有的数据是否过于复杂。将一个复杂的模块拆解成若干个相对简单的单元,不失为明智的选择。
下面就是我们基于ECS而设计的新的游戏架构。


然而,现实总是残酷的。如果一个游戏真要这么简单,或许ECS也就没什么存在的价值了。仅就《守望先锋》分享所知,它们游戏中光 System 就上百个,并且为了保证 System 不保存状态,在游戏帧更新时,System 执行的时序就有了限制。并且很多情况下,很多System关心的组件只有一个(如输入事件),于是就有了Singleton Component。个人觉得,由于不同游戏的不同特质,某些情况下都很难去严格遵循ECS的架构约束,但毕竟架构是为人服务的,而不仅仅是束缚。在我们深刻理解了ECS的思想后,针对实际需要来做一些变通也是未尝不可的。
就拿我参与开发的一款轻MOBA类的多人对战游戏为例,采用的网络通讯方式是protobuf加状态同步,伤害、状态等判定结果几乎都是放在服务器端来处理,客户端主要就是根据每一帧接收的网络消息来处理相应的数据、更新状态。由于客户端使用的语言是Lua,因此会使用一个table数据结构来保存游戏中注册的 System 实例,在帧循环遍历这个注册表,按照顺序依次执行每个 System 的Update函数。System 会处理自己内部维护的组件池,里面放的是注册进来希望被处理的组件,从而根据这些组件的数据来进行一些逻辑上的操作。
解析protobuf数据后,每条协议消息发来的数据其实就是组件所要更新的,但由于服务器端并没有采用ECS这种设计模式,数据设计上也肯定会有些出入的地方,于是需要客户端来解析转换下。为此,引入了Driver的概念。首先,会有若干个表格来存放我们创建了的不同Entity 实例,每个Entity都会持有一个ID。服务器端下发的消息中总会包含某些实体ID,这样处理不同逻辑的 Driver 就会根据这些ID来找到它所需修改数据的Entity,再从Entity找出相关的 Component 组件,将proto消息里的数据更新给这个组件即可,剩下的工作就交给 System 了。
至此,我们的ECS设计变种成了这个样子:


上述设计中,由于多了一层Driver,并且游戏使用的是状态同步机制,因此帮助 System 分担了很多工作。System 仅仅是批量处理 Component 状态的管理者,每一帧遍历系统组件池里的所有组件 (此时的组件已经是 Driver 更新好数据了的) ,我们也可以根据自己的需要来设定刷新间隔,对于一些不需要在每个帧刷新都执行 Update 函数的 System ,可以降低它们的更新频率从而节约一些性能开销。而 Driver 层面,只是数据的一道“搬运工”,负责更新给 System 能够识别的组件的持有数据。
当所在团队改用ECS后,起初都很不习惯这种新的编程模式,总会不知不觉中切换回“类-继承”的编程思路。但随着项目的推进,ECS所带来的一些优势变得愈发明显。首先,就是降低了团队协作成本。往昔的项目经历中后期时,总会出现某些又臭又长的 God Class ,甚至会出现一些功能重复的模块,同一个功能的函数被实现了两次!这种情境下,代码的维护成本可想而知。而在ECS编程模式下,每名开发人员,只需要关心自己负责的模块即可,System 很好地隔离模块之间的耦合。
ECS也并非尽善尽美,随着系统不断地开发与扩展,会发现难免有时 System 要处理的数据过于复杂,很难只用一个单一组件就能表示,或是之前所说的只有一个 Component 会被 System 监控的Singleton情况十分常见。这就考验开发者的权衡取舍能力:如果一味细化 System 的种类,虽能保证模块与组件数据之间的解耦,但却无形中增加了 System 的维护成本,使得原本比较简单的逻辑变得复杂起来,难以维护。另一种做法是,建立组件之间的关联,可以是组件内部持有另一个组件的指针,也可以是几个组件组合成一个新的组件。但这样做就牺牲掉了组件设计上的扁平化原则。
综上所述,世界上没有两全其美的好事,我们经常要去做决策、去权衡每种做法的利弊。一味刻板地循规蹈矩也是不可取的,越来越多的团队开发衍生出了新的ECS变种。但只要我们真正地理解了ECS的主旨与精髓,不断探索与改进,找到适合自己项目的最佳平衡点,它一定会成为助你高效开发的利器。

ainatipen 发表于 2022-1-15 22:48

游戏是复杂多变的,一个好的完整的游戏应该是由多个架构构成,单一的架构都存在自身的优势与劣势。

ECS在未来是一个大型游戏项目的好选择,对于小的游戏团队来说,应该致力于快速迭代开发游戏而不要过多的纠结于架构,现有的架构技术已经足够解决大部分问题了。ECS是一个gameplay层级的架构,对于大部分游戏来说游戏逻辑不是瓶颈,所以没有必要使用。

纯ECS架构是不大现实的,很大的演化可能是变成类似守望先锋那样,出现了非常多的单例,以及Utility函数和解决一些特定问题的特殊技巧。对于程序员而言,进行这样的思考尝试其实是非常有意思的,但是如果是做游戏的话,还是直接实现游戏逻辑来的方便。最后使用ECS必须遵守一定的规则与约束,不然最后代码肯定也是灾难级的。

对于优秀的技术团队,有机会的话可以选择去使用ECS。这里就要谈谈ECS的优势了,一个非常大的优势是可以大规模的提升大部分MMO游戏的开发效率,能良好的适应策划复杂多变的需求。要知道一个MMO游戏上线只是开始,后续的快速迭代开发是常态,能否快速迭代开发也很大程度上决定了一个游戏的成功与失败。然后就是性能问题,性能问题容易变成主要问题,而且就算性能不是问题,如果性能足够高的话,我们可以做更多更复杂更优意思的行为。这里内存Cache Miss是主要原因,内存的性能和CPU差太远,这机会是大部分游戏会碰到的问题。ECS解决了内存管理与生命周期管理并且多线程友好,当然这些对于优秀的游戏开发者来说不是问题,但是如果所有的开发过程中都要考虑这么多问题是低效的,而且当团队有20多个程序员的时候,并不能要求所有人都有这么高的水平。最后就是ECS解决复杂问题的能力,对于一个高复杂度的问题,如果代码中耦合其他不需要的信息,将极大的提高编码与维护成本。

远离性能优化,网络同步,内存管理,生命周期管理,只要遵守一定的规则,想想是非常诱人的。有实力有机会的技术团队,可以选择试试,比起使用无源码的第三方库,全是自己实现的代码,风险也是处于可控范围内的。

对于Unity ECS,感觉做的挺全面的,但不知道具体使用情况如何,估计会有一堆坑要踩。守望先锋的ECS实践是个很好的参考,已经踩了很多坑,提出了一些规则与解决方案。不过守望先锋的逻辑也不算太复杂,如果Component数量爆炸,对架构本身的优化也会变成一个挑战。或许不要把Component分太细会变成一个选择,具体实现还是要看具体的应用场景。

kirin77 发表于 2022-1-15 22:53

是不是不知道,我还没写过游戏。
但是我最近发现我给公司写的CAD软件在架构演化上就自动往那个方向去了。。。(最初是MVC,软件本身两个部分,前面C++后面OCaml)
这还是我不知道ECS的情况下,然后有一天(大概三个月前)偶尔看到ECS的资料,我艹
相关:正在准备写游戏的游戏小白
PS: Xenko用的架构也是和ECS差不多。
页: [1] 2 3
查看完整版本: ECS 真的是「未来主流」的架构吗?