《WorkWithUE5》CitySampleZoneGraph剖析 (五) MassEntity框架解析

《WorkWithUE5》CitySampleZoneGraph剖析 (五) MassEntity框架解析

引言

先说说文章的书写的安排,按照由浅入深的原则来,本来这章准备讲讲蓝图的,但是真正写的时候发现蓝图大部分都是胶水代码,由简单分析到难的方式比较费事,会产生很多的废话,这又与我的少而精的原则相违背。因此经过制衡后,决定不写分析过程,直接分析C++写结论,这样不浪费广大读者时间,我也轻松点,毕竟大家的时间都很宝贵。对整体有个了解后,读者自己分析,举一反三,基本上遇到的问题就都能解决了。涉及到胶水部分就讲一下,其余想了解的东西可以搜一下别人的文章,或者是留言想看那部分,如果多的话我单独写一篇文章。进入C++分析部分可能理解起来就比较困难了,我尽量写的简单一些(一篇好文章并不是要把文字写的多复杂难懂,而是利用最简单的语言讲清楚复杂的东西),提供一些入口和思路,具体细节大家自己下去翻代码就好,都是UE官方的代码都是一样的。然后这个系列基本上就完事了,至于以后写什么暂时还没想好,大概的思路可能是写一些基础又不那么基础的东西。好了,废话不多说,Let us do it!!!!!!

相关概念

1.Subsystem

子系统,就是一个全局的管理类,单例,其生命周期与其继承的父类生命周期一致,自动创建和释放。简单理解就是一堆接口的集合,用起来也很简单,就继承出一个子类,然后在子类实现自己的API,然后调用就行了,其他的就不用管了。CitySample就是由多个Subsystem组成的,每个子系统管理一块功能,而Mass框架的起点就是MassEntitySubsystem,是梦开始的地方。

2.Fragment

MassEntity框架之所以高效,是因为这是一套面向数据的框架,将数据分离出来,每一个Fragment就是一个独立的数据块,这个数据块可能存放的是速度、位置、加速度等(一个Fragment只能存储一种数据),而多个Fragment的集合就是一个Entity。

3.Entity

是Fragment的集合(数据集)。Entity只是单纯的数据,并不包含逻辑,且运行时可以动态添加和删除Fragment。一个Entity在MassEntity框架中就是一个NPC。以后说Entity就是说NPC。

4.Processor

之前的文章大致说过Processor,这里详细说一下,Processor就是对数据进行操作的逻辑,每个Processor都是独立的逻辑单元,可能从Entity数据集合中读取一部分Fragment的值,然后计算相应的逻辑,将结果写入到Entity的对应Fragment中,具体是读还是写,要根据每个Processor的内部逻辑决定。

5.Trait

特性,跑是一种特性,而跑需要速度、位置、加速度三种数据,即三种Fragment,那么给一个MassEntity添加了跑这个特性,就相当于给这个Entity添加了速度Fragment、位置Fragment、加速度Fragment。

6.行为树

CitySample和以往的AIController、BlackBoard、行为树驱动AI的方式不同,这里边写了个轻量级的脚本来执行每个Entity的逻辑,和以往的思维方式一样Conditions对应原来的Decorator,Evaluators对应原来的Service、Tasks对应原来的Task,只不过将这些都封装成一个State中,并且每个State在执行完毕后都可以按照规则跳入到其他的State上,可见UE的设计师非常的聪明,真的是十分的New Bee啊!!!!!!

MassEntity框架

1.什么是MassEntity框架

大家可以看到,CitySample在程序上的表现,最突出的就是他的海量NPC的生成,以往我们创建一个ACharacter的类,这种一个角色就是一个对象的面向对象方法,如果创建成千上万个对象(此对象非彼对象)的话可能机箱就直接爆炸了(当然不会,软件不可能导致硬件的损坏),CitySameple的解决方法是将数据和处理分开,数据就是Fragment,处理就是Processor,而Fragment的集合就是Entity,只有在离玩家一定距离内的Entity才会被实例化成Actor,其他远距离的都是由VAT动画驱动的StaticMesh,根据远近又将StaticMesh分成了高中低三个档次,可见海量Entity下,只有小部分是Actor,而其他的都是StaticMesh(不管多少在内存中都只存放一份),可见大大程度的减少了性能的开销。

