ECS(Entity-Component-System) 是一种将数据与逻辑分离的架构模式,在游戏开发中被广泛采用。当你需要同时处理非常庞大的对象,并希望在运行时灵活组合它们的行为时,ECS 提供了一个优雅的解决方案。本文将从概念、实现、优势、对比四个方面深入解析 ECS 架构模式。
ECS 的核心概念与实现
三个核心概念
ECS(Entity-Component-System) 由三个概念构成:
- Entity(实体):唯一标识符,通常是整数 ID。它不包含数据或行为,只用于关联零个或多个 Component,并且可以在运行时动态增减 Component。
- Component(组件):纯数据结构,不包含逻辑。每个 Component 代表一种属性或能力。
- System(系统):包含处理逻辑的函数,匹配拥有特定 Component 组合的 Entity 并执行操作。
数据以 Component 的形式组织,处理逻辑集中在 System 中,实体的行为由其拥有的 Component 组合决定。
// Entity 只是 ID
using Entity = uint32_t;
// Component 只存储数据
struct Position { float x, y; };
struct Velocity { float dx, dy; };
struct Sprite { Texture* texture; };
// System 处理特定 Component 组合
class MovementSystem {
public:
void update(/* ... */) {
// 遍历所有拥有 Position 和 Velocity 的实体
// 更新它们的位置:pos.x += vel.dx
}
};
例如,玩家实体关联 Position、Velocity、Sprite 和 Health 组件,而静态背景只关联 Position 和 Sprite。通过动态添加或移除 Component(如给敌人添加 Frozen 组件),可以改变实体的行为。
使用示例:
// 创建实体
Entity player = createEntity();
// 添加 Component
addComponent<Position>(player, {0.0f, 0.0f});
addComponent<Velocity>(player, {1.0f, 0.5f});
// System 批量处理
movementSystem.update(); // 更新所有拥有 Position 和 Velocity 的实体
renderSystem.render(); // 渲染所有拥有 Position 和 Sprite 的实体
理解了基本概念后,让我们看看 Entity、System 和 Component 是如何实现的。
Entity 管理
Entity Manager 负责创建和销毁实体,分配唯一 ID:
class EntityManager {
uint32_t nextId = 0;
public:
Entity createEntity() {
return nextId++;
}
};
System 实现
System 遍历拥有特定 Component 组合的实体并执行逻辑:
// 移动系统:处理 Position 和 Velocity
class MovementSystem {
public:
void update(ComponentArray<Position>& positions,
ComponentArray<Velocity>& velocities,
float deltaTime) {
for (auto entity : getEntitiesWith<Position, Velocity>()) {
auto& pos = positions.get(entity);
auto& vel = velocities.get(entity);
pos.x += vel.dx * deltaTime;
pos.y += vel.dy * deltaTime;
}
}
};
// 渲染系统:处理 Position 和 Renderable
class RenderSystem {
public:
void render(ComponentArray<Position>& positions,
ComponentArray<Renderable>& renderables) {
for (auto entity : getEntitiesWith<Position, Renderable>()) {
auto& pos = positions.get(entity);
auto& rend = renderables.get(entity);
draw(rend.texture, pos.x, pos.y, rend.layer);
}
}
};
Component 存储
不同 ECS 框架的核心差异在于 Component 的存储方式,各有权衡。以下是三种主流实现方式:
Archetype ECS(表格式)
Entity 按 Component 组合分组存储在表中。每个 Entity 只存在于一张表,表由其拥有的 Component 组合决定。
// 表 A:存储 Position + Velocity 组合的实体
struct TablePositionVelocity {
vector<Entity> entities; // [1, 2] 用于遍历
vector<Position> positions; // [{10, 20}, {30, 40}]
vector<Velocity> velocities; // [{1, 0.5}, {-1, 2}]
};
// 表 B:存储 Position + Sprite 组合的实体
struct TablePositionSprite {
vector<Entity> entities; // [3]
vector<Position> positions; // [{0, 0}]
vector<Sprite> sprites; // [{bgTexture}]
};
// Entity 到表的映射:用于快速定位 Entity 的数据
struct EntityRecord {
void* table; // Entity 所在的表(实际使用时会转换为具体类型)
size_t row; // 在表中的行号
};
unordered_map<Entity, EntityRecord> entityIndex;
// 映射关系:{1 → {TableA, 0}, 2 → {TableA, 1}, 3 → {TableB, 0}}
// 两种访问模式:
// 1. 随机访问:通过 entityIndex 快速定位
Position& getPosition(Entity e) {
auto& record = entityIndex[e];
return record.table->positions[record.row];
}
// 2. 顺序遍历:System 直接遍历表,无需查哈希表
void MovementSystem(TablePositionVelocity& table) {
for (size_t i = 0; i < table.entities.size(); ++i) {
table.positions[i].x += table.velocities[i].dx;
}
}
特点:
- 查询快:System 直接遍历整张表,数据紧密排列,缓存友好
- 删除快:使用 swap-and-pop(将最后一行移到删除位置,然后 pop_back),O(1) 时间
- 修改 Component 慢:添加/移除 Component 时需要将实体移到新表
代表框架:Flecs、Unity DOTS、Bevy、Unreal Mass
Sparse Set ECS(稀疏集)
每个 Component 类型单独存储在稀疏集中,以 Entity ID 为键。
// 每个 Component 类型独立存储
struct PositionArray {
vector<Position> dense; // [pos1, pos2, pos3]
unordered_map<Entity, int> sparse; // {1→0, 2→1, 3→2}
};
struct VelocityArray {
vector<Velocity> dense; // [vel1, vel2]
unordered_map<Entity, int> sparse; // {1→0, 2→1}
};
// 查询需要求交集
void queryPositionVelocity() {
for (auto entity : positionArray.entities()) {
if (velocityArray.has(entity)) {
auto& pos = positionArray.get(entity);
auto& vel = velocityArray.get(entity);
// 处理...
}
}
}
优点:添加/移除 Component 快(直接操作对应集合)。缺点:查询需要遍历多个集合求交集。代表:EnTT、Shipyard。
Bitset ECS(位图式)
Component 存储在数组中,用位图标记 Entity 是否拥有该 Component。
// Component 数据存储在数组中
vector<Position> positions; // positions[0], positions[1], ...
vector<Velocity> velocities; // velocities[0], velocities[1], ...
// 每个 Entity 有一个位图标记拥有哪些 Component
// 假设 Position=bit0, Velocity=bit1, Sprite=bit2
bitset<32> entityComponents[MAX_ENTITIES];
// Entity 0: 0b0011 = 有 Position 和 Velocity
// Entity 1: 0b0101 = 有 Position 和 Sprite
// 查询需要检查位图
void queryPositionVelocity() {
for (int i = 0; i < entityCount; ++i) {
if ((entityComponents[i] & 0b0011) == 0b0011) {
// Entity i 有 Position 和 Velocity
auto& pos = positions[i];
auto& vel = velocities[i];
}
}
}
优点:内存效率高。缺点:位图大小随 Component 类型数量增长,不适合大量 Component 类型。代表:EntityX、Specs。
三种实现方式对比
| 特性 | Archetype(表格式) | Sparse Set(稀疏集) | Bitset(位图式) |
|---|---|---|---|
| 查询速度 | ⭐⭐⭐ 最快 | ⭐⭐ 需要求交集 | ⭐ 需要遍历位图 |
| 添加/删除 Component | ⭐ 需要移动实体到新表 | ⭐⭐⭐ 最快 | ⭐⭐ 修改位图 |
| 内存效率 | ⭐⭐ 需要维护表和索引 | ⭐⭐ 需要 sparse 映射 | ⭐⭐⭐ 位图紧凑 |
| Component 类型数量 | ⭐⭐⭐ 无限制 | ⭐⭐⭐ 无限制 | ⭐ 位图大小受限 |
| 适用场景 | Component 相对稳定,需要高性能查询 | Component 频繁增删 | Component 类型少,内存敏感 |
| 代表框架 | Flecs, Unity DOTS, Bevy, Unreal Mass | EnTT, Shipyard | EntityX, Specs |
ECS 的优点
架构:可扩展性
ECS 添加新功能几乎不需要修改现有代码,符合开闭原则(Open-Closed Principle)。
当需要为实体添加新能力时,只需三步:定义新 Component、实现对应的 System、给需要该能力的实体添加 Component。整个过程不影响现有代码。
// 添加新功能:让某些实体在水中漂浮
// 1. 定义新 Component
struct Buoyancy { float force; };
// 2. 添加新 System
class BuoyancySystem {
void update(ComponentArray<Position>& positions,
ComponentArray<RigidBody>& bodies,
ComponentArray<Buoyancy>& buoyancies) {
for (auto entity : getEntitiesWith<Position, RigidBody, Buoyancy>()) {
// 应用浮力...
}
}
};
// 3. 给需要浮力的实体添加 Component
addComponent<Buoyancy>(boat, {10.0f});
// 现有的 PhysicsSystem、RenderSystem 等完全不需要修改
架构:可维护性
ECS 将数据与逻辑分离,System 之间天然解耦,使得代码更易于理解和维护。
代码局部性好:相关逻辑集中在一个 System 中,而非分散在多个类的方法里。例如,所有移动相关的逻辑都在 MovementSystem 中,所有渲染逻辑都在 RenderSystem 中。
无隐藏副作用:System 不持有状态,不会出现"调用顺序敏感"或"隐藏的全局状态"问题。每次调用 System 的行为是可预测的。
依赖关系清晰:System 的函数签名明确声明需要哪些 Component,数据流一目了然:
// 一眼就能看出这个 System 需要哪些数据
class PhysicsSystem {
void update(ComponentArray<Position>& positions,
ComponentArray<RigidBody>& bodies,
ComponentArray<Velocity>& velocities) {
// 处理物理逻辑
}
};
// 相比 OOP 中隐藏在对象内部的依赖关系
class GameObject {
void update() {
// 调用了哪些其他对象?修改了哪些状态?不看实现无从得知
}
};
架构:可测试性
由于数据与逻辑分离,System 的测试变得非常简单。
// 测试 MovementSystem
TEST(MovementSystem, UpdatesPosition) {
ComponentArray<Position> positions;
ComponentArray<Velocity> velocities;
// 准备测试数据
Entity entity = 1;
positions.insert(entity, {0.0f, 0.0f});
velocities.insert(entity, {1.0f, 2.0f});
// 执行 System
MovementSystem system;
system.update(positions, velocities, 1.0f);
// 验证结果
EXPECT_EQ(positions.get(entity).x, 1.0f);
EXPECT_EQ(positions.get(entity).y, 2.0f);
}
优势:
- 无需 Mock:不需要模拟复杂的对象依赖关系,只需准备 Component 数据
- 测试隔离:每个 System 独立测试,不受其他 System 影响
- 测试简单:输入是 Component 数据,输出是修改后的数据,逻辑清晰
性能:缓存友好的数据布局
采用连续存储的 ECS 实现(如 Archetype ECS)通过优化数据布局充分利用 CPU 缓存,显著提升性能。
CPU 缓存原理:CPU 访问内存很慢,所以使用多级缓存加速(L1 32KB、L2 256KB、L3 几MB)。缓存以"缓存行"为单位加载数据,每次从内存加载 64 字节。如果程序接下来访问的数据在缓存中(缓存命中),速度很快;否则需要从内存加载(缓存未命中),速度很慢。
ECS 的性能优势来自两个方面:
1. 按需加载数据:只加载需要的 Component 类型
// ECS:只加载需要的数据
vector<Position> positions; // 只有 Position 数据
vector<Velocity> velocities; // 只有 Velocity 数据
for (int i = 0; i < 1000; ++i) {
positions[i].x += velocities[i].dx;
// 缓存只包含 Position 和 Velocity
// 没有无关数据(Sprite、AI、Health)
}
2. 数据连续存储:提高缓存命中率
// ECS:数据连续存储
vector<Position> positions; // 1000 个 Position(每个 8 字节)
vector<Velocity> velocities; // 1000 个 Velocity(每个 8 字节)
for (int i = 0; i < 1000; ++i) {
positions[i].x += velocities[i].dx;
// i=0: 加载 positions[0..7] 和 velocities[0..7] 到缓存
// i=1-7: 全部缓存命中!
// 1000 次迭代仅需约 250 次内存加载
}
性能提升:在游戏等批量处理场景中,采用连续存储的 ECS 实现相对于 OOP 可以带来显著的性能提升。典型场景包括:
- 更新大量敌人的位置和 AI
- 批量渲染可见物体
- 粒子系统的物理模拟
并发:数据与逻辑分离
ECS 将数据(Component)与逻辑(System)分离,使得 System 之间的依赖关系清晰可见,便于并行化。
原理:每个 System 显式声明它需要访问哪些 Component,通过分析这些声明可以自动确定:
- 无冲突:如果两个 System 访问不同的 Component,可以并行执行
- 只读冲突:如果两个 System 都只读同一 Component,可以并行执行
- 写冲突:如果至少一个 System 写某个 Component,必须顺序执行
void gameLoop(float deltaTime) {
// 阶段 1:这些 System 访问不同的 Component,可以并行
std::thread t1([&]() {
physicsSystem.update(positions, rigidBodies, deltaTime);
});
std::thread t2([&]() {
aiSystem.update(positions, aiComponents, deltaTime);
});
std::thread t3([&]() {
animationSystem.update(animations, deltaTime);
});
t1.join(); t2.join(); t3.join();
// 阶段 2:依赖前面结果的 System 顺序执行
collisionSystem.update(positions, colliders);
renderSystem.render(positions, renderables);
}
内存:按需存储
ECS 只为实体分配它实际拥有的 Component,避免浪费内存。例如,静态背景只需要 Position 和 Sprite,无需分配 Velocity、Health、AI 等组件。
优势:
- 稀疏实体节省内存:只有少数 Component 的实体不需要分配多余空间
- 无虚函数表开销:Component 是纯数据(POD),不需要 vtable 指针
劣势:
- 索引结构开销:需要维护 EntityRecord(Archetype)或 sparse 映射(Sparse Set)
- 小对象开销:如果实体拥有大量 Component,索引开销可能超过节省的空间
通过这些设计,ECS 在灵活性、性能、可维护性等方面带来了显著优势。但它与其他架构有什么本质区别?让我们来看看。
与其他架构的对比
与 OOP 的差异
ECS 和 OOP 有两个核心差异:
1. 数据组织方式不同:OOP 将单个对象的所有数据封装在一起;ECS 将相同类型的数据按类型分组存储
2. 行为定义方式不同:组合 vs 继承:OOP 依赖继承定义对象能力,类型在编译时固定;ECS 依赖组合定义实体能力,运行时可以动态增减。
// OOP:通过继承层级定义能力
class GameObject { virtual void update() = 0; };
class Renderable : public GameObject { Sprite sprite; };
class Movable : public GameObject { Vector2 velocity; };
// 需要"可渲染+可移动":多重继承
class Player : public Renderable, public Movable {
// 问题:菱形继承、脆弱基类、继承树难以调整
};
// 需要更多组合?继续创建新类
class FlyingEnemy : public Renderable, public Movable { };
class ShootingEnemy : public Renderable, public Shootable { };
class FlyingShootingEnemy : public Renderable, public Movable, public Shootable { };
// 每种能力组合都需要预先定义一个新类
// ECS:通过组合 Component 定义能力
Entity player = createEntity();
addComponent<Position>(player);
addComponent<Velocity>(player);
addComponent<Sprite>(player);
// 玩家有"位置、移动、渲染"能力
Entity flyingShootingEnemy = createEntity();
addComponent<Position>(flyingShootingEnemy);
addComponent<Velocity>(flyingShootingEnemy);
addComponent<Sprite>(flyingShootingEnemy);
addComponent<Weapon>(flyingShootingEnemy);
// 敌人有"位置、移动、渲染、射击"能力
// 运行时动态改变能力
removeComponent<Velocity>(player); // 玩家被冰冻,失去移动能力
addComponent<Frozen>(player); // 添加冰冻效果
组合优于继承的优势:
- 无需预定义组合:不需要为每种能力组合创建新类
- 运行时灵活性:可以在游戏运行时动态添加/移除能力(如状态效果、Buff/Debuff)
- 避免继承问题:无菱形继承、无脆弱基类、无深层继承树
- 更贴近现实建模:从"对象是什么类"转向"对象有什么能力"
与 EC 框架的区别
许多人容易混淆 ECS 和 EC 框架(如 Unity GameObject 系统)。两者的关键区别在于 Component 是否包含行为:
EC 框架:Component 是包含数据和行为的类,继承自公共接口。
class IComponent {
public:
virtual void update() = 0;
};
class Entity {
vector<IComponent*> components;
public:
void addComponent(IComponent *component);
void updateComponents(); // 调用每个 Component 的 update()
};
ECS:Component 是纯数据结构,逻辑在 System 中集中处理。
这导致了本质区别:EC 框架的行为分散在各个 Component 中,而 ECS 的行为集中在 System 中。ECS 能够批量处理相同类型的数据,利用缓存局部性提升性能,而 EC 框架则无法做到这一点。
与数据驱动设计的关系
ECS ≠ DoD:可以有 ECS 但不遵循 DoD 原则,也可以应用 DoD 但不使用 ECS。
Data-Oriented Design(DoD) 是一种优化方法,专注于数据布局,通过分析访问模式选择合适的数据结构来充分利用 CPU 缓存和 SIMD 指令。
ECS 架构天然适合应用 DoD 原则,因为它将数据按类型分组存储,便于 System 批量处理相同类型的数据。但 ECS 也可以实现得不符合 DoD(如使用链表存储 Component),DoD 也可以不用 ECS(如手动优化数组布局)。
当 ECS 实现为连续的 Component 数组时(如 Archetype ECS),它能够充分利用 DoD 优化:
- 缓存局部性:连续访问内存,减少缓存未命中
- SIMD 向量化:编译器可以自动向量化循环
- 减少间接访问:避免指针跳转
何时使用 ECS
局限性
- 学习曲线陡峭:需要从"对象的行为"思维转向"数据流"思维。新手容易过度拆分 Component 或设计出职责不清的 System。
- 调试困难:实体状态分散在多个 Component 数组中,无法像 OOP 那样直接查看对象。需要专门工具来追踪特定 Entity 的所有 Component。
- Component 依赖管理:某些逻辑需要多个 Component 协同工作,需要手动确保相关 Component 同时存在。例如
AnimationSystem需要Sprite和AnimationState,缺少一个就会出错。 - 不适合层级结构:渲染树等深层树形结构不适合 ECS 的扁平化设计。虽然可以用 Component 存储父子关系(如
Parent、Children组件),但这会频繁触发 Component 查找,性能不如原生树结构。 - 空间数据结构问题:四叉树、八叉树等空间数据结构的布局不匹配 ECS 典型存储方式。通常的做法是在 System 中每帧重建空间结构,或使用运行时 Tag 标记空间网格。
- 小型项目过度设计:如果项目只有几十种对象且逻辑简单,ECS 的基础设施(EntityManager、ComponentArray、System 调度)会带来不必要的复杂度。
适用场景
适合使用 ECS:
- 游戏开发:大量实体、频繁创建销毁、需要高性能
- 模拟系统:粒子系统、物理引擎、AI 群体行为
- 需要运行时动态组合行为:Mod 系统、技能编辑器
不适合使用 ECS:
- 业务逻辑系统:银行、电商等传统后端,对象行为固定,继承层级稳定
- 小型项目:几十个类以内,OOP 更简单直观
- UI 系统:UI 组件通常有深层层级关系(树形结构),ECS 的扁平化设计不适用
- 需要专用数据结构的系统:空间查询、层级关系等场景