UE5 MassEntity入门:Entity概念详解与第一个实操案例
2026/9/10 2:54:05 网站建设 项目流程

最近一直在搞UE的大世界战斗项目,单位数量一上来,Actor的创建销毁就开始卡顿,CPU的GC压力也扛不住。后面切到MassEntity这套框架,算是把这块硬骨头啃下来了。这篇先聊聊最基础但也最容易绕晕的概念:Entity(实体)。网上一搜MassEntity全是"高性能""大规模"这类的词,但真要上手写第一个Entity,很多人连从哪创建、在哪销毁都搞不清楚。这篇我用最直白的方式把Entity这个东西掰开揉碎讲一遍,顺便把第一个可运行的实操代码贴出来。

1. 先把思路捋顺:MassEntity在UE里到底解决什么问题

1.1 为什么要用ECS思维重写游戏对象

先说个直观感受。传统UE开发里,我们习惯了继承一切:写一个怪物类继承AActor,再挂上各种UActorComponent,比如血条组件、移动组件、攻击组件。这种写法在单机小项目里很顺手,但放到大规模场景就出问题了。

举个例子,一个城市场景里有10000个NPC在街上走。每个NPC是一个AActor,每个AActor里可能挂着5-10个Component。这意味着什么?内存里每个Actor对象都有完整的UObject开销,而且每个Component都要走UObject的创建、注册、序列化、GC(垃圾回收)那一整套流程。哪怕这个NPC只是摆个样子,什么都不干,它也要掏这份UObject的钱。实测下来,纯挂在场景里不动的Actor,每个也要占几KB甚至几十KB内存,这个开销在几千上万的数量级下会非常恐怖。

MassEntity的思路是彻底换一种玩法。它把对象拆成两部分:一个是纯粹的数据,叫Fragment(也就是ECS里的Component概念)——注意这里为了和我们UE里熟悉的UActorComponent区分开,MassEntity管数据叫Fragment,后面统一这么说;另一个是逻辑,叫Processor(ECS里的System概念)。Entity本身就是一个ID,是一堆Fragment的集合。它不继承任何东西,不挂在场景里,就是一块连续内存里的记录。创建一个Entity的开销远比new一个Actor要小,遍历一万个Entity做逻辑也比遍历一万个Actor要快得多——因为数据是紧凑排列的,CPU缓存友好。

1.2 MassEntity不是要取代Actor,而是给大场面准备的

刚开始接触MassEntity的人很容易有一个误区:是不是以后所有对象都得用Entity,Actor要废弃了?不是这么回事。

MassEntity专注的是大量同构对象的逻辑和位置更新。比如大批量的敌人、飞行的子弹、移动的粒子单位、车辆流、人群,这些对象没有复杂的交互逻辑,数量又多,正好是MassEntity的舒适区。反观玩家角色、Boss、交互的NPC这类有丰富状态机和行为树的单位,依然需要完整的Actor机制。实际项目里两者经常是配合的:MassEntity负责海量单位的模拟和移动,当玩家靠近某个单位需要交互时,再把Entity转化为实际的Actor来承载复杂逻辑。

所以说,MassEntity解决的定义域非常明确:用DOP(面向数据编程)的方式,吃掉传统OOP(面向对象编程)扛不住的大规模同构对象更新。咱们学Entity,先把这层定位搞明白,后面很多API设计就通了。

2. 拆开Entity:它是ID,是Fragment集合,但绝不是对象

2.1 Entity本身只是个整数ID,这层认知必须先立住

在MassEntity的模型里,Entity就是一个整数编号,不带任何数据,没有任何方法,不能像Actor一样调用GetActorLocation。它的本质是一个索引,指向实体管理器内部的一块数据记录。

这点我一开始也犯迷糊:既然是定义实体的东西,怎么会连个数据都不存?后来想通了——在ECS架构里,Entity只是把多个数据"绑"在一起的胶水。比如一个移动的敌人,它有两个数据:位置和目标方向。在传统Actor写法里,这两个数据挂在某个类的成员变量里,跟Actor是强绑定的。在MassEntity里,位置和目标方向分别存放在两个不同的组件数组里,Entity通过它的ID,把这两个数组里对应下标的数据"串联"起来。数据本身不归属于Entity,Entity只负责建立映射关系。

