前言
这篇文章描述了笔者在制作一款STG独立游戏时是如何使用UE5新的ECS插件来组织游戏逻辑的,主要缘由是因为ECS的核心思想与传统的OOP相差极大,为了给同团队的小伙伴解释这个"不是那么主流的架构",而且我的具体实现方案也是相当的缝合怪,所以终成此文
另外由于本人技术水平所限,很可能有大量冗余以及非最优解,大量违背祖宗的操作,仅供思路参考,如果觉得不正确的欢迎留言讨论,本文会根据开发进展保持长期更新修正
而且由于我想要做到面面俱到,又以计算机专业大二学生也能看懂为基准,同时又使用了UE这种超级巨无霸作为引擎,导致文章内容较为繁琐,可能充斥着很多细枝末节的内容,看得懂的可以选择性跳过
什么是ECS,ECS的优势在哪?
这里首先推荐一下猴叔的回答,在这里感谢他对我深入理解这个编程范式以及后续的修正提供了不小的帮助,后续也有很多编程手法来自猴叔,比如Buff机制与伤害系统

延伸阅读:如何评价《守望先锋》架构设计?
ECS又称实体组件系统,是一种软件架构模式,这里解释一下纯ECS是怎么一回事
-E是Entity,就是实体,更加形象的说法是一捆数据
其中只有一个唯一标识,除此之外什么都没有,其目的用于描述一捆数据,注意是一捆,后面我会解释为什么这样描述,用代码表示就是
struct Entity
{
int Index;
}
-C是Component,也就是组件
其表示一条数据,比如角色的HP,用简单易懂的代码表示就是
struct Component
{
}
struct HitPointComponent:public Component
{
int HitPoint;
}
-S是System,也就是系统
表示一个操作,用于控制所有的核心处理流程,运作机制是筛选出所有符合条件的实体,并操作这些实体
举个例子就是ForceActionSystem,检索所有含有ForceComponent和VelocityComponent的Entity,并做Velocity+=Delta*Force之类的运算,再比如SonicBoom(音爆),检索所有含有VelocityComponent和没有SonicBoomVFXComponent的Entity,给他们加上音爆特效,而不关心他们都是些什么
纯ECS的一些简单的理念
ECS的组件可以动态增删,从而可以把一个东西完全变成另一个东西,由此带来的可扩展性和更好的维护性,因为system只关心,entity有什么,没有就不管
System里只放逻辑,Component里只放数据,从而做到数据与逻辑解耦,Entity用来把一些Component捆到一起来组成一个对象
某一个Component类型的所有对象直接塞进一个顺序存储的数据结构里比如稀疏数组或者什么数据结构里,如果是稀疏数组的话Entity就是数组下标,直接访问所有按下标访问所有Component数组即可得到一个对象(稀疏数组允许中间有空隙,如果下标无效即意味着Entity没有这个Component),当然也有基于archetype的,UE和unity使用的就是这种方法
System处理的时候可以把Component们打包成一个Chunk,做成CPUcache友好的方式,可以极大的增加IO速度
Component之间不能通信,System也不行
Component没有函数,System没有状态
同类型的Component一个Entity只能拥有一个
System之间可以有一些顺序耦合,等等等等
因此传说中的纯ECS可以做到以下的特性:
-解耦,降低复杂度,可维护性好
-代码可重用性,这个项目用完了下个项目还能接着用,即插即用
-方便协作,因为每个程序员只需要关心自己的一亩三分地,尤其是在Component粒度切得足够小的情况下
-cache友好,运行效率高
-天然适合多线程运行,因为读写关系都确定了
看起来爽的批爆,但是这些都是有代价的,落地的时候会有大量的妥协
为什么要用UE来实践而不是Unity的Dots?
因为我个人更习惯c++一些,UE轮子多,省事,而且UE开源,不用打哑谜对暗号瞎猜,而且这俩哥们的ECS框架,可以说是一模一样的,UE的甚至更先进一点
下面开始正片前置
纵观UE的ECS插件Mass框架
官方文档连接:
更加深入的讲解视频,有点硬
首先ECS中的Component在UE中叫Fragment,因为名字已经被别的用了
在这里讲一些需要注意的点:
首先UE的ECS的世界和原本的Actor-Component,UObject什么的是完全两个世界,他们之间通过有限的同步沟通,并不是杂糅在一起的,所以我们尽可能的保证,干净整洁,尽量不做两个世界互相染指的事,方法后面会讲
Archetype:它的意义是存储所有相同构成的Entity的数据,相同构成即为拥有相同的组件类型组合(这句话说起来好绕),这样Archetype直接存储对应Fragment的数组,所有数组的Num相同,想访问直接走下标,没有空隙,速度极快(赞赏),当然实现细节还是挺多的这里不多赘述可以直接看源码
不过这种方法也是有代价的,当改变Entity的构成的时候,比如增加了新的Fragment,会导致整个Entity迁移到另一个Archetype里,会有一些额外的开销,最致命的是无法通过指针来保存一个Fragment或者Entity,因为他们随时会因为这个操作而无效,具体移动的函数签名为void FMassArchetypeData::MoveEntityToAnotherArchetype(const FMassEntityHandle Entity, FMassArchetypeData& NewArchetype)也就是下面截图的这里

