98、实战:游戏引擎核心(ECS架构+组件系统)

说实话,很多做游戏引擎的朋友,一开始都会掉进“继承坑”里。你想想看,一个基类 GameObject,下面派生一堆 PlayerEnemyBullet…… 刚开始挺爽,但项目一膨胀,你就发现改一个基类,所有子类都得跟着抖三抖。我在一个中型项目里就吃过这个亏,后来痛定思痛,全面重构为 ECS 架构。

ECS 是什么?说白了就是 Entity(实体)- Component(组件)- System(系统) 三件套。它把数据和逻辑彻底拆开,让代码变得像搭积木一样灵活。

ECS 的核心思想

我习惯用一句话概括 ECS:实体只是一个 ID,组件是纯数据,系统是处理逻辑的函数。没有继承,没有虚函数,只有数据流和算法。

  • Entity:就是一个整数 ID,用来索引组件。
  • Component:结构体,只存数据,不存方法。比如位置、速度、生命值。
  • System:遍历拥有特定组件的实体,执行逻辑。比如移动系统、渲染系统。

💡 核心优势:缓存友好、易于扩展、方便多线程。你加一个新功能,只需要加一个新组件和一个新系统,完全不用动已有代码。

组件系统的设计

组件怎么存?我推荐用 稀疏集(Sparse Set)。它既有数组的连续内存,又能快速增删。嗯,这里要注意:不要用 std::mapstd::unordered_map 来存组件,否则你的 cache miss 会让你哭的。

来看一个简单的组件定义:

// 组件基类(其实可以不用,但为了类型安全,我习惯加一个空基类)
struct Component {
    virtual ~Component() = default;
};

// 具体组件
struct Position : public Component {
    float x, y;
};

struct Velocity : public Component {
    float vx, vy;
};

struct Health : public Component {
    int hp;
};

每个组件就是一个 POD 结构体。我在项目中遇到过有人给组件加构造函数和虚函数,结果序列化时出了大问题。记住:组件就是数据,别搞花活

实体管理器

实体管理器负责分配和回收 ID。我一般用 std::queue 来缓存被回收的 ID:

class EntityManager {
public:
    Entity create() {
        if (!freeIds_.empty()) {
            Entity id = freeIds_.front();
            freeIds_.pop();
            return id;
        }
        return nextId_++;
    }

    void destroy(Entity id) {
        freeIds_.push(id);
    }

private:
    std::queue<Entity> freeIds_;
    Entity nextId_ = 0;
};

为什么不用 std::vector<bool> 标记存活?因为碎片化严重。你想想看,频繁创建销毁后,ID 会变得很大,而实际存活的实体很少,遍历时浪费严重。

组件存储(稀疏集实现)

这是 ECS 的核心。我直接贴代码,然后解释:

template<typename T>
class ComponentStorage {
public:
    void add(Entity id, T component) {
        if (id >= sparse_.size()) {
            sparse_.resize(id + 1, -1);
        }
        dense_.push_back(component);
        entityToIndex_.push_back(id);
        sparse_[id] = dense_.size() - 1;
    }

    void remove(Entity id) {
        if (!has(id)) return;
        size_t idx = sparse_[id];
        size_t last = dense_.size() - 1;
        // 用最后一个元素覆盖要删除的元素
        dense_[idx] = dense_[last];
        entityToIndex_[idx] = entityToIndex_[last];
        sparse_[entityToIndex_[last]] = idx;
        dense_.pop_back();
        entityToIndex_.pop_back();
        sparse_[id] = -1;
    }

    T* get(Entity id) {
        if (!has(id)) return nullptr;
        return &dense_[sparse_[id]];
    }

    bool has(Entity id) const {
        return id < sparse_.size() && sparse_[id] != -1;
    }

    // 遍历所有组件
    template<typename Func>
    void each(Func func) {
        for (size_t i = 0; i < dense_.size(); ++i) {
            func(entityToIndex_[i], dense_[i]);
        }
    }

private:
    std::vector<int> sparse_;          // 实体ID -> 密集数组索引
    std::vector<T> dense_;             // 连续存储的组件
    std::vector<Entity> entityToIndex_; // 密集数组索引 -> 实体ID
};