这种设计的好处很明显:所有Entity的数据都是紧密排列在数组里的,遍历速度非常快。CPU缓存可以把一整块内存加载到L1/L2缓存里,处理完一批再处理下一批,不像传统对象那样东戳一下西戳一下,缓存命中率低得感人。

2.2 Fragments分三种,别一上来就全用Shared

Fragment是Entity携带的数据单元,它在定义的时候讲究一个"纯"字:Fragment里不应该有任何复杂逻辑,只存数据。但Fragment和Fragment还不一样,MassEntity提供了三种类型,实际用起来差别很大。

  • 普通Fragment(FMassFragment):每个Entity独有一份数据。比如每个敌人的当前位置、当前血量。这类数据参与Archetype的构建,是使用频率最高的一种。
  • SharedFragment(FMassSharedFragment):多个Entity共享同一份数据。比如10000个怪物的皮肤颜色、移动速度配置是相同的,那就没必要求一万份,存一份,让一万个Entity引用它就行。
  • ChunkFragment(FMassChunkFragment):按区块(Chunk)共享的数据,一个区块里的所有Entity共享一份。适合在批量处理时保存这个区块的临时聚合数据,普通业务用到的不多。

需要留意的是SharedFragment很值得玩味。你想想,10000个Entity都用同一个移动速度配置,如果全用普通Fragment,那就得存一万份速度,不仅浪费内存,改速度还得循环改一万次。改成SharedFragment后,改一份配置,所有Entity一起生效,性能上的差距不是一点半点。

2.3 EntityManager是总管家,所有Entity都归它管

每个Entity都不是凭空冒出来的,它由FMassEntityManager统一管理。这玩意儿是Entity世界的World,所有Entity的出生、查找、数据修改、销毁都走它的接口。

FMassEntityManager是UWorld的子系统(UWorldSubsystem),所以拿它的方式很固定:在GameInstance或者GameMode里通过UWorld::GetSubsystem来获取。创建Entity时它会分配实体ID、构建Archetype、初始化数据;销毁Entity时回收ID和内存。如果你加了一个Fragment,改了一个Fragment的数据,都要通过EntityManager或相关的View工具来操作。在MassEntity里,直接new一个Entity是不存在的概念。

3. 实操准备:让第一个Entity跑起来

3.1 开插件、加模块,两步搞定环境

我用的引擎版本是UE5.3,MassEntity在5.2开始已经很稳定了。先打开插件:

  • 插件面板搜索MassEntity,勾选启用;
  • MassAI插件如果你后面要接AI人群逻辑,也可以一并勾上,但本篇只需要MassEntity本体。

接着给项目的Build.cs加上模块依赖:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "MassEntity", "MassSpawner", "StructUtils" });

我遇到过很多次忘记加MassEntity模块导致编译报一堆奇怪的UHT错误的情况。这里注意,补完模块依赖后必须关闭编辑器重新编译。如果编辑器已经开着,UHT有可能会缓存旧模块状态,后面代码里写了MassEntity的头文件也会提示找不到。

3.2 定义一个最基础的Fragment

先定义一个自用的Fragment,用来保存实体的位置和朝向,再加一个移动速度。这里我用一个非常简单的结构体:

USTRUCT() struct FMyMovementFragment : public FMassFragment { GENERATED_BODY() FVector Position = FVector::ZeroVector; FVector Direction = FVector::ForwardVector; float Speed = 500.0f; };

可以看到它继承自FMassFragment,里面全是数据,不写逻辑。USTRUCT、GENERATED_BODY这套宏都要带上,MassEntity的Fragment是要走反射系统的,不写这些后面没法在编辑器里查看数据,也没法序列化配置。

如果你想让Fragment在编辑器里能被初始化数据资产,建议把USTRUCT里的ClassGroup、meta标上,比如:

USTRUCT(BlueprintType) struct FMyMovementFragment : public FMassFragment { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MyMass") FVector Position = FVector::ZeroVector; UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MyMass") FVector Direction = FVector::ForwardVector; UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MyMass") float Speed = 500.0f; };

有没有UPROPERTY声明,效果差别很大。没有的话编辑器根本不会给你显示调试面板。

3.3 从EntityManager拿到实体句柄并创建Entity

