UE5 ECS 原理

ECS的核心思想是数据(Component)与逻辑(System)分离,MassEntity的核心理念也遵循此道,并拥有自己的一套术语。

一、 为什么需要ECS?(传统OOP的"血泪史")

在游戏开发中,最经典的反面教材就是 "继承爆炸""虚表开销"

  1. "钻石继承"与臃肿的基类 :假设你有一个 Animal 基类,派生 DogBird。但如果要做一个"会飞的狗"(Boss战),你是继承 Dog 还是 Flying?为了复用逻辑,往往会在基类里塞满用不上的变量(如 WingSpan),导致内存浪费和逻辑耦合。

  2. 灾难性的缓存命中率 :传统OOP中,遍历一个 std::vector<AActor*> 数组,指针指向的内存是分散在堆上的。CPU 需要频繁从内存中加载数据,而CPU访问主内存的速度比访问缓存(Cache)慢上百倍。当你的"千军万马"(成千上万个单位)同时更新时,CPU 时间全花在"等数据"上,而不是"算逻辑"上。

  3. 修改困难:想要给所有"带物理属性的可移动物体"加一个全局重力影响,如果它们散落在不同的类继承树中,改起来极其痛苦。

二、 ECS解决了什么?

ECS(实体-组件-系统)把"对象"拆解成"数据(组件)"和"逻辑(系统)",带来了三大革命性优势:

  • 极致的内存连续性(Cache-Friendly) :同类组件(比如所有实体的位置 FTransformFragment)被紧密排列在连续内存中。遍历 1 万个实体更新位置,CPU 只需顺序读取,速度比传统OOP快 数倍甚至数十倍

  • 极低的组合成本(Composition over Inheritance) :想要"会飞的狗"?只需给这个实体添加 FlyingTagWingFragment,不需要修改任何类继承关系。

  • 逻辑解耦与并行化 :每个"系统"只关心它需要的组件组合(Query)。因为系统之间不共享状态(数据都在组件里),UE5 的 Mass 可以轻易将这些系统分配到多线程上并行执行。

三、 什么时候(必须/强烈建议)使用 ECS(Mass)?

核心指标只有一个:实体数量是否超过"几百个"?

使用场景 具体例子 是否推荐
大规模群体 AI 城市里的行人、蚂蚁搬家、军团对战(千军万马)、僵尸潮。 强烈推荐(这就是为它设计的)
开放世界环境交互 满地的落叶、飘动的粒子草、被风吹动的布条、大量的可破坏碎片。 推荐
复杂的弹幕/投射物 同时存在上千颗子弹、弹片或追踪飞弹,且需要碰撞计算。 推荐
动态交通系统 GTA 风格的车流,每辆车都独立寻路和避障。 推荐
高频数据计算 LOD(细节层次)距离计算、视锥剔除、网络同步的副本(Replication)筛选。 推荐

四、ECS速度快的核心原理

首先CPU 并不能直接操作位于主板上的 DDR 内存条(RAM) 。CPU 真正"直接"读取的是它内部自带的缓存(Cache)

数据流动的完整链条是这样的:

硬盘/SSD → 内存条(RAM) → CPU 三级缓存(L3) → CPU 二级缓存(L2) → CPU 一级缓存(L1) → CPU 寄存器(Registers) → 逻辑运算单元(ALU)

当 CPU 核心需要读取 entities[i].x 的值时,会严格按照由近到远、由快到慢的顺序向下查找:

层级 名称 容量大小 延迟(时钟周期) 延迟(纳秒) 核心归属
寄存器 (Registers) 几十个 ~ 几百个 Byte 0 ~ 1 个周期 ~0.1 ns 当前核心独占
↓ 未命中则下探
L1 缓存 (一级缓存) 32KB ~ 64KB 1 ~ 3 个周期 ~1 ns 当前核心独占
↓ 未命中则下探
L2 缓存 (二级缓存) 256KB ~ 512KB 10 ~ 15 个周期 ~5 ns 当前核心独占
↓ 未命中则下探
L3 缓存 (三级缓存 / LLC) 8MB ~ 32MB 40 ~ 60 个周期 ~15 ns 所有核心共享
↓ 未命中则下探
内存条 (RAM / 主存) 16GB ~ 128GB 300 ~ 500 个周期 ~100 ns 所有核心共享(总线)
↓ 极其罕见(缺页)
硬盘 (虚拟内存/页面文件) 极大 百万级周期 ~10,000,000 ns 噩梦级延迟

打个比方:你在工位上(CPU寄存器)写代码,工位抽屉(L1缓存)里没有笔,你让前台去楼下仓库(RAM内存)找笔,等你拿到笔时,可能已经过去了一节课的时间。

1. RAM(内存条)
  • 全称R andom A ccess Memory(随机存取存储器)。

  • 通俗理解 :计算机的**"工作台"** 或**"短期记忆"**。

  • 核心特性(极其重要)

    • 易失性(Volatile) :断电后数据立即全部清空

    • 速度:纳秒级(ns),极快。

    • 作用 :存放 CPU 即将要处理的指令和数据。我们之前笔记中提到的 步骤 ⑤(延迟 ~100ns) 指的就是它。

  • 名词解释 :为什么叫"随机"(Random)?因为它允许 CPU 以相同的速度访问内存条上的任意一个地址,不需要从头开始找(区别于老式的磁带)。

2. SSD(固态硬盘)
  • 全称S olid S tate Drive(固态驱动器 / 固态硬盘)。

  • 通俗理解 :计算机的**"大型仓库"** 或**"长期记忆"**。

  • 核心特性(极其重要)

    • 非易失性(Non-Volatile) :断电后数据永久保存

    • 速度 :微秒级(μs)甚至毫秒级(ms),比 RAM 慢 千倍至万倍

    • 作用 :存放操作系统、UE5 工程文件、贴图资源、以及 Windows 的"虚拟内存(分页文件)"。我们之前笔记中提到的 步骤 ⑥(噩梦级延迟) 指的就是它。

逻辑上的"清理"(你作为游戏开发者看到的)

当你关闭游戏进程(比如退出 UE5 编辑器或打包好的游戏 .exe)时:

  • 操作系统(Windows)会立即收回所有物理内存条(RAM)的使用权。 不管你的游戏是用了 2GB 还是 64GB 内存,Windows 会一次性把这些内存页(Pages)全部标记为"空闲"(Free)。

  • 你的游戏程序彻底"失忆"了。游戏代码里所有指向内存的指针(地址)都作废了。游戏再也无法访问之前加载的模型、贴图或 Mass 实体的位置数据。

  • 不会"通知"游戏去清理 :操作系统不会调用你的析构函数(Destructor)。它是直接"拔网线"式的收回,而不是客客气气地让你收拾垃圾。这也是为什么就算游戏有内存泄漏(忘记 delete),关掉游戏后电脑内存也会恢复正常------操作系统会强制替它擦屁股

游戏存档的内容是存到了SSD了

. 量化对比:ECS vs OOP(传统 Actor)的命中率差距

假设你要遍历 1 万个实体,只读取它们的"位置(X坐标)":

  • 传统 OOP(Actor 模式)

    • 内存布局:对象散落在堆内存各处(链表状)。

    • 读取过程 :CPU 读 ActorA 的 X,加载了 64 字节 Cache Line。但这 64 字节里,包含的是虚表指针、名字、UI引用等乱七八糟的冷数据,只有 4 个字节是 X。

    • 命中率:Cache Line 有效利用率极低(约 6%)。CPU 刚用完这 4 字节,剩下的 60 字节全是废数据,被迫频繁去 RAM 搬新的。

    • 结果:L1 缓存命中率极低(可能不到 10%),CPU 大部分时间在"等数据"。

  • ECS MassEntity 模式

    • 内存布局 :所有实体的 FTransformFragment(包含 X)被塞进一个纯粹的、连续的数组(SoA,即结构体数组)。

    • 读取过程 :CPU 读 Entity[0].X,加载 64 字节 Cache Line。因为数组连续,这 64 字节里全是下一个实体的 X 坐标(假设 X 是 float,能装下 16 个实体的 X)。

    • 命中率 :当 CPU 循环到 Entity[1] 时,数据早就躺在 L1 缓存里了

    • 结果 :L1 缓存命中率高达 90%~95%

  1. 内存连续性(空间的局部性)

    ECS 强制把同类型数据(如位置)放在一起。这不仅让 L1 命中,还触发了 CPU 的硬件预取器(Prefetcher) 。CPU 觉得你顺序读得这么爽,干脆提前把后面几十个实体的数据从 RAM 自动搬到 L2 缓存里等着。等你用到时,连去 RAM 的 100 纳秒都省了。

  2. 只取热数据(数据的纯净性)

    在 Mass 处理器(Processor)中,你只查询需要的组件(比如只查 FTransform)。内存里没有夹杂虚表(VTable)和函数指针。Cache Line 里塞满了 100% 有效的"弹药",没有一颗是臭弹(废数据)。

  3. 避免"伪共享(False Sharing)"的硬件锁

    ECS 将只读数据和只写数据分开(比如位置只读、速度只写)。确保一个 Cache Line(64字节)不会同时被两个 CPU 核心争抢,强行让缓存命中率维持在高位

