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_system 变 add_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 (能移动、有伤害、无血量)
看起来挺清楚。然后产品经理来了:
「我们要加一个会移动的箱子,被推的时候会滑。」
「还要一个能开火的炮塔,但它不能移动,且是建筑不是角色。」
「树也要能被砍倒了,加血量。」
现在你面临经典的菱形继承地狱。你的选择:
- 把功能往上提到
GameObject——最后基类变成一个几百个字段的上帝对象,每个Tree实例都白白背着ammoCount、aiState这些永远用不到的字段。内存爆炸,缓存命中率崩盘。 - 多重继承 / 接口 + mixin——C++ 里菱形继承虚表地狱,Java/C# 里接口默认方法拼不出状态,最后你会写出一堆
if (obj instanceof IMovable)。 - 组件化——把「能移动」「有血量」「能开火」拆成独立的数据块,对象只是这些数据块的组合。
第 3 条就是 ECS 的起点。但 ECS 真正的杀手级优势不在于「灵活组合」——那只是 composition over inheritance,老生常谈。真正的优势在内存布局。
1.2 算一下 cache miss 的账
这是最容易被口头带过、但决定一切的地方。让我们用具体数字算。
现代 CPU 的大致延迟量级(不同架构有差异,量级可参考):
| 层级 | 延迟(周期) | 相对成本 |
|---|---|---|
| L1 cache | ~4 | 1× |
| L2 cache | ~12 | 3× |
| L3 cache | ~40 | 10× |
| 主内存 (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;
}
发生了什么:
Vec<Box<Entity>>是指针数组,每个 entity 在堆上的位置是分散的(取决于分配顺序,可能相隔很远)- 每次循环,为了拿到 24 字节的有效数据,CPU 要发起一次随机内存访问,搬 64 字节 cache line 进来
- 那 64 字节里,只有 24 字节是你要的,剩下 40 字节是
name、inventory这些无关字段——纯浪费带宽 - 更糟: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] │ 连续
└─────────────────────────────────────────────────┘
同一个系统跑起来:
Transform列是一块连续内存,Velocity列也是- 顺序访问 → 硬件预取器完美工作,它能识别 stride 模式并提前把后面的数据搬进来
- 每个 cache line 装满的全是有效数据(64 字节的
Velocity列能装 5 个[f32;3],全都要用) name、inventory那些字段根本不在这块内存里,一个字节都不浪费带宽- 顺带:连续同类型数据是自动向量化(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;
注意 Player 和 Enemy 这种零大小类型(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 | 一个批处理任务 | 消费查询结果 |
| Schedule | DAG 任务编排 | 依赖调度 |
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)> 要跑:
- 匹配阶段:哪些 Archetype 同时包含
Position和Velocity?→ Archetype 1、2、4(3 没有 Velocity,排除) - 遍历阶段:对每个匹配的 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 → 继续。
工程含义(很重要):
commands.spawn()之后,你不能在同一个 System 里立刻查询到这个实体。它还不存在。- 同步点是串行的,是并行度的瓶颈。同步点越多,并行窗口被切得越碎。
- 所以如果你能用「改字段」代替「增删组件」,你不仅省了 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}
然后用标准的读写冲突规则判断能否并行(本质就是读写锁语义):
| 关系 | 能并行? |
|---|---|
| 读 / 读 | ✅ 可以 |
| 读 / 写(同一组件) | ❌ 不行 |
| 写 / 写(同一组件) | ❌ 不行 |
| 完全不相交 | ✅ 可以 |
于是:
physics写Position,ai和render读Position→ 冲突,不能同时跑ai写AiState,render读{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();
}
这个例子里值得注意的几个工程点:
detect_collisions里的Without<Invulnerable>:Bevy 的运行时会检查 Query 之间的借用冲突。两个 Query 若可能匹配到同一实体的同一组件且其中一个是可变的,就会 panic。用With/Without让它们在类型层面互斥是标准解法。这是 Rust 借用检查思想在 ECS 层的延伸。query.get_mut(entity)返回Result:实体随时可能已被 despawn。ECS 里不要假设实体存在,永远处理缺失情况。- 事件用于解耦:
detect_collisions不需要知道伤害怎么算,apply_damage不需要知道谁怎么死的。这是 ECS 里的消息总线,跟后端的领域事件是一回事。 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 模型的对比
两者经常被拿来比,但它们解决的是不同问题:
| 维度 | ECS | Actor(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 的类型系统迫使所有隐式假设都变成显式声明,这让整个设计意图无处躲藏。
即使你这辈子都不打算写游戏。
附:动手路径
想上手,建议这个顺序,别一上来就啃渲染:
- 只用
bevy_ecs,不引整个引擎。它是独立 crate,可以当纯粹的 ECS 库用在任何 Rust 项目里(包括后端服务)。先在控制台里把组件、系统、查询、事件玩明白。 - 写一个 benchmark:造十万实体,分别用
Vec<Struct>和 ECS 跑同一个变换,用criterion测差距。自己测出来的数字,比任何文章里的数字都有说服力,也能让你直观感受到规模阈值在哪。 - 故意制造 Archetype 迁移:写一个每帧给所有实体 insert/remove 组件的系统,看性能怎么塌,再改成布尔字段,看它怎么回来。这个对照实验会让 3.3 那一节永久刻进你脑子里。
- 打开 tracing,看你的 System 到底并行了没有,同步点在哪。很多时候你以为在并行,实际上因为一个多余的
&mut全串行了。 - 最后才是渲染、资产、UI 那些引擎功能。
顺带一句:官方文档里的 bevy_ecs 示例和 Bevy Cheat Book(社区维护的实战手册)是最有效的两份材料。但永远以你手上那个版本的 API 文档为准——这是 0.x 项目的生存法则。