游戏引擎架构设计:Entity Component System 模式与数据导向编程

Entity Component System(ECS)已成为现代游戏引擎的核心架构模式,从 Unity DOTS 到 Bevy 引擎,ECS 以缓存友好的数据布局和极致的并行性能重新定义了游戏对象管理。本文深入剖析 ECS 与面向对象设计的本质差异,讲解 Archetype 存储模型、System 调度依赖与脏标记、缓存局部性优化,以及如何在实战中从零实现一个轻量级 ECS 框架。

从 OOP 到 ECS:架构范式的根本转变

传统的游戏开发采用面向对象编程(OOP)来建模游戏世界。一个 GameObject 基类派生出 Player、Enemy、Tree 等子类,每个对象封装了位置、渲染、物理、AI 等属性与行为。这种继承层次在小型项目中直观且高效,但随着项目规模扩大,“钻石继承"问题迫使开发者将功能拆分为组件——Unity 经典的 MonoBehaviour 组件模式即是 OOP 向组件化的妥协。

然而,即使组件化改进了代码复用,OOP 架构仍隐藏着一个致命弱点:缓存局部性(Cache Locality)。当代 CPU 的缓存行大小为 64 字节,主存访问延迟是 L1 缓存的 100~300 倍。当游戏遍历所有对象更新逻辑时,对象的内存布局是交错的——Player 的位置数据旁边是渲染材质、物理碰撞体和脚本引用,这些不相关的数据挤占了宝贵的缓存行。更糟糕的是,GameObject 在堆上零散分配,遍历时的指针跳转(Pointer Chasing)导致 CPU 流水线频繁停顿。

Entity Component System(ECS)彻底颠覆了这种组织方式。ECS 遵循数据导向设计(Data-Oriented Design,DOD)的核心原则:首先定义数据布局,然后围绕数据布局组织处理逻辑。ECS 将游戏世界分解为三个概念:

  • Entity(实体):一个轻量级的标识符(通常是一个整数 ID),不携带任何数据和行为
  • Component(组件):纯数据结构,如 Position { x, y, z }、Velocity { vx, vy, vz }、Health { current, max }
  • System(系统):处理逻辑的函数,对满足特定组件组合条件的实体集合执行操作

这种分离带来的直接好处是:相同类型的组件在内存中以 SoA(Struct of Arrays)形式连续存储。当 PhysicsSystem 需要更新所有 Position + Velocity 实体时,CPU 可以顺序访问连续的内存块,缓存命中率提升数倍,SIMD 并行化也变得可行。

ECS 存储模型:Sparse Set 与 Archetype

ECS 框架的性能高度依赖于底层存储模型。业界主流有两种实现策略:Sparse Set 和 Archetype。

Sparse Set 模型

Sparse Set 将每个组件类型存储为一个独立的密集数组,通过一个稀疏索引数组将实体 ID 映射到组件数据索引。以 EnTT(C++ )和 legion(Rust )早期版本为代表:

// Sparse Set 概念示意
template<typename T>
struct SparseSet {
    std::vector<Entity> sparse;   // 以 Entity ID 为索引,值为 dense 数组下标
    std::vector<T> dense;         // 连续的组件数据
    std::vector<Entity> entities; // dense 数组中每个槽位对应的实体 ID
};

查询具有 Position 和 Velocity 的实体时,遍历较小的那个组件集,通过 sparse 数组检查另一个组件是否存在。Sparse Set 的优势是组件插入和删除为 O(1) 均摊,实体创建销毁非常轻量。劣势是组件查询涉及间接跳转,当组件数量不平衡时(如 10 个实体有位置,10000 个实体有血量),查询效率大幅下降。

Archetype 模型

Archetype 模型由 Unity DOTS(Data-Oriented Tech Stack)推广,其核心思想是:将具有完全相同组件组合的实体归入同一个 Archetype(原型),每个原型内部以 SoA 方式存储所有组件的密集数组。

