原视频:https://www.bilibili.com/video/BV1XTxuzfEB9/

标题:【游戏优化与设计模式】19 ECS框架详解

为什么游戏项目会转向 ECS

在传统面向对象写法里,一个角色类里往往同时包含移动、渲染、碰撞、AI、音效等逻辑。项目前期开发速度快,但随着功能增长,类会越来越“胖”,耦合增加,修改一个功能可能牵动多个模块。

ECS 的核心目标就是把“数据”和“行为”拆开:

  1. Entity(实体)只负责身份标识。
  2. Component(组件)只存数据,不写业务逻辑。
  3. System(系统)只处理一类逻辑,对满足条件的实体批量执行。

这套拆分方式能显著提升可维护性,同时让底层数据更容易做连续存储,对 CPU 缓存更友好。

ECS 三要素怎么理解

Entity:轻量 ID

Entity 可以理解成一个整数 ID。它本身不包含业务信息,只是把不同组件关联起来。

例如玩家实体 Entity#1001 可能挂了以下组件:

  • TransformComponent
  • VelocityComponent
  • HealthComponent
  • PlayerInputComponent

Component:纯数据容器

组件要尽量保持“数据化”。例如:

1
2
3
4
5
6
7
8
struct TransformComponent {
float x, y, z;
float rotation;
};

struct VelocityComponent {
float vx, vy, vz;
};

组件里不放复杂行为,可以减少隐藏依赖,让系统逻辑更可控。

System:逻辑执行单元

System 负责遍历“同时具有某些组件”的实体集合,然后统一处理逻辑。

例如移动系统只关心 Transform + Velocity

1
2
3
4
5
6
7
8
9
void MovementSystem::Update(float dt) {
for (Entity e : query<TransformComponent, VelocityComponent>()) {
auto& t = transforms[e];
auto& v = velocities[e];
t.x += v.vx * dt;
t.y += v.vy * dt;
t.z += v.vz * dt;
}
}

ECS 为什么更容易做性能优化

核心原因是“同类数据集中存储 + 批处理”。

传统对象树中,不同对象的数据通常散落在内存各处。系统更新时会频繁跳转内存地址,缓存命中率低。
ECS 则更容易把同类型组件放进连续数组中,遍历时 CPU 预取更有效。

数据“连续”到底是怎么做到的

核心不是一句“按组件存”,而是下面三件事一起发生:

  1. 按类型分列存储(SoA)
  2. 按组件组合分块存储(Archetype/Chunk)
  3. 查询结果缓存成可线性遍历的视图(View/Query Cache)

对比一下内存形态更直观:

1
2
3
4
5
6
7
8
9
传统对象(AoS,按对象):
[EntityA: Transform+Velocity+Health][EntityB: ...][EntityC: ...]
^ 系统只想读 Transform/Velocity,却被迫跨过很多无关字段

ECS(SoA/Archetype,按列或按块):
Transform: [t0][t1][t2][t3]...
Velocity : [v0][v1][v2][v3]...
Health : [h0][h1][h2][h3]...
^ MovementSystem 顺序扫描 t 和 v,内存访问连续

为什么“连续”会直接变成“读取快”

  1. 缓存行利用率高:CPU 一次抓 64B 左右缓存行,顺序数据几乎都能用上。
  2. 硬件预取有效:线性访问模式明显,CPU 能提前把下一批数据搬进缓存。
  3. 分支更稳定:同一系统做同一类计算,分支预测命中率更高。
  4. SIMD 更容易:同构数据连续,向量化计算门槛更低。

简化结论:
ECS 不是“算法魔法”,而是把访存模式从“随机跳读”改成“顺序流式读”。

查询为什么也能快

很多人只关注存储,忽略了查询。真正高效的 ECS 会把查询路径也做成 O(可遍历块):

  • Archetype ECS:系统直接拿到满足组件签名的 Chunk 列表,进入内层循环就是纯顺序扫描。
  • Sparse Set ECS:通过稠密数组 + 稀疏索引做存在性判断,再对最小集合交集遍历。
  • 热路径缓存:query<Transform, Velocity>() 在系统初始化后缓存匹配结果,避免每帧全量筛选。

ECS 用稀疏集(Sparse Set)怎么存组件

稀疏集的目标是:
既要支持 has/add/remove 的近 O(1) 操作,又要让组件数据线性存放,方便系统顺序遍历。

每个组件池(以 Transform 为例)通常维护三块数据:

  1. sparse[entity] -> denseIndex:从实体 ID 快速找到它在稠密区的位置。
  2. denseEntities[denseIndex] -> entity:反向映射,记录这个槽位属于哪个实体。
  3. denseData[denseIndex] -> Transform:真正组件数据,和 denseEntities 一一对应。

可以把它理解成:

1
2
3
4
5
6
entity id:    1   2   3   4   5
sparse: -1 0 -1 1 2

denseIndex: 0 1 2
denseEntities:[2] [4] [5]
denseData: [T2] [T4] [T5]

其中 -1 表示该实体没有这个组件。

关键操作

  1. has(e)
    先读 idx = sparse[e],再校验 idx 是否有效且 denseEntities[idx] == e
  2. add(e, c)
    ec 追加到 denseEntities/denseData 末尾,然后把 sparse[e] 指向新下标。
  3. remove(e)(核心技巧:swap-remove)
    用最后一个元素覆盖被删位置,同时更新被搬迁实体的 sparse,最后 pop_back
    这样删除是 O(1),但会打乱 dense 的顺序(通常可接受)。

简化实现示意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
template <typename T>
struct SparseSetPool {
static constexpr int INVALID = -1;
std::vector<int> sparse; // entity -> dense index
std::vector<Entity> denseEntity; // dense index -> entity
std::vector<T> denseData; // dense index -> component

bool Has(Entity e) const {
if (e >= sparse.size()) return false;
int idx = sparse[e];
return idx != INVALID && idx < (int)denseEntity.size() && denseEntity[idx] == e;
}

void Add(Entity e, const T& c) {
if (e >= sparse.size()) sparse.resize(e + 1, INVALID);
if (Has(e)) return;
int idx = (int)denseEntity.size();
sparse[e] = idx;
denseEntity.push_back(e);
denseData.push_back(c);
}

void Remove(Entity e) {
if (!Has(e)) return;
int idx = sparse[e];
int last = (int)denseEntity.size() - 1;
Entity moved = denseEntity[last];
denseEntity[idx] = moved;
denseData[idx] = std::move(denseData[last]);
sparse[moved] = idx;
denseEntity.pop_back();
denseData.pop_back();
sparse[e] = INVALID;
}
};

稀疏集查询多组件的常见做法

query<A, B, C>,一般选最小的 dense 集合作为主循环,然后对其余池执行 Has(e) 检查:

  1. 遍历次数接近最小池大小。
  2. Has(e) 是 O(1) 索引判断。
  3. 命中后按 denseIndex 直接读取组件数据。

这就是稀疏集版本 ECS “查询快 + 遍历快”的核心路径。

工程上最关键的两条

  1. 高频组件要“热数据化”
    把每帧都要读写的数据(位置、速度、状态标记)单独做紧凑结构,低频字段拆到冷数据里。
  2. 结构变更要批处理
    Add/Remove Component 会触发实体迁移(尤其 Archetype)。通常在帧末统一回放命令,避免主循环抖动。

一个最小可用 ECS 的设计思路

如果你准备在自己的项目里实践 ECS,建议按下面顺序推进:

  1. 实体管理器:分配/回收 Entity ID。
  2. 组件存储:按组件类型维护独立容器(数组或稀疏集合)。
  3. 查询机制:支持按组件组合筛选实体。
  4. 调度器:定义系统执行顺序(输入 -> 物理 -> 动画 -> 渲染)。
  5. 事件总线:减少系统之间直接依赖。

先做“能跑的最小闭环”,再逐步引入对象池、并行调度、脏标记等高级优化。

ECS 不是银弹:适用边界要明确

ECS 很强,但不是任何场景都必须上:

  • 中小型项目、实体数量少时,传统组件化 OOP 可能更直接。
  • 团队成员不熟悉数据导向设计时,初期学习和重构成本不低。
  • UI、剧情流程等强状态机逻辑,有时用 ECS 反而绕。

实战中常见做法是“混合架构”:
核心高频更新模块(战斗单位、弹道、状态效果)走 ECS;外围模块保持更直观的架构。

落地建议(工程视角)

  1. 先从热点模块试点,不要一次性全量迁移。
  2. 给 Component 命名和职责立规约,避免“万能组件”。
  3. 系统之间只通过数据和事件通信,减少跨系统直接调用。
  4. 把性能分析(Profiler)纳入日常流程,用数据验证改造收益。
  5. 在代码评审里关注“组件是否纯数据”“系统是否单一职责”。

总结

ECS 的价值不只在“跑得更快”,更在于它提供了一种可扩展的工程组织方式:
用统一的数据结构承载状态,用清晰的系统边界管理行为。

当项目进入规模化阶段,ECS 往往能同时改善性能、可维护性和多人协作效率。关键不在于“是否跟风上 ECS”,而在于是否基于项目瓶颈做理性架构选择。