CATS
138.50M · 2026-02-04
ECS(Entity-Component-System) 是一种将数据与逻辑分离的设计模式,在游戏开发中被广泛采用。当你需要同时处理数千个实体,并希望在运行时灵活组合它们的行为时,ECS 提供了一个优雅的解决方案。
它的核心思想很简单:Entity 只是 ID,Component 是纯数据,System 批量处理拥有特定 Component 组合的实体。这种设计带来了两个关键优势:通过组合而非继承实现灵活的行为定义,通过数据连续存储获得显著的性能提升。
本文将详细介绍 ECS 的方方面面:核心概念与设计思想、灵活性与性能优势、与 OOP/EC 框架的本质差异、Archetype/Sparse Set/Bitset 三种主流实现方式的原理与权衡,以及它的局限性和适用场景。
ECS(Entity-Component-System) 由三个概念构成:
在这种设计中,数据存储在 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<Entity>& entities) {
for (auto entity : entities) {
if (hasComponents<Position, Velocity>(entity)) {
auto& pos = getComponent<Position>(entity);
auto& vel = getComponent<Velocity>(entity);
pos.x += vel.dx;
pos.y += vel.dy;
}
}
}
};
例如,玩家实体关联 Position、Velocity、Sprite 和 Health 组件,而静态背景只关联 Position 和 Sprite。通过动态添加或移除 Component(如给敌人添加 Frozen 组件),可以改变实体的行为。
理解了 ECS 的基本概念后,让我们看看它能带来什么优势。
通过组合 Component 定义实体,避免了继承树的僵化问题。实体行为可以运行时动态改变,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<Position>& positions,
ComponentArray<Velocity>& velocities,
ComponentArray<Frozen>& frozen,
float deltaTime) {
// 自动应用到所有拥有 Position + Velocity 的实体
for (auto entity : getEntitiesWith<Position, Velocity>()) {
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<Position>& positions,
ComponentArray<RigidBody>& bodies,
float deltaTime) {
// 碰撞检测、重力、摩擦力...
}
};
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 通常能带来 2-10 倍的性能提升。例如:
ECS 将数据(Component)与逻辑(System)分离,使得 System 之间的依赖关系清晰可见,便于并行化。
原理:每个 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 等组件。
优势:
劣势:
通过这些设计,ECS 在灵活性、性能、可维护性等方面带来了显著优势。但它与其他架构有什么本质区别?让我们来看看。
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 的行为由继承树决定,难以在运行时改变对象类型。
许多人容易混淆 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 数组时,它能够充分利用 DoD 优化:
了解了 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<T> components;
std::unordered_map<Entity, size_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<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);
}
}
};
int main() {
EntityManager entityMgr;
ComponentArray<Position> positions;
ComponentArray<Velocity> velocities;
ComponentArray<Renderable> 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 的存储方式,各有权衡:
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;
}
}
特点:
代表框架:Flecs、Unity DOTS、Bevy、Unreal Mass
每个 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。
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 |
AnimationSystem 需要 Sprite 和 AnimationState,缺少一个就会出错。Parent、Children 组件),但这会频繁触发 Component 查找,性能不如原生树结构。适合使用 ECS:
不适合使用 ECS: