ECS 架构
需要预先学习:
1. 游戏对象的表示:继承的困境
写游戏时第一个遇到的问题往往不是渲染,而是"一个游戏里的东西怎么用代码表示"。最自然的想法来自面向对象:万物皆对象,公共的东西放进基类,特化的东西放进派生类:
class GameObject { /* 位置、名字、Update() 虚函数 */ };
class Character : public GameObject { /* 生命值、移动 */ };
class Player : public Character { /* 输入控制 */ };
class Enemy : public Character { /* AI */ };
class Prop : public GameObject { /* 可交互物体 */ };
游戏小的时候这套结构工作得很好。但游戏的本质是功能的自由组合:策划想要一辆会游泳的飞行汽车、一只会拾取道具的鸟、一个能被玩家附身的箱子。每一个新组合都在逼你继续挖继承树:
- 功能挂在哪个层级?
Fly放进GameObject所有箱子都会飞,放进Player敌人不会飞; - 组合爆炸: 种可组合的功能对应 种子类,继承树指数膨胀;
- 多重继承与菱形继承(见 多态):
FlyingCar同时继承FlyingObject与Car,两个基类又都继承GameObject,虚基类、二义性接踵而来; - 上帝基类:为了共享功能,把所有东西塞进
GameObject——生命值、物理、渲染、音频全在基类里,一个静态装饰物也背着整套成员,这就是 God Object 反模式。
继承表达的是 is-a(是一个)关系,而游戏实体需要的是 has-a(有一个)关系:一辆车有发动机、有轮子、有可驾驶性,而不是"是一类特殊的可驾驶物"。面向对象有一条著名原则——组合优于继承(composition over inheritance),ECS 就是这条原则推到极致的形态。
2. ECS 的三个角色
ECS(Entity-Component-System)是一种数据与逻辑分离的架构模式,由三个角色构成:
| 角色 | 本质 | 职责 | 不负责 |
|---|---|---|---|
| Entity(实体) | 一个 ID | 标识"某个东西存在" | 不存数据、不含逻辑 |
| Component(组件) | 纯数据 | 描述实体某一方面的状态 | 不含行为方法 |
| System(系统) | 纯逻辑 | 处理拥有特定组件的一批实体 | 不存状态 |
一辆车的 Entity 是 ID #42;它由 Transform、Mesh、RigidBody、VehicleInput 等组件组成;PhysicsSystem 处理所有带 RigidBody 的实体、RenderSystem 处理所有带 Mesh 的实体。想造一辆会飞的车,不需要新类,给现有实体加一个 Flying 组件即可——组合代替了继承。
三个关键推论:
- 实体没有类型。"玩家"不是类,只是一组组件的集合:
Transform+Input+Health+Mesh。同一组组件就是同一种行为; - 数据与逻辑彻底分离。组件是被动数据,系统是主动行为,"这个数据归谁管"一目了然;
- 按数据组织,而不是按对象组织。这是 ECS 性能优势的来源,后面细说。
3. Entity:一个 ID
Entity 的全部内容是一个标识符。在典型的 C++ 实现中它就是一个整数:
struct Entity
{
uint32_t index; // 槽位索引
uint32_t generation; // 世代号
};
为什么连"实体的名字"都不存?因为 Entity 不需要知道任何关于自己的事情——名字可以是一个 Name 组件,位置是 Transform 组件。实体本身退化成纯粹的标识:组件通过它关联到同一个"东西"。
3.1 世代号:防止 ID 悬空
实体销毁后,它的 index 会被回收复用。这时一个仍持有旧 ID 的系统再用它查询组件,会错误地命中新实体——经典的悬空引用问题。世代号(generation)用来解决它:
// 每个槽位记录当前世代,销毁时世代 +1
struct EntitySlot
{
uint32_t generation; // 该槽位被复用了几次
bool alive; // 当前是否存活
};
// 查询:世代号不匹配 -> 这个 ID 指向的是"上一任"实体,已失效
bool valid(Entity e) const
{
return slots[e.index].alive &&
slots[e.index].generation == e.generation;
}
槽位每复用一次,generation 加一。旧 ID 的世代号落后于槽位的当前世代,valid 返回 false——不需要 shared_ptr 那样的生命周期管理(见 智能指针与RAII),一个整数的比较就完成了悬空检测。
4. Component:纯数据
组件是只有数据、没有方法的聚合类型:
struct Transform
{
glm::vec3 position;
glm::quat rotation;
float scale;
};
struct RigidBody
{
glm::vec3 velocity;
float mass;
float restitution;
};
struct Health
{
int current;
int max;
};
"纯数据"是一条强约束:组件不定义 update(),不持有指针指向其他实体(需要引用别的实体时,存对方的 Entity ID),不包含虚函数。约束的目的只有一个——让数据可以被无脑地连续存放、批量遍历,这正是下一节 System 的性能前提。
组件的粒度没有硬性规定,实践中的准绳是按系统的访问模式切分:Transform 与 Mesh 分开,因为物理系统改 Transform、渲染系统只读 Transform,两者对 Mesh 的需求不同;如果两个组件总是一起被同一个系统读写,通常说明它们该合并。
5. System:纯逻辑
系统是一段处理一类组件集合的逻辑,通常实现为普通函数或函数对象:
void movementSystem(Registry& reg, float dt)
{
// 查询:所有同时拥有 Transform 与 Velocity 的实体
for (auto [entity, transform, velocity] :
reg.view<Transform, Velocity>().each())
{
transform.position += velocity.value * dt;
}
}
注意这段代码的三个特征:
- 它不关心实体是什么——是玩家、子弹还是粒子,只要有
Transform与Velocity就被处理; - 它不持有状态——所有状态在组件里,系统本身无成员变量(调度参数除外),因此可以被任意重入、并行;
- 它声明了自己的数据依赖——读写
Transform、读Velocity,这个声明后面是系统并行调度的依据。
系统之间不直接调用,而是通过组件衔接:InputSystem 写 Velocity,MovementSystem 读 Velocity 写 Transform,RenderSystem 读 Transform。数据在系统间流动,逻辑彼此独立——替换渲染系统、重排系统顺序(只要数据依赖满足)都不影响其他系统。
6. 数据导向设计:为什么 ECS 快
ECS 的性能优势不是玄学,来自内存布局。
6.1 两个世界的时间差
CPU 访问内存比执行指令慢一到两个数量级,靠缓存(cache)弥合:内存以 64 字节的缓存行(cache line)为单位搬进 CPU,命中缓存则快,不命中(cache miss)就要等几百个周期。于是"数据在内存里怎么排"直接决定程序快慢——数据导向设计(DOD,Data-Oriented Design)就是把"数据如何被访问"放在设计的第一位。
6.2 AoS 与 SoA
面向对象天然产生 AoS(Array of Structs,结构体数组):每个对象的所有成员挨在一起,对象之间排成数组。遍历"所有对象的位置"时,缓存行里一半字节是当前用不到的颜色、生命值——带宽全浪费在搬运不用的数据上:
AoS:按对象组织(对象内部连续,字段之间分散)
┌──────────── 对象 0 ────────────┐┌──────────── 对象 1 ────────────┐
│ position(12B) color(16B) hp(4B)││ position(12B) color(16B) hp(4B)││ ...
└────────────────────────────────┘└────────────────────────────────┘
↑ 只需要 position,其余字段白搬
SoA:按字段组织(同一种字段连续,一次搬 5 个)
position: │ pos0 │ pos1 │ pos2 │ pos3 │ pos4 │ ...
color: │ col0 │ col1 │ col2 │ col3 │ col4 │ ...(不需要就不碰)
hp: │ hp0 │ hp1 │ hp2 │ hp3 │ hp4 │ ...(不需要就不碰)
ECS 的组件存储天然是 SoA(Struct of Arrays,结构体的数组化):Transform 与 RigidBody 是两种组件、两块独立数组。物理系统只遍历 Transform + RigidBody,每次缓存 miss 搬进来的 64 字节全部是要用的数据,有效带宽提高数倍。同时 prefetcher(硬件预取器)面对连续数组也能高效工作,而指针迷宫般的对象图让它束手无策。
6.3 谁在受益
一个万级弹幕场景:MovementSystem 每帧遍历上万个 Transform + Velocity,AoS 每个对象 64 字节缓存行只用到 32 字节,SoA 全部命中目标数据;再加上系统可以按依赖并行(万级数据天然可分块多线程),帧时间从毫秒级压到微秒级并不稀奇。ECS 的流行与现代硬件"算力涨得快、带宽涨得慢"的趋势直接相关。
7. 组件存储:Sparse Set 与 Archetype
组件数据具体怎么组织,主流实现有两派。
7.1 Sparse Set:稀疏集合
EnTT(C++ 最流行的 ECS 库)采用稀疏集合(sparse set):每种组件类型持有两个数组——
sparse(按 entity index 索引): dense(紧凑存放)
index: 0 1 2 3 4 ┌────────┬────────┬────────┐
value: [2] [∞] [0] [1] [∞] │ ent #2 │ ent #3 │ ent #0 │ ← 实体
↑ 无该组件 │ data#2 │ data#3 │ data#0 │ ← 组件数据
└────────┴────────┴────────┘
- dense 数组:紧凑存放"拥有该组件的实体"与它们的组件数据,连续、可高效遍历;
- sparse 数组:
sparse[entity.index]给出该实体在 dense 中的下标(没有就是空标记),实现 O(1) 随机访问。
template <typename T>
class SparseSet
{
std::vector<uint32_t> sparse; // entity index -> dense 下标
std::vector<Entity> denseEnt; // 反向:dense 下标 -> 实体
std::vector<T> denseData; // 与 denseEnt 一一对应的组件数据
public:
void add(Entity e, T comp)
{
sparse.resize(e.index + 1, INVALID);
sparse[e.index] = denseEnt.size(); // 记录新位置
denseEnt.push_back(e);
denseData.push_back(comp); // 数据总是连续追加
}
T* get(Entity e)
{
uint32_t i = sparse[e.index];
return i == INVALID ? nullptr : &denseData[i]; // O(1) 随机访问
}
void remove(Entity e)
{
uint32_t i = sparse[e.index];
sparse[denseEnt.back().index] = i; // 尾部元素填洞
denseEnt[i] = denseEnt.back();
denseData[i] = denseData.back();
denseEnt.pop_back(); // 交换删除,O(1)
denseData.pop_back();
sparse[e.index] = INVALID;
}
};
多类型查询(view<A, B>)取两个集合中较短的那个遍历,对每个实体用 sparse 数组查另一个集合——长度平方级的最坏情况在实践里几乎不出现,因为短的那个通常很短。
7.2 Archetype:原型分块
Unity DOTS、flecs 采用 archetype(原型)存储:按组件类型的组合分组。(Transform, Velocity) 是一个 archetype,(Transform, Mesh) 是另一个。每个 archetype 内部按固定大小的 chunk(块,通常 16KB)存放,同一 chunk 里同一种组件连续排列:
Archetype (Transform, Velocity)
chunk 0: pos: [p0 p1 p2 ... pN] vel: [v0 v1 v2 ... vN]
chunk 1: pos: [.. .. .. ... ..] vel: [.. .. .. ... ..]
Archetype (Transform, Velocity, Health)
chunk 0: pos: [...] vel: [...] hp: [...]
查询 (Transform, Velocity) 时,凡包含这两个组件的 archetype 都命中,遍历它们的 chunk——不用按实体逐个检查,直接拿到整段连续数据。代价是组件的增删:给实体加一个 Health,意味着把它从 (T, V) archetype 搬走,复制进 (T, V, H) archetype——组件越多,组合数越多,搬移越频繁。
7.3 对比
| 维度 | Sparse Set | Archetype |
|---|---|---|
| 查询(多类型) | 以最短集合为驱动,逐实体过滤 | 按组合分组,命中即整段连续数据 |
| 增删组件 | 两个数组各动一下,O(1) | 跨 archetype 搬移,相对贵 |
| 遍历性能 | 好(连续) | 更好(查询结果本身连续、无过滤) |
| 内存 | 每类型两个数组,sparse 有空洞 | chunk 紧凑,但组合碎片多 |
| 迭代中改结构 | 需要处理(见下节) | 搬移本身破坏遍历,需延迟 |
| 代表实现 | EnTT | Unity DOTS、flecs、Bevy(Bevy 为混合式) |
8. 查询与结构修改
系统的核心操作是查询(query / view):给出"需要哪些组件、排除哪些组件",拿到满足条件的实体集合:
// 处理"会动且有碰撞体"的实体,但排除被冻结的
auto view = reg.view<Transform, RigidBody>(entt::exclude<Frozen>);
一个绕不开的问题:遍历中修改集合结构。MovementSystem 遍历时给某实体 add<Exploded>() 或直接 destroy(e),会改动正在遍历的 dense 数组——迭代器失效、元素被跳过或重复访问(swap-remove 的副作用,见上节代码)。
主流解法是延迟生效:
- 遍历期间把
destroy/add/remove记进待办队列,系统跑完再统一执行; - 或者约定"遍历中只允许安全操作"(EnTT 允许遍历时
add不属于当前正在遍历的组件类型,库内部做了处理)。
这是使用 ECS 时最容易踩的坑:代码看起来工作,但组件集合的修改被推迟到了系统边界,依赖"加完组件下一行就生效"的逻辑会悄悄出错。
9. 系统调度与并行
系统本身无状态,天然适合并行。调度器根据每个系统声明的读写组件集合分析依赖:
InputSystem 写 Velocity ┐
MovementSystem 读 Velocity 写 Transform → Input 必须先于 Movement
RenderSystem 读 Transform ┘ Movement 完成后才能 Render
AnimationSystem 读 Skeleton 写 Pose ← 与上面三者无依赖,可与它们并行
只要两个系统的读写集合不相交(或都只读),就可以同时跑;读写集合相交的按声明顺序串行。大规模数据(上万实体)再切成块扔进任务系统(job system)做多线程,见 多线程编程基础。这就是 ECS"声明式并行"的能力:你只声明数据依赖,调度交给框架。
10. 事件与通信
系统之间不直接调用,那"敌人死亡时 UI 要更新、音效要播放"这类跨系统通知怎么做?常见三种:
- 组件即事件:死亡这一刻给实体加一个
JustDied组件,DeathCleanupSystem查询它、处理并移除。事件退化为"存在一帧的标记",查询机制直接复用; - 事件队列:系统把事件 push 进队列(
events.push(EnemyDied{entity})),感兴趣的系统中帧/帧末统一 drain。解耦彻底,时序明确; - 观察者 / 信号:对"组件被添加 / 移除"挂钩子(EnTT 的
on_construct<T>、on_destroy<T>),用于同步派生数据(组件加了就要建渲染代理)。
原则是同一个:通信也走数据,不走调用。所有系统面对的都是组件与队列,没有谁直接持有谁。
11. 一个最小的 C++ ECS
把前面的零件拼成一个能跑的骨架(sparse set 版,省略错误处理):
#include <vector>
#include <cstdint>
// ---------- Entity:带世代的 ID ----------
struct Entity { uint32_t index, generation; };
// ---------- Registry:实体与所有组件池 ----------
class Registry
{
std::vector<uint32_t> generations; // 槽位世代号
std::vector<uint32_t> freeList; // 回收的 index
public:
Entity create()
{
if (!freeList.empty()) // 优先复用槽位
{
uint32_t i = freeList.back();
freeList.pop_back();
return {i, generations[i]}; // 世代号延续
}
generations.push_back(0);
return {(uint32_t)generations.size() - 1, 0};
}
void destroy(Entity e)
{
// 实际实现中还要移除所有组件,此处省略
generations[e.index]++; // 旧 ID 从此失效
freeList.push_back(e.index);
}
};
// ---------- 组件:纯数据 ----------
struct Transform { float x, y; };
struct Velocity { float vx, vy; };
// ---------- 每种组件一个池 ----------
std::vector<std::pair<Entity, Transform>> transformPool; // (实体, 数据) 对
std::vector<std::pair<Entity, Velocity>> velocityPool;
// ---------- System:纯逻辑 ----------
void movementSystem(float dt)
{
// 简化版查询:对每个 Velocity,找同名实体的 Transform
for (auto& [ent, vel] : velocityPool)
{
for (auto& [ent2, tf] : transformPool)
{
if (ent.index == ent2.index) // 实际实现用 sparse set O(1)
{
tf.x += vel.vx * dt;
tf.y += vel.vy * dt;
}
}
}
}
这个骨架刻意保持简单(查询是平方级的,真实实现要用 sparse set),但它展示了 ECS 的全部结构:Registry 管实体生命周期,每种组件一个池,系统是遍历池子的普通函数。完整的开源实现(EnTT 全部头文件也只有几千行)值得通读一遍。
12. 代价与适用场景
ECS 不是银弹,它的代价同样明确:
- 间接性:所有访问都经过"实体 ID → 组件池"一层间接,单点访问(改一个物体的位置)比直接
obj->position啰嗦; - 调试体验下降:没有"对象"可以一口气观察,一个逻辑分散在多个系统里,断点追踪变难;
- 不适合一切皆独特的东西:UI 控件、剧情脚本、配置表——数量少、行为各异、需要频繁单点交互的数据,塞进 ECS 只会更别扭;
- 架构纪律:组件不能加方法、系统不能存状态,这些约束在赶工时很容易被"就这一个例外"侵蚀,一旦破例,架构就开始退化。
适用性判断很简单——数据量与同质性:
| 场景 | 是否适合 ECS |
|---|---|
| 弹幕、粒子、RTS 单位、大规模模拟 | 非常适合:同质数据海量、逻辑可批量 |
| 一般 3D 场景的游戏物体 | 适合:数量中等,组合自由度收益明显 |
| UI、菜单、对话系统 | 不适合:单点交互为主 |
| 小型原型 / Game Jam | 通常不值得:OOP 或脚本更快出活 |
13. 实际引擎中的应用
- EnTT:C++ 生态的事实标准,sparse set 实现,头文件库,Minecraft 基岩版等在用;
- Unity DOTS / Entities:archetype 存储 + Burst 编译 + Job System 三件套,官方的"面向数据栈";
- Unreal Mass Entity:UE5 的大规模实体框架(城市人群、植被),archetype 思路;
- Bevy:Rust 原生 ECS 引擎,ECS 即引擎核心抽象,系统即普通函数加调度声明;
- flecs:以 archetype 实现与丰富的查询 DSL 著称,提供 C 与 C++ 接口;
它们的 API 风格各异,但内里的三件事完全一致:实体是 ID、组件是数据、系统是批量逻辑。理解了这三件事与背后的内存布局,任何一家 ECS 的文档都只剩 API 词典的差别。



