游戏优化与设计模式(19):ECS 框架详解
原视频:https://www.bilibili.com/video/BV1XTxuzfEB9/
标题:
【游戏优化与设计模式】19 ECS框架详解
为什么游戏项目会转向 ECS
在传统面向对象写法里,一个角色类里往往同时包含移动、渲染、碰撞、AI、音效等逻辑。项目前期开发速度快,但随着功能增长,类会越来越“胖”,耦合增加,修改一个功能可能牵动多个模块。
ECS 的核心目标就是把“数据”和“行为”拆开:
- Entity(实体)只负责身份标识。
- Component(组件)只存数据,不写业务逻辑。
- System(系统)只处理一类逻辑,对满足条件的实体批量执行。
这套拆分方式能显著提升可维护性,同时让底层数据更容易做连续存储,对 CPU 缓存更友好。
ECS 三要素怎么理解
Entity:轻量 ID
Entity 可以理解成一个整数 ID。它本身不包含业务信息,只是把不同组件关联起来。
例如玩家实体 Entity#1001 可能挂了以下组件:
TransformComponentVelocityComponentHealthComponentPlayerInputComponent
Component:纯数据容器
组件要尽量保持“数据化”。例如:
1 | struct TransformComponent { |
组件里不放复杂行为,可以减少隐藏依赖,让系统逻辑更可控。
System:逻辑执行单元
System 负责遍历“同时具有某些组件”的实体集合,然后统一处理逻辑。
例如移动系统只关心 Transform + Velocity:
1 | void MovementSystem::Update(float dt) { |
ECS 为什么更容易做性能优化
核心原因是“同类数据集中存储 + 批处理”。
传统对象树中,不同对象的数据通常散落在内存各处。系统更新时会频繁跳转内存地址,缓存命中率低。
ECS 则更容易把同类型组件放进连续数组中,遍历时 CPU 预取更有效。
数据“连续”到底是怎么做到的
核心不是一句“按组件存”,而是下面三件事一起发生:
- 按类型分列存储(SoA)
- 按组件组合分块存储(Archetype/Chunk)
- 查询结果缓存成可线性遍历的视图(View/Query Cache)
对比一下内存形态更直观:
1 | 传统对象(AoS,按对象): |
为什么“连续”会直接变成“读取快”
- 缓存行利用率高:CPU 一次抓 64B 左右缓存行,顺序数据几乎都能用上。
- 硬件预取有效:线性访问模式明显,CPU 能提前把下一批数据搬进缓存。
- 分支更稳定:同一系统做同一类计算,分支预测命中率更高。
- SIMD 更容易:同构数据连续,向量化计算门槛更低。
简化结论:
ECS 不是“算法魔法”,而是把访存模式从“随机跳读”改成“顺序流式读”。
查询为什么也能快
很多人只关注存储,忽略了查询。真正高效的 ECS 会把查询路径也做成 O(可遍历块):
- Archetype ECS:系统直接拿到满足组件签名的 Chunk 列表,进入内层循环就是纯顺序扫描。
- Sparse Set ECS:通过稠密数组 + 稀疏索引做存在性判断,再对最小集合交集遍历。
- 热路径缓存:
query<Transform, Velocity>()在系统初始化后缓存匹配结果,避免每帧全量筛选。
ECS 用稀疏集(Sparse Set)怎么存组件
稀疏集的目标是:
既要支持 has/add/remove 的近 O(1) 操作,又要让组件数据线性存放,方便系统顺序遍历。
每个组件池(以 Transform 为例)通常维护三块数据:
sparse[entity] -> denseIndex:从实体 ID 快速找到它在稠密区的位置。denseEntities[denseIndex] -> entity:反向映射,记录这个槽位属于哪个实体。denseData[denseIndex] -> Transform:真正组件数据,和denseEntities一一对应。
可以把它理解成:
1 | entity id: 1 2 3 4 5 |
其中 -1 表示该实体没有这个组件。
关键操作
has(e)
先读idx = sparse[e],再校验idx是否有效且denseEntities[idx] == e。add(e, c)
把e和c追加到denseEntities/denseData末尾,然后把sparse[e]指向新下标。remove(e)(核心技巧:swap-remove)
用最后一个元素覆盖被删位置,同时更新被搬迁实体的sparse,最后pop_back。
这样删除是 O(1),但会打乱 dense 的顺序(通常可接受)。
简化实现示意:
1 | template <typename T> |
稀疏集查询多组件的常见做法
对 query<A, B, C>,一般选最小的 dense 集合作为主循环,然后对其余池执行 Has(e) 检查:
- 遍历次数接近最小池大小。
Has(e)是 O(1) 索引判断。- 命中后按 denseIndex 直接读取组件数据。
这就是稀疏集版本 ECS “查询快 + 遍历快”的核心路径。
工程上最关键的两条
- 高频组件要“热数据化”
把每帧都要读写的数据(位置、速度、状态标记)单独做紧凑结构,低频字段拆到冷数据里。 - 结构变更要批处理
Add/Remove Component 会触发实体迁移(尤其 Archetype)。通常在帧末统一回放命令,避免主循环抖动。
一个最小可用 ECS 的设计思路
如果你准备在自己的项目里实践 ECS,建议按下面顺序推进:
- 实体管理器:分配/回收 Entity ID。
- 组件存储:按组件类型维护独立容器(数组或稀疏集合)。
- 查询机制:支持按组件组合筛选实体。
- 调度器:定义系统执行顺序(输入 -> 物理 -> 动画 -> 渲染)。
- 事件总线:减少系统之间直接依赖。
先做“能跑的最小闭环”,再逐步引入对象池、并行调度、脏标记等高级优化。
ECS 不是银弹:适用边界要明确
ECS 很强,但不是任何场景都必须上:
- 中小型项目、实体数量少时,传统组件化 OOP 可能更直接。
- 团队成员不熟悉数据导向设计时,初期学习和重构成本不低。
- UI、剧情流程等强状态机逻辑,有时用 ECS 反而绕。
实战中常见做法是“混合架构”:
核心高频更新模块(战斗单位、弹道、状态效果)走 ECS;外围模块保持更直观的架构。
落地建议(工程视角)
- 先从热点模块试点,不要一次性全量迁移。
- 给 Component 命名和职责立规约,避免“万能组件”。
- 系统之间只通过数据和事件通信,减少跨系统直接调用。
- 把性能分析(Profiler)纳入日常流程,用数据验证改造收益。
- 在代码评审里关注“组件是否纯数据”“系统是否单一职责”。
总结
ECS 的价值不只在“跑得更快”,更在于它提供了一种可扩展的工程组织方式:
用统一的数据结构承载状态,用清晰的系统边界管理行为。
当项目进入规模化阶段,ECS 往往能同时改善性能、可维护性和多人协作效率。关键不在于“是否跟风上 ECS”,而在于是否基于项目瓶颈做理性架构选择。
