编程 Bevy ECS 深度拆解:Archetype 列式存储 + 自动并行调度器——为什么游戏引擎的架构正在反向输出给后端工程师

2026-08-18 02:49:16 +0800 CST views 9

Bevy ECS 深度拆解:Archetype 列式存储 + 自动并行调度器——为什么游戏引擎的架构正在反向输出给后端工程师

写在前面:这篇文章不是讲游戏开发

如果你是一个写后端的程序员,看到「Bevy」「游戏引擎」这几个词大概会直接划走。我一开始也是。

但当我真正把 Bevy ECS 的源码逻辑捋清楚之后,我发现了一件挺讽刺的事:这玩意儿本质上是一个内存里的列式数据库 + 一个自动推导依赖的并行任务调度器。

  • 它的 Archetype 存储,跟 ClickHouse、DuckDB 的列式布局是同一套思路
  • 它的 Query 系统,本质是带 WHERE 谓词的投影查询
  • 它的 Change Detection,是一套基于逻辑时钟(tick)的 MVCC 简化版
  • 它的调度器,靠类型签名静态推导读写冲突,然后自动并行——这是很多后端框架吹了半天「自动并行」都没做到的事

换句话说,游戏引擎这帮人为了在 16.6ms 内跑完十万个实体的逻辑,被逼着把数据导向设计(Data-Oriented Design)做到了极致。而这些经验,正在反向输出给写后端、写数据处理、写高并发系统的人。

所以这篇文章的真实主题是:用 Bevy ECS 当解剖样本,讲清楚「面向数据」这套架构到底怎么落地,以及它凭什么快。

一个前置说明,先说清楚免得你踩坑:Bevy 的 API 在 0.x 阶段变动非常大。 这是它最被人诟病的一点——几乎每个版本都有 breaking change(add_systemadd_systems、Bundle 被 Required Components 部分取代、事件系统被重构过)。本文的代码以近期的 0.1x 系列 API 风格书写,讲的原理和架构是稳定的,但具体函数签名请务必对照你手上那个版本的迁移指南(Bevy 官网每个版本都有 Migration Guide)。凡是我不能确定具体版本号的特性,我会明确说「较新版本引入」,不给你编一个精确版本号。


一、背景:为什么继承树会崩,以及 cache miss 到底有多贵

1.1 那个经典的失败案例

任何讲 ECS 的文章都会拿游戏对象举例,我也不例外,因为这个例子确实精准。

假设你用传统 OOP 建模一个游戏(或者任何有大量异质对象的系统):

GameObject
├── Character
│   ├── Player       (能移动、有血量、能开火、受玩家输入控制)
│   └── Enemy        (能移动、有血量、能开火、受 AI 控制)
├── Prop
│   ├── Crate        (有血量、能被摧毁,不能移动)
│   └── Tree         (啥也不干,就站着)
└── Projectile       (能移动、有伤害、无血量)

看起来挺清楚。然后产品经理来了:

「我们要加一个会移动的箱子,被推的时候会滑。」
「还要一个能开火的炮塔,但它不能移动,且是建筑不是角色。」
「树也要能被砍倒了,加血量。」

现在你面临经典的菱形继承地狱。你的选择:

  1. 把功能往上提GameObject——最后基类变成一个几百个字段的上帝对象,每个 Tree 实例都白白背着 ammoCountaiState 这些永远用不到的字段。内存爆炸,缓存命中率崩盘。
  2. 多重继承 / 接口 + mixin——C++ 里菱形继承虚表地狱,Java/C# 里接口默认方法拼不出状态,最后你会写出一堆 if (obj instanceof IMovable)
  3. 组件化——把「能移动」「有血量」「能开火」拆成独立的数据块,对象只是这些数据块的组合

第 3 条就是 ECS 的起点。但 ECS 真正的杀手级优势不在于「灵活组合」——那只是 composition over inheritance,老生常谈。真正的优势在内存布局。

1.2 算一下 cache miss 的账

这是最容易被口头带过、但决定一切的地方。让我们用具体数字算。

现代 CPU 的大致延迟量级(不同架构有差异,量级可参考):

层级延迟(周期)相对成本
L1 cache~4
L2 cache~12
L3 cache~4010×
主内存 (DRAM)~200+50×+

关键点:CPU 从内存读数据不是按字节读的,是按 cache line 读的,通常 64 字节。 你读 1 个字节,硬件也给你搬 64 字节进来。

现在看 OOP 布局。假设:

// OOP 风格:数据和逻辑绑在一个大对象里
struct Entity {
    transform: [f32; 16],   // 4x4 矩阵,64 字节
    velocity:  [f32; 3],    // 12 字节
    health:    f32,         // 4 字节
    name:      String,      // 24 字节(指针+len+cap)
    inventory: Vec<Item>,   // 24 字节
    ai_state:  AiState,     // 假设 64 字节
    // ... 实际项目里还有几十个字段
}
// 假设 sizeof::<Entity>() ≈ 256 字节

