UE5 MassEntity入门:Entity概念详解与第一个实操案例 最近一直在搞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后面统一这么说另一个是逻辑叫ProcessorECS里的System概念。Entity本身就是一个ID是一堆Fragment的集合。它不继承任何东西不挂在场景里就是一块连续内存里的记录。创建一个Entity的开销远比new一个Actor要小遍历一万个Entity做逻辑也比遍历一万个Actor要快得多——因为数据是紧凑排列的CPU缓存友好。1.2 MassEntity不是要取代Actor而是给大场面准备的刚开始接触MassEntity的人很容易有一个误区是不是以后所有对象都得用EntityActor要废弃了不是这么回事。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把这两个数组里对应下标的数据串联起来。数据本身不归属于EntityEntity只负责建立映射关系。这种设计的好处很明显所有Entity的数据都是紧密排列在数组里的遍历速度非常快。CPU缓存可以把一整块内存加载到L1/L2缓存里处理完一批再处理下一批不像传统对象那样东戳一下西戳一下缓存命中率低得感人。2.2 Fragments分三种别一上来就全用SharedFragment是Entity携带的数据单元它在定义的时候讲究一个纯字Fragment里不应该有任何复杂逻辑只存数据。但Fragment和Fragment还不一样MassEntity提供了三种类型实际用起来差别很大。普通FragmentFMassFragment每个Entity独有一份数据。比如每个敌人的当前位置、当前血量。这类数据参与Archetype的构建是使用频率最高的一种。SharedFragmentFMassSharedFragment多个Entity共享同一份数据。比如10000个怪物的皮肤颜色、移动速度配置是相同的那就没必要求一万份存一份让一万个Entity引用它就行。ChunkFragmentFMassChunkFragment按区块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.3MassEntity在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::GetSubsystemUMassEntityManager(World); // 构建要添加的Fragment类型列表 FMassEntityManager::FEntityCreationParams CreationParams; // 或者用FMassEntityTemplateData构建一个模板但最基础的用法是直接添加Fragment类型 TArrayconst 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-SetFragmentDataFMyMovementFragment(Entity, MoveData);读取时也一样FMyMovementFragment OutMoveData EntityManager-GetFragmentDataCheckedFMyMovementFragment(Entity); UE_LOG(LogTemp, Log, TEXT(Entitys 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.AddRequirementFMyMovementFragment(EMassFragmentAccess::ReadWrite); // 将来还可以AddRequirementFMassTag(EMassFragmentPresence::None)之类 } virtual void Execute(FMassEntityManager EntityManager, FMassExecutionContext Context) override { // 批量查询所有具备FMyMovementFragment的实体 EntityQuery.ForEachEntityChunk(EntityManager, Context, [](FMassExecutionContext Ctx) { const TArrayViewFMyMovementFragment Moves Ctx.GetMutableFragmentViewFMyMovementFragment(); 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::GetSubsystemUMassSimulationSubsystem(World); SimSystem-GetSimulation()-AddProcessor( UMyMovementProcessor::StaticClass()-GetDefaultObjectUMyMovementProcessor() );如果没注册Processor你就只能手动在Tick里调EntityManager去逐帧更新那就完全失去MassEntity的意义了。注册过一次之后后面加多少Processor都是同理MassEntity内部会按Phase排序自动每一帧调用。4. 深入理解Entity的管理机制Archetype和Chunk4.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.GetFragmentDataFMyMovementFragment();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方法是ProcessorEntity反而成了一个透明的连接器。一旦适应这套思考方式你再回头看那些成百上千个怪物同时刷新的场景心里就会踏实很多它们不过是一组紧凑的数组加上几段纯逻辑的遍历代码而已。接下来我可以接着写MassEntity的Processor调度细节以及Fragment的更新场景与注意事项有需要的话我会在下一篇继续展开。