SharedFragment:也就是共享的片段,在内存中只保留一份,并且引擎提供了Const的版本,框架保证了部分Entity可以共享相同的片段,比如一些初始值,比如人类或者怪物的移动参数可以是ConstSharedFragment,如果你定义了部分实体是人部分是怪物挂有不同的ConstSharedFragment,那么这些Entity就会正确的寻找到对应的Fragment
UMassEntityTraitBase:用来配置一些Fragment来组合成一个更高级的具备现实意义的逻辑特征,这个注释写的也挺明白的

UMassProcessor:用来配置一个更高级的具备现实意义的逻辑行为,这个压根没写注释,有些解释说Processor是ECS的System在UE中的对应,但我通过观察源码发现似乎并不妥当,ECS中的S一般只会包含一次查询处理的操作,但在官方的比如LODProcessor进行了多次查询并计算,所以Processor之于System,更类似于Trait之于Component,而在UE中ECS的S对应的更像是EntityQuery,这个类怎么用看源码就可以了,大多都很简单
UMassCompositeProcessor:UMassProcessor的子类,看名字也知道是一坨Processor的组合,其更大的意义在于提供了并行功能,他可以根据内部的Processor提供的Order信息
也就是After和Before
来将旗下的所有Procesoor画出一个DAG,交给UE的TaskGraph去处理,还是比较方便的
但!代价也是有的,还记得上述的Archetype吗,这个假定了在Processor处理时Entity的构成是不会改变的,也就是说构成的改变会延迟处理,而UE在一帧中,只会根据Tick的每个阶段组各合成一个CompositeProcessor运行
这会带来很多的麻烦,也就是说无法在同一帧内通过增删Fragment来传递信息,必须要等到下一帧,而有些操作可能一帧要做完,或者需要中途增删很多次,从而延迟好几帧,这明显是不对的,不过后续我还是找到了解决方案
上面说的是ECS的内容,接下来讲一些如何把ECS跟UE原本庞大的系统接起来的
Subsystem:子系统,当成守望先锋中说的单例看就可以了,因为在EntityQuery的依赖中也可以配置这个,它在这里存在的意义更像是守望先锋的ECS分享中的单例组件,而且他配置了多线程读写配置,方便画DAG
UMassTranslator:专门用于ECS世界与UE原本世界进行同步的类,继承自UMassProcessor,源码注释挺清楚的