然后你有 Vec<Box<Entity>>(或者 Java/C# 里的 List<Entity>,同理都是指针数组),要跑一个物理系统:

// 只用 velocity(12B) 和 transform 的 translation 部分(12B)
for e in &mut entities {
    e.transform.translation += e.velocity * dt;
}

发生了什么:

  1. Vec<Box<Entity>>指针数组,每个 entity 在堆上的位置是分散的(取决于分配顺序,可能相隔很远)
  2. 每次循环,为了拿到 24 字节的有效数据,CPU 要发起一次随机内存访问,搬 64 字节 cache line 进来
  3. 那 64 字节里,只有 24 字节是你要的,剩下 40 字节是 nameinventory 这些无关字段——纯浪费带宽
  4. 更糟:256 字节的对象跨了 4 个 cache line,硬件预取器(prefetcher)在指针跳转的随机访问模式下基本失效

有效带宽利用率:24/64 ≈ 37%,而且预取失效。 十万个实体,你就吃十万次准随机访存。

现在看 ECS 的列式布局(这正是 Bevy 的做法):

Table (Archetype: Transform + Velocity)
┌─────────────────────────────────────────────────┐
│ Column: Transform  [T0][T1][T2][T3]...[Tn]      │  连续
│ Column: Velocity   [V0][V1][V2][V3]...[Vn]      │  连续
│ Column: Entity     [E0][E1][E2][E3]...[En]      │  连续
└─────────────────────────────────────────────────┘

同一个系统跑起来:

  1. Transform 列是一块连续内存Velocity 列也是
  2. 顺序访问 → 硬件预取器完美工作,它能识别 stride 模式并提前把后面的数据搬进来
  3. 每个 cache line 装满的全是有效数据(64 字节的 Velocity 列能装 5 个 [f32;3],全都要用)
  4. nameinventory 那些字段根本不在这块内存里,一个字节都不浪费带宽
  5. 顺带:连续同类型数据是自动向量化(SIMD)的前提,编译器可以把循环展开成 AVX 指令

有效带宽利用率接近 100%,且预取有效。

这就是为什么 ECS 在大批量同质操作上能比 OOP 快一个数量级——不是因为算法更好,是因为它不浪费内存带宽。 在现代 CPU 上,绝大多数「性能问题」本质是内存墙问题,计算单元早就在等内存了。

给后端的翻译:这跟「为什么 OLAP 要用列存」是完全同一个道理。你要 SELECT AVG(salary) FROM users,行存要把每行的 name、address、avatar_url 全搬进内存再扔掉,列存只搬 salary 那一列。Bevy 的 Table 就是内存版的列存,Query 就是投影下推。想通这一层,ECS 对你就不再是游戏圈的黑话了。


二、核心概念:三个词,以及它和关系数据库的同构性

ECS = Entity + Component + System。定义极其简单,但每个词的实现细节都有讲究。

2.1 Entity:它只是一个 ID

最反直觉的一点:Entity 里没有数据。 它不是对象,它是一个 ID,一个句柄。

Bevy 里 Entity 大致是这样一个结构(概念上):

struct Entity {
    index:      u32,  // 在实体表中的槽位
    generation: u32,  // 世代号,用于检测悬垂引用
}

为什么要 generation?这是一个非常经典的**代际索引(generational index)**技巧,解决 ABA 问题:

let e = world.spawn(Health(100.0)).id();   // index=5, generation=1
world.despawn(e);                           // 槽位 5 被释放
let e2 = world.spawn(Health(50.0)).id();    // 复用槽位 5!index=5, generation=2

// 现在如果你还捏着旧的 e:
world.get::<Health>(e);   // 返回 None —— generation 不匹配,安全!
// 如果没有 generation,e 会错误地读到 e2 的数据(ABA bug)

这是「用 8 字节的值类型句柄替代裸指针」的经典模式。它给你了指针的 O(1) 访问,又避免了悬垂指针和 GC——后端做连接池、会话管理、对象池时同样适用。

2.2 Component:纯数据,不带行为

Component 就是一个普通结构体,只放数据,不放逻辑:

use bevy::prelude::*;

#[derive(Component, Debug)]
struct Position(Vec3);

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

#[derive(Component)]
struct Health {
    current: f32,
    max: f32,
}

// 标记组件(marker):零大小类型,不占内存,只用于分类
#[derive(Component)]
struct Player;

#[derive(Component)]
struct Enemy;

注意 PlayerEnemy 这种零大小类型(ZST)。它们在列里不占任何空间(Rust 的 ZST 保证 size_of 为 0),但足以把实体划分到不同的 Archetype,从而让 Query 精确筛选。这是一个极其廉价的「打标签」手段——相当于数据库里的一个不占存储的稀疏索引。

2.3 System:一个普通函数

System 就是函数,它声明自己要什么数据,Bevy 负责喂给它:

fn movement_system(mut query: Query<(&mut Position, &Velocity)>, time: Res<Time>) {
    for (mut pos, vel) in &mut query {
        pos.0 += vel.0 * time.delta_secs();
    }
}

这里发生了一件很魔法的事,而它是整个 Bevy 设计的支点:

movement_system 的函数签名,就是它的数据访问声明。

Bevy 通过 Rust 的 trait 系统(SystemParam)在编译期就知道:这个函数要 Position Velocity Time 资源。这份信息是调度器实现自动并行的全部依据。我们后面会详细讲。

2.4 和关系数据库的同构映射

把这张表记住,ECS 对你就通了:

ECS关系数据库说明
Entity主键(row id)一个 ID,不含业务数据
Component列 / 窄表一种数据
Archetype表(相同 schema 的行的集合)拥有相同组件集合的实体
Table 的 Column列存的列连续内存
Query<(&A, &B)>SELECT a, b投影
With<C> / Without<C>WHERE 谓词过滤
Changed<C>变更数据捕获(CDC)只取脏数据
System一个批处理任务消费查询结果
ScheduleDAG 任务编排依赖调度
Commands事务缓冲 / WAL延迟写入,批量提交

Query 的执行本质就是:根据组件签名筛出匹配的 Archetype,然后在每个 Archetype 的 Table 里做连续遍历。 筛 Archetype 这一步只在 Archetype 集合变化时做,之后有缓存,所以每帧的成本就是纯遍历。


三、架构分析:Archetype 到底怎么存

这是 Bevy ECS 的核心,也是理解性能特征的关键。

3.1 Archetype 是什么

Archetype = 一个唯一的组件类型集合。

实体 A: [Position, Velocity]              → Archetype 1
实体 B: [Position, Velocity]              → Archetype 1  (同一个!)
实体 C: [Position, Velocity, Health]      → Archetype 2  (不同)
实体 D: [Position, Health]                → Archetype 3
实体 E: [Position, Velocity, Player]      → Archetype 4

拥有完全相同组件集合的实体,被存在同一个 Archetype 里,它们的组件数据按列连续排布。

内存布局大致长这样:

Archetype 1  [Position, Velocity]   entity_count = 3
┌──────────────────────────────────────────────┐
│ entities:  [A ][B ][F ]                      │
│ Position:  [PA][PB][PF]   ← 连续 f32 数组     │
│ Velocity:  [VA][VB][VF]   ← 连续 f32 数组     │
└──────────────────────────────────────────────┘

Archetype 2  [Position, Velocity, Health]   entity_count = 2
┌──────────────────────────────────────────────┐
│ entities:  [C ][G ]                          │
│ Position:  [PC][PG]                          │
│ Velocity:  [VC][VG]                          │
│ Health:    [HC][HG]                          │
└──────────────────────────────────────────────┘

现在,Query<(&mut Position, &Velocity)> 要跑:

  1. 匹配阶段:哪些 Archetype 同时包含 PositionVelocity?→ Archetype 1、2、4(3 没有 Velocity,排除)
  2. 遍历阶段:对每个匹配的 Archetype,拿到 Position 列和 Velocity 列的指针,做连续的双指针遍历

注意第 2 步:在单个 Archetype 内部,遍历是完全线性的,没有任何分支、没有任何 if has_component 类型检查在第 1 步就做完了。这是 Archetype 方案相对于「每个实体存一个 component 位图,遍历时判断」方案的巨大优势——把 per-entity 的分支,提升成了 per-archetype 的一次性判断。

3.2 Table 存储 vs SparseSet 存储

Bevy 有两种组件存储策略,这个区别很实际,直接影响你的性能:

Table(默认,稠密)

  • 组件数据存在 Archetype 对应的 Table 的列里,连续
  • 遍历极快(就是上面讲的列存优势)
  • 代价:增删组件要做 Archetype 迁移(下面细说)

SparseSet(稀疏)

#[derive(Component)]
#[component(storage = "SparseSet")]
struct Frozen;
  • 组件存在一个独立的稀疏集合里,不参与 Archetype 的身份定义所导致的数据搬迁
  • 增删这个组件非常便宜,不触发 Table 数据搬迁
  • 代价:遍历时是间接查找,不连续,慢

选型规则(这条很实用):

频繁增删、但遍历不频繁的组件 → SparseSet
遍历频繁、增删少的组件 → Table(默认)

典型的 SparseSet 场景:那种一帧内被反复加上去又摘下来的临时状态标记,比如「本帧被击中」「正在冷却」。如果用默认的 Table 存储,你每次 insert/remove 都在触发内存搬迁。

3.3 Archetype 迁移:ECS 最大的性能陷阱

这是新手最容易踩、而且很难自己想明白的坑。

当你给一个实体增加或删除组件时,它的 Archetype 就变了,它的所有 Table 组件数据必须从旧 Table 物理搬移到新 Table。

// 实体 X 当前在 Archetype [Position, Velocity]
commands.entity(x).insert(Health { current: 100.0, max: 100.0 });
// 现在 X 属于 Archetype [Position, Velocity, Health]
// 底层发生了:
//   1. 在新 Archetype 的 Table 里分配一个槽位
//   2. 把 X 的 Position 数据 memcpy 过去
//   3. 把 X 的 Velocity 数据 memcpy 过去
//   4. 写入新的 Health 数据
//   5. 从旧 Table 里移除 X 的行(通常用 swap_remove:把最后一行搬到空出的位置)
//   6. 更新实体表里 X 的位置索引;被 swap 过来的那个实体的索引也要更新

代价分析:

  • 搬迁成本 ∝ 该实体所有 Table 组件的总字节数。组件越多越胖,迁移越贵。
  • swap_remove打乱 Table 内的实体顺序。如果你的逻辑依赖遍历顺序,这里会咬你(ECS 从不保证遍历顺序,别依赖它)。
  • 如果你在每帧给大量实体反复增删组件,你就是在每帧做大量 memcpy + 索引更新。这是我见过的 ECS 性能问题里最常见的一种。

规避方案(按优先级):

方案一:用「启用位」代替增删组件

// 不好:频繁增删组件,每次都触发 Archetype 迁移
commands.entity(e).insert(Stunned);
commands.entity(e).remove::<Stunned>();

// 好:组件常驻,改字段。零迁移成本。
#[derive(Component)]
struct Stunned { active: bool, remaining: f32 }

fn tick_stun(mut q: Query<&mut Stunned>, time: Res<Time>) {
    for mut s in &mut q {
        if s.active {
            s.remaining -= time.delta_secs();
            if s.remaining <= 0.0 { s.active = false; }
        }
    }
}

方案二:确实需要用组件做筛选,就改 SparseSet 存储(见 3.2)

方案三:spawn 时就把组件配齐,不要 spawn 完再一个个 insert

// 不好:三次迁移([] → [A] → [A,B] → [A,B,C])
let e = commands.spawn(Position(Vec3::ZERO)).id();
commands.entity(e).insert(Velocity(Vec3::X));
commands.entity(e).insert(Health { current: 100.0, max: 100.0 });

// 好:一次到位,直接落在最终 Archetype
commands.spawn((
    Position(Vec3::ZERO),
    Velocity(Vec3::X),
    Health { current: 100.0, max: 100.0 },
));

方案四:批量生成用 spawn_batch

// 十万个实体,一次性写入同一个 Archetype,避免逐个的 Archetype 查找开销
commands.spawn_batch((0..100_000).map(|i| {
    (
        Position(Vec3::new(i as f32, 0.0, 0.0)),
        Velocity(Vec3::Y),
    )
}));

3.4 Archetype 碎片化

另一个隐性成本:Archetype 数量爆炸

组件组合是指数级的。如果你有 20 个 marker 组件随意组合,理论上可能产生上千个 Archetype。每个 Archetype 都是一个独立的小 Table,于是:

  • Query 匹配阶段要遍历更多 Archetype
  • 每个 Table 里实体很少(可能就 1-2 个),连续遍历的优势被摊薄——你为了 3 个实体去建立一次遍历循环,预取器都还没热起来就结束了
  • 内存里一堆半空的小分配

这就是所谓的 archetype fragmentation。它的表现是:实体总数不大,但性能莫名其妙地差,profile 显示时间花在 query 迭代的固定开销上。

对策:合并语义相近的 marker 组件,用枚举字段代替多个 marker。

// 不好:4 个 marker,可能和其他组件交叉出大量 Archetype
#[derive(Component)] struct Fire;
#[derive(Component)] struct Ice;
#[derive(Component)] struct Poison;
#[derive(Component)] struct Lightning;

// 好:1 个组件,Archetype 数量不随元素种类增长
#[derive(Component)]
enum Element { Fire, Ice, Poison, Lightning }

代价是你不能再用 Query<&Transform, With<Fire>> 让引擎帮你筛了,得自己在循环里 match这是一个真实的权衡:marker 组件让引擎帮你筛(快,但增加 Archetype 数量),枚举字段让你自己筛(Archetype 少,但每个实体多一次分支)。实体多、组合少 → 用 marker;组合爆炸 → 用枚举。

3.5 Change Detection:一套简化的 MVCC

Bevy 内置变更检测,而且开销极低。原理是逻辑时钟(tick)

概念上,每个组件实例都附带一组 tick:

struct ComponentTicks {
    added:   Tick,  // 何时被添加
    changed: Tick,  // 何时最后一次被可变访问
}

World 有一个全局递增的 change tick。每个 System 记录自己 last_run 的 tick。于是:

  • 你通过 &mut T 访问组件时,Bevy 自动把该组件的 changed 更新为当前 tick
  • Changed<T> 过滤器的判断就是:component.changed > system.last_run
// 只处理这一帧(或自本系统上次运行以来)Position 真的被改过的实体
fn on_position_changed(query: Query<(Entity, &Position), Changed<Position>>) {
    for (e, pos) in &query {
        info!("entity {:?} moved to {:?}", e, pos.0);
    }
}

// 只处理刚被添加 Health 的实体(初始化逻辑)
fn on_health_added(query: Query<(Entity, &Health), Added<Health>>) {
    for (e, h) in &query {
        info!("entity {:?} spawned with {}/{} hp", e, h.current, h.max);
    }
}

这个机制在实际工程里价值极大——它就是 CDC(变更数据捕获)。典型用法:只把变了的数据同步给网络 / 写回数据库 / 重建索引 / 标记 UI 重绘。

但有一个坑必须说清楚:&mut 触发的是「可能改了」,不是「真的改了」。

// 陷阱:即使值没变,只要你拿了 &mut,就会被标记为 changed
fn bad(mut q: Query<&mut Health>) {
    for mut h in &mut q {
        h.current = h.current;   // 什么都没变,但已经被标记 changed!
    }
}

// 正确:先判断,再写。Bevy 的 Mut<T> 提供了 set_if_neq 这类辅助
fn good(mut q: Query<&mut Health>, incoming: Res<IncomingHp>) {
    for mut h in &mut q {
        let new_hp = incoming.value;
        if h.current != new_hp {      // 只在真的不同时才解引用写入
            h.current = new_hp;
        }
    }
}

这跟 Vue/MobX 那些响应式框架的「避免无意义的 setter 触发」是一模一样的问题。写响应式代码的直觉在这里完全适用。

3.6 Commands:延迟的结构性变更

一个关键的架构约束:System 在并行运行时,不能直接对 World 做结构性变更(spawn/despawn/insert/remove)。

原因很直白:结构性变更会导致 Archetype 迁移、Table 重新分配、其他实体位置被 swap。如果 System A 正在遍历某个 Table,System B 同时往里插数据触发了 realloc,A 手上的指针就悬垂了——这在 Rust 里过不了借用检查,在 C++ 里就是 UB。

Bevy 的解法是 Commands:一个命令缓冲

fn spawn_bullets(mut commands: Commands, q: Query<&Transform, With<Shooting>>) {
    for tf in &q {
        // 这里没有立刻 spawn,只是把「要 spawn」这个操作记录进缓冲区
        commands.spawn((
            Bullet,
            Position(tf.translation),
            Velocity(tf.forward() * 50.0),
        ));
    }
}

这些命令排队,在调度器的**同步点(sync point)**统一应用(历史上叫 apply_deferred,不同版本名字有变化)。同步点是一道屏障:所有并行 System 跑完 → 串行地把命令刷进 World → 继续。

工程含义(很重要):

  1. commands.spawn() 之后,你不能在同一个 System 里立刻查询到这个实体。它还不存在。
  2. 同步点是串行的,是并行度的瓶颈。同步点越多,并行窗口被切得越碎。
  3. 所以如果你能用「改字段」代替「增删组件」,你不仅省了 Archetype 迁移,还省了同步点压力。

这套模式后端也很熟:这就是批量写入 + 事务提交,或者 Redis 的 pipeline。攒一批,一次刷,避免逐条操作的同步开销。

同构提示:Commands 的设计约束跟数据库里「不能在遍历游标的同时修改底层表」是同一个问题(迭代器失效)。所有高性能容器/存储都要面对它,解法都类似:要么快照,要么延迟应用。


四、自动并行调度器:靠类型签名推导依赖

这是我认为 Bevy 最漂亮、也最值得后端工程师抄的设计。

4.1 问题:怎么知道两个 System 能不能并行?

传统做法:程序员手写依赖声明。

# 你得自己维护这玩意儿,还得保证它跟代码同步
tasks:
  physics:  { reads: [velocity], writes: [position] }
  render:   { reads: [position, mesh] }
  ai:       { reads: [position], writes: [ai_state] }

这种东西的宿命是:代码改了,声明忘了改,然后线上数据竞争。

Bevy 的做法:根本不用你写,从函数签名推。

fn physics(q: Query<(&mut Position, &Velocity)>) {}
//                    ^^^^ 写 Position    ^^^ 读 Velocity

fn ai(q: Query<(&Position, &mut AiState)>) {}
//               ^^ 读 Position   ^^^^ 写 AiState

fn render(q: Query<(&Position, &Mesh)>) {}
//                   ^^ 读        ^^ 读

Bevy 在把这些函数注册成 System 时,通过 SystemParam trait 递归地收集每个参数的访问需求,构造出一个访问集:

physics: writes = {Position},  reads = {Velocity}
ai:      writes = {AiState},   reads = {Position}
render:  writes = {},          reads = {Position, Mesh}

然后用标准的读写冲突规则判断能否并行(本质就是读写锁语义):

关系能并行?
读 / 读✅ 可以
读 / 写(同一组件)❌ 不行
写 / 写(同一组件)❌ 不行
完全不相交✅ 可以

于是:

  • physicsPositionairenderPosition冲突,不能同时跑
  • aiAiStaterender{Position, Mesh}不相交,可以并行

这份依赖信息是从类型系统里免费得到的,永远不会和代码不同步。 你改了函数签名,依赖关系自动跟着变。这是「让编译器帮你维护正确性」的典范。

Bevy 的多线程执行器基于此,在运行时把无冲突的 System 分发到线程池(底层用 task pool,一个 work-stealing 调度器)上并行执行。你一行并发代码都没写。

4.2 手动排序、系统集与运行条件

自动并行解决「能不能同时跑」,但业务上你常常需要「必须先后跑」。Bevy 给了显式排序 API:

use bevy::prelude::*;

#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum GameSet {
    Input,
    Logic,
    Physics,
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        // 声明集合之间的顺序,一次声明,组内系统自动遵守
        .configure_sets(Update, (
            GameSet::Input,
            GameSet::Logic,
            GameSet::Physics,
        ).chain())          // chain() = 依次串行
        .add_systems(Update, (
            read_input.in_set(GameSet::Input),
            // 同一集合内,这两个若无数据冲突则自动并行
            (update_ai, update_spawner).in_set(GameSet::Logic),
            // 显式约束:integrate 必须在 apply_gravity 之后
            (apply_gravity, integrate).chain().in_set(GameSet::Physics),
        ))
        // 运行条件:只在特定状态 / 满足条件时才跑
        .add_systems(Update, pause_menu.run_if(in_state(AppState::Paused)))
        .add_systems(Update, autosave.run_if(on_timer(
            std::time::Duration::from_secs(60)
        )))
        .run();
}

几个要点:

  • chain():让一串 System 严格串行,隐含 before/after 关系
  • .before(x) / .after(x):细粒度点对点约束
  • SystemSet:把一组 System 打包,对集合排序,比逐个排序好维护得多
  • .run_if(...):条件执行,条件本身也是一个返回 bool 的 System,可以读 World 数据

调度器最终把这些约束构造成一个 DAG,然后做拓扑排序 + 冲突检测 + 并行分发。 如果你写出了环形依赖,Bevy 会在启动时 panic 并告诉你环在哪——这比运行时死锁友好太多。

还有一个很省心的能力:歧义检测(ambiguity detection)。如果两个 System 都写同一个组件、但你没声明顺序,那么它们的执行顺序是不确定的,结果可能不可复现。Bevy 可以把这类「未声明顺序的冲突」报告出来,让你显式决策。这在追查「偶发的、跑十次错一次」的 bug 时是救命功能。

4.3 System 内部并行:par_iter

跨 System 并行之外,单个 System 内部也能并行。当你有十万个实体要跑同一份纯计算时:

fn heavy_physics(mut query: Query<(&mut Position, &Velocity)>, time: Res<Time>) {
    let dt = time.delta_secs();
    // 把实体分批(batch)分发到 ComputeTaskPool 的多个线程
    query.par_iter_mut().for_each(|(mut pos, vel)| {
        pos.0 += vel.0 * dt;
    });
}

