理解 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(数据导向设计) 的程序组织方式,让我们开始围绕数据是什么、数据放在哪里、谁读取它、谁修改它、怎样批量处理它来设计游戏。
理解 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 4CPU 更关心的却是:
下一条要访问的数据在哪里?
它是否已经在 Cache 中?
能否连续读取?
这一批计算能否一起执行?
能否放到其他 CPU Core?1.2当世界里出现十万个对象
假设我们有 100,000 个士兵。
每个 Soldier 都拥有:
Soldier
├── Name
├── Transform
├── Velocity
├── Health
├── Animator
├── Weapon
├── Inventory
├── AI
└── Audio现在我们只想做一件非常简单的事情:
Position += Velocity × dt从游戏设计角度看,我们是在“更新所有 Soldier”。
但从 CPU 角度看,它真正需要的其实只有:
Position
VelocityName、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 工作:
这里并不是严格的硬件性能公式,而只是帮助建立直觉:
- :真正做加减乘除等计算;
- :等待和搬运数据;
- :线程之间的同步、依赖和等待。
传统优化经常只让人想到“减少运算”。
而 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 × dtHealth System 可能是:
for every entity with Health:
process healthPoison 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 运行方式的模型。
ECS 的核心转变
传统对象模型首先问的是:
“这个对象应该做什么?”
ECS 更常问的是:
“这个系统真正需要哪些数据?”
3ECS 为什么可能更快?
这里有一个非常重要的措辞:
可能。
ECS 并不会因为名字叫 ECS 就自动获得性能。
真正带来性能优势的是它背后的数据布局和访问模式。
3.1从对象布局到数据布局
考虑这样一个对象:
Enemy
├── Position
├── Velocity
├── Health
├── Inventory
├── Animator
├── Weapon
└── AIMovement 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 时,就形成了依赖:
因此,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 Optimization3.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。
即:
这不是为了研究移动算法,而是为了观察三种程序组织方式有什么不同。
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 VelocitySystem 并不属于某一个 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 → ReadBevy ECS 的 Query 本身就是 System 参数之一,用于选择性访问 World 中的 Component 数据。4
4.4三种代码真正不同在哪里?
| Unity MonoBehaviour | Unity Entities | Bevy | |
|---|---|---|---|
| 思考中心 | 一个对象 | 一批数据 | 一批数据 |
| 身份 | GameObject / Component | Entity | Entity |
| 状态 | 对象字段 / Component | Component Data | Component |
| 行为 | Method / Update() | System | System |
| 选择对象 | 引用、查找 | Query | Query |
| 典型表达 | “这个对象更新自己” | “处理所有匹配数据” | “处理所有匹配数据” |
| 并行模型 | 通常需要额外设计 | ECS + Jobs | Scheduler 根据访问关系调度 |
真正的区别不是:
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实际上,更合适的理解是:
三者分别解决不同问题。
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 ECSECS 需要解决的是:
Entity 怎样标识?
Component 怎样存?
怎样查询 Component 组合?
怎样快速遍历?
怎样添加 / 删除 Component?而这些问题有很多不同的数据结构答案。
7.4Archetype 与 Sparse Set 解决问题的方式不同
可以做一个非常粗略的对比:
| 思路 | Archetype ECS | Sparse Set ECS |
|---|---|---|
| 主要分组方式 | 按 Component 组合 | 按 Component 类型维护 Pool |
| 查询匹配 | 找符合组合的 Archetype | 组合 / 交集相关 Component Pool |
| 遍历 | 很适合整批同构 Entity | Component 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 Dependency9.4Structural Change 有成本
如前所述,在 Archetype ECS 中:
Add Component
Remove Component可能意味着:
Entity 从一个 Archetype
搬到另一个 Archetype因此 Component 不应该被机械地当作:
随便加删的 bool具体 ECS 对 Structural Change 的优化策略各不相同,但理解这个问题非常重要。
9.5Debugging 的思维方式改变了
传统对象代码经常依赖:
Breakpoint
Call Stack
Object ReferenceECS 调试则更需要观察:
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 的一种非常重要的方式,但绝不是唯一方式。
因此它们之间更接近:
这张图并不是严格的分类学,而是想强调:
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();依然可能是最好的代码。
注释
-
Flecs Documentation,FAQ: What is an archetype?。Flecs 使用 Archetype-Based Storage,将拥有相同 Component 的 Entity 放在一起,以改善 Cache 利用并支持高效查询和向量化。 ↩ ↩2
-
Epic Games,MassEntity Overview。Mass Entity 是 Unreal Engine 面向 Data-Oriented Calculation 的框架,包含 Fragment、Entity、Archetype、Processor 与 Command Buffer 等概念。 ↩ ↩2 ↩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
-
Bevy ECS Documentation,Query。
Query是 Bevy System Parameter,用于选择性访问World中符合条件的 Component 数据。 ↩ -
Unity Documentation,Job system overview。Unity 官方说明 Job System 可独立使用,也可以与 Burst、ECS 组合,用于多核 CPU 工作负载。 ↩
-
Unity Technologies,ECS for Unity。Unity 将 ECS 描述为面向数据的框架,并强调其与现有 GameObject 工作流的兼容关系。 ↩
-
Bevy ECS Documentation,Schedule。Bevy 的 Schedule 保存 System 以及运行顺序、条件和执行所需的相关元数据。 ↩
-
EnTT Documentation,Crash Course: Entity Component System。EnTT 的 ECS 核心采用 Sparse Set Based Model,每个 Component Pool 建立在 Sparse Set Storage 之上。 ↩
CONTINUE EXPLORING