98、实战:游戏引擎核心(ECS架构+组件系统)
说实话,很多做游戏引擎的朋友,一开始都会掉进“继承坑”里。你想想看,一个基类 GameObject,下面派生一堆 Player、Enemy、Bullet…… 刚开始挺爽,但项目一膨胀,你就发现改一个基类,所有子类都得跟着抖三抖。我在一个中型项目里就吃过这个亏,后来痛定思痛,全面重构为 ECS 架构。
ECS 是什么?说白了就是 Entity(实体)- Component(组件)- System(系统) 三件套。它把数据和逻辑彻底拆开,让代码变得像搭积木一样灵活。
ECS 的核心思想
我习惯用一句话概括 ECS:实体只是一个 ID,组件是纯数据,系统是处理逻辑的函数。没有继承,没有虚函数,只有数据流和算法。
- Entity:就是一个整数 ID,用来索引组件。
- Component:结构体,只存数据,不存方法。比如位置、速度、生命值。
- System:遍历拥有特定组件的实体,执行逻辑。比如移动系统、渲染系统。
💡 核心优势:缓存友好、易于扩展、方便多线程。你加一个新功能,只需要加一个新组件和一个新系统,完全不用动已有代码。
组件系统的设计
组件怎么存?我推荐用 稀疏集(Sparse Set)。它既有数组的连续内存,又能快速增删。嗯,这里要注意:不要用 std::map 或 std::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 核心。我写个简单的使用示例:
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