
一个单位要移动、要挨打、要能吃 AOE,死后还可能变成另一种形态——能力一多,继承树就不知道该把它们放哪一层,只能一层层往下叠。ECS(Entity-Component-System,实体-组件-系统)换了一种组织方式:实体只是编号,组件是纯数据,系统是纯逻辑,能力靠贴组件而不是靠继承层级。除了代码组织,它还要解决另一个问题:让成千上万个同质对象在紧凑的内存布局上批量执行逻辑,还能直接并行。这条路线从 1998 年的《神偷》一路走到《守望先锋》、Unity DOTS 和 UE5 Mass。
OOP 的痛点从哪里来
传统写法里,一个角色是“一个对象”,能力靠继承堆叠:
class Character : public Unit {
float hp;
Position pos;
virtual void update(float dt) = 0;
};
class Soldier : public Character { ... };
class Boss : public Soldier { ... }; // 想复用“会飞”的能力?再往上叠一层
三个痛点:
- 继承树僵化。会飞、会嘲讽、会回血的怪物,从哪一层继承?“组合优于继承”是 ECS 对这个问题的直接回答。
- 内存不友好。对象散落在堆上,循环遍历一万个单位时,缓存行里装着一半用不上的数据(见下文图 2 上半部分)。
- 并行难。逻辑写成方法,方法操作自己的 this,两个线程同时跑,同一对象上谁读谁写要小心处理。
三个概念,各一句话
- Entity(实体):一个 32 位整数,只当编号,没有数据也没有行为。
- Component(组件):纯数据结构,只描述状态。
- System(系统):按组件类型批量读取、修改组件,不保存自己的状态。

struct Position { float x, y, z; };
struct Velocity { float vx, vy, vz; };
struct Health { float hp, maxHp; };
entity_t e = world.spawn(); // 发一个 ID,比如 42
world.add(e, Position{ 0.f, 0.f, 0.f });
world.add(e, Velocity{ 1.f, 0.f, 0.f }); // 加组件 = 获得对应能力
world.add(e, Health{ 100.f, 100.f });
加组件就是“贴标签”:有 Health 就能被打,有 Velocity 就会动,什么都不贴就只是一个 ID。要造“会飞的奶妈”,把 Flying 和 Heal 两个组件贴上即可,不用动任何类的层级。
系统怎么写
for each e in world:
if not e.has(Position) or not e.has(Velocity):
continue
pos = e.get(Position)
vel = e.get(Velocity)
pos.x += vel.vx * dt
pos.y += vel.vy * dt
系统不关心实体是谁,只关心“哪些组件在”。同一次循环处理的是同构数据,天然适合并行:两个系统只要不读写同一组组件,就能在不同线程同时跑。
为什么快:数据布局

OOP 的数组是 AoS(Array of Structs):[Character, Character, …],每个元素里 pos、vel、hp 挨着放。循环里只碰 pos,vel 和 hp 也被连带搬进缓存行,纯属浪费带宽。
ECS 存成 SoA(Structure of Arrays):所有 Position 放一个连续数组,所有 Velocity 放另一个。扫描某一列时缓存行里全是有效数据,命中率上升,还能直接喂给 SIMD 指令。第三方基准(100k 粒子,Intel Xeon 8360Y)实测:AoS 布局 L3 命中率 52%、每帧 18.3 ms;SoA 布局命中率 89%、每帧 9.7 ms——只改数据排布,接近 2 倍差距。
注意 SoA 赢在“整列顺序访问”:随机访问单个实体要跨多个缓存行,反而更慢。这一点到“不是银弹”一节还会展开。
引擎里怎么落地:Archetype 与 Chunk