注意适用边界,别乱用:

  • 适合:每个实体的计算彼此独立、且计算量不算太小
  • 不适合:实体数量少(几百个),或每个实体的工作量极小——此时任务分发和线程同步的开销会超过收益,比单线程还慢
  • 闭包内不能做结构性变更(同 3.6 的约束);要改结构就发命令或写入缓冲

务必实测。 我见过太多人无脑把 iter_mut 换成 par_iter_mut 然后性能下降的案例。并行有固定开销,小数据量下它是负收益。

4.4 独占访问:exclusive system

有时候你就是需要对整个 World 的独占可变访问(写工具、做序列化存档、批量重构数据):

fn save_game(world: &mut World) {
    // 独占访问:这个 System 运行期间,没有任何其他 System 能并行
    // 强大,但它是一道并行屏障,别在热路径上用
    let mut query = world.query::<(&Position, &Health)>();
    let snapshot: Vec<_> = query
        .iter(world)
        .map(|(p, h)| (p.0, h.current))
        .collect();
    // ... 序列化写盘
    info!("saved {} entities", snapshot.len());
}

&mut World 参数让它自动成为 exclusive system。能力最强,代价是把并行度砍到 1。它是逃生舱,不是日常工具。


五、代码实战:一个能跑起来的完整例子

