UE5.8 Mass Processor 与 Mass Signal

📑 目录

故事:广场上的两千个行人

去年冬天我在跟进一个开放世界城市 demo。策划希望主城的广场上能有两千名 Mass 行人,他们沿着街道移动、在红灯前停下、看到警车会绕开、路过玩家摊位时停下来看一眼。第一次跑测帧率直接掉到个位数,Intel VTune 里一大块时间被 AI 逻辑吃掉。

项目最初的做法是每个行人都用一个 Actor,Actor 上挂 AAIController,Controller 再跑 Behavior Tree。每帧两千个 Actor 各自 tick,两千份黑板、两千份感知查询、两千次路径请求,CPU 在内存里跳来跳去。换成 Mass Entity 之后,同样规模下逻辑耗时从每帧 6.8 毫秒降到 1.2 毫秒。真正让我意外的是,决定性能上限的已经不再是单个算法有多快,而是 Processor 能不能把活儿攒成一批处理、Query 能不能命中缓存、Signal 能不能只在必要时把 Processor 叫醒。

这篇文章把 Mass Processor 和 Mass Signal 的内部机制拆开。先讲 Processor 的执行模型,再讲 Entity Query 与 Archetype 如何决定谁能进这批处理,然后讲 Signal 怎么在批量世界里做事件驱动,最后落到依赖排序、SIMD 友好性、Gameplay 交互和常见设计模式。

Mass Processor 的执行模型

Mass 把世界看成一个巨大的表。行是 Entity,列是 Fragment。Processor 不是挂在某个 Actor 上的 tick 函数,而是一段对整批 Entity 执行的逻辑。每帧 MassWorld 会遍历所有已注册的 Processor,按依赖顺序调用它们的 Execute。

一个 Processor 的典型生命周期分成三步:

  • Configure:声明 EntityQuery,告诉 Mass 我对哪些 Fragment 感兴趣。
  • Execute:接收 FMassExecutionContext,按 Query 拿到匹配实体的 chunk,逐 chunk 处理。
  • Signal 订阅(可选):通过 RegisterSignalHandler 声明自己对某个 Signal 感兴趣,只在收到 Signal 时运行。
flowchart TD
    subgraph mass_world ["MassWorld"]
        world["Mass World 运行时"]
        archetype["Archetype 存储"]
        query["Entity Query"]
        signal["Signal Subsystem"]
    end
    subgraph processor_group ["Mass Processors"]
        movement["Movement Processor"]
        avoidance["Avoidance Processor"]
        animation["Animation Processor"]
    end
    world --> archetype
    world --> query
    world --> signal
    query --> movement
    query --> avoidance
    query --> animation
    signal --> movement
    signal --> avoidance

上图把世界侧和 Processor 侧分开。Processor 不拥有实体,只通过 Query 临时借用一个视图。这种设计让 Mass 可以在单帧内把同类逻辑集中跑完,避免传统 Actor 模型里每个对象都要单独调度。

Processor 在一帧内的执行路径可以用下面的流程图概括。

flowchart TD
    Start["MassWorld 开始帧"] --> Phase{进入哪个阶段}
    Phase -->|PrePhysics| P1["Movement Processor"]
    Phase -->|PostPhysics| P2["Animation Processor"]
    P1 --> Query1["按 Query 筛选 Archetype"]
    Query1 --> Chunk1["遍历每个 chunk"]
    Chunk1 --> Proc1["批量修改 Fragment"]
    Proc1 --> Signal1{需要发 Signal?}
    Signal1 -->|"是"| Queue["把 Signal 加入队列"]
    Signal1 -->|"否"| End1["阶段结束"]
    Queue --> End1
    P2 --> Query2["按 Query 筛选 Archetype"]
    Query2 --> Chunk2["遍历每个 chunk"]
    Chunk2 --> End2["阶段结束"]

阶段之间的边界很重要。PrePhysics 阶段跑完所有和物理输入相关的 Processor,PostPhysics 阶段再读取物理结果做表现。Signal 队列在阶段切换时统一处理,同一阶段内的连续 Processor 可以直接共享 Fragment 数据。

Mass Processor 的基类是 UMassProcessor。你不需要继承 FTickableGameObject,也不需要实现 Tick。真正干活的地方是 Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context)。框架会保证同一个 Processor 在同一帧内不会被并发执行,因此你可以在里面写单线程逻辑;如果你需要并行,Mass 提供了 FMassProcessingPhaseExecute 内的 chunk 遍历来拆分任务。

