Elytra
Blog游戏引擎

理解 ECS:从对象到数据的游戏架构

ECS 不只是 Entity、Component 和 System 三个概念,而是一种围绕数据访问模式组织游戏世界的方法。本文从现代 CPU 与数据导向设计出发,再通过 Unity DOTS、Bevy 以及其他 ECS 实现理解它的优势、代价与适用边界。


如果一个游戏里只有一个玩家、几扇门和几十个敌人,我们很自然地会把它们看成一个个“对象”:

Player
Enemy
Door
Weapon
Vehicle

每个对象拥有自己的状态,也拥有自己的行为:

Enemy.Update()
Door.Open()
Weapon.Fire()
Vehicle.Move()

这种思维非常符合人的直觉,也是传统面向对象编程和大量游戏引擎 API 的基础。

但当世界逐渐变成:

  • 10 万棵植被;
  • 5 万个投射物;
  • 数万个 NPC;
  • 大量物理碎片;
  • 数千辆车;
  • 每个实体每帧都需要执行若干模拟;

问题就发生了变化。

此时我们真正关心的可能已经不是:

“一名士兵应该设计成什么类?”

而是:

“这一帧 CPU 怎样高效处理十万份 Position 和 Velocity?”

这正是 ECS(Entity Component System) 背后的核心转变。

它不是简单地把一个类拆成三个新名词,更不是用了 ECS 就会自动获得高性能。

ECS 更重要的意义在于:它提供了一种适合 Data-Oriented Design(数据导向设计) 的程序组织方式,让我们开始围绕数据是什么、数据放在哪里、谁读取它、谁修改它、怎样批量处理它来设计游戏。

Loading diagram…

理解 ECS,真正需要理解的不是三个单词,而是这条因果链:

大规模游戏世界
        ↓
大量数据需要重复处理
        ↓
CPU 的瓶颈越来越取决于数据访问与并行
        ↓
Data-Oriented Design
        ↓
ECS
        ↓
Query / Archetype / Chunk
        ↓
批量处理、Cache Locality、并行调度

1为什么游戏开发需要另一种架构?

1.1从对象开始:一个非常自然的世界

传统游戏代码经常围绕对象组织。

以 Unity 为例:

public class Enemy : MonoBehaviour
{
    public Vector3 velocity;
    public float health = 100;

    void Update()
    {
        transform.position += velocity * Time.deltaTime;
    }

    public void TakeDamage(float damage)
    {
        health -= damage;
    }
}

这段代码的优点非常明显:

  • Enemy 就代表一个敌人;
  • 数据和行为放在一起;
  • Update() 描述敌人每帧做什么;
  • TakeDamage() 描述敌人能执行什么操作;
  • Inspector、Prefab、Hierarchy 都能自然围绕这个对象工作。

对于大量玩法逻辑而言,这是一种非常优秀的抽象。

问题并不在于它“错误”。

问题在于:

人的认知模型和 CPU 的执行模型并不是同一件事。

我们喜欢思考:

Enemy 1
Enemy 2
Enemy 3
Enemy 4

CPU 更关心的却是:

下一条要访问的数据在哪里?
它是否已经在 Cache 中?
能否连续读取?
这一批计算能否一起执行?
能否放到其他 CPU Core?

1.2当世界里出现十万个对象

假设我们有 100,000 个士兵。

每个 Soldier 都拥有:

Soldier
├── Name
├── Transform
├── Velocity
├── Health
├── Animator
├── Weapon
├── Inventory
├── AI
└── Audio

现在我们只想做一件非常简单的事情:

Position += Velocity × dt

从游戏设计角度看,我们是在“更新所有 Soldier”。

但从 CPU 角度看,它真正需要的其实只有:

Position
Velocity

Name、Inventory、Weapon、AI 此刻都没有用。

如果这些对象散布在不同内存位置,CPU 就需要不断追踪对象和引用:

Soldier*
   ↓
找到 Position
找到 Velocity

Soldier*
   ↓
找到 Position
找到 Velocity

Soldier*
   ↓
……

对象数量少的时候,这种差异往往并不重要。

但当一个游戏的核心工作变成“每帧对几十万份数据做类似操作”时,数据究竟怎样排列在内存中就会逐渐成为架构问题,而不只是实现细节。


1.3真正昂贵的可能不是“算”,而是“找到数据”

现代 CPU 可以非常快地执行加法、乘法等运算,但 CPU 并不会直接以同样速度访问所有内存。

一个非常简化的层级可以写成:

Register
   ↓
L1 Cache
   ↓
L2 Cache
   ↓
L3 Cache
   ↓
Main Memory

越靠近 CPU Core,容量通常越小,但访问越快。

CPU 在访问一个地址时,还会以 Cache Line 为单位将附近的一段数据加载进 Cache。

于是,如果程序的访问模式是:

[A][B][C][D][E][F][G][H]

接下来需要的数据就在旁边,缓存系统就非常容易发挥作用。

而如果访问模式变成:

[A] ----------> [B] -----> [C] -----------------> [D]

CPU 就更可能需要反复等待新的内存数据。

因此,在大规模模拟中,我们可以用一个非常粗略的概念模型理解一帧 CPU 工作:

T≈Tcompute+Tmemory+TsyncT \approx T_{\text{compute}} + T_{\text{memory}} + T_{\text{sync}}

这里并不是严格的硬件性能公式,而只是帮助建立直觉:

  • TcomputeT_{\text{compute}}:真正做加减乘除等计算;
  • TmemoryT_{\text{memory}}:等待和搬运数据;
  • TsyncT_{\text{sync}}:线程之间的同步、依赖和等待。

传统优化经常只让人想到“减少运算”。

而 Data-Oriented Design 更关心:

CPU 到底怎样获得这些数据?

性能问题首先是数据问题

当每帧处理的数据量很大时,减少一次乘法未必重要;让 CPU 连续访问真正需要的数据、减少 Cache Miss,并让工作能够安全地分配到多个核心,可能更重要。


2ECS 到底改变了什么?

ECS 的名字来自三个概念:

  • Entity
  • Component
  • System

但如果只是记住三者定义,很容易错过 ECS 真正改变的东西。

2.1Entity:身份,而不是“对象本体”

在很多 ECS 中,Entity 本身非常轻量。

它可能近似于:

Entity = 18427

或者由:

Index + Generation

组成。

它最核心的作用只是:

表示“这是世界里的某个实体”。

Entity 通常并不自己保存复杂行为。

也不要把它理解成传统:

class Enemy

的另一个名字。

真正描述 Entity 是什么的,是它拥有的 Component 组合。


2.2Component:描述数据

例如:

Entity 42

Position
Velocity
Health
Enemy
Poisoned

这些 Component 描述了:

它在哪里
它运动多快
它有多少生命
它是不是敌人
它是否中毒

一个典型的数据组件可能非常简单:

struct Position
{
    float3 Value;
}

它并不负责:

Move()
Attack()
Update()
Die()

这和传统 OOP 中:

Data + Behavior

放在一个对象里的习惯明显不同。


2.3System:处理拥有某些数据的实体

假设 Movement System 需要:

Position
Velocity

那么它真正表达的是:

找出所有拥有 Position + Velocity 的 Entity,然后对它们执行移动计算。

逻辑接近:

for every entity with Position + Velocity:
    Position += Velocity × dt

Health System 可能是:

for every entity with Health:
    process health

Poison System:

for every entity with Health + Poisoned:
    reduce Health

于是程序组织的中心发生了变化。

传统方式:

Enemy.Update()
Player.Update()
Vehicle.Update()

ECS:

MovementSystem(Position, Velocity)
HealthSystem(Health)
PoisonSystem(Health, Poisoned)

2.4真正重要的第四个概念:Query

虽然名字叫 Entity Component System,但实际理解现代 ECS 时,还有一个概念极其重要:

Query

System 通常并不是遍历“所有对象”,然后一个一个问:

你有 Position 吗?
你有 Velocity 吗?

而是描述自己需要什么数据:

Query<Position, Velocity>

或者:

Query<
    Position,
    Velocity,
    With<Enemy>,
    Without<Dead>
>

意思是:

找出拥有 Position 和 Velocity、同时是 Enemy、且没有 Dead 标记的实体。

于是:

Component Composition
        ↓
      Query
        ↓
     System

才是一个更接近实际 ECS 运行方式的模型。

Loading diagram…

ECS 的核心转变

传统对象模型首先问的是:

“这个对象应该做什么?”

ECS 更常问的是:

“这个系统真正需要哪些数据?”


3ECS 为什么可能更快?

这里有一个非常重要的措辞:

可能。

ECS 并不会因为名字叫 ECS 就自动获得性能。

真正带来性能优势的是它背后的数据布局和访问模式。


3.1从对象布局到数据布局

考虑这样一个对象:

Enemy
├── Position
├── Velocity
├── Health
├── Inventory
├── Animator
├── Weapon
└── AI

Movement System 实际只关心:

Position
Velocity

如果大量对象的相关数据能够紧密排列:

Position:
[P0][P1][P2][P3][P4][P5]...

Velocity:
[V0][V1][V2][V3][V4][V5]...

那么 CPU 可以非常规律地顺序扫描这些数据。

这就是 Spatial Locality(空间局部性)。

与此同时,某一批数据往往很快再次被访问,这又与 Temporal Locality(时间局部性) 有关。

Data-Oriented Design 并不是简单地说:

“数组比对象快。”

它真正提出的是:

按照实际的数据访问模式组织数据。


3.2AoS 与 SoA

一个经典例子是:

3.3Array of Structures

struct Particle
{
    float x;
    float y;
    float z;

    float vx;
    float vy;
    float vz;

    float lifetime;
};

Particle particles[100000];

内存近似:

[x y z vx vy vz life]
[x y z vx vy vz life]
[x y z vx vy vz life]
...

如果当前算法只需要:

x y z vx vy vz

每个 Particle 中的其他数据也可能一起进入 Cache。

另一种方式是 Structure of Arrays:

Position:
[x y z][x y z][x y z]...

Velocity:
[x y z][x y z][x y z]...

Lifetime:
[l][l][l]...

此时不同 System 可以更集中地读取自己真正需要的数据。

但这并不意味着:

SoA 永远优于 AoS。

真正的判断仍然是:

哪些数据经常一起被访问?

如果几项数据每次总是一起使用,让它们靠近反而可能更合理。

因此 Data-Oriented Design 的重点从来不是背诵某个固定布局,而是访问模式。


3.4Archetype:按照 Component 组合分组

现代 ECS 中非常常见的一种实现方式叫 Archetype ECS。

假设世界里存在:

Entity A:
Position
Velocity

Entity B:
Position
Velocity

Entity C:
Position
Velocity
Health

Entity D:
Position
Health

可以按照 Component 组合把它们分成:

Archetype 1
Position + Velocity
├── A
└── B

Archetype 2
Position + Velocity + Health
└── C

Archetype 3
Position + Health
└── D

这样,当 Movement System 查询:

Position + Velocity

它不需要逐个检查所有 Entity。

它可以直接找到:

Archetype 1
Archetype 2

然后批量处理其中的数据。

这正是 Unity Entities、Bevy、Flecs、Unreal Mass 等不少现代 ECS 中非常重要的思想。12


3.5Chunk:让一批相似实体待在一起

Archetype 解决的是:

哪些 Entity 拥有相同 Component 组合?

接下来还需要解决:

这些数据实际怎样放进内存?

典型 Archetype ECS 会进一步把数据放入连续的内存块。

概念上可以理解为:

Archetype: Position + Velocity + Health

Chunk A
┌─────────────────────────────┐
│ Position Position Position  │
│ Velocity Velocity Velocity  │
│ Health   Health   Health    │
└─────────────────────────────┘

Chunk B
┌─────────────────────────────┐
│ ...                         │
└─────────────────────────────┘

System 可以按 Chunk 处理整批 Entity。

于是程序从:

拿到一个 Object
追踪它的数据
执行一点逻辑
再找下一个 Object

逐渐变成:

找到一整块符合条件的数据
连续处理
处理下一块

这正是现代 CPU 擅长的工作负载之一。


3.6批量处理比“对象调用”更容易优化

传统逻辑:

Enemy1.Update()
Enemy2.Update()
Enemy3.Update()
Enemy4.Update()
...

每个 Update 都可能包含完全不同的事情:

读 Transform
查 AI
修改 Inventory
播放声音
Spawn Object
访问 Player
修改 Physics

从外面很难知道:

这段代码究竟会访问哪些数据?

而 ECS 中,一个 Movement System 可能明确表示:

Read:
    Velocity

Write:
    Position

于是执行模型变得更加规则。

Position[0] += Velocity[0] × dt
Position[1] += Velocity[1] × dt
Position[2] += Velocity[2] × dt
Position[3] += Velocity[3] × dt
...

这种代码不仅有利于 Cache,也更有利于编译器进行循环优化和向量化。


3.7ECS 为什么特别适合并行?

现代 CPU 性能增长越来越依赖多个 CPU Core。

但多线程真正困难的地方从来不是:

“怎样创建一个线程?”

而是:

两个线程会不会同时修改同一份数据?

假设:

MovementSystem
Read:  Velocity
Write: Position

另一个:

HealthSystem
Write: Health

两者访问的数据互不冲突。

理论上:

MovementSystem ────────┐
                       ├── 同时执行
HealthSystem   ────────┘

但如果另一个系统:

AnimationSystem
Read: Position

那么它需要看到 Movement System 更新之后的 Position 时,就形成了依赖:

Loading diagram…

因此,ECS 中显式的数据访问声明具有另一个价值:

它让调度器更容易知道哪些 System 可以并行。

这也是 ECS 与 Job System / Scheduler 天然契合的重要原因。


3.8SIMD 与编译器优化

如果代码最终变成非常规则的循环:

for (int i = 0; i < count; i++)
{
    position[i] += velocity[i] * dt;
}

编译器就更有机会使用:

  • SIMD;
  • Loop Unrolling;
  • Auto Vectorization;
  • 更简单的寄存器分配;
  • 更少的间接访问。

Unity Burst 就特别适合处理这一类数据导向工作负载。Burst 会将符合要求的 .NET IL 编译成经过 LLVM 优化的 Native Code。3

不过这里仍然应该避免倒置因果:

不是:
ECS → SIMD

而是:
规则、连续、可分析的数据处理
        ↓
更容易进行 SIMD / Compiler Optimization

3.9ECS 不等于高性能

假设我们实现一个 ECS:

Entity
 ↓
Dictionary<ComponentType, Component*>
 ↓
Component malloc 在任意位置

然后每次 System 都不断:

Hash Lookup
Pointer Chasing
Random Memory Access

即使它拥有:

Entity
Component
System

三个名字,也未必高效。

ECS ≠ 高性能

ECS 是一种架构模式;真正决定性能的是具体的数据布局、查询算法、内存分配、调度模型以及程序本身的数据访问方式。

用了 ECS 和 做了 Data-Oriented Design 并不是同一句话。


4从同一段代码理解 ECS

抽象概念讲得再多,也不如比较同一个问题。

我们只做一件事情:

每一帧根据 Velocity 更新 Position。

即:

pt+Δt=pt+vΔt\mathbf{p}_{t+\Delta t} = \mathbf{p}_t + \mathbf{v}\Delta t

这不是为了研究移动算法,而是为了观察三种程序组织方式有什么不同。


4.1传统 Unity:让对象更新自己

using UnityEngine;

public class Mover : MonoBehaviour
{
    public Vector3 velocity;

    private void Update()
    {
        transform.position += velocity * Time.deltaTime;
    }
}

它表达的是:

Mover
├── 拥有 Velocity
├── 拥有自己的 Transform
└── Update 自己的位置

思维中心是:

这个 Mover 每一帧应该做什么?

这种代码非常直观。

对于一个玩家、几十个交互物、少量特殊机关,它甚至可能是更好的设计。


4.2Unity Entities:让 System 查询数据

一个简化的 Unity Entities 写法可以是:

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

public struct Velocity : IComponentData
{
    public float3 Value;
}

[BurstCompile]
public partial struct MovementSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float dt = SystemAPI.Time.DeltaTime;

        foreach (var (transform, velocity)
                 in SystemAPI.Query<
                     RefRW<LocalTransform>,
                     RefRO<Velocity>>())
        {
            transform.ValueRW.Position +=
                velocity.ValueRO.Value * dt;
        }
    }
}

暂时不用在意所有 API。

只观察结构:

MovementSystem

Query:
    Write LocalTransform
    Read  Velocity

System 并不属于某一个 Entity。

它表达的是:

给我所有同时拥有 LocalTransform 和 Velocity 的实体。

然后批量处理它们。

思维中心已经变成:

我需要哪些数据?
↓
哪些 Entity 匹配?
↓
对这些数据做什么?

4.3Bevy:同样是 System + Query

在 Bevy 中可以写成:

use bevy::prelude::*;

#[derive(Component)]
struct Velocity(Vec3);

fn movement(
    time: Res<Time>,
    mut query: Query<(&mut Transform, &Velocity)>,
) {
    for (mut transform, velocity) in &mut query {
        transform.translation +=
            velocity.0 * time.delta_secs();
    }
}

再次忽略 API 细节,只观察:

Query<(&mut Transform, &Velocity)>

它已经表达了:

Transform → Write
Velocity  → Read

Bevy ECS 的 Query 本身就是 System 参数之一,用于选择性访问 World 中的 Component 数据。4


4.4三种代码真正不同在哪里?

Unity MonoBehaviourUnity EntitiesBevy
思考中心一个对象一批数据一批数据
身份GameObject / ComponentEntityEntity
状态对象字段 / ComponentComponent DataComponent
行为Method / Update()SystemSystem
选择对象引用、查找QueryQuery
典型表达“这个对象更新自己”“处理所有匹配数据”“处理所有匹配数据”
并行模型通常需要额外设计ECS + JobsScheduler 根据访问关系调度

真正的区别不是:

C# vs Rust

也不是:

MonoBehaviour vs ISystem

而是:

Object → Behavior

变成:

Data → Query → System

不要从语法理解 ECS

真正理解 ECS 以后,看到 Unity Entities、Bevy、Flecs 或其他 ECS 时,首先注意的应该不是 API 名字,而是:

  • 数据如何存储;
  • System 如何声明访问;
  • Query 如何选择实体;
  • 批量处理如何发生;
  • 数据依赖如何影响调度。

5Unity:为什么在 GameObject 之外引入 DOTS?

Unity 是理解 ECS 特别好的案例。

因为它同时拥有两套非常鲜明的世界:

GameObject / MonoBehaviour

和:

Entities / DOTS

我们因此可以清楚看到:

一个原本围绕 Object 设计的成熟游戏引擎,为什么还需要引入 Data-Oriented Architecture?


5.1Unity 原来的世界:GameObject + Component

Unity 经典的核心抽象非常漂亮:

GameObject
├── Transform
├── MeshRenderer
├── Rigidbody
├── Collider
└── MonoBehaviour

它同时解决了很多问题:

  • 编辑器中的 Scene Hierarchy;
  • Prefab;
  • Inspector;
  • Component Composition;
  • 序列化;
  • 脚本生命周期;
  • 对象引用;
  • 设计师工作流。

而且它非常容易理解:

public class Door : MonoBehaviour
{
    public void Open()
    {
        ...
    }
}

然后:

把脚本拖到 GameObject
↓
设置 Inspector
↓
运行

这是非常强大的生产力模型。

问题在于,它的主要设计目标并不是:

让十万份同构数据以最适合现代 CPU 的方式执行。


5.2DOTS 并不等于 ECS

Unity 后来发展出 DOTS:Data-Oriented Technology Stack。

理解 DOTS 时最容易犯的错误之一就是:

DOTS = ECS

实际上,更合适的理解是:

Loading diagram…

三者分别解决不同问题。

5.3Entities

主要关心:

Entity
Component
Archetype
Query
System

也就是:

数据是什么,怎样组织和访问。

5.4C# Job System

主要解决:

怎样安全地把计算分配到多个 CPU Core。

Unity 的 Job System 可以独立于 Entities 使用,并不是只有 ECS 才能使用 Job。5

5.5Burst

主要解决:

怎样把适合的数据导向 C# 代码编译成高质量 Native Code。

Burst 使用 LLVM 后端进行优化。3

因此:

Entities = Data
Jobs     = Parallel Execution
Burst    = Optimized Code Generation

三者结合起来,才构成了 Unity 非常典型的高性能数据导向工作流。


5.6Unity Entities 怎样存储世界?

在 Entities 中,一个 Entity 不再像 GameObject 那样是主要的对象容器。

假设:

Entity A:
LocalTransform
Velocity
Health

Entity B:
LocalTransform
Velocity
Health

Entity C:
LocalTransform
Velocity
Health

它们拥有相同的 Component 组合,因此属于相同 Archetype。

可以概念化为:

Archetype:
LocalTransform + Velocity + Health

Chunk
┌─────────────────────────────────┐
│ LocalTransform                  │
│ A A A A A A A A ...            │
│                                 │
│ Velocity                        │
│ V V V V V V V V ...            │
│                                 │
│ Health                          │
│ H H H H H H H H ...            │
└─────────────────────────────────┘

Movement System 查询:

LocalTransform + Velocity

便可以高效遍历符合条件的 Archetype / Chunk。

这和:

GameObject
→ MonoBehaviour
→ Update

是非常不同的执行模型。


5.7为什么 Structural Change 特别值得注意?

假设 Entity 当前属于:

Archetype A:
Position + Velocity + Health

现在执行:

AddComponent<Poisoned>()

它的新组合变成:

Archetype B:
Position + Velocity + Health + Poisoned

在 Archetype ECS 中,这并不只是:

entity.poisoned = true;

因为 Entity 的组件组合本身发生了变化。

它可能需要从:

Archetype A

移动到:

Archetype B

因此:

Add Component
Remove Component
Spawn
Destroy

这一类操作通常称为 Structural Change。

这也是为什么不少 ECS 会使用:

Command Buffer
Deferred Commands

把结构变化暂存起来,在安全阶段批量执行。

Unreal Mass 也采用了类似思想:改变 Fragment 组合意味着 Entity 可能移动到另一个 Archetype,因此 Mass 提供 Command Buffer 来延后批处理这类变化。2


5.8为什么 Unity 没有抛弃 GameObject?

如果 Entities 更适合高性能,为什么 Unity 不直接把 GameObject 全部替换掉?

因为性能从来不是软件架构唯一的目标。

考虑:

一个 Boss
一个剧情机关
一个主摄像机
一扇特殊的门
一个复杂驾驶舱
一个设置菜单
一个角色手上的特殊道具

这些东西数量少、行为高度特殊,而且经常依赖:

  • Inspector;
  • Hierarchy;
  • Prefab;
  • 动画;
  • 可视化编辑;
  • 大量对象引用。

对于这种工作负载:

Door.Open()

可能比建立:

DoorState
DoorInteraction
DoorOpenRequest
DoorAnimationSystem

更加简单。

所以现代 Unity 更现实的模型并不是:

GameObject
    ↓
被 ECS 淘汰

而是:

              Unity
             /     \
            /       \
 GameObject World   Entity World
       |                |
特殊对象/创作工作流   大规模数据处理

Unity 官方现在也将 ECS 描述为能够与 GameObject 工作流配合的数据导向框架,而不是要求所有项目彻底离开 GameObject。6


5.9Unity DOTS 真正交换了什么?

Unity Entities / DOTS 的价值可以概括成:

为了更强的数据控制能力和大规模 CPU 吞吐量,放弃一部分传统对象模型的便利性。

得到的是:

  • 更明确的数据布局;
  • 更高效的批处理;
  • 更容易表达 Job;
  • 更强的多核利用能力;
  • Burst 优化;
  • 更适合大规模 Simulation。

代价则是:

  • 学习 Archetype / Chunk / Query;
  • System 生命周期;
  • Job Dependency;
  • Structural Change;
  • EntityCommandBuffer;
  • Baking / Authoring;
  • Managed 与 Unmanaged 世界的边界;
  • GameObject 与 Entity 两套心智模型并存。

所以 Unity DOTS 最危险的理解是:

“DOTS 更快,所以整个项目都应该 DOTS。”

更实际的判断是:

哪些工作负载真正值得为 Data-Oriented Architecture 支付复杂度成本?


6Bevy:如果整个引擎从 ECS 开始设计

Unity 的故事是:

已经拥有成熟的 GameObject 世界,然后加入 ECS。

Bevy 则提供了完全不同的观察角度:

如果一个现代游戏引擎从一开始就把 ECS 当作核心 Application Model,会发生什么?


6.1ECS 在 Bevy 中不是“高性能插件”

在 Bevy 中,ECS 并不是一个类似:

Optional High Performance Mode

的东西。

World、Entity、Component、Resource、System、Query、Schedule 等概念共同构成了非常核心的应用模型。

于是你写 Gameplay:

System + Query

做 Input:

System + Resource

处理状态:

Component / Resource

很多插件同样围绕这套模型集成。

因此 Bevy 最显著的架构特点之一是:

引擎与 Gameplay 使用高度统一的语言。


6.2Query 是 Bevy 日常开发的中心

例如:

fn movement(
    mut query: Query<(&mut Transform, &Velocity)>
) {
    ...
}

这里的 Query 已经告诉 ECS:

Transform → mutable / write
Velocity  → immutable / read

如果另一个 System 是:

fn health_system(
    mut query: Query<&mut Health>
) {
    ...
}

它与 Movement System 访问的数据没有冲突。

于是调度器就获得了足够的信息来判断潜在的并行关系。

Bevy 的 Schedule 本身负责组织一组 System 及运行它们所需的顺序、条件和执行元数据。7


6.3为什么 Rust 和 ECS 很契合?

Rust 本身就在迫使程序员思考:

谁拥有数据?
谁能读取数据?
谁能修改数据?
两个地方能否同时修改?

ECS Scheduler 也在思考:

System A 读什么?
System A 写什么?

System B 读什么?
System B 写什么?

二者有没有冲突?

两种模型关注的问题非常接近。

例如:

Query<&Position>

表达:

Read Position

而:

Query<&mut Position>

表达:

Write Position

这让 ECS 的数据访问约束与 Rust 的可变性表达形成了非常自然的结合。

不过也不要把两者理解成:

Rust 天生必须 ECS。

Rust 完全可以编写传统对象架构;ECS 也可以很好地存在于 C、C++、C# 等语言中。

这里只是说:

Rust 对共享可变状态的严格态度,与 ECS 对数据访问关系的显式表达非常契合。


6.4“整个引擎使用同一种语言”的优势

Unity 中我们可能同时面对:

GameObject
MonoBehaviour
Entities
Job
Burst
ScriptableObject
Manager

而 Bevy 倾向于让很多逻辑都回到:

World
Entity
Component
Resource
System
Query
Schedule
Plugin

它的价值不是单纯减少名词,而是形成一种 Architecture Consistency:

当你理解 System 怎样读取 World,就已经理解了大量 Bevy 子系统的基本组织方式。

这使 ECS 不只是优化工具,而成为应用结构本身。


6.5“一切都是 ECS”的代价

这种统一同样有代价。

对于一个非常简单的对象,我们天然会想:

door.open()

但 ECS 设计可能逐渐变成:

DoorState
Interaction
OpenDoorEvent
DoorSystem
AnimationSystem

这会出现一个有趣的问题:

复杂的大规模系统可能变得更规则了,而简单的局部行为反而变得更间接。

传统代码很容易顺着 Call Stack 阅读:

Player.Update()
    ↓
Weapon.Fire()
    ↓
Projectile.Spawn()

ECS 中可能变成:

Input System
↓
Fire Event
↓
Weapon System
↓
Command
↓
Projectile Spawn System