极致的并行性:因为每个系统只操作连续数组的不同段,几乎没有数据依赖,UE5 可以将工作拆分成多个线程(Job System),让多个 CPU 核心同时干活。

如何判断一个数据在哪一个层级?

寄存器的数据(100% 确定)
  • 判断法则只有正在被 CPU 算术逻辑单元(ALU)加减乘除的那个数,才在寄存器里。

  • 实战 :你无法在调试器里单独看一个"变量"属于哪个寄存器,因为寄存器每纳秒都在换数据。只要变量刚刚参与过计算,它就在寄存器;一旦计算完,它就被"写回"缓存或内存了。

当CPU开始遍历第0个实体取位置时(entities[0].x),因为第0个和第1个实体的位置在物理内存上紧挨着,CPU读取第0个实体时,硬件已经强制把第1个实体(甚至第2个部分)的位置数据,连同第0个一起,物理加载进了L1缓存

即使是不连续的数据,只要你读取了其中一个,CPU 的硬件机制依然会把它所在的那块连续 64 字节(Cache Line)强行拽进 L1。

既然指针指向的"目标对象"旁边也会被加载

假设你有 10,000 个实体,每个位置占 24 字节(FVector)。总数据量约 240KB,远超 L1(64KB)的容量。

CPU 的真实动作是"流水线滚动":

  • 第一步(加载 A) :CPU 读取 A,硬件确实把 A、B、C(64字节) 一起拽进了 L1。此时 L1 里躺着 A、B、C。

  • 第二步(处理 B) :CPU 处理 B,发现 B 已经在 L1,零延迟 读取。同时,硬件预取器算出"这小子在顺序读",于是偷偷把 D、E、F(下一个 64 字节) 从 RAM 提前搬到了 L2 缓存(甚至 L3)里候着。

  • 第三步(处理 C):CPU 处理 C,C 也在 L1。

  • 第四步(处理 D) :当 CPU 需要 D 时,发现 D 并不在 L1 (因为 L1 太小,且 A、B、C 已经用完了)。但是!由于预取器提前把 D、E、F 搬到了 L2 ,CPU 只需要花 ~5 纳秒 (L2 延迟),就能把 D 从 L2 请进 L1,不需要去 RAM(~100 纳秒)

问:所以到第三步之前AB是同时在L1的,第三步的时候是L1存不下C,所以存到了L2了,这个时候将L1清空,然后把L2的C搬到L1上去?

. 纠正你的两个关键认知(必看!)

  • 误解一 :"C 是因为 L1 满了才存到 L2 的。"

    真相 :C 一直 都存在 RAM 里。在 CPU 处理 A 的时候,硬件预取器早就把 C 和 D 所在的下一行(64字节) 主动从 RAM 搬进 L2 缓存里"候着"了。C 进 L2,跟 L1 满不满没有半毛钱关系,纯粹是预取器在提前"预热"。

  • 误解二 :"第三步的时候将 L1 清空,然后把 L2 的 C 搬到 L1。"

    真相 :L1 永远不会"清空"自己。它执行的是 "按行覆盖(Eviction)" 。当 CPU 需要 C 时,如果包含 C 的那一行不在 L1,L1 会把最久没用(或根据特定算法)的那一行 (比如包含 A 的那一行)直接覆盖掉 ,腾出位置给 C。包含 B 的那一行可能还在 L1 的另一个角落里安然无恙。

设你的实体位置 FVector 占 24 字节,64 字节的缓存行可以塞下 A(24字节)+ B(24字节)+ C 的前 16 字节。我们按顺序拆解:

时间点 CPU(你)在干什么 后台硬件在干什么 L1 缓存槽里到底放着什么数据块? L2 缓存里放着什么?
① 读取 A 你喊:"把 A 给我!" 内存控制器从 RAM 里取出 块1(A+B+C前半) ,把它塞进 L1 的 槽位 #1 槽位 #1 = 块1 (含 A、B、C的前半段 空的(还没开始干活)
② 预取 (你正在低头处理 A) 预取器预测你下一步要处理后续数据,于是额外 从 RAM 里取出 块2(C后半+D+E+F前半) 。但它没有乱塞进 L1,而是先存进 L2 缓存 里备着。 依然是 槽位 #1 = 块1 存着 块2 (含 D、E
③ 读取 B 你处理完 A,伸手拿 B。 (无额外动作) 查 L1 槽位 #1,发现里面是块1,B 就在块1里,直接命中(秒拿)。 依然存着块2
④ 读取 C 你伸手拿完整的 C(地址48~71)。 L1 发现 C 不完整 (前半在块1,后半在块2)。 L1 控制器决定 :把最旧的槽位 #1(块1)覆盖掉,向 L2 发出请求:"把块2给我!" 拿到块2后 ,CPU 在寄存器 中把"块1里的前半段"和"块2里的后半段"拼凑成完整的 C 进行计算。 槽位 #1 被覆盖 :旧的块1被擦除,换成刚从 L2 拿来的 块2 。 (块2里实际装着 C后半段 + D + E + F前半段 块2 被转移走后,L2 暂时清空(或继续预取后续的块3、块4)。
⑤ 读取 D、E 你接着拿 D、E。 (预取器继续去 RAM 拿后续的块3、块4存进 L2,为后续 F、G 做准备) 查 L1 槽位 #1,发现里面是块2,D 和 E 完整地躺在块2里,直接命中(秒拿)。 预取器已经把 块3、块4 搬进了 L2,等着你下一轮替换。

如果说"缓存"是 CPU 的"高速工作台",那么 Cache Line(缓存行) 就是这个工作台上 "最小的操作单位"

Cache Line 是 CPU 缓存(L1、L2、L3)与内存条(RAM)之间数据交换的最小固定块大小固定为 64 / 128 字节(Byte)

多核时代的"隐形杀手":伪共享(False Sharing)

这是 ECS 必须把数据拆开的最核心理由,也是游戏多线程开发中最容易踩的深坑。

场景:CPU 有两个核心(Core 0 和 Core 1),它们同时在并行处理两个不同的逻辑变量。

  • Core 0 要疯狂修改实体 A 的位置(变量 X)

  • Core 1 要疯狂修改实体 B 的速度(变量 Y)

  • 最坏布局 :变量 X 和 Y 在物理内存上紧挨着,正好处于 同一条 Cache Line(64字节) 里。

硬件冲突(MESI 协议)

  1. Core 0 把这条 Cache Line 读进自己的 L1,修改了 X。

  2. CPU 硬件规定:同一条 Cache Line 不能同时被两个核心以"修改(Modified)"状态持有

  3. 当 Core 1 要修改 Y 时,它发现自己 L1 里的这条 Cache Line 已经"过期"了(被 Core 0 污染)。它必须强制 Core 0 把这条 Cache Line 写回 RAM,然后重新从 RAM 搬到 Core 1 的 L1。

  4. 紧接着 Core 0 又要改 X,它发现自己的 L1 又过期了(被 Core 1 污染),又得去 RAM 重新搬。

结果 :本来 X 和 Y 毫无逻辑关系,但因为挤在了一条 64 字节的"小船上(Cache Line)",两个核心疯狂地互相踢皮球,来回抢夺这条 Cache Line 的所有权 。这导致原本应该是 L1 命中(1纳秒)的操作,硬生生变成了频繁访问 RAM(100纳秒),性能直接崩塌百倍 。这就是臭名昭著的 伪共享(False Sharing)


3. 灵魂拷问:数据怎么避免被"切碎"?(对齐 Alignment)

你之前特别关心的"C 被切两半"问题,根子就在 Cache Line 的边界上。

  • 未对齐(跨行) :如果一个 24 字节的 FVector 从地址 48 开始存放,那么它的后半段在地址 64 之后。为了读这一个 FVector,CPU 必须去 RAM 搬运两次 Cache Line(先搬 0~63,再搬 64~127),延迟翻倍。

  • 对齐(Aligned) :如果强制让每个 FVector 都从 64 的整数倍地址(如 0, 64, 128)开始存放,那么每一个 FVector 都完美地躺在独立的 Cache Line 里,永远不会有跨行读取。

  • 例子分析

    假设有一个 FItem 结构体,它的大小 sizeof(FItem) 是 20 字节,而它的对齐要求 alignof(FItem) 是 8 字节。

    如果不做处理,直接连续存放 20 字节的结构体,下一个元素的起始地址就会是 20。20 不能被 8 整除,就导致未对齐

    但通过 Align(20, 8),结果会是 24 。这意味着在存放完第一个 FItem 后,会填充(Padding) 4 个无用的字节,让第二个 FItem 从地址 24 开始存放。这样,每个元素都对齐到了 8 字节的边界上。

在追求极致性能的 MassEntity 框架中,内存对齐直接关系到CPU缓存的利用效率。

  • 实际场景

    在设计一个行人的实体时,可能会包含以下 Fragment(数据片段):

Fragment类型 大小 (bytes) 对齐要求 访问频率
Transform 64 16
Velocity 12 4
LODState 4 4
Avoidance 32 16
  • 例子分析

    1. 对齐与大小选择TransformAvoidance 的大小(64和32)都是它们对齐要求(16)的整数倍,这样的设计非常"干净"。

    2. 缓存行(Cache Line)友好 :CPU的缓存行通常是 64 字节。Transform 正好 64 字节,意味着一个 Transform 数据可以恰好填满一个缓存行。当处理器需要更新所有实体的位置时,它可以一次性将连续的多个 Transform 数据加载到L1缓存中,实现极高的缓存命中率。

    3. 分组策略 :Mass 还会将相同"原型(Archetype)"的实体数据组织在连续的内存块(Chunk)中。比如,所有行人实体的 Transform 数据会被紧密排列在一起,形成一个纯 Transform 的大数组。这种布局让CPU在遍历时能进行高效的顺序读取。

使用 USTRUCT 时的正确做法

对于被 USTRUCT 宏标记的结构体,由于UHT(Unreal Header Tool)的限制,不能直接使用标准的C++ alignas 关键字。

  • ❌ 错误示例

    像下面这样写会编译报错:

    cpp

    复制代码
    // 这会导致 UHT 解析失败
    USTRUCT(BlueprintType)
    struct alignas(64) FSomeStruct 
    {
        GENERATED_BODY()
        // ...
    };
  • ✅ 正确做法

    官方的推荐方案是嵌套一个原生C++结构体

    1. 定义一个普通的、并使用 alignas 的C++结构体。

    2. 将这个结构体作为 USTRUCT唯一成员变量

    cpp

    复制代码
    // 1. 定义对齐的原生结构体
    struct alignas(64) FAlignedNativeData
    {
        float X, Y, Z;
        // ... 其他数据
    };
    
    // 2. 在 USTRUCT 中嵌套使用
    USTRUCT(BlueprintType)
    struct FMyUStruct
    {
        GENERATED_BODY()
    
        UPROPERTY()
        FAlignedNativeData Data;
    };

通过这种方式,FMyUStruct 的实例在内存中就能达到 64 字节对齐,同时避免与 UHT 发生冲突。

片段 (Fragment):实体的"数据零件"

Fragment 是 MassEntity 中最基础的数据单位,代表一个原子的数据块 。它只包含数据,不包含任何逻辑

  • 类比 :就像乐高积木中的基础颗粒,本身没有功能,但组合起来就能拼成各种东西。

  • 代码示例

    cpp

    复制代码
    // 一个自定义的片段,存储实体的生命值
    USTRUCT()
    struct FHealthFragment : public FMassFragment
    {
        GENERATED_BODY()
        // 实际数据
        float Health = 100.0f;
    };

🏷️ 标签 (Tag):实体的"身份标记"

Tag 是一种特殊的空片段(不包含任何数据)。它的存在或缺失本身,就是一种数据。

  • 作用 :主要用于标记和分类实体。它的存在与否用于查询和过滤,而无需存储额外数据。

  • 类比 :就像乐高积木上的颜色或形状标记。一个红色颗粒和一个蓝色颗粒结构相同,但"红色"这个标签让它们有了不同身份。

  • 代码示例

    cpp

    复制代码
    // 一个标签,用于标记“已经死亡”的实体
    USTRUCT()
    struct FDeadTag : public FMassTag
    {
        GENERATED_BODY()
        // 注意:这里没有任何成员变量
    };

    之后,处理器可以专门查询带有 FDeadTag 的实体来进行清理,或者忽略它们。

🧱 原型 (Archetype):实体的"分类模板"

Archetype具有完全相同 Fragment 和 Tag 组合的一类实体 。拥有相同"零件"和"标记"的实体,就属于同一个 Archetype

  • 作用 :将实体分类,同一 Archetype 的实体数据在内存中被紧密排列在一起,实现缓存友好的高性能访问。

  • 类比 :工厂里生产同一款乐高套装的流水线。这条流水线上所有零件和说明书都完全一样。

  • 特性 :实体的构成可以在运行时改变。比如,当一个实体生命值归零,给它加上 FDeadTag 时,它的"构成"就变了,因此会被从一个 Archetype 迁移到另一个 Archetype

📦 块 (Chunk) 与 块片段 (ChunkFragment):数据分组的"货盘"

Chunk内存中一个连续的、固定大小的区域 ,用于存放同一个 Archetype 的多个实体数据。

ChunkFragment 则是关联到整个 Chunk 的数据,而不是单个实体

  • 作用Chunk 确保数据连续以提高性能;ChunkFragment 用于存储该组实体的共享信息,避免为每个实体重复存储相同数据。

  • 类比Chunk 像物流中心的标准货盘 ,把同一款套装集中码放。ChunkFragment 像贴在货盘上的标签(如目的地),属于整盘货物,而非单个盒子。

问:我怎么感觉fragment怎么和component有点像呢?

为什么你觉得"像"?(概念层面的相似)

  • 都是为了"组合" :在传统 UE 中,你给一个 Actor 挂载 MovementComponent 让它能移动;在 Mass 中,你给一个 Entity 添加 FTransformFragment 让它有位置。

  • 都遵循"组合优于继承" :你不需要写一个 FlyingDog 类,只需要把 FlyingTagWingFragment 组合在一起。

打住! 相似之处仅此而已。接下来的区别,决定了它们完全是两个世界的产物。

为什么"不像"?(本质上的天壤之别)

我们把它们拆成四个维度对比,你一眼就能看穿:

对比维度 UActorComponent(传统组件) FMassFragment(ECS 片段)
① 本质身份 它是一个 UObject(对象) 。 带有虚表(VTable)、指针、引用计数。 它是一个 POD(纯数据结构) 。 只有成员变量,没有虚表,没有析构函数,像个 C 语言的结构体。
② 内存位置 散落在堆(Heap)各个角落 。 每个 Component 都是 new 出来的,地址随机。 全部塞在连续的大数组(Chunk)里 。 所有实体的位置紧挨着,排成一条线。
③ 谁管逻辑? 组件自带逻辑 (如 TickComponent)。 数据和逻辑绑在一起。 片段不带逻辑 。 数据只是"肉",逻辑全在"处理器(Processor)"里。
④ 访问速度 Cache 命中率极低 。 遍历 1000 个 Actor 的位置,CPU 要跳转 1000 次内存地址。 Cache 命中率 > 90% 。 遍历 1000 个位置,CPU 像读流水账一样顺过去。

举一个最"扎心"的例子

假设你要遍历 1000 个敌人,修改它们的 位置(Location)

  • 传统 Component 做法

    cpp

    复制代码
    // 你要拿到敌人的 Actor,再找到它的 RootComponent,再拿位置
    for (AActor* Actor : Enemies) {
        FVector& Loc = Actor->GetRootComponent()->GetRelativeLocation();
        Loc += ...; // 这里的每一步都是指针寻址,CPU 等到心碎
    }
  • Mass Fragment 做法

    cpp

    复制代码
    // Processor 直接拿到所有位置组成的纯数组
    for (FTransformFragment& Frag : TransformArray) {
        Frag.Location += ...; // 数据在内存里排着队等你,CPU 全速狂奔
    }

问:那我又觉得system和processor很像呢?

在经典的 ECS 理论(如 Unity DOTS 或 Flecs)中,这个逻辑处理模块确实就叫 System(系统) 。而在 UE5 的 MassEntity 中,它叫 Processor(处理器)

举个例子,Processor具体在代码中使用的例子:

场景设定:一个简单的移动系统

我们要写一个系统,让所有带有位置(Transform) 和**速度(Velocity)**的实体,每帧向前移动。


1. 头文件(.h)------ 定义这个"工人"