一个 Processor 默认会注册到某个执行阶段。阶段由 EMassProcessingPhase 定义,比如 PrePhysics、PostPhysics、FrameEnd。阶段内再按 Processor 之间的依赖关系排序。后面会专门讲排序。

Entity Queries 与 Archetype

Mass 的 Entity 本身没有类型,它只是一个索引。类型由 Entity 持有的 Fragment 组合决定。一个 Archetype 就是一组 Fragment 的集合,所有具有相同 Fragment 组合的 Entity 放在同一个 Archetype 的连续内存块里。

举个例子。广场上的行人可能有 FTransformFragmentFMassVelocityFragmentFMassAIStateFragment。警车可能有这三个 Fragment 再加上 FMassSirenFragment。玩家角色可能有 FTransformFragmentFMassVelocityFragmentFPlayerInputFragment。于是 Mass 内部会维护三种 Archetype:

  • Archetype A:Transform + Velocity + AIState
  • Archetype B:Transform + Velocity + AIState + Siren
  • Archetype C:Transform + Velocity + PlayerInput
graph LR
    subgraph Q1 ["Query: Transform + Velocity"]
        A1["行人"]
        A2["警车"]
        A3["玩家"]
    end
    subgraph Q2 ["Query: Transform + Velocity + AIState"]
        B1["行人"]
        B2["警车"]
    end
    subgraph Q3 ["Query: 含 Siren"]
        C1["警车"]
    end

Entity Query 就是按 Fragment 条件筛选 Archetype。Query 可以要求必须包含某些 Fragment、必须不包含某些 Fragment、或者只读。Query 在 Configure 阶段被解析成 FMassEntityQuery 对象,运行时会返回所有匹配 Archetype 的 entity 列表。

这个匹配过程非常快,因为 Mass 不需要遍历所有 Entity。它只需要检查当前有哪些 Archetype 满足 Query 的 Fragment 集合。每个 Archetype 要么全匹配、要么全不匹配,因此判断代价和 Archetype 数量成正比,而不是 Entity 数量。

当 Entity 的 Fragment 组合发生变化时,比如给行人加上 FMassStunnedFragment,Mass 会把它从原来的 Archetype 移动到新的 Archetype。这个过程叫做 Archetype 切换。切换的代价主要是把 Fragment 数据从旧内存块复制到新内存块,通常在一帧内完成。为了避免频繁切换,设计 Fragment 时要尽量让高频变化的数据和不常变化的数据分开。

Query 还可以通过 FMassEntityQueryAddRequirement 来声明 Fragment 的访问权限。只读 Fragment 可以让多个 Processor 同时访问;可写 Fragment 会在 Processor 之间建立依赖,影响排序。

Fragment 切分的两条经验

Fragment 切得好不好,直接决定 Query 命中率和 Archetype 切换开销。我通常按两条原则来拆。

第一条是冷热分离。位置、速度、动画状态每帧都变,属于热数据;职业、阵营、出生点这些几乎不变,属于冷数据。把它们放进不同 Fragment,可以减少热 Fragment 的内存 footprint,也能让冷数据在缓存里待得更稳。

第二条是按访问频率分组。如果 Processor A 每帧都要读位置和速度,Processor B 每帧都要读位置和血量,那就把位置和速度放一起、血量单独放。这样 A 的 Query 不会把血量拉进缓存,B 也不会把速度拖进来。听起来很基础,实际做的时候很容易为了图省事把所有数据塞进一个大 Fragment,结果 Cache Miss 直线上升。

Signal 通信机制

Mass 的批量模型很高效,但有个问题:如果 Processor 每帧都遍历所有 Entity,很多计算是白费的。比如行人只有在看到警车时才需要进入逃跑状态,大部分帧里他们只是在走路。Signal 机制就是用来解决这个问题的。

Signal 是一种轻量级广播。某个事件发生时,可以往 Mass Signal Subsystem 发一个 Signal,附带 Signal Name 和可选的 Payload。订阅了这个 Signal 的 Processor 会在下一帧被唤醒执行,没订阅的 Processor 继续睡觉。

sequenceDiagram
    participant Gameplay as "Gameplay 代码"
    participant Signal as MassSignalSubsystem
    participant Query as "Entity Query"
    participant Proc as Processor
    participant Entity as "匹配实体"

    Gameplay->>Signal: 发送 FMassSignalName: PoliceSiren
    Signal->>Query: 查找订阅该 Signal 的 Processor
    Query->>Proc: 标记 Processor 本帧需执行
    Proc->>Entity: 通过 Query 拿到 Transform/AIState
    Entity-->>Proc: 返回 chunk 数据
    Proc->>Entity: 修改 Fragment,如进入 Flee
    Proc->>Signal: 可选:发送下一级 Signal