🔧 小技巧:remove 操作是 O(1) 的,而且不会产生内存碎片。这就是稀疏集的魅力。我曾经用 std::list 存组件,结果遍历时慢得离谱,换成稀疏集后帧率直接翻倍。

系统(System)的实现

系统就是遍历拥有特定组件的实体,然后干活。我习惯用函数对象或者 lambda:

class MovementSystem {
public:
    void update(ComponentStorage<Position>& posStorage,
                ComponentStorage<Velocity>& velStorage,
                float dt) {
        // 同时拥有 Position 和 Velocity 的实体才处理
        posStorage.each([&](Entity id, Position& pos) {
            Velocity* vel = velStorage.get(id);
            if (vel) {
                pos.x += vel->vx * dt;
                pos.y += vel->vy * dt;
            }
        });
    }
};

你看,系统不关心实体是什么,只关心它有没有需要的组件。这就是“组合优于继承”的极致体现。

ECS 架构图

下面这张图是我手绘风格的 SVG,展示了 ECS 的核心数据流:

ECS 架构核心数据流 Entity Manager 分配/回收 ID Entity = uint32_t Component Storage 稀疏集存储 连续内存,O(1) 增删 System 遍历 + 逻辑 移动、渲染、碰撞 拥有 读取 通过 ID 操作 ECS 工作流程 1. EntityManager 创建实体,返回一个 ID。 2. 向实体添加组件(如 Position、Velocity),存入 ComponentStorage。 3. System 遍历 ComponentStorage,筛选出拥有所需组件的实体,执行逻辑。 4. 实体销毁时,回收 ID,并从所有 ComponentStorage 中移除对应组件。

实战:一个简单的移动系统

把上面的代码拼起来,就是一个可运行的 ECS 核心。我写个简单的使用示例:

int main() {
    EntityManager em;
    ComponentStorage<Position> posStorage;
    ComponentStorage<Velocity> velStorage;
    MovementSystem moveSys;

    // 创建实体
    Entity player = em.create();
    posStorage.add(player, {100.0f, 200.0f});
    velStorage.add(player, {50.0f, 0.0f});

    Entity enemy = em.create();
    posStorage.add(enemy, {300.0f, 400.0f});
    velStorage.add(enemy, {-30.0f, 20.0f});

    // 游戏循环(简化)
    float dt = 1.0f / 60.0f;
    for (int i = 0; i < 10; ++i) {
        moveSys.update(posStorage, velStorage, dt);
        // 打印位置
        posStorage.each([](Entity id, Position& p) {
            std::cout << "Entity " << id << ": (" << p.x << ", " << p.y << ")\n";
        });
    }

    return 0;
}

⚠️ 注意:上面的代码为了演示做了大量简化。实际项目中,你需要考虑:

  • 多线程安全(每个系统可以并行跑)
  • 组件快照(用于回放或网络同步)
  • 事件系统(实体创建/销毁时通知其他系统)

避坑指南

我曾经在 ECS 里犯过一个低级错误:在系统遍历组件时,直接修改了组件数组(比如删除当前实体)。结果迭代器失效,程序崩溃。后来我改用“延迟删除”模式——先把要删除的实体 ID 记下来,遍历结束后统一清理。

还有一次,我为了“性能”把组件设计成变长结构体,结果序列化时各种对齐问题。嗯,这里要记住:组件最好是固定大小的 POD 类型,否则你的内存布局会乱成一锅粥。

总结

ECS 架构说白了就是“数据驱动”的编程思想。它让代码更容易测试、更容易扩展、更容易并行。我现在的项目里,所有游戏逻辑都跑在 ECS 上,再也没有出现过“改一个基类,炸一片子类”的惨剧。

如果你正在做一个中型以上的游戏引擎,我强烈建议你试试 ECS。刚开始可能觉得别扭,但用顺手后,你会爱上这种“搭积木”的感觉。

📌 核心要点回顾:

  • 实体 = ID,组件 = 数据,系统 = 逻辑
  • 用稀疏集存组件,保证缓存友好
  • 系统只关心组件组合,不关心实体类型
  • 避免在遍历时修改组件容器

公众号:蓝海资料掘金营,微信deep3321