需要预先学习:


1. 游戏对象的表示:继承的困境

写游戏时第一个遇到的问题往往不是渲染,而是"一个游戏里的东西怎么用代码表示"。最自然的想法来自面向对象:万物皆对象,公共的东西放进基类,特化的东西放进派生类:

class GameObject { /* 位置、名字、Update() 虚函数 */ };
class Character : public GameObject { /* 生命值、移动 */ };
class Player : public Character { /* 输入控制 */ };
class Enemy : public Character { /* AI */ };
class Prop : public GameObject { /* 可交互物体 */ };

游戏小的时候这套结构工作得很好。但游戏的本质是功能的自由组合:策划想要一辆会游泳的飞行汽车、一只会拾取道具的鸟、一个能被玩家附身的箱子。每一个新组合都在逼你继续挖继承树:

  • 功能挂在哪个层级?Fly 放进 GameObject 所有箱子都会飞,放进 Player 敌人不会飞;
  • 组合爆炸:nn 种可组合的功能对应 2n2^n 种子类,继承树指数膨胀;
  • 多重继承与菱形继承(见 多态):FlyingCar 同时继承 FlyingObject 与 Car,两个基类又都继承 GameObject,虚基类、二义性接踵而来;
  • 上帝基类:为了共享功能,把所有东西塞进 GameObject——生命值、物理、渲染、音频全在基类里,一个静态装饰物也背着整套成员,这就是 God Object 反模式。

继承表达的是 is-a(是一个)关系,而游戏实体需要的是 has-a(有一个)关系:一辆车有发动机、有轮子、有可驾驶性,而不是"是一类特殊的可驾驶物"。面向对象有一条著名原则——组合优于继承(composition over inheritance),ECS 就是这条原则推到极致的形态。


2. ECS 的三个角色

ECS(Entity-Component-System)是一种数据与逻辑分离的架构模式,由三个角色构成:

角色 本质 职责 不负责
Entity(实体) 一个 ID 标识"某个东西存在" 不存数据、不含逻辑
Component(组件) 纯数据 描述实体某一方面的状态 不含行为方法
System(系统) 纯逻辑 处理拥有特定组件的一批实体 不存状态

一辆车的 Entity 是 ID #42;它由 Transform、Mesh、RigidBody、VehicleInput 等组件组成;PhysicsSystem 处理所有带 RigidBody 的实体、RenderSystem 处理所有带 Mesh 的实体。想造一辆会飞的车,不需要新类,给现有实体加一个 Flying 组件即可——组合代替了继承。

三个关键推论:

  1. 实体没有类型。"玩家"不是类,只是一组组件的集合:Transform + Input + Health + Mesh。同一组组件就是同一种行为;
  2. 数据与逻辑彻底分离。组件是被动数据,系统是主动行为,"这个数据归谁管"一目了然;
  3. 按数据组织,而不是按对象组织。这是 ECS 性能优势的来源,后面细说。

3. Entity:一个 ID

Entity 的全部内容是一个标识符。在典型的 C++ 实现中它就是一个整数:

struct Entity
{
    uint32_t index;         // 槽位索引
    uint32_t generation;    // 世代号
};

为什么连"实体的名字"都不存?因为 Entity 不需要知道任何关于自己的事情——名字可以是一个 Name 组件,位置是 Transform 组件。实体本身退化成纯粹的标识:组件通过它关联到同一个"东西"。

3.1 世代号:防止 ID 悬空

实体销毁后,它的 index 会被回收复用。这时一个仍持有旧 ID 的系统再用它查询组件,会错误地命中新实体——经典的悬空引用问题。世代号(generation)用来解决它:

// 每个槽位记录当前世代,销毁时世代 +1
struct EntitySlot
{
    uint32_t generation;    // 该槽位被复用了几次
    bool alive;             // 当前是否存活
};

// 查询:世代号不匹配 -> 这个 ID 指向的是"上一任"实体,已失效
bool valid(Entity e) const
{
    return slots[e.index].alive &&
           slots[e.index].generation == e.generation;
}

槽位每复用一次,generation 加一。旧 ID 的世代号落后于槽位的当前世代,valid 返回 false——不需要 shared_ptr 那样的生命周期管理(见 智能指针与RAII),一个整数的比较就完成了悬空检测。


4. Component:纯数据

组件是只有数据、没有方法的聚合类型:

struct Transform
{
    glm::vec3 position;
    glm::quat rotation;
    float     scale;
};

struct RigidBody
{
    glm::vec3 velocity;
    float     mass;
    float     restitution;
};

struct Health
{
    int current;
    int max;
};

"纯数据"是一条强约束:组件不定义 update(),不持有指针指向其他实体(需要引用别的实体时,存对方的 Entity ID),不包含虚函数。约束的目的只有一个——让数据可以被无脑地连续存放、批量遍历,这正是下一节 System 的性能前提。

组件的粒度没有硬性规定,实践中的准绳是按系统的访问模式切分:Transform 与 Mesh 分开,因为物理系统改 Transform、渲染系统只读 Transform,两者对 Mesh 的需求不同;如果两个组件总是一起被同一个系统读写,通常说明它们该合并。


5. System:纯逻辑

系统是一段处理一类组件集合的逻辑,通常实现为普通函数或函数对象:

void movementSystem(Registry& reg, float dt)
{
    // 查询:所有同时拥有 Transform 与 Velocity 的实体
    for (auto [entity, transform, velocity] :
         reg.view<Transform, Velocity>().each())
    {
        transform.position += velocity.value * dt;
    }
}

注意这段代码的三个特征:

  1. 它不关心实体是什么——是玩家、子弹还是粒子,只要有 Transform 与 Velocity 就被处理;
  2. 它不持有状态——所有状态在组件里,系统本身无成员变量(调度参数除外),因此可以被任意重入、并行;
  3. 它声明了自己的数据依赖——读写 Transform、读 Velocity,这个声明后面是系统并行调度的依据。

系统之间不直接调用,而是通过组件衔接:InputSystem 写 Velocity,MovementSystem 读 Velocity 写 Transform,RenderSystem 读 Transform。数据在系统间流动,逻辑彼此独立——替换渲染系统、重排系统顺序(只要数据依赖满足)都不影响其他系统。


6. 数据导向设计:为什么 ECS 快

ECS 的性能优势不是玄学,来自内存布局。

6.1 两个世界的时间差

CPU 访问内存比执行指令慢一到两个数量级,靠缓存(cache)弥合:内存以 64 字节的缓存行(cache line)为单位搬进 CPU,命中缓存则快,不命中(cache miss)就要等几百个周期。于是"数据在内存里怎么排"直接决定程序快慢——数据导向设计(DOD,Data-Oriented Design)就是把"数据如何被访问"放在设计的第一位。

6.2 AoS 与 SoA

面向对象天然产生 AoS(Array of Structs,结构体数组):每个对象的所有成员挨在一起,对象之间排成数组。遍历"所有对象的位置"时,缓存行里一半字节是当前用不到的颜色、生命值——带宽全浪费在搬运不用的数据上:

AoS:按对象组织(对象内部连续,字段之间分散)
┌──────────── 对象 0 ────────────┐┌──────────── 对象 1 ────────────┐
│ position(12B) color(16B) hp(4B)││ position(12B) color(16B) hp(4B)││ ...
└────────────────────────────────┘└────────────────────────────────┘
        ↑ 只需要 position,其余字段白搬

SoA:按字段组织(同一种字段连续,一次搬 5 个)
position: │ pos0 │ pos1 │ pos2 │ pos3 │ pos4 │ ...
color:    │ col0 │ col1 │ col2 │ col3 │ col4 │ ...(不需要就不碰)
hp:       │ hp0  │ hp1  │ hp2  │ hp3  │ hp4  │ ...(不需要就不碰)

ECS 的组件存储天然是 SoA(Struct of Arrays,结构体的数组化):Transform 与 RigidBody 是两种组件、两块独立数组。物理系统只遍历 Transform + RigidBody,每次缓存 miss 搬进来的 64 字节全部是要用的数据,有效带宽提高数倍。同时 prefetcher(硬件预取器)面对连续数组也能高效工作,而指针迷宫般的对象图让它束手无策。

6.3 谁在受益

一个万级弹幕场景:MovementSystem 每帧遍历上万个 Transform + Velocity,AoS 每个对象 64 字节缓存行只用到 32 字节,SoA 全部命中目标数据;再加上系统可以按依赖并行(万级数据天然可分块多线程),帧时间从毫秒级压到微秒级并不稀奇。ECS 的流行与现代硬件"算力涨得快、带宽涨得慢"的趋势直接相关。


7. 组件存储:Sparse Set 与 Archetype

组件数据具体怎么组织,主流实现有两派。

7.1 Sparse Set:稀疏集合

EnTT(C++ 最流行的 ECS 库)采用稀疏集合(sparse set):每种组件类型持有两个数组——

sparse(按 entity index 索引):     dense(紧凑存放)
index:   0    1    2    3    4     ┌────────┬────────┬────────┐
value:  [2]  [∞]  [0]  [1]  [∞]   │ ent #2 │ ent #3 │ ent #0 │  ← 实体
              ↑ 无该组件            │ data#2 │ data#3 │ data#0 │  ← 组件数据
                                  └────────┴────────┴────────┘
  • dense 数组:紧凑存放"拥有该组件的实体"与它们的组件数据,连续、可高效遍历;
  • sparse 数组:sparse[entity.index] 给出该实体在 dense 中的下标(没有就是空标记),实现 O(1) 随机访问。
template <typename T>
class SparseSet
{
    std::vector<uint32_t> sparse;    // entity index -> dense 下标
    std::vector<Entity>   denseEnt;  // 反向:dense 下标 -> 实体
    std::vector<T>        denseData; // 与 denseEnt 一一对应的组件数据

public:
    void add(Entity e, T comp)
    {
        sparse.resize(e.index + 1, INVALID);
        sparse[e.index] = denseEnt.size();       // 记录新位置
        denseEnt.push_back(e);
        denseData.push_back(comp);               // 数据总是连续追加
    }

    T* get(Entity e)
    {
        uint32_t i = sparse[e.index];
        return i == INVALID ? nullptr : &denseData[i];   // O(1) 随机访问
    }

    void remove(Entity e)
    {
        uint32_t i = sparse[e.index];
        sparse[denseEnt.back().index] = i;   // 尾部元素填洞
        denseEnt[i] = denseEnt.back();
        denseData[i] = denseData.back();
        denseEnt.pop_back();                 // 交换删除,O(1)
        denseData.pop_back();
        sparse[e.index] = INVALID;
    }
};

多类型查询(view<A, B>)取两个集合中较短的那个遍历,对每个实体用 sparse 数组查另一个集合——长度平方级的最坏情况在实践里几乎不出现,因为短的那个通常很短。

7.2 Archetype:原型分块

Unity DOTS、flecs 采用 archetype(原型)存储:按组件类型的组合分组。(Transform, Velocity) 是一个 archetype,(Transform, Mesh) 是另一个。每个 archetype 内部按固定大小的 chunk(块,通常 16KB)存放,同一 chunk 里同一种组件连续排列:

Archetype (Transform, Velocity)
chunk 0: pos: [p0 p1 p2 ... pN]  vel: [v0 v1 v2 ... vN]
chunk 1: pos: [.. .. .. ... ..]  vel: [.. .. .. ... ..]

Archetype (Transform, Velocity, Health)
chunk 0: pos: [...] vel: [...] hp: [...]

查询 (Transform, Velocity) 时,凡包含这两个组件的 archetype 都命中,遍历它们的 chunk——不用按实体逐个检查,直接拿到整段连续数据。代价是组件的增删:给实体加一个 Health,意味着把它从 (T, V) archetype 搬走,复制进 (T, V, H) archetype——组件越多,组合数越多,搬移越频繁。

7.3 对比

维度 Sparse Set Archetype
查询(多类型) 以最短集合为驱动,逐实体过滤 按组合分组,命中即整段连续数据
增删组件 两个数组各动一下,O(1) 跨 archetype 搬移,相对贵
遍历性能 好(连续) 更好(查询结果本身连续、无过滤)
内存 每类型两个数组,sparse 有空洞 chunk 紧凑,但组合碎片多
迭代中改结构 需要处理(见下节) 搬移本身破坏遍历,需延迟
代表实现 EnTT Unity DOTS、flecs、Bevy(Bevy 为混合式)

8. 查询与结构修改

系统的核心操作是查询(query / view):给出"需要哪些组件、排除哪些组件",拿到满足条件的实体集合:

// 处理"会动且有碰撞体"的实体,但排除被冻结的
auto view = reg.view<Transform, RigidBody>(entt::exclude<Frozen>);

一个绕不开的问题:遍历中修改集合结构。MovementSystem 遍历时给某实体 add<Exploded>() 或直接 destroy(e),会改动正在遍历的 dense 数组——迭代器失效、元素被跳过或重复访问(swap-remove 的副作用,见上节代码)。

主流解法是延迟生效:

  • 遍历期间把 destroy / add / remove 记进待办队列,系统跑完再统一执行;
  • 或者约定"遍历中只允许安全操作"(EnTT 允许遍历时 add 不属于当前正在遍历的组件类型,库内部做了处理)。

这是使用 ECS 时最容易踩的坑:代码看起来工作,但组件集合的修改被推迟到了系统边界,依赖"加完组件下一行就生效"的逻辑会悄悄出错。


9. 系统调度与并行

系统本身无状态,天然适合并行。调度器根据每个系统声明的读写组件集合分析依赖:

InputSystem    写 Velocity        ┐
MovementSystem 读 Velocity 写 Transform   → Input 必须先于 Movement
RenderSystem   读 Transform       ┘  Movement 完成后才能 Render

AnimationSystem 读 Skeleton 写 Pose  ← 与上面三者无依赖,可与它们并行

只要两个系统的读写集合不相交(或都只读),就可以同时跑;读写集合相交的按声明顺序串行。大规模数据(上万实体)再切成块扔进任务系统(job system)做多线程,见 多线程编程基础。这就是 ECS"声明式并行"的能力:你只声明数据依赖,调度交给框架。


10. 事件与通信