上图展示了一个典型链路。Gameplay 检测到警车鸣笛,发送 PoliceSiren Signal。Signal Subsystem 找到订阅了该 Signal 的 Processor,比如 UMassFleeProcessor。Processor 醒来后只处理当前匹配 Query 的实体,把它们的状态改成逃跑,并可能再发一个 StartFleeAnimation Signal 给动画 Processor。

Signal 和传统的事件委托有两个区别。第一是广播范围:Signal 不指定具体接收者,只通过订阅关系解耦。第二是唤醒语义:Signal 不会立刻中断当前帧的执行,而是把目标 Processor 标记为下一帧需要执行。这意味着 Signal 处理始终是异步的,不会在某帧中间突然插入一段逻辑,避免破坏批量处理的连续性。

Signal 的处理顺序仍然受 Processor 依赖顺序约束。如果 A Processor 发 Signal,B Processor 订阅并处理,B 必须排在 A 之后。否则 Signal 会被延迟到下一帧。这个细节在做帧内事件链时容易踩坑。

Signal 还有一个变体叫 Entity Signal,它只发给某个具体 Entity。这种 Signal 常用于 Gameplay 和单个 Mass Entity 交互,比如玩家对某个 NPC 使用技能。Entity Signal 会唤醒订阅该 Signal 的 Processor,但 Processor 内部只会处理 Signal Payload 里指定的 Entity,其他实体不受影响。

Processor 依赖与执行顺序

当 Processor 数量变多,执行顺序就成了关键问题。假设 UMassMovementProcessor 负责更新位置,UMassAvoidanceProcessor 负责避障,UMassAnimationProcessor 负责把速度转成动画参数。显然避障要在移动之前算完,动画要在移动之后采样。如果顺序错了,行人会先用上一帧的速度播放动画,或者先移动再避障导致穿模。

Mass 用 Fragment 读写关系自动推导依赖。如果 Processor A 写 FMassVelocityFragment,Processor B 读 FMassVelocityFragment,那么 B 必须排在 A 之后。这种依赖是隐式的,你不需要手动写依赖表,只要声明好 Fragment 的读写权限,Mass 会在初始化阶段构建 DAG。

但隐式依赖不够用。有时候两个 Processor 读写完全不同的 Fragment,却仍然需要严格的先后关系。比如 UMassSpawnProcessor 负责创建 Entity,UMassInitializeProcessor 负责给新 Entity 填默认值。它们可能不共享任何 Fragment,但初始化必须发生在创建之后。这时候就要显式声明依赖,使用 UMassProcessorDependencies 数组,或者在 C++ 里调用 ExecutionOrder.ExecuteBeforeExecutionOrder.ExecuteAfter

UCLASS()
class MYGAME_API UMassAvoidanceProcessor : public UMassProcessor
{
    GENERATED_BODY()
public:
    UMassAvoidanceProcessor()
    {
        ExecutionOrder.ExecuteBefore.Add(UMassMovementProcessor::StaticClass());
        ExecutionFlags = (int32)EProcessorExecutionFlags::All;
    }

    virtual void ConfigureQueries() override
    {
        EntityQuery.AddRequirement<FMassVelocityFragment>(EMassFragmentAccess::ReadWrite);
        EntityQuery.AddRequirement<FTransformFragment>(EMassFragmentAccess::ReadOnly);
    }

    virtual void Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context) override
    {
        EntityQuery.ForEachEntityChunk(EntityManager, Context,
            [this](FMassExecutionContext& Ctx)
        {
            const TArrayView<FTransformFragment> Transforms = Ctx.GetFragmentView<FTransformFragment>();
            const TArrayView<FMassVelocityFragment> Velocities = Ctx.GetMutableFragmentView<FMassVelocityFragment>();
            for (int32 i = 0; i < Ctx.GetNumEntities(); ++i)
            {
                Velocities[i].Value += ComputeAvoidance(Transforms[i].GetTransform().GetLocation());
            }
        });
    }
};

上面的代码片段展示了声明依赖和 Query。ExecuteBefore.Add(UMassMovementProcessor::StaticClass()) 显式告诉 Mass:避障要在移动之前跑。AddRequirement 声明了对 Velocity 的读写和对 Transform 的只读。这样 Mass 既有了隐式依赖,也有了显式依赖,排序结果更稳定。