接下来进入最核心的环节:创建Entity。我直接说最小可用流程——先搞一个能在游戏运行时自动执行的类,比如自定义的GameMode或者LevelScriptActor,在BeginPlay里创建一批Entity。

// 假设在某个AActor或UGameInstance中 #include "MassEntityManager.h" #include "MassExecutionContext.h" void AMyMassTestActor::BeginPlay() { Super::BeginPlay(); // 拿到当前世界的MassEntityManager UWorld* World = GetWorld(); UMassEntityManager* EntityManager = UWorld::GetSubsystem<UMassEntityManager>(World); // 构建要添加的Fragment类型列表 FMassEntityManager::FEntityCreationParams CreationParams; // 或者用FMassEntityTemplateData构建一个模板,但最基础的用法是直接添加Fragment类型 TArray<const UScriptStruct*> FragmentTypes; FragmentTypes.Add(FMyMovementFragment::StaticStruct()); // 还可以加别的Fragment,比如FMassTag类型也可以在这里加 FMassEntityHandle Entity = EntityManager->CreateEntity(FragmentTypes, /*SharedFragmentValues*/{}); ensure(EntityManager->IsEntityValid(Entity)); }

我写这段时没有刻意展示全套API,因为MassEntity的创建API在不同引擎版本里略有差异,但核心就是:把Fragment类型列表给EntityManager,它返回一个FMassEntityHandle。这个句柄有两个成员:一个Index,一个SerialNumber。Index是实体在内部数组里的下标;SerialNumber用于防悬垂引用——如果实体被销毁了,再次用旧句柄访问,SerialNumber对不上就能直接判断无效。这是个很实用的机制,比裸指针安全得多。

3.4 给Entity填充数据并读取数据

创建出来的Entity是"空壳",数据都是默认值。你得用EntityManager的API往里面写数据。

FMyMovementFragment MoveData; MoveData.Position = FVector(0.0f, 0.0f, 100.0f); MoveData.Direction = FVector(1.0f, 0.0f, 0.0f); MoveData.Speed = 800.0f; EntityManager->SetFragmentData<FMyMovementFragment>(Entity, MoveData);

读取时也一样:

FMyMovementFragment& OutMoveData = EntityManager->GetFragmentDataChecked<FMyMovementFragment>(Entity); UE_LOG(LogTemp, Log, TEXT("Entity's Position: %s"), *OutMoveData.Position.ToString());

GetFragmentDataChecked这个名字取得真好——查不到就直接ensure失败,做原型时很方便,能帮你尽早发现Fragment类型配错的问题。

3.5 第一个Processor:让这些Entity自己动起来

上面那批Entity创建出来之后,如果没有人去改它们的Position,它们永远是静止的。这就要引入Processor(处理器)了。Processor是MassEntity的逻辑单元,实现MassEntity里最核心的批量遍历更新。

