本文禁止转载
本项目是Unity官方推荐的ECS入门训练中的蜜蜂大战项目
知乎文章同步链接
浅谈ECS工作栈
提到ECS就不得不提JobSystem和Burst编译器,三者共同组成了Unity面向数据的DOTS(Data-Oriented Technology Stack)框架。
ECS(Entity Component System)是Unity引擎中的一种编程模型,旨在提高性能和可扩展性。ECS通过将数据分离,采用实体(Entity)、组件(Component)和系统(System)的结构,以更好地利用现代处理器的多线程能力。
Job System是Unity中一个用于并行执行代码的系统,Job System通过将任务分解成小的作业(Jobs),以结构的形式封装了几类代码执行模式(串行/并行/分批并行),并封装了线程同步和数据依赖等操作,降低了多线程代码开发的难度。
Burst是Unity的一个编译器后端,它可以与Job System一起使用,以进一步优化代码的执行速度。Burst通过生成SIMD(Single Instruction, Multiple Data)指令,使得数据处理效率成倍增长。
简而言之,ECS提供了一种新的编程范式,通过数据驱动和并行执行来提高性能。Job System使得编写并行代码更为容易,而Burst则进一步优化了这些代码,提高了执行速度。在Unity中,这御三家的使用都是独立的,可以分开单独使用某个组件,但如果一起用就可以更充分的提高性能,尤其是在需要处理大量数据的情况下。
为什么要用ECS
性能优势: ECS 提供了在某些场景下更好的性能,例如在处理大规模数据时。它能够充分发挥多核处理器的优势,以实现更高的并行性,提高游戏的运行效率。
同时ECS的按类别组织数据的模式也有利于利用处理器的Cache和SIMD技术,提升数据处理效率。
可扩展性: ECS 天生就支持代码模块化,使得项目更易于扩展和维护。在ECS下,每个业务逻辑被处理为一个System,这种模块化的结构使开发人员更容易添加、删除或替换业务逻辑,而不会影响其他部分的代码。
适应性: ECS 是 Unity 引擎中的一部分,已经被广泛用于一些复杂的游戏和模拟项目。市面上已有大型游戏项目通过采用 ECS 提高了其渲染和物理模拟的性能,如重返帝国等。
实战项目
蜜蜂大战项目介绍
丛林中有两队蜜蜂,两队蜜蜂为了生存,要和对方队伍争抢资源和掐架。此次项目要实现的具体效果描述如下:
- 资源生成在中间。两队蜜蜂分别出生在两边的基地中。
- 在相同位置生成的资源将堆叠在一起。
- 蜜蜂捡起资源并将其放在它们的基地。
- 当资源击中基地的地面时,它会爆炸,生成几只该基地颜色的蜜蜂。
- 没有携带资源的蜜蜂可能会攻击并摧毁敌方蜜蜂。
- 被摧毁的蜜蜂会散发蜜蜂碎片和血点,而最终携带的资源会掉落到地面上。
- 表面上的血点会随时间逐渐减小至消失。
- 每只蜜蜂的显示比例在所有三个轴上振荡。
场景整体效果大概类似这样
我们最终要完成以上8点需求,达成和sample一致的效果,但不同的是,sample中采用的是Unity传统的MonoBehavior脚本加逻辑的实现方式,执行在单线程上,运行效率可谓非常之低。而我们采用ECS工作栈来实现,利用这面向数据三把斧的利器加持,最终要实现10万只蜜蜂也能流畅运行的目标。
使用ECS重新实现前后对比
先放一个对比视频,对比下项目在原版实现和ECS实现两种实现方式下的性能差异。测试机器配置是:CPUi5-13600KF,GPU 4070Ti, 内存64GB。视频前30s是原版实现下3万蜜蜂+1千资源,后30s是10万蜜蜂+1万资源,ECS实现。搭场景的时候偷懒了,在蜜蜂和资源以及特效的外表上,没有完全复刻原版,但是模拟的那8点逻辑是都实现了的,没有偷工减料。
10万蜜蜂同屏作战 UnityECS蜜蜂大战项目实践对比视频
蜜蜂和资源数量相差如此悬殊的情况下,前者的帧率一度来到10-20fps,而ECS实现全程帧率不低于60,性能提升之显著无需多言了(ECS,牛!)。再来看下Profiler,每个线程都基本拉满了,JobSytem恐怖如斯。
方案设计
我们来逐个分析业务的需求,列出对应的System实现相应的业务逻辑。
System设计
-
对于资源和蜜蜂出生而言,都是开始时刻一批产生大量实例,并随着系统的运行会持续产生,我们需要两个System分别用来创建蜜蜂和资源的对象实例(ResourceBirthSystem、BeeBirthSystem)
-
蜜蜂分成两种,一种是专门抢资源带回家生小蜜蜂的Carrier,一种专门战斗的Attacker,由于Carrier的目标可能被提前抢走,Attacker的目标可能被其他蜜蜂干掉,因此两种蜜蜂都需要每帧重新生成目标,如果目标状态不变,就继续维持当前逻辑否则就要寻找新的目标了。这里需要一个为蜜蜂更新目标的逻辑BeeTargetUpdatingSystem。
-
蜜蜂确定好目标后,实现蜜蜂的移动需要对速度以及位置进行更新,为了尽量保持每个system处理数据的独立性,减少耦合,这里我们将速度和位置更新的逻辑拆成两个system,先更新速度,再更新位置。因此这里需要两个System:Bee(VelocityUpdating/Moving)System
-
资源实现堆叠是一个相对复杂的逻辑,处理这个问题的第一直觉是,将地面分成不同的2D网格,每个格子我们称为一个tile,每个tile上记录一个高度,表示已堆叠的资源到达的最高位置,在ResourceMovingSystem之后,每个资源根据当前所在的tile,检查tile的高度和自身位置,如果可以停住则去更新tile的高度,更新自身状态。但这样实际上是有问题的,如果从资源角度出发去更新tile高度,串行执行太慢,并行更新tile高度又会data racing,你可能会想问,为什么不用原子操作?由于原子操作只支持有限的数据类型如int、float等,因此即使原子修改了tile高度,也无法原子记录tile的堆顶resource。最后我采用的解决办法是:tile记录自己区域上空的所有资源,由tile执行这些resource的位置更新,以及tile高度的更新。在tile的视角下并行,虽然在一定程度上减少了并行度,但是避免了data racing带来的程序执行错误的问题,牺牲了一点性能换来正确性。由tile主导的resource移动和状态更新的逻辑放在ResourceMovingSystem。
-
网格在更新资源位置时,需要知道有哪些资源的位置该被更新,或者说,哪些resource处于tile的管辖范围。这里可以为每个tile添加一个DynamicBufferComponent,这也是Entities下的一个特殊的Component类型,类似于动态数组的概念,长度可变。每个resource在出生后,根据自己的位置和地板上tile的位置,判断自己应该被加入到哪个tile的队列中,你可能想问了:更新dynamic buffer的时候难道不会出现data racing问题了吗?ECB很贴心的为我们的DynamicBuffer准备了单线程修改的逻辑,只需要为每个resource记录下“向这个tile的dynamic buffer中添加此resource的entity”这个Command,ECB在下一帧的playback中会采用单线程执行这些CommandBuffer,就不会有线程安全的问题了。这个逻辑我们在ResourceTileUpdatingSystem中实现。
其他的一些System逻辑就相对简单,就不一一列举了。下面这张图列出了所有的system以及它们的执行顺序,从名字上应该也能看出这些system是干嘛的。
Component设计
entities中常用的Component类型有四种:ComponentData、EnableableComponent、TagComponent、SharedComponent,我在下图中简单总结了四者的区别,首先添加或删除Component相当于更改了Archetype,肯定会导致StructralChange没有问题,但更新值不会,除了SharedComponent,SharedComponent值会影响entity存放在哪个chunk下,更新SharedComponent值也会导致StructralChange。EnableableComponent在chunk上采用bitmap的方式存储,当不包含任何数据时,和TagComponent一样,完全不占用chunk内的空间,且更新值不会发生StructralChange,更新成本低,缺点是Enable与否不会影响放在哪个chunk,因此同个chunk下可能同时包括Enabled和非Enabled两种类型的数据,对Cache和SIMD不太友好。
我个人理解中,四者的定位区分还是比较明显的,ComponentData是最常用的,Entity一定会有的Component,TagComponent适用于那些改变不频繁的、起标记作用的Component、EnableableComponent适合那些需要起标记作用,但状态切换又很频繁的Component、SharedComponent适合用来实现能区分出三类或三类以上的标签,但是每个entity又不会频繁改变这一标签的Component。几种Component各自的适用场景不同,具体该用哪个,业务中还是要具体情况具体分析。
资源的状态比较多,资源在开始时会下落,直到摔在地上,资源在被蜜蜂捡走后会随着蜜蜂移动,携带资源的蜜蜂死亡后会资源又会开始下坠。resource总计会有四个状态,其中Docking(停靠)、Falling(下落)、Transporting(运输)
这三个状态间切换的可能很频繁,我们使用EnableableComponent来实现这三个状态。
由于死亡和生还的状态是单向的,一旦切换就不可逆,因此这种状态的切换不会频繁,我们对资源和蜜蜂身上的Alive这个Component实现成ComponentData或者TagComponentData。该Component一旦移除就会发生StructralChange。
表示蜜蜂队伍的实现同理,这里用Team1和Team2两个Tag。表示蜜蜂的角色我们使用Carrier和Attacker两个Tag。
一些实现细节
-
蜜蜂攻击的顺序问题。在sample的实现逻辑中,会出现这样一种结果不确定的逻辑:由于每个Attacker都是随机从对方阵营中选择攻击目标,因此有可能选择的目标恰好也是个Attacker且也选择了自己,由于所有的蜜蜂的攻击范围相同,因此,因此两只蜜蜂究竟谁会死亡的结果是随机的,取决于攻击的逻辑先执行到哪只蜜蜂。在ECS的实现中,由于我们对蜜蜂的生还状态采用ComponentData实现,添加或删除该状态就是StructralChange,因此在Job中就只能使用ECB来更新。在BeeAttackingSystem中,蜜蜂攻击的逻辑是并行执行的,状态的更改又是通过ECB来延迟执行,因此在这种情况下的两只蜜蜂都会将对方的状态改为死亡,这样就出现了和原本的逻辑相差较大的结果。为了解决这个不符合原版逻辑的问题,只出现一只蜜蜂死亡的结果,稍微加了个Trick,通过在创建蜜蜂的时候在每个蜜蜂原本的攻击距离基础上增加较小的随机偏移,使得每只蜜蜂的攻击范围都不相同,这样就可以确保在两只Attacker攻击对方时,不会有两只蜜蜂同时死亡的结果出现了。
-
随机目标选择问题。在蜜蜂更新目标的逻辑上,需要随机选择目标资源或蜜蜂,不管是Carrier还是Attacker都需要获得一个随机数。由于该更新目标的Job是并行执行,不能共享Random的状态,因此每个Job需要创建一个Random并给定不同的随机种子来产生不同的随机数,这里我们使用EntityIndexInQuery作为种子,确保每个蜜蜂的种子都不同,同时再加上framecnt,确保同一个蜜蜂两次更新目标时获得的随机种子也不同。
-
蜜蜂二次实例化的问题。资源在基地坠落会产生新的小蜜蜂,这要求我们在游戏中间还可以产生新的不同类型蜜蜂的Entity。我们可以每帧创建原始蜜蜂Entity,并逐个添加Tag:Attacker/Carrier和Team1/Team2,再为每个类型蜜蜂的实例化设置不同的个数。但在创建Entity,添加Component的过程中也是会发生StructralChange的,虽然数量不多,但是坏在每帧都有。因此我们也可以预先创建四个类型的蜜蜂Entity,每帧直接根据需求的个数对应调用实例化即可。但是这样产生了另外的问题,我们预先创建的蜜蜂Entity也会被各种Query查询到,从而进入到游戏逻辑中,然后被敌对的Attacker杀死。我们的确可以加个特殊的Tag来标识这些蜜蜂不参战,但这样我们每个Query都要进行相应的改动,非常不优雅。这里我们可以使用Prefab这个Entities内置的Component类型,程序运行中EntityQuery会自动过滤掉这个Component,这些特殊的Entity就可以避免被搅入游戏逻辑中。同时Instantiate方法实例化出的Entity会自动去掉这一Component,因此PrefabComponent并不会干扰正常的业务逻辑,可以放心大胆使用。
本文禁止转载