前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >UE5的ECS:MASS框架(二)

UE5的ECS:MASS框架(二)

作者头像
quabqi
发布2022-01-07 14:26:09
6.4K0
发布2022-01-07 14:26:09
举报
文章被收录于专栏:Dissecting UnrealDissecting Unreal

前面一篇说了Mass框架的内存结构,也就是ECS中的Entity和Component,也用了一个很简单的示例说明Entity和Archetype怎么创建和销毁。然后也了解到MassEntity的对外API接口基本集中在UMassEntitySubsystem中,上一章的例子也只是一个简单的案例,实际Entity有非常多种操作方式,所以我把创建和销毁Entity对应API列在了下面,通过注释可以了解详细是做什么的,详细实现就不多说了,可以参考上一章。

这一篇会主要讲解Mass具体的执行,也就是ECS中的System内部的执行原理。在开始前,要先介绍Mass的一个基础类FMassEntityQuery,这个类就是专门用于查询和修改Entity(Archetype)数据的,也是ECS能执行起来最关键的一个类。

从注释看可能有点抽象,用传统ECS的方式强行解释一下这两点:

  1. 定义了System需要的数据格式,就是说要先声明要执行的数据格式,好用来执行System。
  2. 在合适的数据集下触发System执行。就是说当符合这个格式的数据变了的时候,回调一下System。

在初始化Query时,最主要的就是配置Requirement。具体就是下面这个结构

可以看到有3个成员,StructType,AcessMode,Presence。其中StructType表示符合要求的MassFragment,AcessMode有3种,None,ReadOnly,ReadWrite。Presence用的时候必须要填All,Any,None,Optional之一,没填就是None。All就是所有类型都要满足的Archetype,Any就是匹配一个类型满足的,而Optional和Any差不多,但只有在AccessMode是ReadOnly,ReadWrite时生效,None就是反向排除用的。当执行查询的时候,会按照这里成员参数设置的值来进行匹配。

可以看到,这里的条件非常多,如果什么都不做,每次查询的时候都直接去匹配,性能肯定不太好,因此ECS比较关键的一点就是要建立加速结构。在上一章说Archetype的时候有提到,内部还保存了一个Descriptor的结构,可以看到有4个类型是BitSet的成员,这4个成员就是方便加速而建立的,如下图所示。

这里就直接说BitSet实现,本质是继承了TBitArray,内部维护了一个全局的类型记录器,每种类型都会对应一个唯一的Index。按上一章那个例子来看,一共有2种类型在Mass中使用(下面这两个),那么Descriptor里实际就有两个位用作标识,10就表示Archetype里是FloatFragment,01表示Int32Fragment,而11表示两个Fragment都有。当然实际业务类型肯定有几十上百个,这里只是举例。

这样,原来需要对Archetype挨个匹配类型的操作,现在就可以改为BitArray之间的位运算,BitArray内部迭代的时候,可以一次迭代32位,如下所示,也就是一次就能匹配32个类型,这样就起到了加速的作用。

一次迭代32位,实际要比较多少次跟Fragment总数量有关

当然这只是Archetype的加速结构。前面我们知道在数据改变时都会记录版本号,所以只要版本号没变,已经查询过匹配到的Archetype,如果之前有不用重新查询,所以为了加速,Query内部还做了缓存,版本号变了的时候才会更新缓存数据。

CacheArchetypes就是这个缓存函数。当版本号或者Subsystem和之前缓存的不同时候,就会重新获取一遍,内部其实调用的是GetValidArchetypes得到匹配的Archetype,这就是遍历所有Archetype列表用上面这个加速结构匹配得到的。这里不贴实际代码,不过还是有必要自己看一下,UE还留了一个TODO,目前没优化,以后估计会更新。拿到了对应的Archetype后,还没有结束,因为Archetype的布局不一定和Query的Requirement列表一致,哪个Requirement对应哪个Fragment的Index都是不确定的,所以还要对缓存的Archetype建立一组这样的映射,大致就如下图所示。我这里为了图看着简单,只画了EntityFragments的映射,但实际上是EntityFragments/ChunkFragments/ConstSharedFragments/SharedFragments这4种映射都会做。如果Requirement的Presence是Optional或Any,那么匹配的Index就可以不存在,下面数组会填一个INDEX_NONE占位

当然Query本身不是用来缓存对应的Archetype的,而是为了多次执行起来更快。Query需要通过调用下面这3个函数来执行,内部就会根据情况自动做缓存。

代码语言:javascript
复制
void ForEachEntityChunk(UMassEntitySubsystem& EntitySubsystem, FMassExecutionContext& ExecutionContext, const FMassExecuteFunction& ExecuteFunction)
void ForEachEntityChunk(const FArchetypeChunkCollection& Chunks, UMassEntitySubsystem& EntitySubsystem, FMassExecutionContext& ExecutionContext, const FMassExecuteFunction& ExecuteFunction)
void ParallelForEachEntityChunk(UMassEntitySubsystem& EntitySubsystem, FMassExecutionContext& ExecutionContext, const FMassExecuteFunction& ExecuteFunction)

其中前两个是单线程执行,后面一个是并行执行。并行执行内部会把Chunk拆成多份发到TaskGraph里多个线程一起执行。这里会有多个参数,其中Chunks如果不是空的,就不使用缓存的Archetype查询而只使用参数提供的Chunk,如果Chunks是空的就会从EntitySubsystem中获取对应缓存,然后来执行。所以这里最后一个参数需要填入FMassExecuteFunction,其实就是个TFunction<void(FMassExecutionContext&)>,这也就是业务提供的执行函数,即需要对符合Query规则的每个Entity要执行的函数。

内部还是转调到Archetype上的下面这个函数里:

代码语言:javascript
复制
void ExecuteFunction(FMassExecutionContext& RunContext, const FMassExecuteFunction& Function, const FMassQueryRequirementIndicesMapping& RequirementMapping, const FArchetypeChunkCollection& ChunkCollection)

这里可以看到又有个关键的类FMassExecutionContext。

从成员变量可以看出来,保存了各种FragmentView。按照编程命名习惯,View只是原始数据的一个片段引用,或者叫视图,不代表原始数据,像ArrayView或std::string_view,数据库的View都是这种概念,因此Context里面保存的只是执行相关的数据引用。前面Query内部缓存的也不是Archetype本身,而是ArchetypeHandle,其实都是类似性质,保存的引用而已。这些View就是把Query前面建立的那些映射和Archetype的数据做绑定而得到的,当然如果是参数提供的Chunk,没缓存映射的情况就会按照Requirement挨个查询后绑定。因此从Context里得到的就是符合Query要求的Entity数据片段。

这样,Query就可以作为ECS中的System这一角色负责执行了,下面是一个非常简单的例子,一共创建了100个Float类型的Entity,200个Int32类型的Entity,150个Float+Int32类型的Entity。然后System要做的事情就是对包含Float的所有Entity赋值20.f。

当然实际业务在用的时候,每次都要建立一个Query,如果像上面代码Query是个局部变量,这肯定很坑,但Query不是局部变量又能放哪呢?会感觉代码没地方写,因此UE又提供了一个UMassProcessor。这个类继承UObject,代码很多,但其实逻辑并不复杂,和Runnable差不多,需要自己的业务继承,并实现下面两个函数。自己继承后,业务逻辑和Query就写在这里。

代码语言:javascript
复制
virtual void ConfigureQueries();
virtual void Execute(UMassEntitySubsystem& EntitySubsystem, FMassExecutionContext& Context);

通过名字你应该也能知道,ConfigureQueries就是负责配置Query的地方。而Execute就是最终执行Query的地方。ConfigureQueries这个函数本身没有参数,是在UMassProcessor的PostInitProperties里被调用。也就是初始化时候调用的(CDO除外),所以我们可以把Query放到继承的Processor成员变量里,只要Processor创建出来了,Query只会创建一次并缓存在Processor中。我把上面那个例子重新按照这个模式写了一下,就像下面这样:

直接像下面这样执行就可以了。

你可能注意到了,这里需要的是一个FMassProcessingContext,而前面的Query用到的是FMassExecutionContext这明显是两个不同的类,那么又有什么区别呢?

可以看到成员变量完全不同,并不是数据的View,而是一个CommandBuffer以及其他的辅助数据等。而且在执行的时候需要通过UE::Mass::Executor::Run这个函数。如果你再细心一些,可能又会发现这个Processor是个UObject,这样本身Processor执行可以挂在World的Tick不同周期里。

其实UE和Unity的ECS一样,也有历史问题,有了ECS那原来的那些GameObject怎么兼容?原来场景里的这些UObject,那些Actor怎么兼容?当然UE肯定不只是硬把Actor塞到这个AuxData里就完事这么简单,肯定还是要在执行层面上做点事情,而且要符合UE特色才行,这个FMassExecutionContext就是UE给的解决办法。

其实UObject或Actor等和Entity关联都不是什么大问题,上面AuxData这个成员变量就解决了,最主要的问题就是要把ECS和本身的业务关联起来。

具体业务肯定不是很简单的像上面一样对每个float就只做赋值20这么简单,而是很复杂的一个流程,那么一帧肯定对不同的Actor有很多的操作,比如更新动画,更新物理等,本身动画和物理都是个复杂的流程,prephysics发请求,duringphysics在子线程执行,postphysics用物理结果更新动画等,这明显不是一个Processor就能做完的,所以需要多个Processor能在多个阶段执行,这个下面说。

然后UE特色又是什么?我觉得渲染的这套实现就是UE特色,不是一个简单的API调用就搞定了,而是通过很多具体业务收集得到CommandBuffer,FMassExecutionContext这里的CommandBuffer也是一样的道理。可以参考之前的RHI流程:

当然除了CommandBuffer,还有对应的Pipeline:FMassRuntimePipeline,就是满足多个Processor需要执行的问题。

可以看到Pipeline上面有个成员是Processors的数组。而Processor的一个子类UMassCompositeProcessor,也有对应的ChildPipeline成员,这样就组成了一个业务流程的有向图,除此外也提供了依赖条件等,这里代码很简单,直接阅读源码就好,就不展开细说了。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档