然后我们又会发现一个问题,内存不连续了!这个没有办法的根本不可能避免,总有事物需要同步,而且uobject多线程操作会有一些影响GC什么的,所以在写入Uobject的时候需要标注为GameThread
在这里分享一个比官方原版直接设置更快的MassToActorTransform同步方法,我精简了一些细节,来自Github,这也是我推荐阅读的文章,包含了一些细节
官方原版:
void UMassSceneComponentLocationToActorTranslator::Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context)
{
EntityQuery.ForEachEntityChunk(EntityManager, Context, [this](FMassExecutionContext& Context)
{
const TConstArrayView<FMassSceneComponentWrapperFragment> ComponentList = Context.GetFragmentView<FMassSceneComponentWrapperFragment>();
const TArrayView<FTransformFragment> LocationList = Context.GetMutableFragmentView<FTransformFragment>();
const int32 NumEntities = Context.GetNumEntities();
for (int32 i = 0; i < NumEntities; ++i)
{
if (USceneComponent* AsComponent = ComponentList[i].Component.Get())
{
AsComponent->SetWorldLocation(LocationList[i].GetTransform().GetLocation() + FVector(0.f, 0.f, AsComponent->Bounds.BoxExtent.Z));
}
}
});
}
速度更快的版本,减少了直接SetWorldLocation一些额外的无用检测
void UTransformMassToActorTranslator::Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context)
{
EntityQuery.ForEachEntityChunk(EntityManager, Context, [this](FMassExecutionContext& Context)
{
const TConstArrayView<FMassSceneComponentWrapperFragment> ComponentList = Context.GetFragmentView<FMassSceneComponentWrapperFragment>();
const TConstArrayView<FTransformFragment> TransformList = Context.GetFragmentView<FTransformFragment>();
const int32 NumEntities = Context.GetNumEntities();
for (int32 EntityIndex = 0; EntityIndex < NumEntities; ++EntityIndex)
{
if (USceneComponent* Component = ComponentList[EntityIndex].Component.Get())
{
const FTransform& Transform = TransformList[EntityIndex].GetTransform();
FastUpdateComponentWorldTransform(Component, Transform);
}
}
});
}
void UTransformMassToActorTranslator::FastUpdateComponentWorldTransform(USceneComponent* InComponent, const FTransform& InTransform)
{
//Data
InComponent->SetComponentToWorld(InTransform);
InComponent->UpdateBounds();
//Physics
if (UPrimitiveComponent* PrimitiveComponent = Cast<UPrimitiveComponent>(InComponent))
{
FBodyInstance BodyInstance = PrimitiveComponent->BodyInstance;
FPhysicsCommand::ExecuteWrite(BodyInstance.ActorHandle, [&](const FPhysicsActorHandle& Actor)
{
FPhysicsInterface::SetGlobalPose_AssumesLocked(BodyInstance.ActorHandle, InTransform);
});
}
//Render
InComponent->MarkRenderTransformDirty();
//Children
for (auto ChildrenComponent : InComponent->GetAttachChildren())
{
FTransform ChildrenWorldTransform = ChildrenComponent->GetRelativeTransform() * InTransform;
FastUpdateComponentWorldTransform(ChildrenComponent, ChildrenWorldTransform);
}
}
如何生成Entity:一般我们会调用UMassSpawnerSubsystem这个子系统中的函数