需要留意的是,依赖只能保证同阶段内的顺序。如果 A 在 PrePhysics 阶段,B 在 PostPhysics 阶段,那么无论 A 和 B 有没有依赖,A 一定先跑。跨阶段的 Signal 也会因此延迟一帧。

批量处理与 SIMD 友好性

Mass 的核心优势来自批量处理。Query 返回的实体不是散落的指针,而是按 Archetype 分好的 chunk。每个 chunk 里的 Fragment 数据在内存中连续排列。Processor 拿到 chunk 后可以顺序访问,CPU 缓存命中率很高。

Processor 内部最常见的写法是用 ForEachEntityChunk 遍历所有匹配 chunk,再用一个内层循环处理 chunk 里的每个实体。内层循环要尽量写成对数组的顺序访问,少做分支判断,少访问外部状态。这样才能让编译器更容易生成 SIMD 代码。

一帧的总处理时间可以粗略拆成:

Ttotal=c=1k(Oquery+ncOproc)T_{\text{total}} = \sum_{c=1}^{k} \left( O_{\text{query}} + n_c \cdot O_{\text{proc}} \right)

其中 kk 是匹配 Query 的 chunk 数量,ncn_c 是第 cc 个 chunk 里的实体数,OqueryO_{\text{query}} 是切换 chunk 时的固定开销,OprocO_{\text{proc}} 是单个实体的处理开销。当 ncn_c 足够大时,OqueryO_{\text{query}} 被摊薄,整体接近线性增长。

SIMD 友好的写法通常有这几个要点:

  • 把数据存在 Fragment 里,而不是每次去 Actor 上取。
  • 避免在循环里调用虚函数或者查询 UObject。
  • 分支尽量提到循环外面,或者用掩码批量处理。
  • 如果算法需要临时存储,用栈上数组或者 chunk 级缓存,而不是每实体动态分配。

举个例子,计算两千个行人的移动时,如果每个实体都去 GetActorLocationSetActorLocation,开销会爆炸。正确做法是把位置存在 FTransformFragment 里,Processor 直接读写字节数组,跑完后再把最终位置同步回 Actor 代理。这种批量计算被称为“批量化游戏逻辑”。

Mass 的 chunk 大小不是固定的,通常和 Archetype 的内存分配策略有关。Entity 数量多时,chunk 会很大,批处理收益明显;Entity 数量少但种类多时,chunk 变小,OqueryO_{\text{query}} 占比上升。这就是为什么设计 Fragment 时要想办法让同类 Entity 聚集。

Mass Signal 与 Gameplay 交互

Mass 世界和 Gameplay 世界之间通常有一个代理层。真正在屏幕上看到的是 Actor,但逻辑实体是 Mass Entity。Gameplay 代码很少直接操作 Mass Entity,而是通过 Signal 或 Fragment 间接交互。

最常见的交互方式有三种:

第一种是 Gameplay 发 Signal 驱动 Mass。 比如玩家按下技能键,Gameplay 代码找到目标 Mass Entity,发送 ApplyStun Entity Signal。订阅了这个 Signal 的 Processor 会在下一帧把 FMassStunnedFragment 加到目标 Entity 上。这种写法把 Gameplay 的偶发事件和 Mass 的批量处理解耦。

第二种是 Mass 发 Signal 驱动 Gameplay。 比如 Mass 行人进入死亡状态,Processor 发送 MassEntityDied Signal。Gameplay 侧的 AMassActorSpawner 订阅这个 Signal,在收到后销毁对应的 Actor 代理、播放音效、触发任务逻辑。Signal 在这里把批量世界和 UObject 世界连了起来。

第三种是通过 Fragment 共享状态。 比如玩家输入被写到 FPlayerInputFragment 里,Mass Entity 读取这个 Fragment 决定移动方向。这种写法适合每帧都变化的高频数据,比 Signal 更直接。

三种方式并不是互斥的。通常高频连续数据走 Fragment,低频离散事件走 Signal,需要世界副作用的走 Signal + Gameplay 回调。

这里有个实际踩过的坑。我们曾经把玩家输入直接放到一个全局 Fragment 里,所有 Entity 都读它。结果玩家 Entity 和 AI Entity 的 Query 都命中了这个 Fragment,导致大量无关实体每帧都访问玩家输入。后来把玩家输入拆成独立的 Fragment,只在需要响应玩家的 Entity 上挂载,缓存命中率立刻好了很多。

性能分析

调 Mass 性能时,VTune 和 Unreal Insights 是两个主力工具。Insights 里有 Mass 专用 track,可以看到每个 Processor 的执行时间、每个 Query 匹配的 Entity 数量、Signal 的发送次数。

我通常会按下面顺序排查:

  • 先看哪个 Processor 耗时最高。Insights 会列出每个 Processor 的独占时间,找出大户。
  • 再看这个 Processor 的 Query 命中了多少 Entity。如果命中数很少但执行频率高,说明 Processor 被不必要地唤醒了,检查 Signal 订阅。
  • 然后看 Fragment 访问模式。如果 Processor 在循环里频繁读写不同 Fragment,说明内存布局可能不够紧凑。
  • 最后看 Archetype 数量。Archetype 太多会导致 Query 匹配和 chunk 切换开销上升,考虑合并或拆分 Fragment。

有一个数字很值得记:在两千个行人的 demo 里,我们把所有位置更新放到一个 Processor 里,每帧耗时大约 0.18 毫秒;如果把位置更新拆成十个 Processor,总耗时升到 0.7 毫秒。差距主要来自 Query 和 chunk 遍历的重复开销。Processor 不是越细越好,合并同类逻辑往往能省钱。

另一个容易被忽略的点是 Signal 的爆发。如果一帧内发了上千个 Signal,Signal Subsystem 的订阅查找和 Processor 唤醒会成为瓶颈。解决思路是把同类型事件攒成批量 Signal,或者把 Signal 改成 Direct 调用(只在同一 Processor 内部传递)。

常见设计模式

状态机 + Signal

把 AI 状态存在 Fragment 里,比如 EAIState::IdleEAIState::FleeEAIState::Dead。每个状态对应一个 Processor,Processor 通过 Query 过滤出处于该状态的 Entity。状态转换通过 Signal 触发。这种写法清晰,但状态多时要注意 Processor 数量。

单一职责 Processor

每个 Processor 只干一件事:移动、避障、动画、寻路、死亡清理。Processor 之间通过 Fragment 共享数据,通过 Signal 触发事件。这种写法容易调试,但要控制依赖数量,避免形成过长的执行链。

事件缓冲 Processor

有些事件不适合立刻处理,比如寻路请求。可以专门做一个 UMassPathRequestBufferProcessor,收集本帧所有需要寻路的 Entity,批量调用 Navigation System 的异步接口,结果写回 Fragment。这样避免在循环里逐个发起寻路请求。

代理同步 Processor

Mass Entity 不直接渲染,需要同步到 Actor 代理。可以写一个 UMassActorSyncProcessor,每帧把 Transform、动画状态等 Fragment 同步到关联的 Actor。这个 Processor 通常放在 FrameEnd 阶段,确保所有逻辑已经跑完。

Signal 节流

对于高频但低优先级的事件,可以用 Fragment 记录上一次处理时间,Processor 只在间隔超过阈值时才响应 Signal。这能防止某个事件在一帧内反复触发。

延迟初始化 Processor

新创建的 Entity 不要立刻参与全部逻辑。可以写一个 UMassDeferredInitProcessor,只在 Entity 带有 FMassPendingInitFragment 时运行,初始化完成后移除该 Fragment。这样能把一次性设置和持续 tick 逻辑分开,避免每帧都检查“我是不是新来的”。

Query 结果缓存

如果某个 Processor 的 Query 很复杂,匹配的 Archetype 又多,可以考虑把 Query 结果缓存到 Processor 内部,只在 Archetype 发生变化时重新计算。Mass 提供了 FMassEntityQuery::RegisterWithEntityManager,可以监听 Archetype 变更事件,避免每帧都重新走一遍匹配流程。

写在最后

Mass Processor 和 Mass Signal 是 UE5.8 做大规模 AI 的两块基石。Processor 负责把逻辑批量化,Signal 负责在批量世界里做事件驱动。两者结合起来,才能既享受 ECS 的缓存友好,又不失 Gameplay 的响应能力。

设计时我最常提醒自己的一句话是:别把 Mass 当成 Actor 用。如果你还在每个 Processor 里频繁访问 UObject、遍历 Actor 列表、做大量分支判断,那就白换到 Mass 了。反过来,如果你的 AI 只有几十个实体,行为又非常复杂,Mass 的收益可能不明显,甚至因为代理同步和 Archetype 切换带来额外成本。

回到广场行人的 demo。我们把移动、避障、动画、状态机拆成四个 Processor,用 Signal 处理警车鸣笛和玩家互动事件,用 Fragment 共享位置和速度。两千个行人在中端硬件上稳在 60 帧,CPU 时间主要花在渲染和动画上。这个结果是传统 Actor 方案很难做到的。