ECS 架构模式

25 阅读13分钟

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
    }
};

例如,玩家实体关联 PositionVelocitySpriteHealth 组件,而静态背景只关联 PositionSprite。通过动态添加或移除 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 MassEnTT, ShipyardEntityX, 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,避免浪费内存。例如,静态背景只需要 PositionSprite,无需分配 VelocityHealthAI 等组件。

优势

  • 稀疏实体节省内存:只有少数 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 需要 SpriteAnimationState,缺少一个就会出错。
  • 不适合层级结构:渲染树等深层树形结构不适合 ECS 的扁平化设计。虽然可以用 Component 存储父子关系(如 ParentChildren 组件),但这会频繁触发 Component 查找,性能不如原生树结构。
  • 空间数据结构问题:四叉树、八叉树等空间数据结构的布局不匹配 ECS 典型存储方式。通常的做法是在 System 中每帧重建空间结构,或使用运行时 Tag 标记空间网格。
  • 小型项目过度设计:如果项目只有几十种对象且逻辑简单,ECS 的基础设施(EntityManager、ComponentArray、System 调度)会带来不必要的复杂度。

适用场景

适合使用 ECS

  • 游戏开发:大量实体、频繁创建销毁、需要高性能
  • 模拟系统:粒子系统、物理引擎、AI 群体行为
  • 需要运行时动态组合行为:Mod 系统、技能编辑器

不适合使用 ECS

  • 业务逻辑系统:银行、电商等传统后端,对象行为固定,继承层级稳定
  • 小型项目:几十个类以内,OOP 更简单直观
  • UI 系统:UI 组件通常有深层层级关系(树形结构),ECS 的扁平化设计不适用
  • 需要专用数据结构的系统:空间查询、层级关系等场景