而不是EntityManager中的CreateEntity或者什么的别的指令(虽然最后还是调他),因为在UMassSpawnerSubsystem中的DoSpawning做了一些额外的操作,其中第二个函数提供了类似构造函数的功能,SpawnData为参数,InitializerClass为初始化类,在源码中唯一一个用的到的地方为UMassSpawnLocationProcessor这个,来根据SpawnData中提供的Transform把Entity生成在对应的地方,而这个参数在Processor中由AuxData提供,仿照这个写法,也可以让Processor接受一些参数进行处理,写起来方便一些
UMassSpawnLocationProcessor执行的一些细节,可以在Log里看到一些信息
接下来我的ProjectileSpawnProcessor也用到了这种手法
现在我们已经基本了解了UE的ECS基本调性,接下来我将施展大量违背祖宗的操作,真正的正片开始了
在UE中使用ECS的一些改造
首先借鉴一些思想(比如函数式和数据驱动),对上述的纯ECS进行了改造,诞生出一个既不OOP也不FP更不DOP的ECS plus pro max,纯度堪比神罗,解决了部分ECS在工程中落地的痛点
当然了软件开发没有银弹,一招鲜吃遍天是不可能的,解决一些问题的同时会引入其他代价
在一个现成的引擎中使用ECS有哪些问题?
- 所有逻辑都在system,数据都在Component,切分的粒度不好把控,切的小了写起来又臭又长,切的大了又不方便复用
- Entity之间的交互不好处理,两个Entity交互(比如碰撞处理)那肯定会造成cachemiss
- 与原本的OOP代码沟通十分不便
- 抽象要求较高,对大脑不友好
所以接下来就是我在UE中使用ECS的几条总结
- 首先就是ECS用于只处理游戏逻辑,与渲染逻辑隔离,可以使用类似unity的fixedupdate,如果考虑网络的话甚至还能顺带着方便做帧同步(不过我没试过)
- 基本放弃使用Actor-Component那些现成的处理逻辑,我曾经想在ecs里绑定胶囊体的碰撞回调,但是在两个世界中辗转腾挪太麻烦了….
- 使用ECS的主要目的是逻辑职责划分清晰复用性强,方便制定策划与程序都能理解的逻辑架构,至于性能,那点L2缓存命中率的提升真的不是特别重要
- 放弃纠结随机读写带来的cachemiss,这几乎是不可避免的
- Fragment里可以放函数,因为函数也是数据,更学术点的叫法是Closure(闭包)或者lambda,在UE中对应的可以TDelegate,也可以是TFunction,当然c++的原生函数指针也可以
- Fragment里放的函数只写简单的脚本,尽量不写复杂逻辑
- Fragment尽量把关联性强的数据都放在一起,不要切的粒度太细,比如速度加速度加加速度都应该写进移动Fragment里,不然写起来真的很繁琐
- Processor用于处理一整个不可分割的数据处理管线,一次流程可能进行多次查询,每次查询的Entity也可能不同,有点类似显卡的渲染管线
- Processor可以不指定前后执行顺序,目前项目中没有指定过并且运行良好
- Processor只要涉及了UObject,直接设置为gamethread,甚至多线程都不是特别重要,因为我使用ECS的目的不是处理海量实体
- 游戏的逻辑世界运行起来像是一张巨大的2D表,Processor们疯狂检索对应的数据并处理,与模拟的区别就在于游戏世界有玩家的输入,不过也可以看做新增的一列叫PlayerInput
- 策划填表的内容我一般放进ConstSharedFragment中,因为填表内容与ConstSharedFragment的特性简直完美符合(不可变,内存唯一),而运行时的信息则是复制一份放进Fragment中,非常契合
- Entity之间的交互(如碰撞)延迟处理,例如检测到碰撞,将碰撞信息(谁撞了谁,碰撞角度什么的)存进SharedFragment或者Subsystem里,其他Processor或者直接在SubsystemTick的时候统一处理
- 有的时候我们会发现一个emmm….东西,比如背包里的一块石头,他究竟应该是一个Entity,还是单纯的就是一个struct,这种时候我会觉得还是看有没有扩展的必要来决定,作为一个Entity肯定是写起来更加麻烦的,但是有的时候作为Entity可能会更加灵活,比如我把石头丢掉地上,可能就只需要加个tag或者删掉引用就能搞定,而不需要在根据一个struct再去生成一个entity,或者类似饥荒那种的食物有新鲜度的设定,一个Processor就能处理所有的在地上的食物和在包里的食物,还能根据引用比如如果Entity的所有者是冰箱则损耗减半等,struct可能就不会那么方便
实际的例子
现在我举个实际的例子,由于各个游戏的数据处理流程可能千差万别,所以我以最常见的移动处理器和伤害系统为例,这另两个系统代表了两种典型处理,其区别在于移动处理器每帧处理所有会移动的Entity,或者模拟游戏中每帧处理所有会耗电的Entity,我称之为迭代处理,而伤害系统则是每帧清空缓存的所有伤害信息,或者碰撞系统也是每帧清空缓存的所有碰撞信息,我称之为交互处理
目前项目是2D清版射击,所以游戏逻辑都是2D的,目前一次基于速度的移动逻辑大概可以抽象为以下几步
- 根据力或者速度甚至是直接一个函数得出来一个本帧期望的delta
- delta结合场景的信息和阻挡响应函数(比如沿边滑动或者反弹)返回一个落点
- 移动,并根据修正过的delta更新速度
当然还有基于位置的
- 还是得出来一个delta,但直接移动
- 根据约束(比如阻挡约束或者弹力约束)修正位置
- 根据修正过的位置更新速度
这里,我暂时使用基于位置的移动所以,我总结了四次查询,依赖和具体做什么如下

