ECS 设计模式详解
作者:互联网
2026-03-05
ECS(Entity-Component-System) 是一种将数据与逻辑分离的设计模式,在游戏开发中被广泛采用。当你需要同时处理数千个实体,并希望在运行时灵活组合它们的行为时,ECS 提供了一个优雅的解决方案。
它的核心思想很简单:Entity 只是 ID,Component 是纯数据,System 批量处理拥有特定 Component 组合的实体。这种设计带来了两个关键优势:通过组合而非继承实现灵活的行为定义,通过数据连续存储获得显著的性能提升。
本文将详细介绍 ECS 的方方面面:核心概念与设计思想、灵活性与性能优势、与 OOP/EC 框架的本质差异、Archetype/Sparse Set/Bitset 三种主流实现方式的原理与权衡,以及它的局限性和适用场景。
ECS 是什么
ECS(Entity-Component-System) 由三个概念构成:
- Component(组件):纯数据结构,不包含逻辑。每个 Component 代表一种属性或能力。
- Entity(实体):唯一标识符,通常是整数 ID。它不包含数据或行为,只用于关联零个或多个 Component,并且可以在运行时动态增减 Component。
- System(系统):包含处理逻辑的函数,匹配拥有特定 Component 组合的 Entity 并执行操作。
在这种设计中,数据存储在 Component 中,处理逻辑集中在 System 中,实体的行为由其拥有的 Component 组合决定。这带来两个关键优势:首先,System 不与特定 Entity 绑定,而是基于 Component 组合匹配,可以自动应用到任何拥有对应 Component 的实体,提升了代码复用性;其次,数据按类型分组连续存储,System 批量处理时能充分利用 CPU 缓存,带来显著的性能提升。
// 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(std::vector& entities) {
for (auto entity : entities) {
if (hasComponents(entity)) {
auto& pos = getComponent(entity);
auto& vel = getComponent(entity);
pos.x += vel.dx;
pos.y += vel.dy;
}
}
}
};
例如,玩家实体关联 Position、Velocity、Sprite 和 Health 组件,而静态背景只关联 Position 和 Sprite。通过动态添加或移除 Component(如给敌人添加 Frozen 组件),可以改变实体的行为。
理解了 ECS 的基本概念后,让我们看看它能带来什么优势。
ECS 的优点
组合而非继承
通过组合 Component 定义实体,避免了继承树的僵化问题。实体行为可以运行时动态改变,System 基于 Component 组合自动匹配实体。这种组合模式提高了可维护性:
- 耦合性低:继承在编译时固定类型关系,修改父类会影响所有子类,形成连锁反应;而组合通过添加/移除 Component 在运行时动态改变实体行为,维护时无需重构类层级
- 职责单一:System 职责单一且基于 Component 组合匹配,修改渲染不影响物理。修复 MovementSystem 的 bug 只需改一处代码就能应用到所有相关实体
- 扩展成本低:新增功能时,新的 System 会自动匹配已有实体,新的 Component 无需修改已有代码
// 创建敌人:组合需要的 Component
Entity enemy = entityMgr.createEntity();
positions.insert(enemy, {100.0f, 100.0f});
velocities.insert(enemy, {-1.0f, 0.0f});
ai.insert(enemy, {/* AI 数据 */});
// 运行时添加冻结效果:直接添加 Frozen Component
struct Frozen { float duration; };
frozen.insert(enemy, {5.0f});
// MovementSystem 基于 Component 组合匹配,职责单一
class MovementSystem {
public:
void update(ComponentArray& positions,
ComponentArray& velocities,
ComponentArray& frozen,
float deltaTime) {
// 自动应用到所有拥有 Position + Velocity 的实体
for (auto entity : getEntitiesWith()) {
if (frozen.has(entity)) continue; // 有 Frozen 则跳过
auto& pos = positions.get(entity);
auto& vel = velocities.get(entity);
pos.x += vel.dx * deltaTime;
pos.y += vel.dy * deltaTime;
}
}
};
// 物理系统和渲染系统各自独立,互不影响
class PhysicsSystem {
public:
void update(ComponentArray& positions,
ComponentArray& bodies,
float deltaTime) {
// 碰撞检测、重力、摩擦力...
}
};
性能:缓存友好的数据布局
ECS 通过优化数据布局充分利用 CPU 缓存,显著提升性能。
CPU 缓存原理:CPU 访问内存很慢,所以使用多级缓存加速(L1 32KB、L2 256KB、L3 几MB)。缓存以"缓存行"为单位加载数据,每次从内存加载 64 字节。如果程序接下来访问的数据在缓存中(缓存命中),速度很快;否则需要从内存加载(缓存未命中),速度很慢。
ECS 的性能优势来自两个方面:
1. 按需加载数据:只加载需要的 Component 类型
// ECS:只加载需要的数据
vector positions; // 只有 Position 数据
vector velocities; // 只有 Velocity 数据
for (int i = 0; i < 1000; ++i) {
positions[i].x += velocities[i].dx;
// 缓存只包含 Position 和 Velocity
// 没有无关数据(Sprite、AI、Health)
}
2. 数据连续存储:提高缓存命中率
// ECS:数据连续存储
vector positions; // 1000 个 Position(每个 8 字节)
vector 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 通常能带来 2-10 倍的性能提升。例如:
- 更新 1000 个敌人的位置和 AI
- 渲染 500 个可见物体
- 计算 200 个粒子的物理模拟
并发性:数据与逻辑分离
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 有两个核心差异:
数据组织方式不同。OOP 将单个对象的所有数据封装在一起,分散存储;ECS 将相同类型的数据分组连续存储。这导致性能差异:ECS 的数据布局对缓存友好,System 批量处理时能充分利用 CPU 缓存;OOP 对象分散存储,访问时频繁触发缓存未命中。
行为定义方式不同。OOP 依赖继承定义对象能力,类型在编译时固定;ECS 依赖组合定义实体能力,运行时可以动态增减。
class GameObject { virtual void update() = 0; };
class Renderable : public GameObject { Sprite sprite; };
class Movable : public GameObject { Vector2 velocity; };
// 需要"可渲染+可移动":继承多个基类
class Player : public Renderable, public Movable {
// 问题:菱形继承、类型膨胀、继承树难以调整
};
这是"组合优于继承"原则的体现。ECS 的行为由 Component 组合决定,无需修改类层级即可扩展;OOP 的行为由继承树决定,难以在运行时改变对象类型。
与 EC 框架的区别
许多人容易混淆 ECS 和 EC 框架(如 Unity GameObject 系统)。两者的关键区别在于 Component 是否包含行为:
EC 框架:Component 是包含数据和行为的类,继承自公共接口。
class IComponent {
public:
virtual void update() = 0;
};
class Entity {
vector 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 数组时,它能够充分利用 DoD 优化:
- 缓存局部性:连续访问内存,减少缓存未命中
- SIMD 向量化:编译器可以自动向量化循环
- 减少间接访问:避免指针跳转
了解了 ECS 与其他架构的差异后,让我们深入看看它是如何实现的。
实现原理
实体管理
Entity Manager 负责创建和销毁实体,分配唯一 ID:
class EntityManager {
uint32_t nextId = 0;
public:
Entity createEntity() {
return nextId++;
}
};
组件存储
Component 按类型分组存储在连续数组中,使用 Entity ID 作为索引映射:
template<typename T>
class ComponentArray {
std::vector components;
std::unordered_mapsize_t> entityToIndex;
public:
void insert(Entity entity, T component) {
entityToIndex[entity] = components.size();
components.push_back(component);
}
T& get(Entity entity) {
return components[entityToIndex[entity]];
}
};
系统实现
System 遍历拥有特定 Component 组合的实体并执行逻辑:
// 移动系统:处理 Position 和 Velocity
class MovementSystem {
public:
void update(ComponentArray& positions,
ComponentArray& velocities,
float deltaTime) {
for (auto entity : getEntitiesWith()) {
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& positions,
ComponentArray& renderables) {
for (auto entity : getEntitiesWith()) {
auto& pos = positions.get(entity);
auto& rend = renderables.get(entity);
draw(rend.texture, pos.x, pos.y, rend.layer);
}
}
};
一个完整的例子
int main() {
EntityManager entityMgr;
ComponentArray positions;
ComponentArray velocities;
ComponentArray renderables;
// 创建可移动可渲染的实体(玩家)
Entity player = entityMgr.createEntity();
positions.insert(player, {0.0f, 0.0f});
velocities.insert(player, {1.0f, 0.5f});
renderables.insert(player, {playerTexture, 1});
// 创建只能渲染的实体(背景)
Entity background = entityMgr.createEntity();
positions.insert(background, {0.0f, 0.0f});
renderables.insert(background, {bgTexture, 0});
// 游戏循环
MovementSystem movementSys;
RenderSystem renderSys;
while (running) {
movementSys.update(positions, velocities, deltaTime);
renderSys.render(positions, renderables);
}
}
三种存储方式
不同 ECS 框架的核心差异在于 Component 的存储方式,各有权衡:
Archetype ECS(表格式)
Entity 按 Component 组合分组存储在表中。每个 Entity 只存在于一张表,表由其拥有的 Component 组合决定。
// 表 A:存储 Position + Velocity 组合的实体
struct TablePositionVelocity {
vector entities; // [1, 2] 用于遍历
vector positions; // [{10, 20}, {30, 40}]
vector velocities; // [{1, 0.5}, {-1, 2}]
};
// 表 B:存储 Position + Sprite 组合的实体
struct TablePositionSprite {
vector entities; // [3]
vector positions; // [{0, 0}]
vector sprites; // [{bgTexture}]
};
// Entity 到表的映射:用于快速定位 Entity 的数据
struct EntityRecord {
void* table; // Entity 所在的表(实际使用时会转换为具体类型)
size_t row; // 在表中的行号
};
unordered_map 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 dense; // [pos1, pos2, pos3]
unordered_mapint> sparse; // {1→0, 2→1, 3→2}
};
struct VelocityArray {
vector dense; // [vel1, vel2]
unordered_mapint> 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 positions; // positions[0], positions[1], ...
vector 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
局限性
- 学习曲线陡峭:需要从"对象的行为"思维转向"数据流"思维。新手容易过度拆分 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 的扁平化设计不适用
- 需要专用数据结构的系统:空间查询、层级关系等场景
相关推荐
专题
+ 收藏
+ 收藏
+ 收藏
+ 收藏
+ 收藏
最新数据
相关文章
指针、引用和常量的关系
C++学习笔记(33):智能指针(工厂函数)
学而时习之:C++中的标准模板5.2
学而时习之:C++中的预处理
C++ RAII:从“人肉记账”到“自动保姆”的资源管理革命
告别 C 风格枚举:为什么你应该使用 enum class
从智能指针窥见现代C++的生存法则:告别内存泄漏,这篇就够了
C++学习笔记(30):智能指针(unique_ptr)
Leetcode第一题:用C++解决两数之和问题
static 关键字:从 C 到 C++,一篇文章彻底搞懂它的“七十二变”
AI精选
