ECS 架构详解:原理、伪代码与工程取舍

ECS 架构详解封面:实体、组件与系统
封面:实体是编号,组件是数据,系统是逻辑

一个单位要移动、要挨打、要能吃 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(系统):按组件类型批量读取、修改组件,不保存自己的状态。
ECS 三要素:实体是编号,组件是数据,系统是逻辑
图 1:ECS 三要素——实体通过 ID 关联一组组件,系统按类型批量读写组件

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

系统不关心实体是谁,只关心“哪些组件在”。同一次循环处理的是同构数据,天然适合并行:两个系统只要不读写同一组组件,就能在不同线程同时跑。

为什么快:数据布局

AoS 与 SoA 内存布局对比
图 2:AoS 与 SoA 的内存布局——ECS 按列存储,让缓存和 SIMD 发挥效用

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 与 Chunk 结构
图 3: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 再决定。

参考

滚动至顶部