Archetype: [Position, Velocity, Health]
  EntityChunk 0 (容量 128):
    Position[]:  [p0, p1, ..., p127]
    Velocity[]:  [v0, v1, ..., v127]
    Health[]:    [h0, h1, ..., h127]
    EntityIDs[]: [e0, e1, ..., e127]

Archetype: [Position, Renderable]
  EntityChunk 0:
    Position[]:    [p128, p129, ...]
    Renderable[]:  [r128, r129, ...]

当实体添加或移除组件时,它从一个 Archetype “迁移"到另一个 Archetype——数据从原 Chunk 复制到新 Chunk, old 槽位通过交换填充保持连续。Archetype 的优势是多组件查询极为高效:找到所有匹配的 Archetype 后,直接顺序遍历其 Chunk 中的 SoA 数据即可。劣势是组件变更触发数据复制,不适合组件频繁变动的场景(如每帧添加移除临时组件)。

Bevy 引擎(Rust)采用了混合策略:以 Archetype 为主存储,辅以 Sparse Set 处理频繁变动的组件。Flecs(C)则提供了高度优化的 Archetype 实现,支持同一实体的多个同类型组件实例(多态组件),以及通过关系(Relation)表达父子层级、库存系统等复杂数据结构。

System 设计与并行调度

System 是 ECS 中处理逻辑的原子单元。一个好的 System 应该是纯函数式的:给定一组组件输入,产生确定的输出,不依赖全局可变状态。这种设计使得 System 天然可并行——只要两个 System 不读写相同的组件类型,它们就可以在不同的 CPU 核上同时执行。

Unity DOTS 的 System 依赖声明如下:

public partial struct MovementSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        foreach (var (position, velocity) in
            SystemAPI.Query<RefRW<Position>, RefRO<Velocity>>())
        {
            position.Value += velocity.Value * SystemAPI.Time.DeltaTime;
        }
    }
}

RefRW<T> 表示对组件的可读写引用,RefRO<T> 表示只读引用。DOTS 的 Job System 根据这些声明自动推断 System 之间的依赖关系,构建有向无环图(DAG),最大化并行度。

Bevy 的 System 使用 Rust 函数和参数类型来隐式声明依赖:

fn movement_system(
    time: Res<Time>,
    mut query: Query<(&mut Position, &Velocity)>
) {
    for (mut position, velocity) in query.iter_mut() {
        position.0 += velocity.0 * time.delta_seconds();
    }
}

Query<(&mut Position, &Velocity)> 声明了 System 的组件依赖,Res<Time> 声明了全局资源的读取。Bevy 的调度器(Scheduler)在编译期或运行期解析函数签名,自动推断可并行性。

调度依赖与脏标记

System 之间的依赖不仅限于数据冲突,还有逻辑时序。例如,PhysicsSystem 必须在 MovementSystem 之后执行,RenderSystem 必须在所有状态更新之后执行。ECS 框架通过显式的依赖图(Dependency Graph)或阶段(Stage/Phase)机制来解决时序问题。

Bevy 使用 app.add_systems(Update, (movement, physics).chain()) 声明线性依赖链。Unity DOTS 使用 [UpdateBefore(typeof(PhysicsSystem))] 和 [UpdateAfter(typeof(MovementSystem))] 属性注解。Flecs 则通过 Pipeline 定义 System 的管线顺序,支持分支和条件执行。

System 的变更传播是另一个核心问题。当 DamageSystem 将实体的 Health 降到 0 时,DeathSystem 需要感知并处理死亡逻辑。ECS 框架通常提供事件(Event)或命令缓冲(Command Buffer)机制解耦这种跨 System 通信:

// Bevy 事件系统
#[derive(Event)]
struct DeathEvent(Entity);

fn death_system(
    mut commands: Commands,
    mut events: EventReader<DeathEvent>,
) {
    for event in events.read() {
        commands.entity(event.0).despawn();
    }
}