2.MassEntity框架图解

①如图,MassEntitySubsystem利用 FMassArchetypeData(ArcheType原型)来生成每个Entity,同一个ArcheType生成的Entity具有相同的Fragment,并且每个Entity有唯一的EntityID。ArcheType中存放了每个Fragment的数组,通过Entity的EntityID来寻找每个Entity中的Fragment的数据。而ArcheType中的数据就是通过UMassEntityConfigAsset来配置的(在蓝图中对应MassCrowdAgentConfig_MetaHuman),举个例子,如果配置了UMassMovementTrait特性,则这个Archetype就包含了UMassMovementTrait特性的 FAgentRadiusFragment 、FTransformFragment、FMassVelocityFragment、FMassForceFragment四个Fragment。

②Processor在执行ConfigureQueries();函数时,配置自己这个Processor的执行需要那些Fragment,然后找到符合条件的Archetype,然后通过Archetype找到所有符合条件的Entity,根据Processor自己的逻辑对每个Entity的Fragment进行读写操作。

③UMassEntitySubsystem只是解决了Entity的创建,整个CitySample有很多Subsystem,这只是沧海一粟

3.UMassEntitySubsystem::CreateArchetype()函数讲解

这个函数是梦开始的地方,UMassEntitySubsystem创建Archetype,然后根据Archetype来创建Entity,创建Archetype后,主要的操作就是对一个Archetype进行内存布局的分配,Entity的存储是按照一块一块来存储在Archetype的,块的概念就是Chunk,之所以按照块来存储,是为了加快Processor的处理速度,即Processor读取Entity时是按照Chunk来操作的。

首先从FragmentsAndTagsList中获得这个Archetype的组成,存储在FMassArchetypeCompositionDescriptor

然后将创建时唯一的Hash值存起来

然后调用Archetype中的这个函数,利用获得的组成信息,来初始化Archetype的内存分布

4.FMassArchetypeData::Initialize()函数讲解

承接第3步的调用,主要做的事情就是计算出每个Entity的大小TotalBytesPerEntity,然后根据NumEntitiesPerChunk = ChunkAvailableSize / TotalBytesPerEntity;求出每个Chunk中有多少个Entity,然后根据NumEntitiesPerChunk排列每个Chunk的位置,可以看到Tags、FMassSharedFragment(同类型Entity共享),只保存在Archetype中,不占用Entity内存

5.UMassEntitySubsystem::CreateEntity()函数讲解

这个函数是用来创建Entity的,值得注意的是,真正的Entity是保存在Archetype的TArray<FMassArchetypeChunk> Chunks中的(按Chunk存储Entity),MassEntitySubsystem只是通过FMassEntityHandle获取Entity的一个引用,然后将结果备份在TChunkedArray<FEntityData> Entities;中。

首先调用了ReserverEntiity(),这个函数就是请求一个FMassEntityHandle然后备份了一个结果保存在Entities,然后调用InternalBuildEntity(),在这里边调用FMassArchetypeData::AddEntity()来创建Entity,即真正的Entity的创建是在Archetype中!!!!!!!!

6.FMassArchetypeData::AddEntity()讲解

大概就是找到一个Chunk,然后把这个Entity放在这个Chunk中,这里要注意一点,这里将Entity的ID与其ChunkID进行组合成AbsoluteIndex并将映射关系存储在EntityMap,在Processor通过EntityID在Archetype中寻找Entity时,Archetype会先通过EntityMap找到AbsoluteIndex,从而来找到具体的位置。

7.小结

通过以上分析,已经对整个MassEntity的处理框架有个了解了,没错,不知不觉中已经完了,就是这么简单。无非就是不同的Subsystem解决不同的问题,不同的Processor处理不同的数据,N个Subsystem+N个Processor就组成了整个CitySameple,是不是很简单?那么接下来,以StateTree运行的逻辑作为例子来对整个系统进行进一步的解读。

StateTree运行解析

1.上图


①UMassSignalProcessorBase、UMassStateTreeActivationProcessor、UMassObserverProcessor是处理StateTree执行的三个Processor都继承于UMassProcessor

②其中Execute()执行的东西都差不多,具体操作看方框

2.UMassSignalProcessorBase与UMassStateTreeProcessor信号处理方式解析

这边要注意一下