因此调试习惯也会变化。

你不再只问:

谁调用了这个函数?

还要问:

这个 Component 为什么变了?
哪个 System 写了它?
这个 System 在哪个 Schedule?
它与谁存在 Ordering?
这一帧 Event 从哪里产生?

这就是 ECS 非常典型的代价:

局部调用关系变弱,整体数据流关系变强。


7ECS 的不同实现与生态

到这里还有一个很容易产生的误解:

“ECS 是不是就是 Unity Entities 或 Bevy 那种 Archetype + System?”

不是。

ECS 更像一个架构家族。

不同实现可能采用完全不同的数据结构和性能取舍。


7.1Unreal Engine Mass Entity

Unreal Engine 也提供了自己的 Data-Oriented Entity Framework:

Mass Entity。

其概念包括:

Entity
Fragment
Archetype
Processor
Query
Command Buffer

其中 Fragment 可以理解为 Mass 中的数据组件,而 Processor 承担处理逻辑。

Epic 官方把 Mass Entity 描述为用于 Data-Oriented Calculation 的框架。Entity 由 Fragment 组合而成,相同组合的 Entity 被组织进 Archetype。2

它特别适合观察一个事实:

ECS / Data-Oriented Architecture 并不是 Unity 特有的发展路线。

成熟的传统大型引擎同样需要面对大规模实体模拟、交通、人群、AI 等数据处理问题。


7.2Flecs:把 ECS 本身做到很完整

Flecs 是一个专门的 C / C++ ECS 框架。

它非常适合作为学习 ECS 本身的案例,因为它没有一个 Unity 或 Unreal 那样庞大的完整引擎包在外面。

Flecs 采用 Archetype / SoA 风格的数据组织,并提供:

  • Query;
  • System;
  • Relationship;
  • Hierarchy;
  • Prefab;
  • Multithreaded Scheduler。

其官方文档对 Archetype 的解释非常直接:

拥有相同 Component 的 Entity 被存储在一起,从而改善 CPU Cache 利用,并有利于向量化和快速查询。1

因此,如果想研究:

Archetype
Query
Relationship
Scheduler

本身是怎样工作的,Flecs 是一个很好的参考对象。


7.3EnTT:ECS 并不一定基于 Archetype

另一个非常有代表性的 C++ 库是 EnTT。

EnTT 的 ECS 核心采用 Sparse Set 风格的设计,而不是简单复制 Archetype ECS。

它的 Component Pool 基于 Sparse Set,每种 Component 都可以拥有自己的紧凑存储;View / Group 再用于组合不同 Component 的遍历。8

这正好说明:

ECS
≠
Archetype ECS

ECS 需要解决的是:

Entity 怎样标识?
Component 怎样存?
怎样查询 Component 组合?
怎样快速遍历?
怎样添加 / 删除 Component?

而这些问题有很多不同的数据结构答案。


7.4Archetype 与 Sparse Set 解决问题的方式不同

可以做一个非常粗略的对比:

思路Archetype ECSSparse Set ECS
主要分组方式按 Component 组合按 Component 类型维护 Pool
查询匹配找符合组合的 Archetype组合 / 交集相关 Component Pool
遍历很适合整批同构 EntityComponent Pool 通常也可紧凑遍历
添加 / 删除 Component可能移动 Archetype修改相关 Component Storage
典型实现Unity Entities、Flecs、Mass 等EnTT 等
核心取舍查询与批处理非常强灵活的独立 Component Storage

这张表只能作为建立直觉的入口。

真实 ECS 的实现通常还有:

  • Table;
  • Sparse Storage;
  • Shared Component;
  • Tag;
  • Relation;
  • Change Detection;
  • Hybrid Storage;

并不会永远严格落入两个盒子。

ECS 是架构家族,不是固定数据结构

看到两个项目都宣称自己使用 ECS,并不意味着它们拥有相同的内存布局、Query Complexity、Structural Change 成本或并行策略。

比较 ECS 时,真正应该继续问:

Component 到底存在哪里?


8ECS 的优势究竟是什么?

经过前面的分析,现在可以更准确地总结 ECS 的价值。

8.1大规模同构数据处理

ECS 特别喜欢这种问题:

对所有拥有某些数据的 Entity 做类似计算。

例如:

所有 Position + Velocity
所有 Health + Poisoned
所有 Projectile
所有 Visible + Transform
所有 AI + Perception

也就是:

N 很大
+
数据结构类似
+
处理逻辑类似

这是非常典型的 ECS 工作负载。


8.2Composition 比深层继承更自然

传统继承可能逐渐形成:

Entity
└── Actor
    └── Character
        └── Enemy
            └── FlyingEnemy
                └── PoisonFlyingEnemy

需求一多,就容易出现:

“这个对象到底应该继承哪一个父类?”

ECS 更自然的做法是组合:

Entity
├── Position
├── Velocity
├── Enemy
├── Flying
└── Poison

增加能力不一定意味着增加新的类层级。

不过这一优势并不是 ECS 独占的。

Unity GameObject 本身也是 Component-Based Design。

所以不能简单地说:

“使用 Component 就是 ECS。”


8.3数据访问关系更加明确

System 可以声明:

Read:
Position
Velocity

Write:
Acceleration

这使得:

  • 依赖分析;
  • Job Scheduling;
  • Race Detection;
  • Parallelism;

都更加容易。


8.4更容易围绕硬件优化

当数据处理逐渐变成:

简单数据
+
连续内存
+
规则循环
+
明确访问权限

程序就更容易利用:

  • Cache;
  • Prefetch;
  • SIMD;
  • Multiple Cores;
  • Native Compiler Optimization。

因此 ECS 经常和 Data-Oriented Design 一起出现。