命令缓冲将结构变更(创建/销毁实体、添加/移除组件)延迟到当前帧的所有 System 执行完毕后统一应用,避免了遍历过程中修改存储结构的复杂度。

从零实现轻量级 ECS

以下是一个用 TypeScript 实现的极简 ECS 框架,演示 Archetype 存储和 System 查询的核心机制:

type Component = Record<string, unknown>;
type ComponentClass<T extends Component> = new (...args: unknown[]) => T;

class Archetype {
  private components = new Map<ComponentClass<Component>, Component[]>();
  private entities: number[] = [];

  constructor(public readonly signature: Set<ComponentClass<Component>>) {}

  addEntity(entity: number, comps: Map<ComponentClass<Component>, Component>) {
    this.entities.push(entity);
    for (const [Cls, arr] of this.components) {
      arr.push(comps.get(Cls)!);
    }
  }

  getColumn<T extends Component>(cls: ComponentClass<T>): T[] {
    return this.components.get(cls) as T[];
  }

  getEntities(): readonly number[] { return this.entities; }
}

class ECSWorld {
  private nextEntity = 0;
  private entities = new Map<number, Archetype>();
  private archetypes = new Map<string, Archetype>();

  spawn(components: Map<ComponentClass<Component>, Component>): number {
    const entity = this.nextEntity++;
    const sig = new Set(components.keys());
    const key = [...sig].map(c => c.name).sort().join(',');

    let archetype = this.archetypes.get(key);
    if (!archetype) {
      archetype = new Archetype(sig);
      this.archetypes.set(key, archetype);
    }
    archetype.addEntity(entity, components);
    this.entities.set(entity, archetype);
    return entity;
  }

  query<T extends Component[]>(...classes: { [K in keyof T]: ComponentClass<T[K]> }): T[][] {
    const results: T[][] = [];
    for (const archetype of this.archetypes.values()) {
      if (classes.every(c => archetype.signature.has(c))) {
        const columns = classes.map(c => archetype.getColumn(c));
        const count = columns[0].length;
        for (let i = 0; i < count; i++) {
          results.push(columns.map(col => col[i]) as T);
        }
      }
    }
    return results;
  }
}

这个简化版 ECS 演示了 Archetype 的核心思想:实体根据组件签名归入对应的原型,查询时只需过滤匹配的原型并顺序读取其 SoA 数据列。生产级 ECS(如 EnTT、Bevy、Flecs)在此基础上增加了 Chunk 内存池、多线程调度、反射系统、序列化和世界快照等高级功能。

缓存局部性与 SIMD 优化

ECS 的终极性能优势来自缓存局部性的最优化。现代 CPU 的缓存层次为 L1(3264KB,4 周期)、L2(256512KB,12 周期)、L3(864MB,40 周期)和主内存(200300 周期)。以下数据展示了不同内存访问模式的性能差异:

访问模式L1 命中率相对性能
顺序数组遍历>99%1.0x(基准)
随机数组访问~50%0.1x
链表遍历~30%0.05x
哈希表遍历~60%0.2x

ECS 的 SoA 布局将顺序遍历的性能发挥到极致。当 PhysicsSystem 处理 10000 个位置+速度实体时,所有数据都在连续的内存块中,CPU 预取器可以完美预测访问模式,几乎每 64 字节的缓存行都包含有效数据。

SIMD(Single Instruction Multiple Data)指令集进一步放大了 ECS 的优势。在 SoA 布局中,加载 4 个 Position.x 值到 AVX-256 寄存器只需一条 vmovups,然后一条 vaddps 即可完成 8 个浮点数的并行加法。Unity DOTS 的 Burst 编译器自动将 C# Job 代码向量化,Bevy 的 Rust 编译器也在稳定版中支持自动 SIMD 向量化。

实战:用 ECS 实现简单物理模拟

以二维弹性碰撞模拟为例,对比 OOP 与 ECS 的实现差异。

OOP 方式:

class Ball extends GameObject {
  position = { x: 0, y: 0 };
  velocity = { x: 0, y: 0 };
  radius = 10;
  update(dt: number) {
    this.position.x += this.velocity.x * dt;
    this.position.y += this.velocity.y * dt;
    // 边界反弹、碰撞检测...
  }
}
// 每帧:balls.forEach(b => b.update(dt))

ECS 方式:

// 纯数据组件
class Position { constructor(public x = 0, public y = 0) {} }
class Velocity { constructor(public x = 0, public y = 0) {} }
class CircleCollider { constructor(public radius = 10) {} }

// 纯逻辑 System
function movementSystem(world: ECSWorld, dt: number) {
  for (const [pos, vel] of world.query(Position, Velocity)) {
    pos.x += vel.x * dt;
    pos.y += vel.y * dt;
  }
}

function collisionSystem(world: ECSWorld) {
  const entities = world.query(Position, Velocity, CircleCollider);
  for (let i = 0; i < entities.length; i++) {
    for (let j = i + 1; j < entities.length; j++) {
      const [p1, v1, c1] = entities[i];
      const [p2, v2, c2] = entities[j];
      const dx = p1.x - p2.x, dy = p1.y - p2.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      if (dist < c1.radius + c2.radius) {
        // 弹性碰撞响应...
        resolveCollision(v1, v2, p1, p2, dist);
      }
    }
  }
}

ECS 版本不仅分离了数据与逻辑,还使得 movementSystem 可以并行化(各实体更新相互独立),collisionSystem 可以通过空间哈希(Spatial Hash)或四叉树(QuadTree)优化碰撞检测范围。更重要的是,添加新功能(如重力、摩擦力、轨迹渲染)只需新增组件和 System,无需修改现有代码。

生态系统与框架选型

C++ 生态中,EnTT(EnTT 是 Entity Component System 的递归缩写)是最广泛使用的 ECS 库,被 Minecraft Bedrock 版、Godot 引擎和众多独立游戏采用。它以 Sparse Set 为主存储,API 简洁,头文件仅包含实现。

Rust 生态中,Bevy 是一个现代数据驱动游戏引擎,内置完善的 ECS。Bevy 的 ECS 采用 Archetype 存储,Query 系统利用 Rust 的类型系统实现零成本抽象,System 并行调度器自动生成依赖图。

C 生态中,Flecs 提供了高性能的 Archetype ECS,独特的关系系统(Relationship)允许表达父子层级、所有权和图结构。Flecs 的查询语言支持复杂的组件过滤、排序和分组。

对于 Web 端,bitecs(JavaScript)和 ecsy(JavaScript/TypeScript)提供了浏览器友好的 ECS 实现。虽然 JavaScript 缺乏 Rust/C++ 的内存布局控制能力,但 TypedArray 和 SharedArrayBuffer 仍可实现基本的 SoA 布局,配合 Web Workers 实现多线程 System 调度。

总结

ECS 不仅是游戏引擎的架构选择,更是数据导向编程思想在交互式实时系统中的最佳实践。通过将数据布局置于设计首位,ECS 最大化了缓存局部性、SIMD 并行度和多核利用率。从 Unity DOTS 到 Bevy,从 EnTT 到 Flecs,ECS 已经成为现代游戏引擎的事实标准。对于追求极致性能的游戏、VR/AR 应用、物理模拟和实时可视化系统,掌握 ECS 架构是提升性能的必经之路。


📚 延伸阅读:《游戏引擎实现原理》卷一《引擎内核》从零实现引擎的对应章节——12.1 从继承到组合到数据:三种组织方式 。本专题讲原理与多引擎对照,书稿讲在自研 helio 引擎上把同一件事从零实现出来、并在本机真跑验证,两者互补。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist