从 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引擎上把同一件事从零实现出来、并在本机真跑验证,两者互补。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。