UE5.8 Mass Entity System 与 ECS

📑 目录

UE5.8 Mass Entity System 与 ECS

傍晚六点,城市广场的喷泉边涌出大量行人。钟楼方向同时飞起几千只鸽子,翅膀扇动带起一片灰影。镜头继续拉高,广场上同时活跃着一万两千个路人和八千只鸟。他们不需要像主角那样拥有完整骨骼、复杂交互或精细面部表情,但必须移动、避让、播放简单动画,还要在远处以较低细节渲染。

如果按传统 AActor 思路实现,每个路人都是一个 Actor,每个鸽子也是一个 Actor。每个对象附带若干 UActorComponent,Tick 分散在各处,内存在堆上随机分配。帧时间轻松突破四十毫秒,画面变成幻灯片。团队开始寻找一个能同时支撑海量实体、又保留必要表现力的方案。Unreal Engine 5.8 里的 Mass Entity System 正是为了这种场景而生。

一、ECS 数据导向设计

传统面向对象的做法把数据和行为绑在一起。一个行人 Actor 内部既有位置、速度、血量,也有移动、寻路、播放动画的逻辑。对象之间通过虚函数和事件互相调用,CPU 在大量对象之间跳来跳去,缓存命中率极低。

ECS 把这三件事拆开:

  • Entity 只是一个标识符,类似数据库里的主键;
  • Component,在 Mass 里叫 Fragment,是纯粹的数据结构;
  • System,在 Mass 里叫 Processor,是处理数据的逻辑单元。

这种拆分让同一种数据可以集中存放。所有速度存在一个数组里,所有位置存在另一个数组里。Processor 读取时顺序访问内存,缓存行被充分利用。现代 CPU 的主频增长早已放缓,真正决定性能的是缓存命中率和并行效率。