9ECS 的代价是什么?

任何架构都不是免费的。

ECS 最大的风险之一,恰恰是因为它的优势太吸引人,于是很容易被应用到不需要它的地方。


9.1简单行为可能变复杂

传统代码:

door.Open();

非常清楚。

ECS 化之后可能出现:

Door
DoorState
InteractionRequest
InteractionSystem
DoorSystem
DoorAnimationSystem

如果整个游戏只有三扇特殊的门,这种设计可能并没有产生价值。


9.2数据和行为分离之后,局部可读性下降

OOP 强调:

一个对象的数据
+
这个对象能做什么

放在一起。

ECS 则可能让:

Health

存在于一个文件,

DamageSystem

存在于另一个文件,

DeathSystem

又在其他地方。

好处是系统之间的数据关系更加明确。

代价则是:

想理解“一个 Entity 到底会发生什么”,需要观察多个 System。


9.3System Ordering 会成为新问题

复杂项目中很快会出现:

Input
  ↓
Movement
  ↓
Physics
  ↓
Transform Propagation
  ↓
Animation
  ↓
Rendering

如果某些 System:

Before X
After Y
RunIf Z
FixedUpdate
PostUpdate

越来越多,那么复杂度并没有消失。

它只是从:

Object Dependency

转移成:

Data Dependency
+
Schedule Dependency

9.4Structural Change 有成本

如前所述,在 Archetype ECS 中:

Add Component
Remove Component

可能意味着:

Entity 从一个 Archetype
搬到另一个 Archetype

因此 Component 不应该被机械地当作:

随便加删的 bool

具体 ECS 对 Structural Change 的优化策略各不相同,但理解这个问题非常重要。


9.5Debugging 的思维方式改变了

传统对象代码经常依赖:

Breakpoint
Call Stack
Object Reference

ECS 调试则更需要观察:

World
Entity
Component State
Query
System Execution
Schedule
Change History

于是:

控制流变得更弱,数据流变得更重要。

这不仅是一种性能设计变化,也是一种 Debugging 模型变化。


10什么时候应该使用 ECS?

这里不能得到一个简单的:

ECS > OOP

结论。

真正的问题应该是:

你的工作负载是什么?


10.1非常适合 ECS 的问题

例如:

100,000 个 Projectile
50,000 棵可模拟植被
20,000 个单位
大规模 Crowd
大量粒子式 Gameplay Entity
大规模 Buff / Status
大量可见性计算
大规模 AI 感知
大规模网络实体

这些问题通常具有:

数量巨大
+
Component 结构重复
+
处理逻辑规则
+
大量批处理机会

ECS 非常容易发挥优势。


10.2不一定值得 ECS 化的问题

例如:

一个主角
一个特殊 Boss
一个剧情 Camera
几个 UI Panel
一个高度定制的机关
一个复杂的驾驶舱
一套编辑器工具

这里真正的瓶颈通常不是:

怎样在一个 Cache Line 里塞更多 DoorState

而是:

开发效率
可视化编辑
可读性
特殊行为
工作流

此时传统对象模型可能更加合适。


10.3一个很实用的判断问题

当你发现自己经常说:

“我要对所有拥有 XXX 的对象做同一种事情。”

就值得开始考虑 ECS / Data-Oriented Design。

例如:

所有带 Velocity 的对象
所有中毒的单位
所有需要更新 LOD 的实体
所有活跃 Projectile
所有 Visible Mesh

反过来,如果你不断说:

“这个东西是唯一的,它需要很多特别行为。”

那么把它强行塞进高度数据导向的 ECS 模型,收益可能很有限。


11ECS、OOP 与 Component-Based Design 到底是什么关系?

最后有必要把几个经常混在一起的概念拆开。

11.1Object-Oriented Programming

重点倾向于:

Object
=
State + Behavior

思考:

这个对象是谁? 它能做什么?


11.2Component-Based Design

重点倾向于:

Object
=
多个能力的组合

例如 Unity:

GameObject
├── Transform
├── Rigidbody
├── AudioSource
└── MyBehaviour

它解决了深层继承的一部分问题。

但它仍然可以是以对象为中心的。


11.3Entity Component System

典型 ECS 更倾向于:

Entity = Identity
Component = Data
System = Behavior
Query = Selection

处理逻辑围绕:

哪些 Entity 拥有哪些数据?

展开。


11.4Data-Oriented Design

DOD 的范围比 ECS 更大。

它首先问:

程序实际上怎样访问数据?

然后再据此选择:

  • 数据结构;
  • 内存布局; -批处理方式;
  • 并行策略;
  • 算法。

ECS 是实现 Data-Oriented Architecture 的一种非常重要的方式,但绝不是唯一方式。

因此它们之间更接近:

Loading diagram…

这张图并不是严格的分类学,而是想强调:

ECS 同时吸收了 Component Composition 与 Data-Oriented Thinking,但三者并不能简单画等号。

Component-Based ≠ ECS

Unity 的 GameObject 本身就是高度组件化的:

GameObject + Component

但传统 GameObject 架构并不会因此自动成为现代 Data-Oriented ECS。

判断一个系统是否真正采用 ECS,不应该只看它有没有叫做 Component 的东西。


12最终应该怎样理解 ECS?

如果只记住一句:

Entity 是 ID,Component 是数据,System 是逻辑。

这没有错。

但它还没有解释 ECS 为什么在现代游戏引擎中如此重要。

更完整的理解应该是:

ECS 将程序组织中心从“对象拥有什么行为”,转向“系统需要访问哪些数据”,从而为批量处理、数据局部性和并行调度建立更适合的结构。

因此,ECS 真正值得学习的并不只是三个名词,而是背后的思考路径:

我要模拟什么对象?
        ↓
这些对象实际上有哪些数据?
        ↓
哪些逻辑会一起访问哪些数据?
        ↓
这些数据应该怎样存?
        ↓
怎样批量查询?
        ↓
哪些工作能够并行?

Unity DOTS 展示的是:

一个传统 Object-Oriented / Component-Based 游戏引擎,怎样增加一条 Data-Oriented 高性能路线。

Bevy 展示的是:

如果从引擎设计开始,就把 ECS 作为 Application Model,会形成怎样统一的架构。

Flecs、EnTT、Unreal Mass 又进一步说明:

ECS 并不是 Unity 或 Bevy 的专属技术,更不是某一种固定数据结构,而是一整类围绕 Entity、Component、Query 和数据处理组织程序的架构。

所以 ECS 最终不是:

“OOP 的继任者。”

也不是:

“只要性能要求高就应该使用的技术。”

更准确的结论是:

当游戏面对大量结构相似的数据,并需要高吞吐量地反复处理它们时,从对象转向数据,往往会打开一套完全不同的设计空间。ECS 正是其中最成熟、最具代表性的表达方式之一。

而当世界里只有一扇门时:

door.Open();

依然可能是最好的代码。


注释

  1. Flecs Documentation,FAQ: What is an archetype?。Flecs 使用 Archetype-Based Storage,将拥有相同 Component 的 Entity 放在一起,以改善 Cache 利用并支持高效查询和向量化。 ↩ ↩2

  2. Epic Games,MassEntity Overview。Mass Entity 是 Unreal Engine 面向 Data-Oriented Calculation 的框架,包含 Fragment、Entity、Archetype、Processor 与 Command Buffer 等概念。 ↩ ↩2 ↩3

  3. Unity Technologies,Unity’s Data-Oriented Technology Stack。Unity 官方将 Entities/ECS、C# Job System 与 Burst 作为 DOTS 的关键技术,并说明 Burst 基于 LLVM 将 IL/.NET bytecode 编译为优化后的 Native Code。 ↩ ↩2

  4. Bevy ECS Documentation,Query。Query 是 Bevy System Parameter,用于选择性访问 World 中符合条件的 Component 数据。 ↩

  5. Unity Documentation,Job system overview。Unity 官方说明 Job System 可独立使用,也可以与 Burst、ECS 组合,用于多核 CPU 工作负载。 ↩

  6. Unity Technologies,ECS for Unity。Unity 将 ECS 描述为面向数据的框架,并强调其与现有 GameObject 工作流的兼容关系。 ↩

  7. Bevy ECS Documentation,Schedule。Bevy 的 Schedule 保存 System 以及运行顺序、条件和执行所需的相关元数据。 ↩

  8. EnTT Documentation,Crash Course: Entity Component System。EnTT 的 ECS 核心采用 Sparse Set Based Model,每个 Component Pool 建立在 Sparse Set Storage 之上。 ↩

CONTINUE EXPLORING

沿着这篇文章继续

在地图中查看

On this page

1为什么游戏开发需要另一种架构?1.1从对象开始:一个非常自然的世界1.2当世界里出现十万个对象1.3真正昂贵的可能不是“算”,而是“找到数据”2ECS 到底改变了什么?2.1Entity:身份,而不是“对象本体”2.2Component:描述数据2.3System:处理拥有某些数据的实体2.4真正重要的第四个概念:Query3ECS 为什么可能更快?3.1从对象布局到数据布局3.2AoS 与 SoA3.3Array of Structures3.4Archetype:按照 Component 组合分组3.5Chunk:让一批相似实体待在一起3.6批量处理比“对象调用”更容易优化3.7ECS 为什么特别适合并行?3.8SIMD 与编译器优化3.9ECS 不等于高性能4从同一段代码理解 ECS4.1传统 Unity:让对象更新自己4.2Unity Entities:让 System 查询数据4.3Bevy:同样是 System + Query4.4三种代码真正不同在哪里?5Unity:为什么在 GameObject 之外引入 DOTS?5.1Unity 原来的世界:GameObject + Component5.2DOTS 并不等于 ECS5.3Entities5.4C# Job System5.5Burst5.6Unity Entities 怎样存储世界?5.7为什么 Structural Change 特别值得注意?5.8为什么 Unity 没有抛弃 GameObject?5.9Unity DOTS 真正交换了什么?6Bevy:如果整个引擎从 ECS 开始设计6.1ECS 在 Bevy 中不是“高性能插件”6.2Query 是 Bevy 日常开发的中心6.3为什么 Rust 和 ECS 很契合?6.4“整个引擎使用同一种语言”的优势6.5“一切都是 ECS”的代价7ECS 的不同实现与生态7.1Unreal Engine Mass Entity7.2Flecs:把 ECS 本身做到很完整7.3EnTT:ECS 并不一定基于 Archetype7.4Archetype 与 Sparse Set 解决问题的方式不同8ECS 的优势究竟是什么?8.1大规模同构数据处理8.2Composition 比深层继承更自然8.3数据访问关系更加明确8.4更容易围绕硬件优化9ECS 的代价是什么?9.1简单行为可能变复杂9.2数据和行为分离之后,局部可读性下降9.3System Ordering 会成为新问题9.4Structural Change 有成本9.5Debugging 的思维方式改变了10什么时候应该使用 ECS?10.1非常适合 ECS 的问题10.2不一定值得 ECS 化的问题10.3一个很实用的判断问题11ECS、OOP 与 Component-Based Design 到底是什么关系?11.1Object-Oriented Programming11.2Component-Based Design11.3Entity Component System11.4Data-Oriented Design12最终应该怎样理解 ECS?注释