cpp

复制代码
// MyMovementProcessor.h
#pragma once
#include "MassProcessor.h"
#include "MyMovementProcessor.generated.h"

UCLASS()
class UMyMovementProcessor : public UMassProcessor
{
    GENERATED_BODY()

public:
    UMyMovementProcessor();

protected:
    // 重写两个核心函数
    virtual void ConfigureQueries() override;
    virtual void Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context) override;

private:
    // 这就是“筛选器”,相当于工人的“招聘条件”
    FMassEntityQuery EntityQuery;
};

2. 配置查询(ConfigureQueries)------ 招工条件

cpp

复制代码
// MyMovementProcessor.cpp
#include "MyMovementProcessor.h"
#include "MassCommonFragments.h"      // 包含 FTransformFragment
#include "MassMovementFragments.h"    // 包含 FVelocityFragment

UMyMovementProcessor::UMyMovementProcessor()
{
    // 告诉调度器,这个系统在“物理模拟后”运行
    ExecutionFlags = (int32)(FMassProcessorExecutionFlags::PostPhysics);
}

void UMyMovementProcessor::ConfigureQueries()
{
    // 开始配置招聘条件
    EntityQuery.AddRequirement<FTransformFragment>(EMassFragmentAccess::ReadWrite);
    EntityQuery.AddRequirement<FVelocityFragment>(EMassFragmentAccess::ReadOnly);
    EntityQuery.RegisterWithProcessor(*this);
}

解读 :这个系统明确宣布:"我要处理所有同时拥有FTransformFragmentFVelocityFragment的实体。位置我要改(ReadWrite),速度我只看看(ReadOnly)。"


3. 执行逻辑(Execute)------ 这就是 System 在代码里真正干的事!

cpp

复制代码
void UMyMovementProcessor::Execute(FMassEntityManager& EntityManager, FMassExecutionContext& Context)
{
    // 1. 获取每帧的微小时间增量(DeltaTime)
    const float DeltaTime = Context.GetDeltaTimeSeconds();

    // 2. 执行查询,把符合条件的数据拽出来批量处理
    EntityQuery.ForEachEntityChunk(EntityManager, Context, [this, DeltaTime](FMassExecutionContext& Context)
    {
        // 【重点来了】这里是系统真正工作的“心脏”!

        // 3. 获取两个纯数据数组的指针(它们在内存里完全连续!)
        //    这就是 ECS 缓存友好的核心:系统直接拿到两排紧挨着的纯数据。
        TArrayView<FTransformFragment> TransformList = Context.GetMutableFragmentView<FTransformFragment>();
        TArrayView<const FVelocityFragment> VelocityList = Context.GetFragmentView<FVelocityFragment>();

        // 4. 获取这一块(Chunk)里有多少个实体
        const int32 NumEntities = Context.GetNumEntities();

        // 5. 【纯 CPU 友好循环】没有任何虚函数,没有指针跳转!
        for (int32 i = 0; i < NumEntities; ++i)
        {
            // 从“速度数组”里取数据(只读)
            const FVector& Vel = VelocityList[i].Velocity;
            
            // 从“位置数组”里取数据并修改(可写)
            FVector& Loc = TransformList[i].Transform.GetLocation();
            
            // 物理移动公式:新位置 = 老位置 + 速度 * 时间
            Loc += Vel * DeltaTime;
        }
    });
}

数组遍历速度快,100% 是因为内存连续 。而且,它和你刚刚学的 ECS 跑得快的底层逻辑,是同一套硬件机制

相关推荐
mengzhi啊2 天前
UE5 把剑绑定到骨骼-给要播放的动画及动画蒙太奇添加武器
ue5
directx3d_beginner2 天前
32,每帧更新怪物朝向改为c++
ue5
日月云棠2 天前
UE5源码分析之Editor——AnimationEditor模块全面分析
ue5
吴梓穆3 天前
UE5 播放透明视频
ue5
日月云棠3 天前
UE5源码分析之Editor——AnimationBlueprintEditor模块全面分析
ue5
远离UE43 天前
UE5 不同贴图采样类型的区别
ue5
日月云棠4 天前
UE5源码分析——ActorPickerMode模块全面分析
ue5
weixin_404679315 天前
虚幻5 如何打开关卡蓝图
ue5
dong1326977 天前
UE5FPS游戏开发教程(一)
ue5