ECS的核心思想是数据(Component)与逻辑(System)分离,MassEntity的核心理念也遵循此道,并拥有自己的一套术语。
一、 为什么需要ECS?(传统OOP的"血泪史")
在游戏开发中,最经典的反面教材就是 "继承爆炸" 和 "虚表开销"。
-
"钻石继承"与臃肿的基类 :假设你有一个
Animal基类,派生Dog、Bird。但如果要做一个"会飞的狗"(Boss战),你是继承Dog还是Flying?为了复用逻辑,往往会在基类里塞满用不上的变量(如WingSpan),导致内存浪费和逻辑耦合。 -
灾难性的缓存命中率 :传统OOP中,遍历一个
std::vector<AActor*>数组,指针指向的内存是分散在堆上的。CPU 需要频繁从内存中加载数据,而CPU访问主内存的速度比访问缓存(Cache)慢上百倍。当你的"千军万马"(成千上万个单位)同时更新时,CPU 时间全花在"等数据"上,而不是"算逻辑"上。 -
修改困难:想要给所有"带物理属性的可移动物体"加一个全局重力影响,如果它们散落在不同的类继承树中,改起来极其痛苦。
二、 ECS解决了什么?
ECS(实体-组件-系统)把"对象"拆解成"数据(组件)"和"逻辑(系统)",带来了三大革命性优势:
-
极致的内存连续性(Cache-Friendly) :同类组件(比如所有实体的位置
FTransformFragment)被紧密排列在连续内存中。遍历 1 万个实体更新位置,CPU 只需顺序读取,速度比传统OOP快 数倍甚至数十倍。 -
极低的组合成本(Composition over Inheritance) :想要"会飞的狗"?只需给这个实体添加
FlyingTag和WingFragment,不需要修改任何类继承关系。 -
逻辑解耦与并行化 :每个"系统"只关心它需要的组件组合(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%。
-
-
内存连续性(空间的局部性) :
ECS 强制把同类型数据(如位置)放在一起。这不仅让 L1 命中,还触发了 CPU 的硬件预取器(Prefetcher) 。CPU 觉得你顺序读得这么爽,干脆提前把后面几十个实体的数据从 RAM 自动搬到 L2 缓存里等着。等你用到时,连去 RAM 的 100 纳秒都省了。
-
只取热数据(数据的纯净性) :
在 Mass 处理器(Processor)中,你只查询需要的组件(比如只查
FTransform)。内存里没有夹杂虚表(VTable)和函数指针。Cache Line 里塞满了 100% 有效的"弹药",没有一颗是臭弹(废数据)。 -
避免"伪共享(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 协议):
-
Core 0 把这条 Cache Line 读进自己的 L1,修改了 X。
-
CPU 硬件规定:同一条 Cache Line 不能同时被两个核心以"修改(Modified)"状态持有。
-
当 Core 1 要修改 Y 时,它发现自己 L1 里的这条 Cache Line 已经"过期"了(被 Core 0 污染)。它必须强制 Core 0 把这条 Cache Line 写回 RAM,然后重新从 RAM 搬到 Core 1 的 L1。
-
紧接着 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 | 高 |
-
例子分析
-
对齐与大小选择 :
Transform和Avoidance的大小(64和32)都是它们对齐要求(16)的整数倍,这样的设计非常"干净"。 -
缓存行(Cache Line)友好 :CPU的缓存行通常是 64 字节。
Transform正好 64 字节,意味着一个Transform数据可以恰好填满一个缓存行。当处理器需要更新所有实体的位置时,它可以一次性将连续的多个Transform数据加载到L1缓存中,实现极高的缓存命中率。 -
分组策略 :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++结构体:
-
定义一个普通的、并使用
alignas的C++结构体。 -
将这个结构体作为
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类,只需要把FlyingTag和WingFragment组合在一起。
打住! 相似之处仅此而已。接下来的区别,决定了它们完全是两个世界的产物。
为什么"不像"?(本质上的天壤之别)
我们把它们拆成四个维度对比,你一眼就能看穿:
| 对比维度 | 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);
}
解读 :这个系统明确宣布:"我要处理所有同时拥有FTransformFragment和FVelocityFragment的实体。位置我要改(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 跑得快的底层逻辑,是同一套硬件机制