引擎按“组件组合”给实体分组:
- Archetype(原型):一种组件组合,比如
{ Position, Velocity }、{ Position, Velocity, Health }。 - Chunk:固定大小(约 16 KiB)的连续内存块,只装同一 Archetype 的实体,块内组件按列存放(SoA),满一块再开一块。
- 系统只遍历组件组合匹配的 Archetype,不匹配的自动跳过。
代价是结构变更贵:增删组件等于实体从一个 Archetype 搬到另一个。所以引擎提供 ECB(Entity Command Buffer,实体命令缓冲):系统把“要加什么、删什么”写进缓冲,帧末统一执行,攒批搬家。
一个完整的最小例子
// 组件
struct Bullet { Position pos; Velocity vel; float life; }
// 生成:一次性创建 1000 颗子弹
for i in 0..999:
e = world.spawn()
world.add(e, Bullet{ spawn_pos, dir * speed, 5.0 })
// MoveBulletsSystem:推进位置与生命
for each e with Bullet:
b = world.get(e, Bullet)
b.pos += b.vel * dt
b.life -= dt
if b.life <= 0:
world.ecb().destroy(e) // 不立即删,攒进命令缓冲
// RenderSystem:可以并行
for each e with Bullet and Mesh:
draw(mesh, world.get(e, Bullet).pos)
// 每帧调度:帧末自动执行 ECB,统一删实体
world.run(MoveBulletsSystem)
world.run(RenderSystem)
删除走 ECB 而不是在遍历中途删,是为了避免数组中途位移打乱遍历。这就是“数据集中 + 系统批量”:写起来多一层抽象,换来每帧稳定、可并行地跑完同质数据。
历史:从《神偷》到 Unity DOTS
- 1998《神偷:暗黑计划》(Looking Glass):目前可考证最早的实体-组件式游戏架构,之后《网络奇兵 2》也沿用。
- 2002《地牢围攻》(Scott Bilas,GDC 2002):把数据驱动、组合式组件做成引擎核心,是现代 ECS 的直接前身。
- 2007 Adam Martin 的系列博客:给出今天的标准定义——实体是 ID、组件是数据、系统是逻辑,代码只存在于系统里。
- 2011 寒霜引擎(DICE,GDC《Culling the Battlefield》):用数据导向设计重写《战地 3》的剔除系统,是数据导向设计(DOD)工业化的标志性案例。
- 2017《守望先锋》(Tim Ford,GDC):46 个系统、103 种组件,但只有 3 个系统直接碰网络代码——ECS 让回滚、预测这些核心循环变得可控,这一讲之后 ECS 开始被广泛采用。
- 2018 至今:Unity 发布 DOTS(Entities + C# Job System + Burst),虚幻引擎推出 Mass;开源侧有 Bevy(Rust)、EnTT(C++)、flecs(C)。
不是银弹
- 规模小就别上。几百个对象,OOP 更直接,Archetype、ECB 这些开销是纯负担。
- 随机访问多时 SoA 反而慢。第三方实测,随机访问场景 SoA 比 AoS 慢约 70%,因为取一个元素要碰多个缓存行。
- 实体频繁增删时优势变小。结构变更贵,SoA 对“稳定的实体集合”最友好。
- 内存带宽受限时,访问模式比布局更重要。Factorio 开发者分享过:他们的模拟是带宽瓶颈,按实体逐个跑系统(entity-at-a-time)反而比系统逐个扫实体(system-at-a-time)快,因为多个系统用同一批组件时数据已在缓存里。别把 ECS 当教条。
- 团队心智成本。调试时“实体 42 是谁”需要额外的映射工具,小团队上手期长。
总结
ECS 把“对象”换成“数据 + 规则”:数据按类型排布吃满缓存,规则按批次执行、可直接并行,能力靠贴组件组合而不是改继承树。代价是代码更反直觉、结构性变更更贵。适合大规模同质实体(子弹、单位、粒子);小项目、随机访问主导、增删极频繁的场景,先 Profile 再决定。
参考
- Tim Ford,Overwatch Gameplay Architecture and Netcode,GDC 2017(视频见 GDC Vault / YouTube)
- DICE,Culling the Battlefield: Data Oriented Design in Practice,GDC 2011
- Wikipedia:Entity component system
- Unity Entities 官方文档
- Adam Martin:Entity Systems are the future of MMOG development
- Hacker News:ECS 性能讨论(含 Factorio 开发者视角)
- 第三方 SoA/AoS 基准(100k 粒子):Go ECS 实现实测