光讲原理不落地就是耍流氓。下面是一个自洽的小例子,涵盖组件、资源、事件、查询过滤、命令、排序。

Cargo.toml

[package]
name = "ecs-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
# 版本号请按你实际使用的版本填写,0.x 阶段 API 变动较大
bevy = "0.16"

# 这条非常重要:Bevy 在 debug 模式下慢得不像话(没优化的数学库 + 大量泛型)
# 依赖库始终按 release 优化编译,你的代码保持 debug 可调试
[profile.dev.package."*"]
opt-level = 3

[profile.dev]
opt-level = 1

主程序:

use bevy::prelude::*;

// ---------- Components ----------

#[derive(Component, Debug)]
struct Position(Vec3);

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

#[derive(Component)]
struct Health {
    current: f32,
    max: f32,
}

#[derive(Component)]
struct Enemy;

#[derive(Component)]
struct Player;

// 频繁增删的临时状态 → 用 SparseSet,避免 Archetype 迁移抖动
#[derive(Component)]
#[component(storage = "SparseSet")]
struct Invulnerable {
    remaining: f32,
}

// ---------- Resources(全局单例数据)----------

#[derive(Resource)]
struct Score(u32);

#[derive(Resource)]
struct SpawnTimer(Timer);

// ---------- Events(跨 System 解耦通信)----------