UCLASS() class UMyMovementProcessor : public UMassProcessor { GENERATED_BODY() public: UMyMovementProcessor() { // 指定这个Processor在什么阶段执行 ExecutionPhase = EMassProcessingPhase::PostPhysics; bRequiresGameThreadExecution = true; } virtual void ConfigureQueries() override { EntityQuery.AddRequirement<FMyMovementFragment>(EMassFragmentAccess::ReadWrite); // 将来还可以AddRequirement<FMassTag>(EMassFragmentPresence::None)之类 } virtual void Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context) override { // 批量查询所有具备FMyMovementFragment的实体 EntityQuery.ForEachEntityChunk(EntityManager, Context, [](FMassExecutionContext& Ctx) { const TArrayView<FMyMovementFragment> Moves = Ctx.GetMutableFragmentView<FMyMovementFragment>(); const float DeltaTime = Ctx.GetWorld()->GetDeltaSeconds(); for (int32 i = 0; i < Moves.Num(); ++i) { FMyMovementFragment& Move = Moves[i]; Move.Position += Move.Direction * Move.Speed * DeltaTime; } }); } private: FMassEntityQuery EntityQuery; };

ConfigureQueries和Execute是MassProcessor里两个最重要的方法。前者声明这个Processor关心什么数据,后者在运行时批量执行数据更新。你注意看,这里的遍历是按Chunk批量取视图的,每次拿到的一串Fragment数据在内存里是连续的,所以写入时缓存非常友好。这和传统写法里逐个GetComponent然后Update是两回事。

写完Processor后,还需要把它注册到MassEntity模块才能生效。最省事的方式是继承一个带Assist类的接口,比如UMassCompositeProcessor,然后手动加上去。但如果你想快速跑通,可以在自定义的GameMode里手动调一下:

// 在你的GameMode或GameState里找个地方 UMassSimulationSubsystem* SimSystem = UWorld::GetSubsystem<UMassSimulationSubsystem>(World); SimSystem->GetSimulation()->AddProcessor( UMyMovementProcessor::StaticClass()->GetDefaultObject<UMyMovementProcessor>() );

如果没注册Processor,你就只能手动在Tick里调EntityManager去逐帧更新,那就完全失去MassEntity的意义了。注册过一次之后,后面加多少Processor都是同理,MassEntity内部会按Phase排序,自动每一帧调用。

4. 深入理解Entity的管理机制:Archetype和Chunk

4.1 Archetype是什么?为什么说它决定了Entity的存储方式

想要真正理解Entity,绕不开Archetype(模型原型)这个概念。Archetype本质上是一个Fragment组合模板:如果两个Entity有完全相同的Fragment类型集合,它们就属于同一个Archetype。例如所有带FMyMovementFragment的实体归为一类,所有同时带FMyMovementFragment和FLifeTimeFragment的实体归为另一类。

每个Archetype内部会维护多个Chunk。Chunk是一块连续内存,存储着该Archetype下所有Entity的数据。MassEntity在遍历时按Chunk取一整块数据,效率很高。因为相同Fragment组合的实体被放在相邻内存区域,所以Archetype的设计本质上就是把数据按"需求模式"切分好,让每次查询尽可能命中一小块连续空间。

创建Entity时,EntityManager会根据你传入的Fragment类型列表去寻找或创建对应的Archetype。所以不要频繁创建不同Fragment组合的Entity,那会导致Archetype数量膨胀,每个Archetype的Chunk却不能塞满,内存碎片化严重,遍历时也会多跳几个地方,反而削弱性能。实际项目中Entity的Fragment组合应该提前规划好,数量控制在少数几种。

4.2 Chunk与FMassEntityView:批处理和单实体访问两条路

你写Processor时,绝大多数情况都是通过FMassExecutionContext的GetMutableFragmentView拿到一把"数据视图"进行操作。这是一种批处理视角:一次拿一整个Chunk的同一类Fragment数组。

但如果只是调试、或者某个个别Entity需要单独改数据,可以用FMassEntityView来访问:

FMassEntityView EntityView(EntityManager->GetArchetype(Entity), Entity); FMyMovementFragment& Move = EntityView.GetFragmentData<FMyMovementFragment>();

FMassEntityView的核心是保存了一个Archetype指针和实体句柄,访问比直接用EntityManager更轻量。不过这种API在业务代码里别乱用,它适合做系统内部的高频访问。你如果只是在PlayerController里改某个Entity的速度,用EntityManager就足够了。

4.3 Entity的销毁与句柄失效:SerialNumber有多重要

生产环境里,Entity的生命周期不可能是只增不减的。MassEntity的销毁API很简单:

EntityManager->DestroyEntity(Entity);

但销毁后的句柄怎么办?MassEntity的设计是用SerialNumber标记"这一代的实体编号"。Entity被销毁后,如果下一次再创建一个新实体,它的Index可能复用旧的Index,但SerialNumber会+1。旧句柄的SerialNumber对不上,IsEntityValid就会返回false。这在异步系统里特别重要:你一个Processor在后台泡查询,另一个地方把Entity销毁了,如果没有SerialNumber,等你拿旧Index去写数据时,可能写到了新实体的头上,出问题非常隐蔽。

因此实际项目里保存Entity句柄时,一定要检查有效性,别拿着旧句柄一顿猛操作。我见过一次线上问题,就是因为没有检查句柄有效性,在销毁一批Entity之后又对其中几个做了移动数据写入,结果几个新生成的Entity莫名其妙瞬移,排查了半天。

5. 新手绕不开的几个坑和排查技巧

5.1 Fragment类型没加对:AddRequirement的坑

Processor里ConfigureQueries时,如果AddRequirement的Fragment类型拼错了或漏了,编译器不会报错,但运行时这个Processor的查询结果永远是空的,Entity压根不会进你的ForEachEntityChunk。

排查这个问题很快:先确认创建Entity时Fragment类型到底有没有加进去。在MassEntity模块里有个调试工具,你可以用UE_LOG或者直接在GameMode里打印EntityManager的统计信息:

UE_LOG(LogTemp, Log, TEXT("Entity Count: %d"), EntityManager->DebugGetEntityCount());

如果数量对了但还是不动,那重点检查Processor有没有注册,注册后是哪个Phase。如果Phase设置错了,比如填了PrePhysics但实际你想等物理结束后再更新,结果就不对。

5.2 在编辑器里看不到Entity的数据变化

MassEntity不像Actor,不会在Outliner里列出来。你需要在编辑器里打开调试工具。UE5.3之后MassEntity提供了比较完善的调试面板:

  • 控制台命令Mass.Debug.Archetypes 1,可以看到当前所有Archetype以及里面的Entity数量。
  • 控制台命令Mass.Debug.Entities 1,可以列出实体ID和数据摘要。

这两个命令在运行时非常有用。我还是认为调试这种数据密集型系统,先看数据表比断点逐个看变量高效得多。

5.3 与Actor协同:Entity转Actor的桥接方式

很多业务场景需要Entity负责大范围模拟,玩家靠近后再生成真正的Actor来承载具体逻辑。MassEntity官方有自动Actor生成机制,核心概念是MassEntityToActor通知:实体可以注册一个"通知Fragment",当它进入某个区域或满足条件时,系统回调一个接口,你在回调里Spawn出Actor,并同步双方位置。

这样做的性能优势很明显:远离玩家视野的Entity只是数据拼起来的记录,不占Actor资源;玩家靠近了才生成Actor,该有的技能、动画、AI都能正常挂。

5.4 踩坑总结:Entity操作不要太随性

这里把常见问题整理成一张表,平时排查直接对着看。

问题现象根本原因解决方案
Processor的ForEachEntityChunk里收不到任何实体Fragment类型没匹配上或没AddRequirement检查ConfigureQueries里AddRequirement的UScriptStruct是否和Entity创建时一致
Entity数据改了但画面没反应没有Actor表现层,或没做Actor同步确认MassEntity到Actor的同步链路是否接通
大批量创建后内存增长异常Archetype数量过多导致Chunk内存碎片化收敛Fragment组合种类,避免随意组合
编辑器崩溃且报SerialNumber相关断言使用了已销毁的Entity句柄用IsEntityValid检查后再访问数据

6. 第一次跑通Entity之后,下一步往哪走

当你亲手创建出几十个Entity,并且能看到它们在一帧一帧移动时,MassEntity的"数据驱动"理念就已经在你心里扎根了。下一步有几个方向可以继续深入:

  • 首先是SharedFragment。如果你有百来个Entity,试试把它们的位置和朝向属性拆一部分到SharedFragment里,观察内存占用和帧率变化。SharedFragment在处理同质化对象时优势极大。
  • 其次是Tag(标签)。Tag也是一种Fragment变体,不含数据,只用来标记实体种类。比如"已激活"“可攻击”“在移动中”。Processor查询时通过AddTagRequirement筛选,能让逻辑划分得更清晰。
  • 再往后是Subsystem与Processor的执行调度。搞清楚MassEntity里各类Subsystem(比如MassSimulationSubsystem)的初始化顺序、Phase优先级的含义,这决定了你未来能否把复杂系统的更新顺序编排好。

我个人做项目时的感受是:Entity这个概念本身很好理解,难的是把"数据"和"逻辑"拆开的思维方式转变。你过去写Actor时习惯在类里封装字段和方法,到了MassEntity这里,字段是Fragment,方法是Processor,Entity反而成了一个透明的连接器。一旦适应这套思考方式,你再回头看那些成百上千个怪物同时刷新的场景,心里就会踏实很多:它们不过是一组紧凑的数组,加上几段纯逻辑的遍历代码而已。

接下来我可以接着写MassEntity的Processor调度细节,以及Fragment的更新场景与注意事项,有需要的话我会在下一篇继续展开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询