①UMassStateTreeProcessor在UMassStateTreeProcessor::Initialize()的时候调用了父类的UMassSignalProcessorBase::SubscribeToSignal(),这个函数就是在UMassSignalSubsystem(处理信号的子系统)按照传递的名字来在UMassSignalSubsystem中的TMap<FName, UE::MassSignal::FSignalDelegate> NamedSignals;按照传入的信号的名字来注册了一个代理,并且绑定了回调函数UMassSignalProcessorBase::OnSignalReceived(),绑定回调后,UMassSignalProcessorBase::Execute()会自动调用,每次调用这个函数,会在缓冲区里边找到要处理信号的Entity,然后对每个Entity执行SignalEntities()函数(注意,这个函数是在UMassStateTreeProcessor子类中实现的),UMassStateTreeProcessor::SignalEntities()函数先判断那些Entity符合条件,然后对符合条件的Entity利用SignalSubsystem->SignalEntities()发送信号,然后执行相应的OnSignalReceived()回调,周而复始。这边我就不截图了,大家自己看源码吧。

②UMassSignalProcessorBase::Execute()的调用是其父类内部调用的,暂且不要管他,这里的Execute()执行逻辑需要提一下,他用的是双缓存模型来处理Entity的信号,我们知道当读取一个Chunk时,肯定要将这个Chunk的数据保存起来,如果只用一个缓冲区来存的话,那么读数据的过程中有写入怎么办(脏读)?写数据的时候有读怎么办?因此读写要频繁的锁缓冲区,而利用双缓存来解决的话,在OnSignalReceived()回调来信号的时候对当前的缓冲区写入数据,每次执行Execute()的时侯都会利用CurrentFrameBufferIndex来切下一个换缓冲区进行读,从而利用缓冲区的切换解决了频繁加减锁操作。

③UMassStateTreeProcessor::SignalEntities()这边只看到了"NewStateTreeTaskRequired"信号的调用,读下代码,可以看到,只是找到了上一次行为树没执行的Entity,然后对这些Entity发送执行行为树的信号,UMassStateTreeActivationProcessor::Execute()对Entity发送"StateTreeActivate"信号,可见这是个激活行为树的Processor,其他调用SignalSubsystem->SignalEntities()发送信号的地方大家自己搜一下吧,这边激活了行为树,每个Entity的StateTree就开始执行起来了。

3.StateTree的执行

①上图

②可以看到UMassStateTreeSubsystem是管理StateTree的子系统,StateTree是一种资产配置文件,在StateTreeOnPIEStarted()、PostLoad()的时候会执行InitInstanceStorageType();和void Link();,Link()会调用FStateTreeNodeBase的Link(),因为StateTree也是Fragment的数据,所以每个Entity都有,但是树中的数据是哪来的呢?就是通过Link()来从其他Fragment中链接过来的,那链接到哪了呢?可见FStateTreeNodeBase的Link并没有什么卵用,只是其子类FMassZoneGraphFindWanderTarget中的Link执行了相关操作,而FMassZoneGraphFindWanderTarget是每个自定义的Task,所有Task都是继承自FMassStateTreeTaskBase,可见数据链接到了每个Task中,而链接的调用是在UStateTree::Link();

③这边再提一下,UScriptStruct是行为树中的蓝色的State的图表

④然后我们看FMassZoneGraphFindWanderTarget和FMassZoneGraphPathFollowTask,我们的Entity走的并不是传统的NavigationVolument,而是路网(之前第二章说的就是路网的生成),每个路网都是AZoneGraphData通过ZoneGraphSubSystem来管理,通过GetZoneGraphStorage()获得路网。FMassZoneGraphFindWanderTarget就是从路网中找到一个合适的目标点,然后将这个目标点传给FMassZoneGraphPathFollowTask,然后执行其RequestPath();函数,这样我们的人就动起来了。

结束语

好了,基本上就是这些了,学会这些已经掌握了60%的东西了,剩下40%就是你的努力了,这边只是给大家一个入口,希望大家能花些时间来研究下,理解相关概念的同时,也要理解分析问题的方法,掌握个大概,然后再逐个击破,然后反复。最后的最后,黄金无足色,白璧有微瑕,不足的地方请指正,6592个字了不啰嗦了,至于什么时候更新,抽空更!!!!!!

发布于 2022-09-17 12:01