系统之间不直接调用,那"敌人死亡时 UI 要更新、音效要播放"这类跨系统通知怎么做?常见三种:

  1. 组件即事件:死亡这一刻给实体加一个 JustDied 组件,DeathCleanupSystem 查询它、处理并移除。事件退化为"存在一帧的标记",查询机制直接复用;
  2. 事件队列:系统把事件 push 进队列(events.push(EnemyDied{entity})),感兴趣的系统中帧/帧末统一 drain。解耦彻底,时序明确;
  3. 观察者 / 信号:对"组件被添加 / 移除"挂钩子(EnTT 的 on_construct<T>、on_destroy<T>),用于同步派生数据(组件加了就要建渲染代理)。

原则是同一个:通信也走数据,不走调用。所有系统面对的都是组件与队列,没有谁直接持有谁。


11. 一个最小的 C++ ECS

把前面的零件拼成一个能跑的骨架(sparse set 版,省略错误处理):

#include <vector>
#include <cstdint>

// ---------- Entity:带世代的 ID ----------
struct Entity { uint32_t index, generation; };

// ---------- Registry:实体与所有组件池 ----------
class Registry
{
    std::vector<uint32_t> generations;      // 槽位世代号
    std::vector<uint32_t> freeList;         // 回收的 index

public:
    Entity create()
    {
        if (!freeList.empty())              // 优先复用槽位
        {
            uint32_t i = freeList.back();
            freeList.pop_back();
            return {i, generations[i]};     // 世代号延续
        }
        generations.push_back(0);
        return {(uint32_t)generations.size() - 1, 0};
    }

    void destroy(Entity e)
    {
        // 实际实现中还要移除所有组件,此处省略
        generations[e.index]++;             // 旧 ID 从此失效
        freeList.push_back(e.index);
    }
};

// ---------- 组件:纯数据 ----------
struct Transform { float x, y; };
struct Velocity  { float vx, vy; };

// ---------- 每种组件一个池 ----------
std::vector<std::pair<Entity, Transform>> transformPool;   // (实体, 数据) 对
std::vector<std::pair<Entity, Velocity>>  velocityPool;

// ---------- System:纯逻辑 ----------
void movementSystem(float dt)
{
    // 简化版查询:对每个 Velocity,找同名实体的 Transform
    for (auto& [ent, vel] : velocityPool)
    {
        for (auto& [ent2, tf] : transformPool)
        {
            if (ent.index == ent2.index)       // 实际实现用 sparse set O(1)
            {
                tf.x += vel.vx * dt;
                tf.y += vel.vy * dt;
            }
        }
    }
}

这个骨架刻意保持简单(查询是平方级的,真实实现要用 sparse set),但它展示了 ECS 的全部结构:Registry 管实体生命周期,每种组件一个池,系统是遍历池子的普通函数。完整的开源实现(EnTT 全部头文件也只有几千行)值得通读一遍。


12. 代价与适用场景

ECS 不是银弹,它的代价同样明确:

  • 间接性:所有访问都经过"实体 ID → 组件池"一层间接,单点访问(改一个物体的位置)比直接 obj->position 啰嗦;
  • 调试体验下降:没有"对象"可以一口气观察,一个逻辑分散在多个系统里,断点追踪变难;
  • 不适合一切皆独特的东西:UI 控件、剧情脚本、配置表——数量少、行为各异、需要频繁单点交互的数据,塞进 ECS 只会更别扭;
  • 架构纪律:组件不能加方法、系统不能存状态,这些约束在赶工时很容易被"就这一个例外"侵蚀,一旦破例,架构就开始退化。

适用性判断很简单——数据量与同质性:

场景 是否适合 ECS
弹幕、粒子、RTS 单位、大规模模拟 非常适合:同质数据海量、逻辑可批量
一般 3D 场景的游戏物体 适合:数量中等,组合自由度收益明显
UI、菜单、对话系统 不适合:单点交互为主
小型原型 / Game Jam 通常不值得:OOP 或脚本更快出活

13. 实际引擎中的应用

  • EnTT:C++ 生态的事实标准,sparse set 实现,头文件库,Minecraft 基岩版等在用;
  • Unity DOTS / Entities:archetype 存储 + Burst 编译 + Job System 三件套,官方的"面向数据栈";
  • Unreal Mass Entity:UE5 的大规模实体框架(城市人群、植被),archetype 思路;
  • Bevy:Rust 原生 ECS 引擎,ECS 即引擎核心抽象,系统即普通函数加调度声明;
  • flecs:以 archetype 实现与丰富的查询 DSL 著称,提供 C 与 C++ 接口;

它们的 API 风格各异,但内里的三件事完全一致:实体是 ID、组件是数据、系统是批量逻辑。理解了这三件事与背后的内存布局,任何一家 ECS 的文档都只剩 API 词典的差别。