flowchart LR
    A["传统 Actor"] --> B["对象各自持有组件
数据分散在堆上"] C["Mass Entity"] --> D["Entity = 轻量句柄"] D --> E["Fragments 连续数组"] D --> F["Tags 零字节标记"] E --> G["Processors 批量读写"] F --> G G --> H["渲染 / 动画 / 物理"]

这种设计思路叫 Data-Oriented Design,简称 DOD。它不是某一门语言的特性,而是一种以数据流为核心的组织方式。Mass 把 DOD 的思想嫁接到 Unreal 的 UObject 体系里,既保留了引擎的反射、序列化、编辑器集成,又能在运行时处理海量同质实体。

假设一个 Processor 要更新 NN 个实体的速度,每个实体的计算开销是 cc,每发生一次缓存未命中需要额外付出 tmisst_{miss}。整体时间可以粗略写成:

Ttotal=Nwc+mtmissT_{total} = \frac{N}{w} \cdot c + m \cdot t_{miss}

其中 ww 是 SIMD 或指令级并行的有效宽度,mm 是缓存未命中次数。DOD 的目标不是让 cc 变小,而是让 mm 尽可能小,让 CPU 把大部分时间花在真正有用的计算上。

二、FMassEntityManager:实体的总账本

FMassEntityManager 是 Mass 世界的核心账本。它负责创建和销毁实体,管理 Fragment 和 Tag 的注册,维护 Chunk 的分配。所有 Processor 都通过它查询数据,而不是直接操作内存。

一个实体在 Mass 里表现为 FMassEntityHandle,本质上是一个索引加版本号。索引指向内部数组的槽位,版本号用于检测已销毁实体的句柄是否被误用。这种设计让实体的创建和销毁非常轻量,不会因为频繁增删导致内存碎片。

FMassEntityManager 还提供命令缓冲机制。游戏线程可以在一帧的任意时刻下发创建、删除、添加 Fragment、移除 Tag 等命令,真正的内存操作会在合适的时机批量执行。这对多线程 Processor 很重要:避免在并行遍历数据时修改 Chunk 结构。

flowchart TD
    subgraph mass ["Mass Entity System"]
        em["FMassEntityManager"]
        fragments["Fragments"]
        tags["Tags"]
        chunks["Chunks"]
        processors["Processors"]
        render["渲染与动画"]
    end
    em --> fragments
    em --> tags
    fragments --> chunks
    chunks --> processors
    processors --> render

在编辑器里,UMassSubsystem 会把 FMassEntityManager 挂到 World 上,生命周期跟随关卡。运行时可以通过 UMassSubsystem::GetEntityManager() 拿到引用,但大多数业务代码只需要在 Processor 里处理查询结果,不需要直接接触管理器。

命令缓冲带来的另一个好处是可以在逻辑层大胆下发修改,而不用关心当前是否正在并行遍历数据。例如一个碰撞 Processor 检测到有实体死亡,它不会立即销毁实体,而是向缓冲里追加一条删除命令。所有 Processor 跑完之后,FMassEntityManager 再统一执行这些命令。这种延迟执行模式消除了大量锁竞争,也让代码结构更清晰。

三、Fragment、Tag 与 Chunk

Fragment 是 Mass 里的数据单元。它必须继承 FMassFragment,使用 USTRUCT() 标记,并且尽量保持为简单的 POD 结构。下面是一个速度 Fragment 和一个表示鸟群的 Tag:

USTRUCT()
struct FMassVelocityFragment : public FMassFragment
{
    GENERATED_BODY()
    FVector3f LinearVelocity;
};

USTRUCT()
struct FMassBirdTag : public FMassTag
{
    GENERATED_BODY()
};

Tag 继承 FMassTag,不携带任何字段。它的作用是给实体分类。某个实体身上有 FMassBirdTag,查询时就可以只处理鸟类。由于 Tag 没有数据,Mass 用位图或紧凑标记来存储,内存开销几乎为零。

Chunk 是 Mass 存储数据的基本单元。同一个 Chunk 里的所有实体拥有完全相同的 Fragment 和 Tag 组合,这种组合称为 Archetype。Chunk 内部把每个 Fragment 存成独立数组,也就是 Structure of Arrays,简称 SoA。例如一个 Chunk 里有一百只鸟,那么 FMassVelocityFragment 会是一百个 FVector3f 紧紧排在一起,FMassTransformFragment 是另一百个变换矩阵紧紧排在一起。

graph LR
    Header["Chunk Header"] --> A0["FMassTransformFragment
Transform[N"]] Header --> B0["FMassVelocityFragment
Velocity[N"]] Header --> C0["FMassLODFragment
LOD[N"]] Header --> TagBits["Tag 位图"] A0 --> A1["紧凑排列的 float3 数组"] B0 --> B1["紧凑排列的 float3 数组"] C0 --> C1["紧凑排列的 uint8 数组"]

SoA 的优点是单个 Processor 读取同一种数据时,缓存行只加载有用的字段。如果采用传统的 Array of Structures,读取速度时会把相邻实体的变换、血量、状态一起读进缓存,浪费带宽。Mass 的 Chunk 大小经过调整,保证多个 Fragment 数组都能对齐到缓存行,进一步减少 mm

当实体添加或移除 Fragment、Tag 时,Mass 会把它从原 Chunk 移出,再放入与新 Archetype 匹配的 Chunk。这个过程叫 Archetype 迁移。迁移会触发内存拷贝和索引重建,数量少时几乎可以忽略,数量大时可能吃掉一整帧的预算。设计数据布局时应尽量让同一类实体保持稳定组合,避免每帧都在不同 Archetype 之间来回移动。常见的做法是把可能频繁变化的状态拆成独立的 Fragment,而不是用 Tag 的增减来表达临时状态。

Tag 除了分类,还经常被用作查询过滤条件。一个 Processor 只想处理站立不动的行人,就可以在查询里要求 FMassStandingTag 必须存在,同时要求 FMassMovingTag 必须不存在。由于 Tag 以紧凑位图存储,过滤的开销很低。设计 Tag 时要避免过度细分,Tag 组合爆炸会导致 Archetype 数量激增,每个 Archetype 都需要自己的 Chunk 列表,管理成本会随之上升。

四、Processor 的批量处理

Processor 继承 UMassProcessor。它不像 Actor 的 Tick 那样逐个对象调用,而是一次性处理匹配查询的所有实体。每个 Processor 在 ConfigureQueries() 里声明自己需要读取或写入哪些 Fragment,需要哪些 Tag 必须存在或必须不存在。查询结果会以 Chunk 列表的形式交给 Execute()

下面是一个简化的 Processor 骨架:

UCLASS()
class UMassBirdFlockProcessor : public UMassProcessor
{
    GENERATED_BODY()
protected:
    virtual void ConfigureQueries() override;
    virtual void Execute(FMassEntityManager& EntityManager,
                         FMassExecutionContext& Context) override;
private:
    FMassEntityQuery Query;
};

查询配置通常长这样:

void UMassBirdFlockProcessor::ConfigureQueries()
{
    Query.AddRequirement<FMassVelocityFragment>(EMassFragmentAccess::ReadWrite);
    Query.AddRequirement<FMassTransformFragment>(EMassFragmentAccess::ReadOnly);
    Query.AddTagRequirement<FMassBirdTag>(EMassFragmentPresence::All);
    Query.RegisterWithProcessor(*this);
}

ReadOnlyReadWriteReadOnlyUnbound 等访问标记帮助 Mass 计算依赖关系,决定哪些 Processor 可以并行执行,哪些必须串行。ReadOnlyUnbound 表示 Processor 读取数据但不按该 Chunk 做逐实体循环,常用于全局查询。

sequenceDiagram
    participant GT as "Game Thread"
    participant EM as FMassEntityManager
    participant P1 as "寻路 Processor"
    participant P2 as "避让 Processor"
    participant P3 as "动画 LOD Processor"
    GT->>EM: 开始帧
    EM->>P1: 遍历 FMassPathFollowFragment
    P1->>P2: 输出 FMassVelocityFragment
    P2->>P3: 输出 FMassForceFragment
    P3->>EM: 提交姿态与 LOD
    EM->>GT: 结束帧

Execute() 内部,Mass 通常把 Chunk 切分成更小的切片,交给 Task Graph 并行处理。业务代码只需要写一个 lambda,按索引遍历 Fragment 数组。Processor 之间通过 Fragment 内容传递状态,而不是函数调用。例如寻路 Processor 输出目标速度,避让 Processor 读取周围实体的位置并输出最终力,动画 Processor 根据速度计算骨骼姿态。

这种批量模型非常适合万人广场。一帧之内,所有行人的寻路、所有鸽子的扇翅、所有实体的 LOD 都分别在自己的 Processor 里被顺序处理,内存访问模式高度规则。

Processor 之间的执行顺序由依赖声明决定。Mass 会在初始化阶段构建一张依赖图,没有依赖的 Processor 可以并行执行,存在读写冲突的 Processor 会被串行化。业务代码不需要手动加锁,只要正确声明 Fragment 的访问权限,框架会自动处理调度。错误声明访问权限是常见的性能陷阱,例如把只读 Fragment 标成 ReadWrite,会导致所有读取它的 Processor 都被迫串行。

五、缓存友好性与内存布局

缓存友好性不是玄学,而是可以用数字估算的。假设一个 Chunk 里有 NN 个实体,某个 Fragment 的步长是 SS 字节,CPU 缓存行大小是 LL 字节。那么顺序遍历这个 Fragment 时,理论上的缓存未命中次数约为:

M=NSLM = \left\lceil \frac{N \cdot S}{L} \right\rceil

SS 越小,MM 就越小。所以 Mass 鼓励 Fragment 只放必要字段。一个速度 Fragment 用 FVector3f 而不是 FVector,就能省下一半字节。一百个实体也许差距不大,但一万个、十万个实体累积起来,带宽节省非常可观。

Mass 的 Chunk 还会做内存对齐。每个 Fragment 数组在 Chunk 内的起始地址对齐到缓存行边界,避免同一个缓存行跨越两个数组,造成伪共享或额外加载。Processor 内部可以显式使用 FPlatformMisc::Prefetch 提前把下一批数据拉进缓存,对于数据量极大的情况能把 tmisst_{miss} 隐藏到计算背后。

LOD 系统也服务于缓存友好性。距离摄像机较远的实体可以关闭模拟、降低更新频率,甚至直接从 Chunk 中移除某些 Fragment。这样活跃数据量减少,缓存命中率自然上升。广场远端的人群不需要每帧做精细避让,只需要每隔几帧更新一次位置,省下的 CPU 时间可以留给近处的高精度角色。

除了空间局部性,Mass 也照顾到分支预测。Processor 的循环体里处理的是同一 Archetype 的实体,代码路径一致,CPU 的分支预测器能稳定工作。反观传统 Actor,每个对象可能处于不同状态,Tick 函数内部充满条件分支,预测失败会浪费大量流水线周期。把状态相同或相近的实体分到同一 Archetype,相当于让 CPU 一直跑同一段热代码。

六、与 Niagara 和动画系统的集成

Mass 本身不渲染任何东西。它只产出数据,真正的视觉表现由 Representation 子系统负责。每个需要显示的实体可以携带 FMassRepresentationFragment,里面记录视觉类型、LOD 等级、当前使用的 ISM 索引或 Niagara 系统实例。

对于鸟群这种大规模同质对象,常见做法是把位置、速度、朝向写入 Niagara 的粒子数据。Niagara 以 GPU 粒子方式渲染成千上万只鸟,CPU 端只需要每帧更新一个数据缓冲。Mass 通过 FMassNiagaraTranslator 或自定义 Translator 把 Fragment 数组映射到 Niagara 的 User Parameter 或 Data Interface。整个过程在批量循环里完成,不需要为每只鸟单独调用 Niagara API。

对于行人这种需要骨骼动画的对象,Mass 会把动画状态写入 FMassSkeletalMeshFragment,再由 UMassAnimationProcessor 同步到 Animation Blueprint 里的变量。远处角色可以使用 Instanced Skeletal Mesh 或简化的动画序列,近处角色再切回完整骨骼。UE5.8 新增的 MetaHuman Crowd 实验性插件把这种思路产品化:用模块化头部、身体、服装组合出海量 MetaHuman,由 Mass 驱动行为,用 Nanite 和动态 LOD 渲染,镜头拉近时平滑过渡到高保真个体。

集成时最需要注意的是数据同步时机。Mass 的 Processor 通常在游戏线程或任务线程并行运行,而渲染线程和 RHI 线程有自己的节奏。Representation 子系统会把需要显示的数据收集到一个缓冲区,在合适的渲染阶段提交。写 Translator 时要避免直接操作渲染资源,否则容易出现线程竞争。

FMassRepresentationFragment 里通常只存轻量索引,真正的网格、材质、粒子资产由 Representation 子系统统一管理。当鸟群距离摄像机较远时,系统可以把一百只鸟合并到一个 Instanced Static Mesh 实例里;距离拉近后,再拆分成更小的批次或切换到 Niagara GPU 粒子。这种切换对 Mass 业务逻辑透明,Processor 继续更新速度和位置,显示层根据 LOD 决定如何呈现。

七、调试工具

Mass 的调试工具主要分三块:编辑器面板、控制台命令、运行时可视化。

编辑器里打开 Window -> Developer Tools -> Mass Inspector,可以看到当前世界所有实体的列表、每个实体的 Fragment 和 Tag、Chunk 分布、Archetype 统计。它还能实时显示某个 Query 匹配了哪些实体,帮助检查 Processor 的查询条件是否写错。

控制台常用命令包括:

stat Mass
stat MassEntity
showdebug Mass

stat Mass 会列出各 Processor 的耗时,stat MassEntity 显示实体数量、Chunk 占用率、Archetype 数量。showdebug Mass 在视口里绘制调试信息,例如实体位置、LOD 等级、当前激活的 Tag。

另外还有一些 Mass.Debug.* 开头的 CVar,可以单独开关某类可视化。例如只显示鸟群实体的速度向量,或者只高亮处于避让状态的行人。调试 ECS 最棘手的问题往往不是逻辑错误,而是实体意外进入了错误的 Archetype,或者某个 Fragment 没有正确更新。Mass Inspector 让这种排查变得直观。

性能调优时可以先看 stat MassEntity 里的 Chunk 占用率。如果很多 Chunk 只装了不到一半的实体,说明 Archetype 切分太细,或者创建实体时没有合理分批。Chunk 占用率低会浪费内存,也会降低缓存效率。把同类型实体集中生成,让 Chunk 尽量填满,是上线前常见的优化项。

八、UE5.8 的 Mass 改进

Unreal Engine 5.8 在 Mass 上做了不少增量优化,也推出了一些面向产品化的功能。最明显的是 MetaHuman Crowd 实验性插件,它把 Mass、MetaHuman、Nanite 和动画系统串成一条完整管线。开发者可以用蓝图或 C++ procedurally 组合人群,系统在远处用 Instanced Skeletal Mesh 或简化序列,在近处切换到高保真 MetaHuman,过渡由 Mass LOD 控制。

移动避让方面,5.8 继续完善 UMassMovingAvoidanceProcessorUMassStandingAvoidanceProcessor。它们把周围实体当作胶囊或药丸形碰撞体,结合 FNavigationObstacleHashGrid2D 做邻近查询,输出 FMassForceFragment。配合 ZoneGraph 的 FMassZoneGraphCachedLaneFragment,车辆可以在车道上行驶,行人可以沿人行道移动,互相之间还能保持合理间距。

网络复制也值得关注。Mass Replication 子系统以服务器向客户端单向复制的方式工作,为每个连接客户端单独计算 Mass Replication LOD,只发送可见或相关的实体数据。这对大型多人世界很重要,能显著降低带宽。

底层实现上,5.8 的 Chunk 分配器增加了内存池策略,频繁创建和销毁实体的场景下分配开销更低。命令缓冲的并行提交路径也得到加强,多个 Processor 可以在不锁 EntityManager 的情况下批量追加命令。调试器新增了按 Archetype 着色的视口覆盖层,一眼就能看出场景中哪些区域实体组合复杂、哪些区域过于单一。

结语

Mass Entity System 不是要让所有对象都变成 ECS 实体。主角、可互动物品、复杂剧情对象仍然适合 Actor。它的主场是广场上的一万两千个行人、天空中飞舞的鸽群、战场上成编制的士兵。这些实体数量庞大、行为相似、对缓存极其敏感,用传统 OOP 做会浪费大量 CPU 周期。

理解 Mass 的关键在于接受一种以数据为中心的视角:实体只是句柄,Fragment 是连续内存,Processor 是批量逻辑。把这三件事组织好,再配合 Niagara 和动画系统的 Representation 层,就能在 UE5.8 里实现真正的大规模同屏模拟。

第一次接入 Mass 时,可以先从鸟群这种纯 Niagara 渲染的对象入手。把位置、速度、朝向做成 Fragment,写一个简单 Processor 更新速度,再让 Translator 把数据喂给 Niagara。熟悉 Fragment、Query、Processor 的写法之后,再逐步加入骨骼动画、避让逻辑和 LOD。把广场人群拆成几个稳定 Archetype,比一开始就追求万能架构要稳妥得多。实体数量上去以后,缓存命中和并行遍历带来的收益会越来越明显,CPU 开销最终会被压到一个可以接受的范围内。