// 注:事件系统在较新版本有过重构,请对照你的版本 API
#[derive(Event)]
struct DamageEvent {
    target: Entity,
    amount: f32,
}

#[derive(Event)]
struct DeathEvent {
    entity: Entity,
}

// ---------- SystemSet ----------

#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum GameSet {
    Spawn,
    Simulate,
    Damage,
    Cleanup,
}

// ---------- Systems ----------

fn setup(mut commands: Commands) {
    // 一次性把组件配齐,避免多次 Archetype 迁移
    commands.spawn((
        Player,
        Position(Vec3::ZERO),
        Velocity(Vec3::X * 2.0),
        Health { current: 100.0, max: 100.0 },
    ));

    // 批量生成:走 spawn_batch,全部落在同一个 Archetype
    commands.spawn_batch((0..1000).map(|i| {
        let f = i as f32;
        (
            Enemy,
            Position(Vec3::new(f * 2.0, 0.0, 0.0)),
            Velocity(Vec3::new(0.0, (f * 0.1).sin(), 0.0)),
            Health { current: 30.0, max: 30.0 },
        )
    }));

    info!("setup done");
}

/// 定时生成敌人:演示 Commands 的延迟写入
fn spawn_enemies(
    mut commands: Commands,
    time: Res<Time>,
    mut timer: ResMut<SpawnTimer>,
) {
    if !timer.0.tick(time.delta()).just_finished() {
        return;
    }
    commands.spawn((
        Enemy,
        Position(Vec3::new(50.0, 0.0, 0.0)),
        Velocity(Vec3::NEG_X),
        Health { current: 30.0, max: 30.0 },
    ));
}

/// 运动积分:写 Position,读 Velocity
fn movement(mut query: Query<(&mut Position, &Velocity)>, time: Res<Time>) {
    let dt = time.delta_secs();
    for (mut pos, vel) in &mut query {
        pos.0 += vel.0 * dt;
    }
}

/// 无敌帧倒计时:演示什么时候该真的 remove 组件
fn tick_invulnerable(
    mut commands: Commands,
    mut query: Query<(Entity, &mut Invulnerable)>,
    time: Res<Time>,
) {
    for (e, mut inv) in &mut query {
        inv.remaining -= time.delta_secs();
        if inv.remaining <= 0.0 {
            // SparseSet 存储,移除很便宜
            commands.entity(e).remove::<Invulnerable>();
        }
    }
}

/// 碰撞检测:注意这里用了两个不相交的 Query(Without 保证互斥)
/// 若没有 Without,Bevy 会在运行时报「同一组件被两个 Query 同时可变借用」
fn detect_collisions(
    players: Query<(Entity, &Position), (With<Player>, Without<Invulnerable>)>,
    enemies: Query<&Position, With<Enemy>>,
    mut damage_events: EventWriter<DamageEvent>,
) {
    for (player_entity, player_pos) in &players {
        for enemy_pos in &enemies {
            if player_pos.0.distance(enemy_pos.0) < 1.0 {
                damage_events.send(DamageEvent {
                    target: player_entity,
                    amount: 10.0,
                });
            }
        }
    }
}

/// 应用伤害:消费事件,写 Health,产出死亡事件
fn apply_damage(
    mut commands: Commands,
    mut damage_events: EventReader<DamageEvent>,
    mut death_events: EventWriter<DeathEvent>,
    mut query: Query<&mut Health>,
) {
    for ev in damage_events.read() {
        let Ok(mut health) = query.get_mut(ev.target) else {
            continue;   // 实体可能已经没了,安全跳过
        };
        health.current -= ev.amount;

        if health.current <= 0.0 {
            death_events.send(DeathEvent { entity: ev.target });
        } else {
            // 给一段无敌帧
            commands.entity(ev.target).insert(Invulnerable { remaining: 1.0 });
        }
    }
}

/// 处理死亡:despawn + 加分
fn handle_deaths(
    mut commands: Commands,
    mut death_events: EventReader<DeathEvent>,
    mut score: ResMut<Score>,
) {
    for ev in death_events.read() {
        commands.entity(ev.entity).despawn();
        score.0 += 100;
        info!("entity {:?} died, score = {}", ev.entity, score.0);
    }
}

/// 变更检测:只在 Health 真的变过时才输出,这就是 CDC
fn report_health_changes(query: Query<(Entity, &Health), Changed<Health>>) {
    for (e, h) in &query {
        info!("{:?} hp -> {:.1}/{:.1}", e, h.current, h.max);
    }
}

/// 出界清理
fn despawn_out_of_bounds(
    mut commands: Commands,
    query: Query<(Entity, &Position)>,
) {
    for (e, pos) in &query {
        if pos.0.length() > 1000.0 {
            commands.entity(e).despawn();
        }
    }
}

// ---------- App ----------

fn main() {
    App::new()
        .add_plugins(MinimalPlugins)
        .add_plugins(bevy::log::LogPlugin::default())
        .insert_resource(Score(0))
        .insert_resource(SpawnTimer(Timer::from_seconds(
            2.0,
            TimerMode::Repeating,
        )))
        .add_event::<DamageEvent>()
        .add_event::<DeathEvent>()
        .add_systems(Startup, setup)
        .configure_sets(
            Update,
            (
                GameSet::Spawn,
                GameSet::Simulate,
                GameSet::Damage,
                GameSet::Cleanup,
            )
                .chain(),
        )
        .add_systems(
            Update,
            (
                spawn_enemies.in_set(GameSet::Spawn),
                // movement 和 tick_invulnerable 访问集不相交 → 自动并行
                (movement, tick_invulnerable).in_set(GameSet::Simulate),
                // 伤害链必须严格有序
                (detect_collisions, apply_damage, handle_deaths)
                    .chain()
                    .in_set(GameSet::Damage),
                (report_health_changes, despawn_out_of_bounds)
                    .in_set(GameSet::Cleanup),
            ),
        )
        .run();
}

这个例子里值得注意的几个工程点:

  1. detect_collisions 里的 Without<Invulnerable>:Bevy 的运行时会检查 Query 之间的借用冲突。两个 Query 若可能匹配到同一实体的同一组件且其中一个是可变的,就会 panic。用 With/Without 让它们在类型层面互斥是标准解法。这是 Rust 借用检查思想在 ECS 层的延伸。
  2. query.get_mut(entity) 返回 Result:实体随时可能已被 despawn。ECS 里不要假设实体存在,永远处理缺失情况。
  3. 事件用于解耦detect_collisions 不需要知道伤害怎么算,apply_damage 不需要知道谁怎么死的。这是 ECS 里的消息总线,跟后端的领域事件是一回事。
  4. opt-level 那段 profile 配置:这不是可选项。Bevy 在纯 debug 下慢到无法开发,因为大量小函数(向量数学)没内联。

六、性能优化:一份实战清单

按「收益 / 成本」排序,前面的先做。

6.1 先开优化编译(最高性价比)

上面 Cargo.toml 里的 [profile.dev.package."*"] opt-level = 3 能带来数量级差异。这是 Bevy 新手 90% 的「性能问题」的真实原因——他们在 debug 模式下测性能。

发布构建再加一层:

[profile.release]
lto = "thin"          # 链接时优化,跨 crate 内联
codegen-units = 1     # 牺牲编译速度换运行时性能
panic = "abort"       # 去掉 unwind 表,减小体积

6.2 别在热路径上做结构性变更

前面 3.3 讲透了。规则:

  • 每帧增删组件 → 改成布尔字段 / 枚举状态
  • 必须增删 → 考虑 SparseSet 存储
  • spawn 时一次配齐组件,别 spawn 完追加
  • 批量创建用 spawn_batch

6.3 控制 Archetype 数量

  • 合并 marker 组件为枚举(见 3.4)
  • 别为每个细微状态都开一个新组件类型
  • 怀疑碎片化时,先量:统计 Archetype 数量和每个 Archetype 的实体数。如果你有 500 个 Archetype、平均每个 3 个实体,问题就在这

6.4 Query 要精确

// 不好:把整个 Transform(含矩阵)拉进来,还要了可变访问
fn bad(mut q: Query<&mut Transform>) { /* 其实只读了 translation */ }

// 好:只读,且只要真正需要的组件
fn good(q: Query<&Position>) {}
  • 只读就用 &T,不要图省事写 &mut T——可变访问会污染 change detection,还会阻断并行
  • With/Without 尽量在 Archetype 层面就筛掉,别在循环里 continue
  • Changed<T> 避免重复处理没变的数据,这个在大规模场景下收益极大

6.5 减少同步点

每个 Commands 的应用点都是串行屏障。把产生命令的 System 聚在一起,让调度器能合并同步点,而不是「命令-同步-命令-同步」交替。

6.6 par_iter 要实测

见 4.3。数据量小的时候它是负优化。先 profile,再并行。

6.7 用对工具去量

不要靠猜。Bevy 生态有内置的诊断插件(帧时间、实体计数等),以及 tracing 集成,可以把每个 System 的耗时导出成火焰图/时间线。配合 tracy 这类工具能看到每帧每个 System 在哪个线程上跑了多久、同步点卡了多少。

优化的铁律没有例外:先测量,找到那 5% 的热点,再动手。 我上面列的所有条目,都得用你自己的 profile 数据来决定做不做。


七、把这套东西搬到后端:什么能抄,什么不能

这是我最想聊的部分。

7.1 能抄的:数据导向布局(SoA)

「结构体数组」改「数组的结构体」——这个变换跟游戏无关,是纯粹的性能技巧。

// AoS (Array of Structs):传统写法
struct Order { id: u64, amount: f64, status: u8, customer_name: String }
let orders: Vec<Order>;
// 统计总额:要把 customer_name 等无关数据全搬进 cache

// SoA (Struct of Arrays):列式写法
struct Orders {
    ids:            Vec<u64>,
    amounts:        Vec<f64>,      // 统计总额只需扫这一列
    statuses:       Vec<u8>,
    customer_names: Vec<String>,
}

任何时候你在做「对大批量同质数据的批处理」——风控规则批量评估、指标聚合、时序数据处理、批量序列化——SoA 都值得考虑。这也正是 Pandas → Polars/Arrow、行存 → 列存背后的同一个原理。

7.2 能抄的:从类型签名推导依赖