F2DTransformFragment:只有一个2D的位置和一个旋转值
F2DMovementFragment:里面包含了速度,加速度,力,和质量

F2DShapeFragment:一个简单的2D图形信息,目前我只用到了圆形和矩形
FShapeCellLocationFragment和UMassEnhancedShapeSubsystem:这俩放在一起说是因为在移动逻辑中,需要高频查询xx附近的xx,这种时候直接遍历时间复杂度达到了On的平方,所以肯定需要一个用于加速查询的全局数据结构来降低时间复杂度,所以Entity需要储存一个该Entity与加速结构的查询关系,至于这个数据结构是什么就根据情况来选择了,什么八叉树kd树或者直接简单点切格子都可以,在这里我选择了UE自带的THierarchicalHashGrid2D,这个数据结构要是细讲又能水一篇文章所以在此不表,只放张注释的截图
FColliderHitDelegateFragment和F2DTransformConstraintsFragment:就是俩函数,存了发生如果碰撞的话发生的事情和一些位置约束(比如Block)
而这些查询都做了什么呢,具体如下
DirectMove:就是根据力什么的算速度然后直接移动,只关心移动和位置信息
ShapeSync:这个查询只负责将移动后的信息同步给上面的讲的加速结构,由于目前我的游戏项目中只有有形状的会考虑是否发生碰撞,所以这个查询到的Entity与上面的DirectMove查询到的Entity是不同的,会更少一些
Hit:顾名思义,看看谁撞了,把信息存下来延迟处理
ConstraintSolving:处理不同Entity之间的约束
这样,一个2DMovementProcessor就完成了它的任务,所有的移动处理都走此Processor
而伤害系统则是处理所有的伤害信息,伤害信息可能来自上面讲的碰撞,碰撞的时候会尝试根据两个Entity生成一个伤害信息并添加进伤害系统的待处理队列,每帧伤害系统都会将待处理的队列清空,而处理的时候会根据Entity携带的BuffList对伤害信息进行修改等等,这个猴叔讲的很清楚这里就不赘述了:
蓝图(lua)与c++之争
曾经我经常会因为这个逻辑究竟是应该写在c++里,还是蓝图(lua)里而纠结,而ECS解决了我这个痛点,我的方案是写在Processor用C++写,因为这个写成了之后几乎不太变动,而在Processor中触发的委托或者函数,使用蓝图或者脚本语言搞定,这里是需要经常迭代修改的,而且由于只需要关心"在这个时候发生了什么",所以需要的心智负担也会轻很多,不太需要太多的上下文信息,策划也可以简单写写或者连连看,而具体怎么绑进去,反正最后都是函数指针那就都是体力活了在此不表
原文发表于知乎。