这个思想我认为被严重低估了。让「我要访问什么数据」成为函数签名的一部分,而不是写在注释、文档或配置文件里。

一旦访问需求是类型系统里的一等公民,你就能免费得到:

  • 自动并行(无冲突的任务自动并发)
  • 自动冲突检测(编译期或启动期发现数据竞争)
  • 永不过期的依赖信息(改代码即改依赖)

对比一下你手上的后端框架:那些靠字符串配置、靠注解、靠运行时反射来声明依赖的地方,是不是都有「声明和实现不同步」的历史欠账?

7.3 能抄的:代际索引句柄

(index, generation) 替代裸指针 / 裸 ID,能在不引入 GC 和引用计数的前提下安全检测「对象已失效」。做连接池、会话表、缓存槽位、对象池时非常好用。

7.4 能抄的:命令缓冲 + 同步点

攒批 + 统一提交,避免逐条操作的同步成本。以及那条重要的架构纪律:遍历期间不修改底层结构。

7.5 不该抄的:别把 ECS 当万能架构

必须说清楚,否则这篇文章就变成布道了:

ECS 不适合的场景:

  • 实体数量少(几十几百)。列式布局的收益来自规模,小数据量下你只是把代码写复杂了,性能没变化。
  • 业务逻辑以复杂的单对象状态机为主,而不是「对大批量对象做同质操作」。ECS 擅长「一万个东西做同一件事」,不擅长「一个东西做一万件复杂的事」。后者用状态机 + 普通结构体更清晰。
  • 强事务性、强一致性的业务。ECS 的延迟命令模型和「实体随时可能不存在」的假设,跟需要严格事务语义的业务是冲突的。
  • 团队不熟悉。ECS 的心智模型跟 OOP 差别很大,调试方式也不同(你不能在一个地方打断点看到某个对象的全部行为,逻辑分散在各个 System 里)。这个迁移成本是真实的,别低估。

一句话总结适用边界:数据量大 + 操作同质 + 组合多变 → ECS 有优势;否则老老实实写普通代码。

7.6 和 Actor 模型的对比

两者经常被拿来比,但它们解决的是不同问题:

维度ECSActor(Erlang/Akka)
并行的粒度数据类型切分(谁能碰哪个组件)对象实例切分(每个 actor 独占自己的状态)
通信方式共享数据 + 调度器保证无冲突消息传递,无共享
强项大批量同质数据的吞吐高并发独立会话、容错、分布式
数据局部性极好(列式连续)一般(每个 actor 状态分散)
天然分布式否(依赖共享内存)是(消息可跨网络)

所以:做单机高吞吐批处理看 ECS;做分布式高并发会话看 Actor。不是替代关系。


八、总结:三个带走的东西

写到这里,如果你只记三件事:

1. 性能的主战场在内存布局,不在算法常数。

Archetype 列式存储比 OOP 对象数组快,不是因为它做了更聪明的计算,而是因为它不浪费内存带宽、且对预取器友好。在现代 CPU 上,计算单元长期在等内存。任何性能工作都应该先问:我的数据布局对吗?我搬进 cache 的字节里有多少是真正要用的?

2. 把「访问需求」编码进类型系统,让编译器帮你维护并发正确性。

Bevy 的自动并行不是什么黑魔法,就是「从函数签名读出读写集,然后做读写锁冲突判断」。魔法在于这份信息永远不会和代码不同步。这个思路可以迁移到任何需要调度、需要并发、需要依赖分析的系统里。

3. 没有免费的抽象,ECS 也有它的账单。

Archetype 迁移的 memcpy、Archetype 碎片化、同步点串行、心智模型的迁移成本、以及 Bevy 在 0.x 阶段每个版本都要改代码的现实。任何架构的收益都对应着一组代价,工程判断的能力就体现在你是否清楚自己在付什么。

至于 Bevy 本身:它还在 0.x,API 会继续变,生态(尤其是编辑器和资产工作流)跟成熟商业引擎比还有明显差距。如果你是要做商业游戏交付,请谨慎评估。 但如果你的目的是学习一套被打磨到极致的数据导向架构,它的源码是我读过的最清晰的教材之一——Rust 的类型系统迫使所有隐式假设都变成显式声明,这让整个设计意图无处躲藏。

即使你这辈子都不打算写游戏。


附:动手路径

想上手,建议这个顺序,别一上来就啃渲染:

  1. 只用 bevy_ecs,不引整个引擎。它是独立 crate,可以当纯粹的 ECS 库用在任何 Rust 项目里(包括后端服务)。先在控制台里把组件、系统、查询、事件玩明白。
  2. 写一个 benchmark:造十万实体,分别用 Vec<Struct> 和 ECS 跑同一个变换,用 criterion 测差距。自己测出来的数字,比任何文章里的数字都有说服力,也能让你直观感受到规模阈值在哪。
  3. 故意制造 Archetype 迁移:写一个每帧给所有实体 insert/remove 组件的系统,看性能怎么塌,再改成布尔字段,看它怎么回来。这个对照实验会让 3.3 那一节永久刻进你脑子里。
  4. 打开 tracing,看你的 System 到底并行了没有,同步点在哪。很多时候你以为在并行,实际上因为一个多余的 &mut 全串行了。
  5. 最后才是渲染、资产、UI 那些引擎功能。

顺带一句:官方文档里的 bevy_ecs 示例和 Bevy Cheat Book(社区维护的实战手册)是最有效的两份材料。但永远以你手上那个版本的 API 文档为准——这是 0.x 项目的生存法则。

推荐文章

PHP中获取某个月份的天数
2024-11-18 11:28:47 +0800 CST
CSS 媒体查询
2024-11-18 13:42:46 +0800 CST
css模拟了MacBook的外观
2024-11-18 14:07:40 +0800 CST
程序员茄子在线接单