前言
这是一本写给 Rust 开发者的 Bevy 引擎深度概念书。
为什么写这本书
Bevy 不仅是一个游戏引擎,更是 Rust 类型系统与数据驱动架构结合的典范之作。 它用 60 个模块化 crate 构建了一个完整的游戏引擎,其中 ECS (Entity Component System) 架构贯穿始终——从核心的 World、Entity、Component,到渲染管线、UI 框架、动画系统, 无一不建立在 ECS 之上。
然而,Bevy 的官方文档偏重 API 使用层面。要真正理解 Bevy 为什么这样设计—— 为什么函数可以自动变成 System?为什么渲染需要双 World?为什么 Archetype 迁移 不可避免?——需要深入源码。
这本书就是这趟源码之旅的导游。
这本书适合谁
- 有 Rust 基础的开发者,想深入理解 Bevy 的设计哲学
- 正在使用 Bevy 的开发者,想知道引擎内部如何运作
- 对 ECS 架构感兴趣的系统程序员
- 想学习 Rust 高级设计模式的开发者(Bevy 是绝佳的案例库)
这本书不包含什么
- 不是入门教程(不会从零教你写第一个窗口)
- 不含可运行的游戏项目
- 不涉及 GPU 编程的数学推导
如何阅读
全书分五个部分:
- 引擎全景(第 1-2 章)——建立整体认知
- ECS 内核(第 3-10 章)——全书核心,从 World 到 Schedule 逐层剖析
- 通信与关系(第 11-13 章)——Commands、Event、Message、Observer、Hierarchy
- 引擎子系统(第 14-21 章)——展示 ECS 如何驱动渲染、UI、动画等
- 高级主题(第 22-26 章)——Reflect、并发、跨平台、Rust 设计模式总结
建议顺序阅读第二部分(ECS 内核),其余部分可按兴趣跳读。
基于的版本
本书基于 Bevy 0.19.0-dev 源码分析。Bevy 迭代迅速,具体 API 可能变化, 但核心的架构设计思想具有持久价值。
图表说明
全书包含 39 张图表,涵盖内存布局、数据流、架构关系、时序、trait 层级等。 所有图表均使用文本格式(ASCII art 或 Mermaid),可在任何环境下阅读。
第 1 章:Bevy 的设计哲学
导读:本章是全书的起点。我们将从四个维度理解 Bevy 引擎的核心设计理念: 模块化、数据驱动、零 boilerplate 和 Rust 适配性。这些理念不是口号——它们 深刻影响了 Bevy 的每一个架构决策,贯穿本书后续所有章节。
1.1 模块化:58 个 crate,按需组合
打开 Bevy 的根 Cargo.toml,你会看到:
// 源码: Cargo.toml:17
[workspace]
members = [
"crates/*",
// ...
]
crates/ 目录下有 58 个独立的 crate。这不是偶然的代码拆分,而是刻意的架构决策:每个 crate 都可以独立编译、独立测试、独立依赖。
这些 crate 大致分为以下几层:
┌─────────────────────────────────────────┐
│ bevy (伞 crate) │
├─────────────────────────────────────────┤
│ bevy_pbr bevy_ui bevy_sprite ... │ ← 功能层
├─────────────────────────────────────────┤
│ bevy_render bevy_asset bevy_scene │ ← 基础设施层
├─────────────────────────────────────────┤
│ bevy_app bevy_ecs bevy_reflect │ ← 核心层
├─────────────────────────────────────────┤
│ bevy_utils bevy_math bevy_tasks │ ← 工具层
└─────────────────────────────────────────┘
图 1-1: Bevy crate 层级结构
用户通过 Feature Flags 选择需要的功能。Bevy 的默认 features 仅有 4 个:
// 源码: Cargo.toml:134
default = ["2d", "3d", "ui", "audio"]
但总共提供了 160+ 个 feature flags,从图片格式 (png, jpeg, webp) 到渲染后端 (webgl2, webgpu),从音频编解码 (mp3, flac, vorbis) 到平台支持 (x11, wayland, android-game-activity)。
这种设计的意义在于:一个嵌入式系统可以只用 bevy_ecs 做数据管理;一个服务器可以用 bevy_app + bevy_ecs 做游戏逻辑而不引入任何渲染代码。模块化不是可选的美德,而是架构的基石。
如果不这样做会怎样?Unity 的单体架构就是一个反面案例——即使你只想做一个服务器端的逻辑模拟,也不得不引入渲染管线的依赖。这不仅增大了二进制体积,还导致编译时间膨胀。Bevy 将模块化推到极致的代价是 crate 之间的依赖关系变得复杂——58 个 crate 的版本必须严格同步,跨 crate 的 API 变更需要同时更新多处。但 Cargo workspace 的统一版本管理和 Bevy 的 CI 系统有效地缓解了这个问题。从第 2 章的 Plugin 体系可以看到,这种模块化如何在 App 构建阶段被组装成一个完整的引擎。
要点:Bevy 是 58 个可独立使用的 crate 的集合,通过 Feature Flags 精确裁剪。
1.2 数据驱动:ECS 作为统一范式
Bevy 选择了 ECS (Entity Component System) 作为其唯一的编程范式。这不是"ECS 可选"——引擎的每个子系统都构建在 ECS 之上:
| 子系统 | ECS 中的表现 |
|---|---|
| 游戏对象 | Entity + Component 组合 |
| 全局配置 | Resource(特殊的 Component) |
| 游戏逻辑 | System(普通函数) |
| UI 元素 | Entity + Node Component |
| 光源 | Entity + PointLight/DirectionalLight Component |
| 相机 | Entity + Camera Component |
| 音频 | Entity + AudioPlayer Component |
传统引擎(如 Unity)中,GameObject 通过继承获得行为。这会导致两个经典问题:
- 菱形继承:FlyingEnemy 同时继承 Flying 和 Enemy,哪个 update 先执行?
- God Object:基类不断膨胀,所有对象都拖着不需要的字段。
但"组合优于继承"这个教科书答案并不是 Bevy 选择 ECS 的全部理由。更深层的动机在于数据布局对性能的决定性影响。OOP 中对象的字段在内存中是"行式"排列的——一个 GameObject 的 Transform、Renderer、Collider、Health 紧挨着存储。当你的系统只需要遍历所有 Position 时,CPU 缓存被 Renderer 和 Collider 等无关字段"污染"了,每次 cache line 加载都浪费了大量带宽。ECS 的列式存储(SoA)将同类型组件紧密排列,遍历时的缓存命中率可以达到接近 100%。这不是理论上的微优化——在有数万实体的场景中,SoA 布局相比 AoS(行式)可以带来 5-10 倍的遍历性能差异。此外,OOP 的继承层级使得"这个对象到底拥有哪些能力"在编译期难以穷举,这让自动并行变得几乎不可能。ECS 将每个 System 的数据需求显式编码为 Query 类型签名,调度器可以在初始化时就构建完整的数据访问依赖图,从而安全地并行执行互不冲突的 System。如果 Bevy 采用 OOP 架构,就需要开发者手动标注线程安全性,或者像 Unity 的 Job System 那样要求开发者显式地声明 NativeArray 的读写权限——这正是 Bevy 试图避免的 boilerplate。
ECS 通过组合解决这两个问题:
传统 OOP: ECS 组合:
┌──────────────┐ Entity = Position + Velocity
│ GameObject │ + Health
│ ├─ Transform │ Entity = Position + Velocity
│ ├─ Renderer │ + AIController
│ ├─ Collider │
│ └─ Health │ 组件随意组合,无继承层级
└──────────────┘
图 1-2: OOP 继承 vs ECS 组合
在 Bevy 中,一个实体到底"是什么",不由继承层级决定,而由它挂载了哪些 Component 决定。官方 UI 示例里的按钮实体就是一个直接的例子:
#![allow(unused)] fn main() { // 源码: examples/ui/widgets/button.rs:83 ( Button, Node { width: px(150), height: px(65), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, BorderColor::all(Color::WHITE), BackgroundColor(Color::BLACK), ) }
这里没有 UIButton 继承树,也没有"对象附带方法"的封装边界。按钮之所以是按钮,是因为它同时拥有 Button、Node、BorderColor、BackgroundColor 等组件。数据与行为彻底分离:Component 只存数据,System 只写逻辑。这种分离让 Bevy 能够自动分析 System 之间的数据依赖,实现自动并行执行;也让热重载、反射和编辑器集成更自然,因为 Component 是可序列化、可观察的纯数据。
要点:ECS 不是 Bevy 的可选功能,而是引擎的统一范式。数据(Component)与行为(System)的分离使自动并行成为可能。
1.3 零 boilerplate:函数即系统
Bevy 最让开发者惊叹的特性,大概就是这个:
#![allow(unused)] fn main() { // 源码: examples/hello_world.rs:9 fn hello_world_system() { println!("hello world"); } }
这就是一个合法的 System。没有 trait 要实现,没有 struct 要定义,没有生命周期要标注。一个普通的 Rust 函数,直接就能被 Bevy 调度执行。
更实用的例子来自官方 remote/server.rs:
#![allow(unused)] fn main() { // 源码: examples/remote/server.rs:79 fn move_cube( mut query: Query<&mut Transform, With<Cube>>, time: Res<Time>, ) { for mut transform in &mut query { transform.translation.y = -cos(time.elapsed_secs()) + 1.5; } } }
函数签名本身就是声明式的数据需求:
Query<&mut Transform, With<Cube>>:我需要所有带Cube标记的实体的Transform,可变访问Res<Time>:我需要只读访问全局Time资源
Bevy 在编译期通过 trait 的泛型机制(all_tuples! 宏为 0~16 个参数生成实现)自动将这个函数包装成 System。运行时,调度器根据参数声明的读写权限判断哪些 System 可以并行。
这种设计的代价是什么?运行时几乎没有——Rust 的单态化 (monomorphization) 确保这一切在编译期完成,运行时零开销。但编译期的代价不可忽视:all_tuples! 宏为 0 到 16 个参数的每种排列都生成独立的 trait 实现,单态化后每种具体的参数组合都会产生独立的机器码。这是 Bevy 编译时间较长的主要原因之一。如果没有这套基于 trait 的自动转换,用户就不得不手动实现 System trait、声明数据依赖、管理生命周期——正如第 8 章将详细展示的,这套魔法的复杂度全被隐藏在了编译器背后。
Rust 设计亮点:Bevy 利用 Rust 的 trait system 和泛型实现了函数到 System 的 自动转换。
SystemParamtrait 使用 GAT (Generic Associated Types) 实现参数的 生命周期参数化,all_tuples!宏为 0~16 个参数的元组生成 blanket impl。 这是 Rust 类型系统在工程中最优雅的应用之一。详见第 8 章。
要点:Bevy 的 System 是零 boilerplate 的——普通函数即系统,函数签名即数据依赖声明。
1.4 Rust 适配性:所有权模型如何塑造引擎架构
Bevy 不是一个"碰巧用 Rust 写的引擎"。Rust 的所有权模型深刻地塑造了 Bevy 的架构决策。
双 World 架构
Bevy 的渲染系统运行在独立的 RenderApp 子应用中,拥有自己的 World。为什么?
在 Rust 中,&mut World 是独占引用。如果渲染线程和逻辑线程共享同一个 World,它们无法同时持有 &mut World。一个朴素的解决方案是用 Mutex<World> 或 RwLock<World> 包装——但这会让每次组件访问都带上锁的开销,而且锁的粒度太粗(整个 World 一把大锁),并行度极低。另一种做法是对 World 内部的每个 Table、每个 Column 分别加细粒度锁——但这会导致数以千计的锁对象、大量的死锁风险和不可预测的性能。Bevy 的选择是从所有权层面彻底分离:让每个 World 只属于一个执行上下文。这种设计将"如何共享数据"的问题转化为"在哪个时间点复制数据"的问题——Extract 阶段成为两个 World 之间唯一的数据交换窗口,语义清晰且性能可控。这是 Rust 所有权模型"迫使"出的架构,但事后看来,它比任何锁方案都更优雅。
Bevy 的解决方案不是 unsafe 绕过,而是从架构上分离:
graph LR
MW["Main World<br/>(游戏逻辑)<br/>独占 &mut World"]
RW["Render World<br/>(GPU 准备)<br/>独占 &mut World"]
MW ==>|"Extract<br/>数据复制/移动"| RW
图 1-3: 所有权驱动的双 World 架构
每帧的 Extract 阶段将需要渲染的数据从 Main World 复制到 Render World。两个 World 各自独占自己的 &mut World,天然安全。
UnsafeWorldCell
ECS 的多 System 并行需要同时访问 World 的不同部分。Rust 的 &mut 独占保证在这里成为障碍。这个问题的本质是:Rust 的借用检查器以"整个 struct"为粒度判断冲突——它不理解"System A 只访问 Position 列,System B 只访问 Velocity 列"这样的语义。编译器只看到两个 &mut World,必须拒绝。Bevy 的解决方案是 UnsafeWorldCell——在编译期验证不了的情况下,将借用检查推迟到运行时:
- System 初始化时声明自己的数据需求 (
FilteredAccess) - 调度器验证并行运行的 System 不会访问相同的可变数据
- 验证通过后,通过
UnsafeWorldCell绕过编译期借用检查
这不是"放弃安全"——而是将安全保证从编译期转移到运行时的调度器。ECS 的 Query 声明就是运行时的"借用标注"。
Rust 设计亮点:Bevy 将 Rust 编译期的借用规则(
&T可共享,&mut T必独占) 映射到了运行时的调度器。FilteredAccess就是运行时的借用检查器。 这种"编译期理念 → 运行时实现"的迁移是 Bevy 最深刻的 Rust 设计决策。详见第 9 章。
Component 的 Send + Sync 约束
所有 Component 必须满足 Send + Sync + 'static。这不是随意的限制——ECS 的自动并行依赖于数据可以安全地跨线程访问。不满足这个约束的类型(如 Rc<T>)无法成为 Component,必须用 NonSend 存储。
这些约束不是 Bevy 的"限制",而是 Rust 类型系统的自然延伸。Bevy 的架构就是顺着 Rust 的所有权模型"长出来"的。
要点:Rust 的所有权模型不是 Bevy 的障碍,而是其架构的驱动力。双 World、UnsafeWorldCell、Send + Sync 约束——这些设计都是所有权模型的自然产物。
本章小结
- Bevy 不是单体引擎,而是由
bevy_app、bevy_ecs、bevy_render等 crate 组合出来的模块化体系。 - ECS 不是 Bevy 的一个子系统,而是整个引擎的统一建模方式:对象、UI、光源、资源和逻辑都被压到同一套数据模型里。
- “函数即系统”不是语法糖,而是把数据访问需求编码进函数签名,让调度器能据此做依赖分析和自动并行。
- Rust 的所有权模型没有被 Bevy 绕开,反而直接塑造了双 World、
UnsafeWorldCell和Send + Sync这些核心架构决策。
第 2 章:App、Plugin 与主循环
导读:上一章我们了解了 Bevy 的设计哲学。本章深入
bevy_appcrate, 理解 Bevy 应用的骨架:App构建器如何组装引擎、Plugintrait 如何让 58 个 crate 无缝协作、SubApp如何实现渲染隔离、Main Schedule 如何 编排每帧的执行顺序。
2.1 App 构建器模式
App 是 Bevy 应用的入口。它的 struct 定义出人意料地简洁:
#![allow(unused)] fn main() { // 源码: crates/bevy_app/src/app.rs:86 pub struct App { pub(crate) sub_apps: SubApps, pub(crate) runner: RunnerFn, fallback_error_handler: Option<ErrorHandler>, } }
只有三个字段:
sub_apps:所有子应用的集合(包括 Main 和 Render 等)runner:应用的生命周期管理函数(由WinitPlugin或ScheduleRunnerPlugin提供)fallback_error_handler:全局错误兜底处理
App 的真正力量在于它的 Builder API——通过链式调用注册 Plugin、System、Resource:
fn main() { App::new() .add_plugins(DefaultPlugins) // 注册默认插件组 .add_systems(Startup, setup) // 添加启动系统 .add_systems(Update, game_logic) // 添加每帧系统 .insert_resource(Score(0)) // 插入资源 .run(); // 启动主循环 }
App::new() 创建一个空的 App,内含一个空的 Main SubApp。所有的引擎功能——窗口、渲染、输入、音频——都通过 Plugin 注册。这意味着一个没有任何 Plugin 的 App 就是一个空壳。
为什么 App 自身要如此"空"?这是 Bevy 模块化哲学的极致体现——App 不对"引擎应该包含什么"做任何假设。一个传统引擎通常会在核心类中硬编码窗口管理、渲染循环和资源加载的逻辑,即使你只想用它做服务器端模拟,这些无用的代码也会被链接进来。Bevy 的 App 只提供一个"骨架"——SubApps 容器和一个 runner 函数。连主循环的实现都不在 App 内部,而是由 Plugin 提供(WinitPlugin 提供窗口事件循环,ScheduleRunnerPlugin 提供无窗口循环)。这个设计的代价是初始化比较冗长——即使是一个简单的"Hello World"也需要注册 DefaultPlugins。但换来的是第 1 章讨论的完全可裁剪性:从纯 ECS 服务器到完整的 3D 游戏,使用的是同一个 App,只是注册的 Plugin 不同。
Rust 设计亮点:
App使用 Builder 模式,每个方法返回&mut Self支持链式调用。 这在 Rust 中很常见,但 Bevy 的独特之处在于 Builder 的目标不是构建一个值, 而是构建一个运行时系统——Plugin 注册、System 调度、Resource 插入都在 build 阶段完成,run()时已完全配置好。
要点:App 是一个 Builder,它本身几乎不含逻辑——所有功能通过 Plugin 注入。
2.2 Plugin trait:引擎的扩展基石
Plugin 是 Bevy 模块化的核心机制。它的 trait 定义简洁而完整:
#![allow(unused)] fn main() { // 源码: crates/bevy_app/src/plugin.rs:56 pub trait Plugin: Downcast + Any + Send + Sync { /// Configures the App to which this plugin is added. fn build(&self, app: &mut App); /// Has the plugin finished its setup? fn ready(&self, _app: &App) -> bool { true } /// Finish adding this plugin to the App. fn finish(&self, _app: &mut App) {} /// Runs after all plugins are built and finished. fn cleanup(&self, _app: &mut App) {} fn name(&self) -> &str { ... } fn is_unique(&self) -> bool { true } } }
四个生命周期方法形成 Plugin 的完整生命周期:
graph TD
A["App::add_plugins(MyPlugin)"] --> B["build()<br/>立即执行:注册 System、Resource、Message"]
B -->|"app.run() 之后"| C{"ready()?<br/>轮询:Plugin 是否完成异步初始化"}
C -->|"全部 ready"| D["finish()<br/>所有 Plugin build 完成后执行"]
D --> E["cleanup()<br/>最后的清理机会"]
图 2-1: Plugin 生命周期
绝大多数 Plugin 只需实现 build()。例如,一个最简的 Plugin 可以是一个普通函数:
#![allow(unused)] fn main() { // 函数也实现了 Plugin trait pub fn my_plugin(app: &mut App) { app.add_systems(Update, hello_world); } }
Plugin 在引擎中的分布
如果按当前源码树搜索 impl Plugin for,可以看到 Plugin 实现横跨
43 个 crate。精确数量会随提交变化,但分布特征很稳定:渲染相关 crate
最密集,框架层和诊断/工具层也大量使用 Plugin 组织功能。
渲染层: bevy_render / bevy_pbr / bevy_core_pipeline / bevy_post_process
框架层: bevy_app / bevy_diagnostic / bevy_dev_tools
功能层: bevy_sprite_render / bevy_ui_widgets / ...
图 2-2: Plugin 在源码树中的分布趋势
每个 Plugin 在 build() 中做的事情大同小异:
- 注册 Component 类型(通过
app.register_type::<T>()) - 插入 Resource(通过
app.insert_resource()) - 添加 System 到特定 Schedule(通过
app.add_systems()) - 添加 Message 类型(通过
app.add_message::<T>())
要点:Plugin 是 Bevy 的组装契约——每个 crate 通过 Plugin 将自己的 System、Resource、Message 等能力注册到 App 中。
2.3 PluginGroup 与 DefaultPlugins
单个 Plugin 注册单个功能,PluginGroup 将多个 Plugin 打包成一组:
#![allow(unused)] fn main() { // 源码: crates/bevy_app/src/plugin_group.rs pub trait PluginGroup: Sized { fn build(self) -> PluginGroupBuilder; } }
Bevy 提供两个内置的 PluginGroup:
DefaultPlugins:包含窗口、渲染、输入、音频等全部功能MinimalPlugins:仅包含最小的运行时(TaskPool + Schedule Runner)
DefaultPlugins 的加载链大致如下:
DefaultPlugins
├── TaskPoolPlugin ← 线程池
├── TypeRegistrationPlugin ← 类型注册
├── FrameCountPlugin ← 帧计数
├── TimePlugin ← 时间系统
├── TransformPlugin ← Transform 传播
├── HierarchyPlugin ← 父子关系
├── DiagnosticsPlugin ← 性能诊断
├── InputPlugin ← 键盘/鼠标/手柄
├── WindowPlugin ← 窗口管理
├── AssetPlugin ← 资源加载
├── ScenePlugin ← 场景系统
├── RenderPlugin ← 渲染核心
├── ImagePlugin ← 图片处理
├── PbrPlugin ← PBR 材质
├── AudioPlugin ← 音频播放
├── UiPlugin ← UI 框架
├── AnimationPlugin ← 动画系统
└── GizmoPlugin ← 调试可视化
PluginGroup 支持自定义——可以禁用某些 Plugin 或替换实现:
#![allow(unused)] fn main() { App::new() .add_plugins( DefaultPlugins .set(WindowPlugin { primary_window: Some(Window { title: "My Game".into(), resolution: (800., 600.).into(), ..default() }), ..default() }) .disable::<AudioPlugin>() // 禁用音频 ) .run(); }
要点:PluginGroup 是 Plugin 的分组机制,支持按需启用/禁用/替换。
2.4 SubApp:渲染子应用与并行 World
SubApp 是 Bevy 实现 Main World / Render World 分离的机制:
#![allow(unused)] fn main() { // 源码: crates/bevy_app/src/sub_app.rs:20 type ExtractFn = Box<dyn FnMut(&mut World, &mut World) + Send>; }
每个 SubApp 拥有自己的 World 和 Schedule,通过 ExtractFn 与 Main World 交换数据。
Extract 的执行时机值得深入理解。在默认更新路径中,Main App 会先跑完当前轮次的默认 Schedule,然后对每个 SubApp 依次执行 extract 和 update。也就是说,Extract 发生在 Main World 完成当前帧逻辑之后、Render SubApp 开始执行自己的 Schedule 之前。在 Extract 阶段,Main World 被短暂地借给 ExtractFn,Render World 同时以 &mut World 的方式提供给同一个闭包——此时两个 World 都不在执行各自的 System,不存在并发访问。ExtractFn 将渲染需要的数据(如 Transform、可见性、材质参数)从 Main World 复制或移动到 Render World。默认模式下,这一过程仍属于同一轮 update;只有额外启用 PipelinedRenderingPlugin 时,渲染才会移到另一线程,与下一轮模拟形成 N / N+1 的流水线重叠。SubApp 分离的核心价值首先是把逻辑世界和渲染世界、调度阶段与数据同步边界明确拆开,然后才是在可选流水线模式下进一步提升吞吐。
App
├── Main SubApp (默认)
│ ├── World ← 游戏逻辑数据
│ └── Schedules ← First, Update, PostUpdate...
│
└── Render SubApp (由 RenderPlugin 创建)
├── World ← 渲染数据 (GPU 资源)
├── Schedules ← ExtractSchedule, Render
└── ExtractFn ← 每帧从 Main World 提取数据
图 2-3: SubApp 架构
为什么需要 SubApp?
- 所有权隔离:Rust 不允许两个线程同时持有
&mut World。分离后各自独占。 - Schedule 独立:渲染有自己的执行阶段(Extract → Prepare → Queue → Render),与主循环解耦。
- 数据清晰:Render World 每帧清空重建,不持有持久状态——简化了资源管理。
要点:SubApp 通过所有权隔离实现 Main World 和 Render World 的并行,是 Rust 所有权模型在架构层面的体现。
2.5 Main Schedule 全景
Bevy 的主循环不是一个简单的 loop { update(); render(); }。它由一系列有序的 Schedule 组成:
#![allow(unused)] fn main() { // 源码: crates/bevy_app/src/main_schedule.rs:221 impl Default for MainScheduleOrder { fn default() -> Self { Self { labels: vec![ First.intern(), PreUpdate.intern(), RunFixedMainLoop.intern(), Update.intern(), SpawnScene.intern(), PostUpdate.intern(), Last.intern(), ], startup_labels: vec![ PreStartup.intern(), Startup.intern(), PostStartup.intern(), ], } } } }
完整的调度流程:
graph TD
subgraph Startup["首次运行 (Startup)"]
S1["PreStartup"] --> S2["Startup"] --> S3["PostStartup"]
end
subgraph Main["每帧循环 (Main)"]
M1["First<br/>消息更新 / 时间更新"]
M2["PreUpdate<br/>输入/窗口准备"]
M3["StateTransition<br/>默认 feature 集启用"]
M4["RunFixedMainLoop"]
M5["Update<br/>用户逻辑"]
M6["SpawnScene<br/>场景生成"]
M7["PostUpdate<br/>Transform 传播 / UI 布局 / 可见性计算"]
M8["Last<br/>帧结束"]
M1 --> M2 --> M3 --> M4 --> M5 --> M6 --> M7 --> M8
subgraph Fixed["FixedMain (× N 次)"]
F1["FixedFirst"]
F2["FixedPreUpdate"]
F3["FixedUpdate<br/>固定时间步长 (默认 64Hz)"]
F4["FixedPostUpdate"]
F5["FixedLast"]
F1 --> F2 --> F3 --> F4 --> F5
end
M3 --> F1
end
图 2-4: Main Schedule 完整阶段图
每个 Schedule 的职责:
| Schedule | 职责 | 谁在用 |
|---|---|---|
| First | 推进消息队列、更新时间 | 引擎内部, TimePlugin |
| PreUpdate | 刷新输入状态、处理窗口等前置更新 | InputPlugin 等 |
| StateTransition | 执行状态切换与 OnEnter/OnExit | StatesPlugin |
| RunFixedMainLoop | 按固定步长执行 FixedUpdate | 物理、网络同步 |
| Update | 用户游戏逻辑 | 用户 System |
| SpawnScene | 生成场景实体 | ScenePlugin |
| PostUpdate | Transform 传播、UI 布局、可见性 | TransformPlugin, UiPlugin |
| Last | 帧结束清理 | 引擎内部 |
在默认 feature 集中,bevy_state 会插入 StateTransition;如果显式裁掉该 feature,这个阶段就不存在。用户的 System 通常注册到 Update(每帧逻辑)或 FixedUpdate(固定步长逻辑);引擎内部的 System 则分布在 First、PreUpdate、PostUpdate 等阶段。
为什么每个 Schedule 存在于这个特定的位置?这个顺序不是随意的,而是由数据依赖关系严格决定的。First 必须在最前面,因为 message_update_system 和 time_system 都在这里推进本轮消息与时间状态。PreUpdate 紧随其后,因为输入与窗口等前置更新必须先完成,用户的 Res<ButtonInput> 才能看到当前帧的值。在默认 feature 集中,StateTransition 被插在 PreUpdate 之后、RunFixedMainLoop 之前,这样状态切换既能消费刚更新好的输入/时间,又能在固定步和主逻辑运行前完成 OnExit / OnEnter。RunFixedMainLoop 随后执行,是因为物理模拟需要最新的时间信息但不应该依赖于本轮 Update 的结果。SpawnScene 位于 Update 之后,确保用户在 Update 中排队的场景生成会在 PostUpdate 的 Transform 传播和渲染提取之前落地。Last 在最后面,为引擎提供一个帧结束的清理时机。这种分层设计也与第 9 章中讨论的 Schedule 调度器紧密相关——每个 Schedule 内部的 System 可以并行执行,但 Schedule 之间的顺序是严格串行的。
FixedUpdate 的追赶机制
FixedUpdate 默认以 64Hz 运行,但渲染帧率可能高于或低于这个值。Bevy 采用追赶机制:
- 如果渲染帧率 > 64Hz:某些帧不执行 FixedUpdate
- 如果渲染帧率 < 64Hz:单帧内可能执行多次 FixedUpdate(追赶)
这保证了物理模拟等系统的时间一致性,不受渲染帧率波动影响。
要点:默认 feature 集下,Main Schedule 每帧由 8 个有序阶段组成(包含 StateTransition),FixedUpdate 嵌套其中,用户逻辑在 Update 阶段执行。
2.6 Feature Flags:裁剪策略
Bevy 的 140+ Feature Flags 按层级组织:
Profile Features(高层级)
// 源码: Cargo.toml
2d = ["2d_api", "2d_bevy_render"]
3d = ["3d_api", "3d_bevy_render"]
ui = ["ui_api", "ui_bevy_render"]
dev = ["bevy_dev_tools", "file_watcher", "embedded_watcher"]
Collection Features(中层级)
default_app = ["async_executor", "bevy_asset", "bevy_state", ...]
default_platform = ["std", "bevy_winit", "x11", "wayland", ...]
common_api = ["bevy_animation", "bevy_camera", "bevy_color", ...]
Individual Features(底层级)
bevy_render = ["dep:bevy_render"]
png = ["bevy_image/png"]
shader_format_glsl = ["bevy_render/shader_format_glsl"]
这种分层设计让用户可以在不同粒度上裁剪引擎:
#![allow(unused)] fn main() { // 完整的 3D 游戏 [dependencies] bevy = { version = "0.19", features = ["default"] } // 只用 ECS + 资源管理的服务器 [dependencies] bevy = { version = "0.19", default-features = false, features = ["bevy_asset"] } // 嵌入式设备上的纯 ECS [dependencies] bevy_ecs = "0.19" }
要点:Feature Flags 分三个层级(Profile → Collection → Individual),支持从完整游戏到纯 ECS 的任意裁剪。
本章小结
本章我们理解了 Bevy 应用的骨架:
- App 是一个 Builder,本身几乎不含逻辑
- Plugin 是模块化的契约,实现广泛分布于 43 个 crate
- PluginGroup 将 Plugin 打包,支持按需定制
- SubApp 通过所有权隔离实现并行 World
- Main Schedule 在默认 feature 集下由 8 个有序阶段 + 嵌套的 FixedUpdate 组成
- Feature Flags 分三层,支持精确裁剪
下一章,我们将进入 ECS 内核的第一站:World——一切数据的容器。
第 3 章:World — 一切数据的容器
导读:从本章开始,我们进入 ECS 内核的深水区。World 是 Bevy ECS 的根对象—— 所有的 Entity、Component、Resource、Archetype、Schedule 都存活在某个 World 中。 理解 World 的内部结构,是理解整个 ECS 的前提。
3.1 World 的内部结构
World struct 有 16 个字段,每一个都承担着明确的职责:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/mod.rs:98 pub struct World { id: WorldId, pub(crate) entities: Entities, pub(crate) entity_allocator: EntityAllocator, pub(crate) components: Components, pub(crate) component_ids: ComponentIds, pub(crate) resource_entities: ResourceEntities, pub(crate) archetypes: Archetypes, pub(crate) storages: Storages, pub(crate) bundles: Bundles, pub(crate) observers: Observers, pub(crate) removed_components: RemovedComponentMessages, pub(crate) change_tick: AtomicU32, pub(crate) last_change_tick: Tick, pub(crate) last_check_tick: Tick, pub(crate) last_trigger_id: u32, pub(crate) command_queue: RawCommandQueue, } }
这些字段可以按职责分为五组:
┌─────────────────────────────────────────────────────┐
│ World │
│ │
│ ┌─ 身份 ──────────────────────────────────────────┐ │
│ │ id: WorldId │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ 实体管理 ──────────────────────────────────────┐ │
│ │ entities: Entities (实体元数据表) │ │
│ │ entity_allocator (ID 分配器) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ 类型注册 ──────────────────────────────────────┐ │
│ │ components: Components (组件类型注册表) │ │
│ │ component_ids: ComponentIds (TypeId→ComponentId) │ │
│ │ bundles: Bundles (Bundle 元数据缓存) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ 数据存储 ──────────────────────────────────────┐ │
│ │ archetypes: Archetypes (原型索引) │ │
│ │ storages: Storages (Tables+SparseSets) │ │
│ │ resource_entities (资源→实体映射) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ 事件与变更 ────────────────────────────────────┐ │
│ │ observers: Observers (观察者调度) │ │
│ │ removed_components (移除组件消息集) │ │
│ │ change_tick: AtomicU32 (变更时钟) │ │
│ │ last_change_tick / last_check_tick │ │
│ │ last_trigger_id (触发器编号) │ │
│ │ command_queue (延迟命令队列) │ │
│ └─────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
图 3-1: World 内部结构分组
逐一说明关键字段:
| 字段 | 类型 | 职责 |
|---|---|---|
id | WorldId | 唯一标识,防止跨 World 误用 Query |
entities | Entities | 存储所有实体的元数据(EntityLocation) |
entity_allocator | EntityAllocator | 管理空闲 Entity ID 的分配与回收 |
components | Components | 组件类型注册表,存储 ComponentInfo |
component_ids | ComponentIds | TypeId → ComponentId 的快速映射 |
archetypes | Archetypes | 所有 Archetype 的集合与迁移图 |
storages | Storages | 三种存储后端:Tables、SparseSets、NonSends |
bundles | Bundles | Bundle 元数据缓存,加速 spawn/insert |
observers | Observers | Observer 调度表,分发生命周期事件 |
change_tick | AtomicU32 | 全局变更时钟,驱动 Change Detection |
command_queue | RawCommandQueue | 延迟命令缓冲区 |
为什么 World 要把所有这些职责集中在一个 struct 中?一种替代设计是将类型注册、实体管理和数据存储分别放在独立的 Registry 对象中——例如一个 ComponentRegistry、一个 EntityManager、一个 StorageBackend。这种分离看似更"干净",但在 Rust 中会导致严重的借用问题:如果这些对象各自独立存在,任何需要同时访问类型信息和存储数据的操作(比如 spawn 一个实体)都需要同时借用多个对象,要么产生冗余的引用传递,要么迫使调用方管理复杂的借用生命周期。将所有状态集中在 World 中,调用方只需一个 &mut World 就能完成任何操作。这也使得 UnsafeWorldCell 的设计成为可能——只需将一个 World 指针包装为细粒度访问,而不需要对多个独立对象分别做安全抽象。另一个好处是序列化和快照:整个游戏状态就是一个 World,保存和恢复只需操作一个对象。这种"上帝对象"在通常的软件工程实践中是反模式,但在 ECS 的上下文中,World 更像一个内存数据库——它的"大"是有意为之的。change_tick 字段使用 AtomicU32 而非普通 u32,是因为多个 System 可能通过 UnsafeWorldCell 并行读取变更时钟,原子操作保证了无锁的帧间变更检测(第 10 章将详述)。
要点:World 是一个"数据库"——它管理实体分配、类型注册、数据存储和事件分发。
3.2 UnsafeWorldCell:从编译期借用到运行时验证
ECS 需要多个 System 并行访问 World 的不同部分。但 Rust 的借用规则不允许多个 &mut World 同时存在。UnsafeWorldCell 是 Bevy 对这个问题的解答。
问题:&mut World 的独占性
#![allow(unused)] fn main() { // 这在 Rust 中不可能: fn run_systems(world: &mut World) { let a = &mut world.query::<&mut Position>(); // &mut World let b = &mut world.query::<&mut Velocity>(); // &mut World ← 编译错误! // 即使 Position 和 Velocity 是完全不相交的数据 } }
编译器无法知道两个 Query 访问的是 World 的不同部分。它只看到两个 &mut World,必须拒绝。
解决方案:UnsafeWorldCell
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/unsafe_world_cell.rs:84 pub struct UnsafeWorldCell<'w> { ptr: *mut World, #[cfg(debug_assertions)] allows_mutable_access: bool, _marker: PhantomData<(&'w World, &'w UnsafeCell<World>)>, } }
UnsafeWorldCell 将 &mut World 的独占保证从编译期转移到了运行时:
graph TD
subgraph 编译期["编译期 (Rust 默认)"]
CW["&mut World<br/>独占访问<br/>编译器保证<br/>一次一个"]
end
subgraph 运行时["运行时 (Bevy ECS)"]
UW["UnsafeWorldCell<br/>&self 共享访问<br/>调度器保证不相交<br/>多 System 并行"]
end
CW -->|转化| UW
S1["System 初始化时<br/>声明 FilteredAccess"] --> S2["调度器构建时<br/>验证并行 System 的 Access 不重叠"]
S2 --> S3["运行时<br/>通过 UnsafeWorldCell 执行已验证的并行访问"]
图 3-2: 编译期借用 vs 运行时借用
Bevy 没有放弃安全——它将安全保证的时机从编译期推迟到了调度器构建期。如果两个 System 声明了冲突的 Access(例如都要 &mut Transform),调度器会将它们排成串行执行。
Rust 设计亮点:
UnsafeWorldCell的PhantomData<(&'w World, &'w UnsafeCell<World>)>精确控制了 variance。第一个&'w World使生命周期协变(可以缩短), 第二个&'w UnsafeCell<World>使内部可变性不变(阻止非法的生命周期扩展)。 这确保了UnsafeWorldCell的生命周期行为与安全引用一致。
这个 variance 技巧值得深入理解,因为它防御了一类微妙的 soundness 漏洞。如果 UnsafeWorldCell 只有协变的 PhantomData<&'w World>,那么编译器会允许将 UnsafeWorldCell<'long> 当作 UnsafeWorldCell<'short> 使用——这对于不可变引用是安全的。但 UnsafeWorldCell 实际上提供了内部可变性(通过 &self 方法修改 World 的内容),如果允许协变的生命周期缩短,就可能构造出一个已经过期的 UnsafeWorldCell 仍然在修改 World 的场景。加入 &'w UnsafeCell<World> 使得 UnsafeWorldCell 在 'w 上变为不变(invariant)——生命周期既不能延长也不能缩短。如果不做这个处理,恶意或不小心的代码可以将短生命周期的 World 引用"延长",导致 use-after-free。这与第 8 章中 SystemParam 的 GAT 设计异曲同工——两者都需要精确控制生命周期的 variance 来保证 unsafe 代码的 soundness。
安全保证的层次
| 层次 | 谁负责 | 验证什么 |
|---|---|---|
| Component trait | 编译器 | Send + Sync + 'static |
| SystemParam | 编译器 | 参数类型合法 |
| FilteredAccess | 调度器 (运行时) | 并行 System 不冲突 |
| UnsafeWorldCell | Bevy 内部 | 不相交访问的底层执行 |
从用户视角看,这一切是透明的——你只需写普通函数,Bevy 保证安全的并行。
要点:UnsafeWorldCell 是 Bevy 将编译期借用检查延伸到运行时的关键机制。安全性由调度器的 FilteredAccess 验证保证。
3.3 DeferredWorld:受限的观察者视图
当 Observer 或 Component Hook 被触发时,World 正处于修改中间状态。此时给回调函数一个完整的 &mut World 是危险的——回调可能会触发新的结构变更(如 spawn 新实体),导致状态不一致。
DeferredWorld 是一个受限的 World 视图,它禁止结构性变更:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/deferred_world.rs:23 /// A [`World`] reference that disallows structural ECS changes. /// This includes initializing resources, registering components /// or spawning entities. pub struct DeferredWorld<'w> { world: UnsafeWorldCell<'w>, } }
| 操作 | &mut World | DeferredWorld |
|---|---|---|
| 读取组件/资源 | ✓ | ✓ |
| 修改组件/资源 | ✓ | ✓ |
| 生成/销毁实体 | ✓ | ✗ |
| 注册组件类型 | ✓ | ✗ |
| 初始化资源 | ✓ | ✗ |
| 发送 Commands | — | ✓ (延迟执行) |
在 DeferredWorld 中,结构性变更只能通过 Commands 提交,待当前操作完成后才执行。这保证了 Observer 和 Hook 不会破坏 World 的中间状态。
如果不提供 DeferredWorld 会怎样?假设一个 on_add hook 在组件被添加的过程中收到 &mut World,它可以在这个 hook 中 spawn 新实体——而 spawn 可能触发 Archetype 重新分配,导致当前正在写入的 Table 的指针失效。更严重的是,hook 中 spawn 的新实体可能触发另一个组件的 on_add hook,形成递归触发链,每一层都持有 &mut World 的可变引用——这在 Rust 中直接违反借用规则。DeferredWorld 通过类型系统在编译期阻止了这一切:它的 API 中根本没有 spawn、despawn 或 insert 等结构性变更方法。所有需要结构性变更的操作必须通过 Commands 提交到延迟队列,在当前操作完成、所有指针和引用都释放之后才被执行。这种"类型级别的能力限制"是 Rust 类型系统在安全 API 设计中的典型应用——与其在文档中写"请勿在 hook 中 spawn 实体",不如让编译器直接拒绝。这个设计与第 7 章中 FilteredAccess 的理念一致:将安全规则编码到类型系统中,而不是依赖运行时检查或开发者自律。
要点:DeferredWorld 是 World 的受限视图,禁止结构性变更,用于 Observer 和 Hook 回调。
3.4 WorldId 与多 World 隔离
每个 World 有一个全局唯一的 WorldId:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/identifier.rs:14 pub struct WorldId(usize); }
WorldId 通过全局原子计数器分配,确保不同 World 的 ID 永不重复:
#![allow(unused)] fn main() { static MAX_WORLD_ID: AtomicUsize = AtomicUsize::new(0); }
WorldId 的主要作用是防止跨 World 误用。QueryState 在创建时记录所属 World 的 ID,运行时会验证:
#![allow(unused)] fn main() { // QueryState 验证 World 匹配 fn validate_world(&self, world_id: WorldId) { assert!( world_id == self.world_id, "Attempted to use a QueryState from a different World" ); } }
这在 Main World 和 Render World 共存时尤为重要——如果误将 Main World 的 Query 用在 Render World 上,会立即 panic 而不是产生未定义行为。
WorldId 的实际影响比它看起来要深远。QueryState 在初始化时缓存了 Archetype 匹配结果和 ComponentId 映射——这些缓存都是相对于特定 World 的。如果将一个 World 的 QueryState 用在另一个 World 上,即使两个 World 碰巧有相同的组件类型注册顺序,ComponentId 的分配也可能不同(取决于 Plugin 的注册顺序),导致 Query 访问到完全错误的内存位置。WorldId 检查在 debug 模式下是一个廉价的 assert,在 release 模式下可以被优化掉。这是一种"开发期安全网"的设计思路——在开发阶段尽早发现错误,在生产环境中零开销。这种模式在 Bevy 的其他地方也有体现,例如第 4 章中 Entity 的 generation 检查。
要点:WorldId 是跨 World 安全的最后防线,防止 Query、Resource 等在错误的 World 上执行。
本章小结
本章我们剖析了 World 的内部结构:
- World 有 15 个字段,分为身份、实体管理、类型注册、数据存储、事件与变更五组
- UnsafeWorldCell 将借用检查从编译期推迟到运行时,由调度器的 FilteredAccess 保证安全
- DeferredWorld 是受限视图,防止 Observer/Hook 在回调中破坏 World 状态
- WorldId 防止跨 World 误用
下一章,我们将聚焦 World 中最基础的概念:Entity——轻量级的身份标识。
第 4 章:Entity — 轻量级身份标识
导读:上一章我们了解了 World 的整体结构。现在我们聚焦 ECS 三要素中最简单 却最巧妙的一个——Entity。Entity 不是对象、不是类型、不包含任何数据。 它只是一个 64 位的身份标识。但就是这 64 位,包含了防止 use-after-free 的 generation 机制、高效的 freelist 分配策略,以及精确定位到存储位置的四元组。
4.1 64 位编码:index + generation
Entity 的 struct 定义出人意料地底层:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/entity/mod.rs:424 #[repr(C, align(8))] pub struct Entity { #[cfg(target_endian = "little")] index: EntityIndex, // NonMaxU32 (u32 大小,但不能为 u32::MAX) generation: EntityGeneration, // u32 #[cfg(target_endian = "big")] index: EntityIndex, // NonMaxU32 } }
通过 #[repr(C, align(8))],Entity 被精确布局为一个 u64——与机器字长匹配,比较和拷贝都是单指令操作。
为什么选择 32+32 的均分方案,而不是 48+16 或其他比例?48 位 index 可以支持约 2800 亿个并发实体——这远超任何实际需求,浪费了 index 空间。16 位 generation 只有 65536 代,一个频繁回收的 index 可能在游戏运行几小时后就耗尽 generation 空间,导致 use-after-free 保护失效。32+32 是一个经过实践检验的平衡点:32 位 index 支持约 42 亿个并发实体(远超任何游戏的实体数量),32 位 generation 支持约 42 亿次回收——即使一个 index 每帧被回收一次,也需要运行数百小时才会溢出。另一种方案是使用 64 位 index 而不要 generation——但这意味着完全放弃 use-after-free 保护,或者需要引入引用计数等更重的机制。ECS 中实体引用被大量存储在组件中(如父子关系、目标引用),引用计数的开销会显著影响性能。32+32 的 Copy 语义让 Entity 可以像整数一样自由传递,这与第 5 章中 Component 的 'static 约束相呼应——Entity 就是一个可以安全存储在任何组件中的轻量级句柄。
Entity (64 bits / 8 bytes)
┌──────────────────┬──────────────────┐
│ index (32 bit) │ generation (32b) │
└──────────────────┴──────────────────┘
↓ ↓
在 Entities 中的 防止 use-after-free
存储位置索引 的版本号
图 4-1: Entity 64 位内存布局
index 和 generation 的字段顺序根据字节序 (cfg(target_endian)) 调整,确保在任何平台上 Entity 都可以安全地与 u64 互转。
generation 防止 use-after-free
generation 是 Entity 安全性的关键。考虑以下场景:
时刻 1: 生成 Entity { index: 5, generation: 1 }
时刻 2: 销毁该实体,index 5 回到空闲列表
时刻 3: 新实体分配到 index 5 → Entity { index: 5, generation: 2 }
此时如果有人持有旧的 Entity { index: 5, generation: 1 },
使用它访问 World 时:
index 5 存在 → 但 generation 1 ≠ 当前 generation 2 → 拒绝访问
这与操作系统的文件描述符 (fd) 回收类似,但 Bevy 通过 generation 使旧句柄自动失效,无需额外的引用计数。
Rust 设计亮点:Entity 是一个 Copy 类型(8 字节,寄存器大小),可以自由复制传递。 但它不是智能指针——没有引用计数,没有析构函数。Generation 机制在无运行时开销的 前提下提供了 use-after-free 保护。
要点:Entity = 32 位 index + 32 位 generation,用 8 字节实现了零开销的身份标识与 use-after-free 防护。
4.2 Entities 分配器
所有实体的元数据存储在 Entities 中:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/entity/mod.rs:827 pub struct Entities { meta: Vec<EntityMeta>, } }
EntityMeta 记录每个 index 对应的状态:要么是活跃实体的 EntityLocation,要么是空闲链表中的下一个空闲 index。
分配新实体时,优先从空闲链表中取(O(1));链表为空时,在 meta 末尾追加(摊销 O(1))。回收实体时,将 index 插入空闲链表头部,generation 递增。
graph TD
S0["初始状态<br/>meta = [ ]"] --> S1["分配 Entity A<br/>meta = [Meta(loc_A, gen=1)]<br/>→ Entity{0, 1}"]
S1 --> S2["分配 Entity B<br/>meta = [Meta(loc_A, gen=1), Meta(loc_B, gen=1)]<br/>→ Entity{1, 1}"]
S2 --> S3["销毁 Entity A<br/>meta = [Meta(FREE→nil, gen=1), Meta(loc_B, gen=1)]<br/>freelist_head = 0"]
S3 --> S4["分配 Entity C<br/>从 freelist 取 index 0, generation+1<br/>meta = [Meta(loc_C, gen=2), Meta(loc_B, gen=1)]<br/>→ Entity{0, 2}"]
图 4-2: 实体分配与回收流程
为什么选择 freelist 而不是其他分配策略?一种替代方案是使用位图(bitmap)——用一个位数组标记哪些 index 空闲,分配时扫描找到第一个空闲位。位图的优势是内存紧凑(每个 index 只占 1 bit),但分配是 O(n/64) 的,在最坏情况下需要扫描整个位数组。另一种方案是始终递增不回收——这避免了 generation 的复杂性,但 index 会无限增长,meta 数组也会无限膨胀。Freelist 的 O(1) 分配和 O(1) 回收使其成为高频实体创建/销毁场景的最佳选择。游戏中"子弹"类实体的生命周期可能只有几帧——每帧创建数百个、销毁数百个是常见负载。freelist 在这种场景下几乎无开销,而位图扫描可能成为瓶颈。代价是空闲链表本身需要存储在 EntityMeta 中,复用了 EntityLocation 的空间来存储"下一个空闲 index"——这是一种经典的联合体(union)内存复用技巧。
要点:Entities 使用 freelist 实现 O(1) 分配与回收,generation 在回收时递增。
4.3 EntityLocation 四元组定位
每个活跃实体都有一个 EntityLocation,精确指向其数据在存储中的位置:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/entity/mod.rs:1210 pub struct EntityLocation { pub archetype_id: ArchetypeId, // 哪个原型 pub archetype_row: ArchetypeRow, // 原型内第几行 pub table_id: TableId, // 哪个存储表 pub table_row: TableRow, // 表内第几行 } }
为什么需要四个字段?因为 Bevy 有两层索引:
graph TD
E["Entity { index: 42, generation: 3 }"]
E -->|"Entities.meta[42]"| LOC["EntityLocation"]
LOC -->|"archetype_id: 7"| ARCH["Archetype 7<br/>组件组合的分组<br/>archetype_row: 15"]
LOC -->|"table_id: 3"| TAB["Table 3<br/>实际存储 Table 组件<br/>table_row: 15"]
TAB -->|"table.get_column(ComponentId)[table_row]"| DATA["实际的组件数据"]
图 4-3: EntityLocation 四元组寻址流程
archetype_id 和 table_id 看似冗余,实际上是因为多个 Archetype 可以共享同一个 Table(当它们仅在 SparseSet 组件上不同时)。这一点将在第 6 章 Archetype 中详细讨论。
四元组设计的性能意义在于:从 Entity 到组件数据的访问路径是两次数组索引——entities.meta[index] 获取 EntityLocation,然后 table.column[table_row] 获取组件值。两次索引,两次可能的 cache miss,但没有任何哈希查找或树遍历。如果不使用四元组而是用哈希表(HashMap<Entity, ComponentData>),每次组件访问都需要哈希计算和可能的哈希冲突处理——在紧密循环中这个开销是不可接受的。如果不存储 archetype_row,就需要在 Archetype 的实体列表中线性搜索——O(n) 的开销。四个字段各 4 字节,EntityLocation 总共 16 字节,对于 10 万个实体占用约 1.6MB——这是一个合理的内存代价。
要点:EntityLocation 是一个四元组(archetype_id, archetype_row, table_id, table_row),两层索引实现 O(1) 数据定位。
4.4 EntityRef 与 EntityMut:安全的动态访问
直接使用 EntityLocation 需要 unsafe 操作。Bevy 提供了两个安全的包装类型:
EntityRef<'w>:不可变实体引用,可以读取任意组件EntityMut<'w>:可变实体引用,可以读写任意组件
#![allow(unused)] fn main() { // 通过 World 获取安全的实体引用 let entity_ref: EntityRef = world.entity(entity); let position: &Position = entity_ref.get::<Position>().unwrap(); // 可变引用 let mut entity_mut: EntityMut = world.entity_mut(entity); entity_mut.insert(Velocity(Vec3::ZERO)); entity_mut.remove::<Health>(); }
EntityRef 和 EntityMut 遵循 Rust 的标准借用规则:
- 多个
EntityRef可以共存(&T规则) EntityMut必须独占(&mut T规则)
这些类型在 Query 中也出现——Query<EntityRef> 获取对所有匹配实体的动态只读访问,Query<EntityMut> 获取动态可变访问。它们适用于组件集合在编译期未知的场景(如编辑器、调试工具)。
要点:EntityRef/EntityMut 是 Entity 的安全访问接口,遵循标准的 Rust 借用规则。
4.5 Entity Disabling 与 DefaultQueryFilters
Bevy 支持"禁用"实体而不销毁它——通过添加 Disabled 组件:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/entity_disabling.rs:128 #[derive(Component, Clone, Debug, Default)] pub struct Disabled; }
关键机制是 DefaultQueryFilters——一个全局过滤器,默认排除所有带 Disabled 的实体:
graph TD
Q1["Query<&Position>"]
Q1 -->|"自动添加 DefaultQueryFilters"| Q2["实际执行: Query<&Position, Without<Disabled>>"]
Q2 -->|"匹配结果"| Q3["只返回未被禁用的实体"]
如果你确实需要查询被禁用的实体,可以显式覆盖:
#![allow(unused)] fn main() { // 只查询被禁用的实体 fn find_disabled(query: Query<Entity, With<Disabled>>) { ... } // 查询所有实体(包括被禁用的) fn find_all(query: Query<Entity, Allow<Disabled>>) { ... } }
禁用的实体保留所有组件和关系,只是从默认查询中"隐身"。这比销毁-重建更高效,适用于对象池、暂停等场景。
需要注意的是,DefaultQueryFilters 会给每个 Query 添加额外的过滤条件,即使你的世界中没有任何 Disabled 实体。这是一个微小但持续的性能开销。
从性能角度看,Disabled 的影响主要体现在 Archetype 匹配阶段而非迭代阶段。Without<Disabled> 是一个 Archetypal 过滤器(IS_ARCHETYPAL = true),这意味着它在 Archetype 级别过滤,不需要逐行检查。包含 Disabled 组件的实体会被划分到单独的 Archetype 中,Query 在匹配时直接跳过这些 Archetype——不会遍历其中的任何一行。因此,Disabled 的"隐身"几乎是零开销的,前提是 Disabled 实体数量不多到产生大量额外 Archetype(每种"正常组件组合 + Disabled"都是一个新的 Archetype)。这与第 6 章讨论的 Archetype 碎片化问题有关——如果游戏中频繁地禁用和启用不同组件组合的实体,可能导致 Archetype 数量膨胀。对于对象池等场景,考虑用 SparseSet 存储的标记组件作为替代方案,避免 Archetype 迁移开销。
要点:Disabled 组件配合 DefaultQueryFilters 实现实体的"软隐藏",保留数据但从默认查询中排除。
本章小结
本章我们深入了 Entity 的设计:
- Entity 是 8 字节的值类型:32 位 index + 32 位 generation
- Generation 机制在零运行时开销下防止 use-after-free
- Entities 使用 freelist 实现 O(1) 分配与回收
- EntityLocation 四元组实现 O(1) 数据定位
- EntityRef/EntityMut 提供安全的动态组件访问
- Disabled + DefaultQueryFilters 实现软隐藏
下一章,我们将深入 Entity 身上承载的核心数据——Component,以及它们的底层内存存储。
第 5 章:Component 与 Storage — 数据与内存
导读:本章是全书技术密度最高的章节之一。我们将深入 Component trait 的约束、 两种存储策略(Table vs SparseSet)、底层的类型擦除内存容器(BlobArray、Column、 ThinArrayPtr),以及 Required Components 的依赖机制。理解这一章,就理解了 Bevy ECS 数据层的全貌。
5.1 Component trait
所有能挂载到 Entity 上的数据都必须实现 Component trait:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/component/mod.rs (简化) pub trait Component: Send + Sync + 'static { const STORAGE_TYPE: StorageType = StorageType::Table; type Mutability: ComponentMutability; fn register_required_components( _components: &mut Components, _required_components: &mut RequiredComponents, ) {} } }
三个约束,每个都有明确的动机:
| 约束 | 动机 |
|---|---|
Send | 组件数据可能被移动到其他线程(System 并行执行) |
Sync | 组件引用可能被多个线程共享读取 |
'static | 组件存储在 World 中,不能包含临时引用 |
这三个约束看似严格,但它们是 ECS 自动并行的基础设施成本。如果 Component 不要求 Send,调度器就不能将包含该组件的 System 分配到任意线程——这会极大地限制并行度。如果不要求 Sync,多个只读 System 就不能同时访问同一个组件列——即使它们只是读取数据。'static 的约束则确保组件可以被存储在 World 中任意长的时间,不依赖于任何栈上的引用。如果允许组件包含临时引用(如 &'a str),一旦引用的源数据被释放,World 中就会出现悬垂引用——而 World 的生命周期由引擎控制,用户无法预知组件何时被访问。对于确实需要非 Send 类型的场景(如平台相关的窗口句柄),Bevy 提供了 NonSend 存储作为逃生通道(本章 5.8 节),但代价是这些组件不参与并行调度。
实际使用中,通过 #[derive(Component)] 自动实现:
#![allow(unused)] fn main() { #[derive(Component)] struct Position(Vec3); #[derive(Component)] #[component(storage = "SparseSet")] // 指定存储策略 struct AnimationState { ... } #[derive(Component)] #[component(immutable)] // 禁止运行时修改 struct EntityName(String); }
要点:Component = Send + Sync + 'static,确保数据可以安全地跨线程访问和持久存储。
5.2 StorageType:Table vs SparseSet
每个 Component 类型选择一种存储策略。这个选择影响着内存布局和操作性能。
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/component/mod.rs pub enum StorageType { Table, // 列式存储,优化迭代 SparseSet, // 稀疏集合,优化增删 } }
两者的核心差异
Table 存储 (默认) SparseSet 存储
同一类型的组件连续排列: sparse → dense 间接映射:
┌─────┬─────┬─────┬─────┐ sparse: [_, _, 0, _, 1, _, _, 2]
│ C₀ │ C₁ │ C₂ │ C₃ │ ↓ ↓ ↓
└─────┴─────┴─────┴─────┘ dense: [C₂] [C₄] [C₇]
← CPU 缓存行可以预取整行 →
实体与数据不连续存储
顺序遍历速度极快
图 5-1: Table vs SparseSet 内存布局
| 操作 | Table | SparseSet | 说明 |
|---|---|---|---|
| 遍历全部 | O(n) 缓存友好 | O(n) 指针跳转 | Table 快 2-5x |
| 随机访问 | O(1) | O(1) | 相当 |
| 添加组件 | O(n) 需 Archetype 迁移 | O(1) 直接插入 | SparseSet 快得多 |
| 删除组件 | O(n) 需 Archetype 迁移 | O(1) swap-remove | SparseSet 快得多 |
图 5-2: Table vs SparseSet 性能对比
选择指南
- Table(默认):适合频繁遍历、很少增删的组件(Position、Velocity、Health)
- SparseSet:适合频繁增删、很少遍历的组件(AnimationState、BuffEffect、DebugTag)
经验法则:如果一个组件会在运行时频繁地被 insert 和 remove,考虑用 SparseSet 避免 Archetype 迁移的开销。
为什么 Table 的缓存友好度如此重要?现代 CPU 的 L1 缓存行通常是 64 字节。当你遍历一个 Column<Position> 时(假设 Position 是 Vec3 = 12 字节),每次 cache line 加载可以预取约 5 个 Position 值。CPU 的硬件预取器会检测到这种线性访问模式,在你实际读取之前就将后续的 cache line 从 L2/L3 甚至主存中拉入 L1。相比之下,SparseSet 的 dense 数组虽然也是连续的,但 sparse→dense 的间接查找在随机访问时会引入额外的跳转。当遍历 SparseSet 的 dense 数组时,两者都受益于线性扫描;但当需要从 Entity 查找特定组件时,SparseSet 还要经过 sparse 和 dense 两层索引。也正因为如此,Table 更偏向优化"大批量顺序遍历",SparseSet 更偏向优化"频繁增删和按实体定位"。这种性能特征与第 7 章中 Dense 和 Archetype 两种 Query 迭代路径的选择直接相关。
要点:Table 优化迭代,SparseSet 优化增删。默认选 Table,频繁增删选 SparseSet。
5.3 Immutable Component 与 Required Components
Immutable Component
通过 #[component(immutable)] 标记的组件无法在运行时修改:
#![allow(unused)] fn main() { #[derive(Component)] #[component(immutable)] struct EntityId(u64); }
在 Query 中请求 &mut EntityId 会编译失败。这对于不应被修改的标识性数据很有用。
Required Components
#[require] 属性定义组件间的依赖关系:
#![allow(unused)] fn main() { #[derive(Component)] #[require(Transform, Visibility)] struct Mesh3d { ... } }
当你 spawn 一个带 Mesh3d 的实体时,如果缺少 Transform 和 Visibility,Bevy 会自动用它们的 Default 值补全。这形成一个依赖 DAG (有向无环图):
graph TD
Mesh3d -->|require| Transform
Mesh3d -->|require| Visibility
Transform -->|require| GlobalTransform
Visibility -->|require| InheritedVisibility
图 5-3: Required Components 依赖 DAG
依赖关系在组件注册时解析,不影响运行时性能。如果手动指定了被依赖的组件,Bevy 不会用 Default 覆盖——用户提供的值优先。
要点:Required Components 自动补全依赖组件,形成编译期可验证的依赖 DAG。
5.4 Table 列式存储:Column = BlobArray + Ticks
Table 存储是 Bevy ECS 的主力存储后端。每个 Table 包含多个 Column,每个 Column 存储一种组件类型的所有实例。
Column 结构
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/storage/table/column.rs (简化) pub struct Column { data: BlobArray, // 组件值 (类型擦除) added_ticks: ThinArrayPtr<UnsafeCell<Tick>>, // 何时添加 changed_ticks: ThinArrayPtr<UnsafeCell<Tick>>, // 何时修改 #[cfg(feature = "track_location")] changed_by: ThinArrayPtr<UnsafeCell<&'static Location<'static>>>, } }
四个并行数组,索引同步——Row N 在所有数组中对应同一个实体:
Column (Position 类型, 3 个实体)
data: ┌──────┬──────┬──────┐
│ Pos₀ │ Pos₁ │ Pos₂ │ ← BlobArray (类型擦除的组件值)
└──────┴──────┴──────┘
added_ticks: ┌──────┬──────┬──────┐
│ T=10 │ T=25 │ T=30 │ ← 组件被添加时的 Tick
└──────┴──────┴──────┘
changed_ticks: ┌──────┬──────┬──────┐
│ T=10 │ T=42 │ T=30 │ ← 组件被修改时的 Tick
└──────┴──────┴──────┘
changed_by: ┌──────┬──────┬──────┐
│ loc₀ │ loc₁ │ loc₂ │ ← 调试用:修改来源位置
└──────┴──────┴──────┘
Row 0 → Entity₀ 的 Position + 变更信息
Row 1 → Entity₁ 的 Position + 变更信息
Row 2 → Entity₂ 的 Position + 变更信息
图 5-4: Column 四数组并行结构
added_ticks 和 changed_ticks 驱动了变更检测系统(第 10 章),使 Added<T> 和 Changed<T> 过滤器成为可能。
Table 结构
一个 Table 包含多个 Column,对应同一组实体的所有 Table 存储组件:
Table 0 (Archetype 1 的数据):
┌────────────────────────────────────────┐
│ Column<Position>: [P₀] [P₁] [P₂] │
│ Column<Velocity>: [V₀] [V₁] [V₂] │
│ Column<Health>: [H₀] [H₁] [H₂] │
│ entities: [E₀] [E₁] [E₂] │
└────────────────────────────────────────┘
当迭代一个 Query<(&Position, &Velocity)> 时,CPU 对每个 Column 做线性扫描——这是最友好的缓存访问模式。
要点:Column 由四个并行数组组成(数据 + 两个 Tick + 调试位置),Table 包含多个 Column,列式布局实现缓存友好的遍历。
5.5 BlobArray:类型擦除的内存基石
Column 中的 data 字段不是 Vec<T>——因为 ECS 需要在不知道具体类型的情况下管理组件数据。BlobArray 是一个类型擦除的内存容器:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/storage/blob_array.rs (简化) pub(super) struct BlobArray { item_layout: Layout, // 元素的 size + align data: NonNull<u8>, // 裸指针指向堆内存 pub drop: Option<unsafe fn(OwningPtr<'_>)>, // 元素的 drop 函数 #[cfg(debug_assertions)] capacity: usize, // 已分配容量 (仅 debug 模式) } }
注意 pub(super) 可见性——BlobArray 仅对 storage 模块内部可见,外部代码永远不会直接接触它。核心字段只有三个:Layout 记录每个元素的大小和对齐,NonNull<u8> 指向裸内存,drop 是可选的析构函数指针。capacity 仅在 debug 模式下存在,用于边界检查。
元素访问通过指针算术实现:
BlobArray (element size = 12, align = 4)
data ──→ ┌──────────┬──────────┬──────────┬ ...
│ elem [0] │ elem [1] │ elem [2] │
│ 12 bytes │ 12 bytes │ 12 bytes │
└──────────┴──────────┴──────────┘
访问 elem[i]: data.as_ptr().add(i * 12)
图 5-5: BlobArray 内存布局
为什么不用 Vec<T>?
- 类型擦除:ComponentId 是运行时值,编译期不知道具体类型
- 统一管理:所有 Column 用相同的 API 操作,不需要为每个类型生成代码
- 精确控制:swap_remove、初始化、销毁都是显式操作
BlobArray 的安全不变量(safety invariants)是理解整个存储层的关键。首先,item_layout 必须精确匹配实际存储元素的 Layout——如果 size 或 align 不对,指针算术会计算出错误的偏移量,导致读写越界。其次,drop 函数指针必须与实际类型的析构逻辑一致——BlobArray 不会自动 drop 其内容,调用者必须在移除元素时显式调用 drop 函数。对于没有析构逻辑的类型(如纯数字组件),drop 为 None,这允许 BlobArray 在清理时跳过析构调用,直接回收内存——这是一个重要的性能优化。最后,BlobArray 的 capacity 和实际使用的 len(由外部 Column 管理)之间的关系必须被严格维护:写入超过 capacity 的位置是未定义行为。这些不变量全部由 Column 和 Table 的安全 API 在内部维护,用户代码永远不会直接接触 BlobArray——这正是 Rust 的 unsafe 封装理念:unsafe 的边界越小越好,安全 API 的表面积越大越好。
Rust 设计亮点:BlobArray 用
Layout + NonNull<u8> + drop fn实现了完整的 类型擦除容器。这是 unsafe Rust 的正确用法——在内部使用 unsafe 操作裸内存, 对外暴露安全的 Column/Table API。用户永远不会直接接触 BlobArray。
要点:BlobArray 是类型擦除的内存基石,用指针算术和 Layout 在不知道类型的情况下管理组件数据。
5.6 SparseSet:sparse→dense 双数组结构
SparseSet 存储用于标记为 StorageType::SparseSet 的组件。每个组件类型一个 ComponentSparseSet:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/storage/sparse_set.rs (简化) pub struct ComponentSparseSet { dense: Column, // 稠密数组:组件数据 + ticks entities: Vec<Entity>, // 稠密数组:对应的实体 sparse: SparseArray<EntityIndex, TableRow>, // 稀疏数组:entity → dense 位置 } }
三个部分协同工作:
ComponentSparseSet (存储 AnimationState 组件)
假设 Entity 2, 4, 7 拥有该组件:
sparse (按 entity index 索引):
┌───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ _ │ _ │ 0 │ _ │ 1 │ _ │ _ │ 2 │ _ │
└───┴───┴─│─┴───┴─│─┴───┴───┴─│─┴───┘
│ │ │
▼ ▼ ▼
dense: ┌──────┬──────┬──────┐
│ AS₂ │ AS₄ │ AS₇ │ ← Column (组件数据)
└──────┴──────┴──────┘
entities: [E₂] [E₄] [E₇]
查找: Entity(4) → sparse[4] = 1 → dense[1] = AS₄ ✓
查找: Entity(3) → sparse[3] = None → 该实体没有此组件 ✓
图 5-6: SparseSet 双数组结构与查找流程
swap_remove 删除操作
删除是 O(1) 的——将最后一个元素移到被删位置,更新 sparse 映射:
删除 Entity 4 的组件:
Before: After:
sparse[4] = 1 sparse[4] = None
sparse[7] = 2 sparse[7] = 1 ← 更新!
dense: [AS₂] [AS₄] [AS₇] dense: [AS₂] [AS₇]
entities: [E₂] [E₄] [E₇] entities: [E₂] [E₇]
最后元素移到位置 1
图 5-7: SparseSet swap-remove 操作
SparseSet 的内存开销值得仔细分析。sparse 数组按 Entity index 索引,这意味着它的大小取决于"最大的 Entity index",而非"实际拥有该组件的实体数量"。如果你的 World 中有 10000 个实体(index 0-9999),但只有 10 个拥有某个 SparseSet 组件,sparse 数组仍然需要分配 10000 个槽位(每个槽位通常 4 字节),而 dense 数组只有 10 个元素。这意味着在实体总量很大但组件持有率很低的情况下,SparseSet 的内存开销可能远超预期。反过来说,如果大多数实体都拥有该组件,sparse 数组的"空洞"很少,内存利用率就很高。这也是为什么 SparseSet 适合"标记型"组件(少数实体拥有)而非"通用型"组件(几乎所有实体都有)——后者用 Table 存储更节省内存且遍历更快。另外,SparseSet 的 dense 数组使用 Column 作为底层存储,这意味着它同样拥有变更检测的 tick 数组——Added 和 Changed 过滤器对 SparseSet 组件同样有效,只是查询路径不同(第 7 章)。
要点:SparseSet 通过 sparse→dense 间接映射实现 O(1) 查找、O(1) 增删,代价是遍历时失去缓存局部性。
5.7 ThinArrayPtr:极致的指针优化
Column 中的 tick 数组使用 ThinArrayPtr 而非标准的 Vec<T>:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/storage/thin_array_ptr.rs (简化) pub struct ThinArrayPtr<T> { data: NonNull<T>, _marker: PhantomData<Box<[T]>>, } }
与 Vec<T> 的对比:
Vec<T> | ThinArrayPtr<T> | |
|---|---|---|
| 内存占用 | 24 字节 (ptr + len + cap) | 8 字节 (ptr) |
| 长度信息 | 自带 | 外部管理 (Column 统一) |
| 容量信息 | 自带 | 外部管理 |
| Drop | 自动 | ManuallyDrop |
Column 中的四个数组长度总是一致的,由 Column 统一管理。每个数组都用独立的 Vec<T> 会浪费 3 × 16 = 48 字节的冗余 len/cap 字段。ThinArrayPtr 只保留一个裸指针,将长度管理委托给外部。
在一个有上千个 Column 的 World 中,这节省了可观的内存。
ThinArrayPtr 的设计代价是放弃了 Vec<T> 提供的自动边界检查和自动 drop。Column 必须在每次操作时手动维护长度一致性,手动调用元素的析构函数——任何疏忽都会导致内存泄漏或 double-free。这是一种典型的"安全性换性能"的权衡,但 Bevy 通过将所有 unsafe 操作封装在 Column 的安全 API 内部,将这种风险限制在了很小的代码范围内。从外部看,Column 的行为与四个同步的 Vec<T> 完全一致——用户和上层代码永远不需要知道底层使用了 ThinArrayPtr。这种"内部 unsafe、外部 safe"的分层设计在第 3 章的 UnsafeWorldCell 和第 5.5 节的 BlobArray 中也有体现。
要点:ThinArrayPtr 去掉了 Vec 的长度和容量字段,将管理职责上移到 Column 级别,每个 Column 节省 48 字节。
5.8 NonSend 存储:线程本地数据
不满足 Send 约束的类型无法存储在 Table 或 SparseSet 中。Bevy 为它们提供了 NonSend 存储:
#![allow(unused)] fn main() { // 获取线程本地资源 fn use_renderer(renderer: NonSend<Renderer>) { renderer.draw(); } }
NonSend 数据只能在主线程访问。典型用例包括:
- 平台相关的窗口句柄
- OpenGL 上下文(某些平台要求单线程)
- 不满足 Send 的第三方库类型
NonSend 的 System 不能与其他 System 并行——调度器会将它们限制在主线程串行执行。
要点:NonSend 是 !Send 类型的特殊存储通道,仅限主线程访问,不参与并行调度。
本章小结
本章我们深入了 Bevy ECS 的数据层:
- Component =
Send + Sync + 'static,通过 derive 宏自动实现 - Table 列式存储优化迭代,SparseSet 稀疏存储优化增删
- Column = BlobArray + Ticks,四数组并行驱动变更检测
- BlobArray 用
Layout + NonNull<u8> + drop fn实现类型擦除 - SparseSet 用 sparse→dense 双数组实现 O(1) 增删查
- ThinArrayPtr 去掉冗余的长度/容量字段,节省内存
- Required Components 自动补全依赖,形成 DAG
- NonSend 存储
!Send类型,限制主线程访问
下一章,我们将看到这些存储如何被 Archetype 组织——Archetype 是连接 Entity 和 Storage 的索引层。
第 6 章:Archetype — 组合索引与实体迁移
导读:上一章我们深入了 Component 的存储层——Table 和 SparseSet。 但数据存储只是一半,另一半是"如何找到数据"。Archetype 正是 Entity 与 Storage 之间的索引层:它记录了每个实体拥有哪些组件,决定了数据存储在哪张 Table 里, 并缓存了组件增删时的迁移路径。理解 Archetype,就理解了 Bevy ECS 的组织骨架。
6.1 Archetype 的概念:组件集合的唯一指纹
一个 Archetype 唯一对应一组组件的组合。World 中所有拥有完全相同组件集合的实体, 都属于同一个 Archetype。
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:383 pub struct Archetype { id: ArchetypeId, table_id: TableId, edges: Edges, entities: Vec<ArchetypeEntity>, components: ImmutableSparseSet<ComponentId, ArchetypeComponentInfo>, pub(crate) flags: ArchetypeFlags, } }
六个字段各司其职:
| 字段 | 类型 | 职责 |
|---|---|---|
id | ArchetypeId | 全局唯一标识,u32 包装 |
table_id | TableId | 指向存储 Table 组件的 Table |
edges | Edges | 缓存 Bundle 增删后的目标 Archetype |
entities | Vec<ArchetypeEntity> | 属于此 Archetype 的所有实体 |
components | ImmutableSparseSet | 组件 ID → 存储类型的映射 |
flags | ArchetypeFlags | Hook/Observer 的位标志缓存 |
每个 World 初始化时,都会创建一个空 Archetype(ArchetypeId::EMPTY = 0),
它不包含任何组件。新实体在分配阶段首先进入这个空 Archetype,然后通过 Bundle
操作迁移到目标 Archetype。
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:90 impl ArchetypeId { pub const EMPTY: ArchetypeId = ArchetypeId(0); } }
Archetype 的设计动机源自 ECS 面临的一个核心问题:如何快速找到"所有同时拥有 Position 和 Velocity 的实体"?朴素的做法是为每个 Query 遍历所有实体,逐个检查它是否拥有所需的组件——这是 O(N) 的,N 为总实体数。Archetype 将这个问题转化为集合匹配:Query 只需找到哪些 Archetype 的组件集合是 Query 要求的超集,然后直接遍历这些 Archetype 中的实体列表。由于 Archetype 数量远小于实体数量(通常是几十到几百个 Archetype 对应数万实体),匹配阶段的开销大幅降低。但 Archetype 也引入了一个权衡:每种独特的组件组合都产生一个新的 Archetype。如果游戏中存在大量"几乎相同但略有差异"的组件组合(比如为每种 buff 使用不同的标记组件),Archetype 数量会爆炸式增长——这就是所谓的"Archetype 碎片化"。碎片化不仅增加 Query 匹配时需要检查的 Archetype 数量,还可能导致每个 Archetype 中只有少量实体,降低 Table 遍历的效率。这就是为什么 SparseSet 组件(不影响 Archetype 划分的 Table 列)对于频繁增删的标记型组件如此重要——它们不会产生新的 Archetype。
要点:Archetype 是组件集合的唯一指纹,World 中每种独特的组件组合恰好对应一个 Archetype。
6.2 Archetype 与 Table 的 N:1 映射
Archetype 和 Table 不是一一对应的。多个 Archetype 可以共享同一张 Table, 条件是它们的 Table 存储组件完全相同。区别仅在于 SparseSet 组件不同。
Archetype 0 (空) → Table 0 (空)
Archetype 1 {Pos, Vel} → Table 1 {Pos, Vel}
Archetype 2 {Pos, Vel, Tag} → Table 1 {Pos, Vel} ← 共享!
(Tag 是 SparseSet 存储)
Archetype 3 {Pos, Health} → Table 2 {Pos, Health}
图 6-1: Archetype 与 Table 的 N:1 映射关系
为什么这样设计?因为 SparseSet 组件不存储在 Table 里,所以它们的增删 不需要改变 Table 结构。两个 Archetype 只有 SparseSet 组件不同时,它们的 Table 组件集是相同的,自然可以共享同一张 Table。
这带来一个重要推论:添加或删除 SparseSet 组件时,实体的 Table Row 不变, 只需要更新 Archetype 记录。这就是 SparseSet 组件增删代价低的另一个原因。
Archetype 的 table_id 在创建时确定,之后永不改变:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:469 impl Archetype { pub fn table_id(&self) -> TableId { self.table_id } } }
Rust 设计亮点:Archetype 与 Table 的 N:1 设计将"组件组合的多样性"与 "物理存储的共享"解耦。SparseSet 组件像是 Archetype 的"标签维度",不影响 密集存储布局。这让频繁增删标记组件的成本降到最低。
要点:仅 Table 存储的组件决定 Table ID,SparseSet 组件不影响。多个 Archetype 可共享同一 Table。
6.3 Edges 缓存:原型迁移图
当实体的组件集合发生变化(insert/remove),它需要从一个 Archetype 迁移到另一个。 Edges 缓存了这些迁移的结果,避免重复计算:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:206 pub struct Edges { insert_bundle: SparseArray<BundleId, ArchetypeAfterBundleInsert>, remove_bundle: SparseArray<BundleId, Option<ArchetypeId>>, take_bundle: SparseArray<BundleId, Option<ArchetypeId>>, } }
三种操作各有一个缓存表:
| 缓存 | Key | Value | 语义 |
|---|---|---|---|
insert_bundle | BundleId | ArchetypeAfterBundleInsert | 插入 Bundle 后的目标 |
remove_bundle | BundleId | Option<ArchetypeId> | 移除 Bundle 后的目标 |
take_bundle | BundleId | Option<ArchetypeId> | 取出 Bundle 后的目标 |
Insert 操作的缓存值比较丰富,不仅包含目标 Archetype ID,还记录了每个组件 是"新增"还是"已存在":
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:127 pub(crate) struct ArchetypeAfterBundleInsert { pub archetype_id: ArchetypeId, bundle_status: Box<[ComponentStatus]>, // Added or Existing pub required_components: Box<[RequiredComponentConstructor]>, inserted: Box<[ComponentId]>, // added first, then existing added_len: usize, } }
bundle_status 对应 Bundle 中的每个组件:如果目标实体已有该组件,标记为
Existing(只更新值),否则标记为 Added(需要触发 on_add hook)。
Archetype Graph 可以用有向图表示:
graph LR
A0["Arch 0<br/>(empty)"] -->|"+Health"| A1["Arch 1<br/>{Health}"]
A0 -->|"+Pos, Vel"| A2["Arch 2<br/>{Pos, Vel}"]
A1 -->|"+Pos, Vel"| A3["Arch 3<br/>{Pos, Vel, Health}"]
A2 -->|"+Health"| A3
图 6-2: Archetype Graph 迁移路径
每条边首次遍历时计算并缓存,之后永久命中:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:219 impl Edges { pub fn get_archetype_after_bundle_insert( &self, bundle_id: BundleId ) -> Option<ArchetypeId> { self.get_archetype_after_bundle_insert_internal(bundle_id) .map(|bundle| bundle.archetype_id) } } }
为什么 Edges 缓存如此重要?如果没有 Edges 缓存,每次 insert 操作都需要:计算当前组件集合与新 Bundle 的并集、在 Archetypes.by_components 哈希表中查找这个并集是否已存在、如果不存在则创建新的 Archetype 并更新所有反向索引。这个过程涉及组件集合的哈希计算(O(k),k 为组件数量)和哈希表查找。对于一个每帧都在大量实体上执行 insert/remove 的游戏来说,这些查找的累积开销是显著的。Edges 将这个 O(k) 的哈希查找替换为 O(1) 的 SparseArray 直接索引——BundleId 是一个整数,直接作为 SparseArray 的下标。更重要的是,ArchetypeAfterBundleInsert 不仅缓存了目标 Archetype ID,还预计算了每个组件是 Added 还是 Existing——这消除了迁移过程中逐组件判断状态的开销。如果不做这个缓存,每次 insert 都需要遍历 Bundle 中的每个组件,检查它是否已存在于目标 Archetype 中,才能决定是否触发 on_add hook。这种"首次计算、永久缓存"的模式在 Bevy 的多处出现——QueryState 的 Archetype 匹配缓存(第 7 章)也是同样的思路。
要点:Edges 以 BundleId 为 key 缓存迁移目标,首次计算后永久缓存,将 O(n) 的组件集合比较摊销为 O(1) 的查表操作。
6.4 实体迁移的完整流程
当调用 entity.insert(SomeBundle) 时,实体可能需要从当前 Archetype 迁移到新
Archetype。这个流程涉及多步操作和连锁更新。
以一个具体例子说明:实体 E2 位于 Archetype A({Pos, Vel}),我们要 insert Health:
步骤 1: 查 Edges 缓存
──────────────────────────────────────────
Arch A.edges.get_insert(HealthBundle)
→ 命中: 目标 Arch B = {Pos, Vel, Health}
→ 未命中: 计算新 Archetype 并缓存
步骤 2: 在目标 Table 中分配新行
──────────────────────────────────────────
Table B 末尾分配 Row, 写入所有组件值
步骤 3: 从源 Table 搬运旧组件 (swap-remove)
──────────────────────────────────────────
Table A: Table A (after):
Row 0: [P₀][V₀] ← E0 Row 0: [P₀][V₀] ← E0
Row 1: [P₁][V₁] ← E1 Row 1: [P₃][V₃] ← E3 (moved!)
Row 2: [P₂][V₂] ← E2 ◄ 移除 Row 2: [P₂][V₂] ← E2 (removed)
Row 3: [P₃][V₃] ← E3
E2 的 Pos, Vel 搬到 Table B 的新行
E3 被 swap 到 Row 2 的位置
步骤 4: 从源 Archetype 移除 (swap-remove)
──────────────────────────────────────────
Arch A.entities:
Before: [E0, E1, E2, E3]
After: [E0, E1, E3] (E3 moved to index 2)
步骤 5: 在目标 Archetype 中登记
──────────────────────────────────────────
Arch B.entities.push(E2)
步骤 6: 连锁 EntityLocation 更新
──────────────────────────────────────────
更新 E2: archetype=B, row=new_row
更新 E3: archetype_row=2 (被 swap 到了 E2 的旧位置)
若 Table 中也发生了 swap: 更新被 swap 实体的 table_row
图 6-3: 实体迁移完整流程 (6 步)
关键的 swap_remove 方法返回被交换的实体信息:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:623 pub(crate) fn swap_remove(&mut self, row: ArchetypeRow) -> ArchetypeSwapRemoveResult { let is_last = row.index() == self.entities.len() - 1; let entity = self.entities.swap_remove(row.index()); ArchetypeSwapRemoveResult { swapped_entity: if is_last { None } else { Some(self.entities[row.index()].entity) }, table_row: entity.table_row, } } }
swapped_entity 就是被连锁影响的实体——它的 EntityLocation 需要更新。
如果被移除的恰好是最后一个元素,则不需要 swap,swapped_entity 为 None。
Rust 设计亮点:swap-remove 是 O(1) 删除,但代价是打乱顺序。Bevy 通过
ArchetypeSwapRemoveResult精确报告被影响的实体,让调用者能及时更新EntityLocation。这是一个"O(1) 操作 + O(1) 修复"的经典模式。
实体迁移的性能开销值得深入分析。最昂贵的部分不是 Edges 查找(O(1)),而是 Table 级别的数据搬运。当实体从一个 Table 迁移到另一个 Table 时,它的每个 Table 存储组件都需要被逐字节复制——一个拥有 10 个组件的实体可能需要搬运数百字节的数据。此外,swap-remove 操作会影响一个"无辜"的实体(被 swap 到空位的那个),它的 EntityLocation 需要更新。如果大量实体在同一帧频繁迁移,这种连锁更新会成为性能瓶颈。这就是为什么 Bevy 推荐使用 SparseSet 存储频繁增删的标记组件——SparseSet 组件的增删只改变 Archetype(不改变 Table),ArchetypeMoveType::SameTable 路径跳过了整个 Table 数据搬运。实际游戏开发中,一个常见的反模式是用 Table 存储的标记组件(如 Poisoned、Stunned)来表示临时状态——每次添加/移除都触发完整的 Archetype 迁移。将这些组件改为 SparseSet 存储可以消除迁移开销,这种优化效果在有数千实体的场景中尤为明显。
要点:实体迁移 = 查 Edges + Table 搬运 (swap-remove) + Archetype 登记 + 连锁更新被 swap 实体的 Location。
6.5 Archetypes 集合与 ArchetypeGeneration
World 中所有 Archetype 由 Archetypes 集合管理:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:773 pub struct Archetypes { pub(crate) archetypes: Vec<Archetype>, by_components: HashMap<ArchetypeComponents, ArchetypeId>, pub(crate) by_component: ComponentIndex, } }
三个数据结构:
archetypes: 按 ID 顺序的 Archetype 列表,只增不删by_components: 从组件集合到 ArchetypeId 的哈希查找by_component: 从单个 ComponentId 到包含它的所有 Archetype 的反向索引
ArchetypeGeneration 记录了 Archetype 列表的"版本号"——实际上就是最新的
ArchetypeId:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:747 pub struct ArchetypeGeneration(pub(crate) ArchetypeId); }
QueryState 用它实现增量更新:只检查上次见过的 generation 之后新增的 Archetype, 而不必每帧扫描全部 Archetype。这是 Query 缓存高效的关键之一(第 7 章详述)。
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:973 impl Index<RangeFrom<ArchetypeGeneration>> for Archetypes { type Output = [Archetype]; fn index(&self, index: RangeFrom<ArchetypeGeneration>) -> &Self::Output { &self.archetypes[index.start.0.index()..] } } }
通过 RangeFrom<ArchetypeGeneration> 索引,可以只获取新增的 Archetype 切片。
要点:Archetypes 只增不删,ArchetypeGeneration 支持增量更新,避免重复扫描。
6.6 Bundle 操作:BundleInserter 与 BundleSpawner
Bundle 是 Bevy 中组件操作的原子单位。Bundle trait 将多个 Component 打包成一组, 供 spawn、insert、remove 使用。
BundleInserter:向已有实体插入组件
BundleInserter 缓存了插入操作所需的全部上下文,避免重复查找:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/bundle/insert.rs:24 pub(crate) struct BundleInserter<'w> { world: UnsafeWorldCell<'w>, bundle_info: ConstNonNull<BundleInfo>, archetype_after_insert: ConstNonNull<ArchetypeAfterBundleInsert>, archetype: NonNull<Archetype>, archetype_move_type: ArchetypeMoveType, change_tick: Tick, } }
它在创建时就确定了源 Archetype 和目标 Archetype 之间的关系。
archetype_move_type 区分三种情况:
graph LR
INSERT["Bundle Insert"] --> SA["SameArchetype<br/>目标 = 源<br/>所有组件已存在,只更新值"]
INSERT --> NA["NewArchetype<br/>需要搬运到新 Archetype/新 Table"]
INSERT --> ST["SameTable<br/>Archetype 变了,但 Table 相同<br/>(仅 SparseSet 变化)"]
图 6-4: Bundle Insert 的三种迁移类型
BundleSpawner:批量生成新实体
BundleSpawner 专门优化 spawn 场景。因为新实体总是从空 Archetype 开始,
BundleSpawner 预先确定目标 Archetype 和 Table:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/bundle/spawner.rs:18 pub(crate) struct BundleSpawner<'w> { world: UnsafeWorldCell<'w>, bundle_info: ConstNonNull<BundleInfo>, table: NonNull<Table>, archetype: NonNull<Archetype>, change_tick: Tick, } }
由于 spawn 不需要从旧位置搬运数据,BundleSpawner 比 BundleInserter 更轻量。
SpawnBatch 优化
当使用 world.spawn_batch(iter) 批量生成大量同类实体时,Bevy 复用同一个
BundleSpawner。所有实体共享同一个 Archetype 查找结果——查表只做一次,
后续每个实体只需要分配 Entity ID + 写入组件数据。
spawn_batch([BundleA; 1000]):
1. 创建 BundleSpawner (查 Archetype, 查 Table) — 1 次
2. 预分配 Table 容量 (reserve 1000 行) — 1 次
3. 对每个元素: — 1000 次
a. allocate Entity ID
b. write components to Table row
c. push to Archetype.entities
图 6-5: SpawnBatch 批量优化流程
对比逐个 spawn:每次都要查 Archetype(虽然 Edges 缓存命中),
SpawnBatch 彻底省去了重复的查找步骤。
SpawnBundleStatus:简化的状态判定
spawn 操作中所有组件都是"新增的",因此 Bevy 用一个特殊的零成本状态类型:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:181 pub(crate) struct SpawnBundleStatus; impl BundleComponentStatus for SpawnBundleStatus { unsafe fn get_status(&self, _index: usize) -> ComponentStatus { ComponentStatus::Added // spawn always adds } } }
Rust 设计亮点:
SpawnBundleStatus是一个零大小类型 (ZST),编译期完全内联。 与ArchetypeAfterBundleInsert的运行时Box<[ComponentStatus]>相比, spawn 路径不需要堆分配。这是"编译期多态消除运行时开销"的典型应用。
要点:BundleInserter 缓存迁移上下文,BundleSpawner 专精 spawn 路径,SpawnBatch 通过复用 Spawner 实现批量优化。
6.7 ArchetypeFlags:Hook 与 Observer 的快速跳过
每个 Archetype 维护一组位标志,记录其包含的组件是否注册了 Hook 或 Observer:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:358 bitflags::bitflags! { pub(crate) struct ArchetypeFlags: u32 { const ON_ADD_HOOK = (1 << 0); const ON_INSERT_HOOK = (1 << 1); const ON_DISCARD_HOOK = (1 << 2); const ON_REMOVE_HOOK = (1 << 3); const ON_DESPAWN_HOOK = (1 << 4); const ON_ADD_OBSERVER = (1 << 5); const ON_INSERT_OBSERVER = (1 << 6); // ... more flags } } }
这些标志在 Archetype 创建时根据其包含的组件计算。运行时通过位操作快速判断 是否需要触发 Hook/Observer:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:673 impl Archetype { pub fn has_add_hook(&self) -> bool { self.flags().contains(ArchetypeFlags::ON_ADD_HOOK) } } }
如果一个 Archetype 的所有组件都没有注册 on_add hook,has_add_hook() 返回
false,Insert 路径可以完全跳过 hook 触发逻辑。这是一个 O(1) 的快速出口。
要点:ArchetypeFlags 用位标志缓存 Hook/Observer 状态,实现 O(1) 的快速跳过判断。
6.8 ComponentIndex:反向索引
Archetypes 维护了一个从 ComponentId 到 Archetype 集合的反向索引:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:765 pub type ComponentIndex = HashMap<ComponentId, HashMap<ArchetypeId, ArchetypeRecord>>; }
当 Query 需要知道"哪些 Archetype 包含组件 A"时,不需要遍历所有 Archetype, 直接查 ComponentIndex 即可。这在 Query 匹配阶段大幅减少了扫描量。
ArchetypeRecord 记录了组件在 Table 中的列索引(如果是 Table 存储组件):
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/archetype.rs:782 pub struct ArchetypeRecord { pub(crate) column: Option<usize>, } }
column = None 表示该组件使用 SparseSet 存储。
要点:ComponentIndex 提供从组件到 Archetype 的反向查找,加速 Query 匹配。
本章小结
本章我们剖析了 Archetype 的完整结构:
- Archetype 是组件集合的唯一指纹,由组件 ID 集合决定
- N:1 映射:多个 Archetype 可共享 Table(区别仅在 SparseSet 组件)
- Edges 缓存 Bundle 操作的迁移目标,首次计算后永久缓存
- 实体迁移 = 查 Edges → swap-remove 搬运 → 连锁更新 EntityLocation
- BundleInserter/BundleSpawner 缓存迁移上下文,SpawnBatch 批量复用
- ArchetypeFlags 用位标志实现 Hook/Observer 的 O(1) 快速跳过
- ArchetypeGeneration 支持增量更新,ComponentIndex 支持反向查找
下一章,我们将进入 Query 系统——Archetype 的匹配、缓存和迭代策略。Query 是 System 访问数据的唯一窗口,它将 Archetype 索引和 Table 存储的效能充分释放出来。
第 7 章:Query — 高效的数据查询引擎
导读:上一章我们了解了 Archetype 如何组织实体和组件的映射关系。 本章进入 Query 系统——System 访问 ECS 数据的唯一窗口。我们将深入 WorldQuery trait 体系、QueryState 的匹配缓存、Dense 与 Archetype 两种 迭代路径、FilteredAccess 冲突检测机制、ParamSet 互斥解法、QueryBuilder 运行时动态查询,以及 par_iter 并行迭代。
7.1 WorldQuery trait 体系
Query 的类型系统建立在三层 trait 之上:
graph TD
WQ["WorldQuery<br/>(底层统一接口)"]
WQ --> QD["QueryData<br/>(读取数据)"]
WQ --> QF["QueryFilter<br/>(过滤实体)"]
QD --> D1["&T"]
QD --> D2["&mut T"]
QD --> D3["Entity"]
QD --> D4["Option<&T>"]
QD --> D5["Ref<T>"]
QD --> D6["Has<T>"]
QD --> D7["AnyOf<..>"]
QF --> F1["With"]
QF --> F2["Without"]
QF --> F3["Changed"]
QF --> F4["Added"]
QF --> F5["Or<(..)>"]
QF --> F6["Spawned"]
图 7-1: WorldQuery trait 层级
WorldQuery:底层统一接口
WorldQuery 是所有查询类型的基础 trait:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/world_query.rs:44 (简化) pub unsafe trait WorldQuery { type Fetch<'w>: Clone; type State: Send + Sync + Sized; const IS_DENSE: bool; fn shrink_fetch<'wlong: 'wshort, 'wshort>( fetch: Self::Fetch<'wlong> ) -> Self::Fetch<'wshort>; unsafe fn init_fetch<'w, 's>( world: UnsafeWorldCell<'w>, state: &'s Self::State, last_run: Tick, this_run: Tick, ) -> Self::Fetch<'w>; unsafe fn set_archetype<'w, 's>( fetch: &mut Self::Fetch<'w>, state: &'s Self::State, archetype: &'w Archetype, table: &'w Table, ); unsafe fn set_table<'w, 's>( fetch: &mut Self::Fetch<'w>, state: &'s Self::State, table: &'w Table, ); fn update_component_access( state: &Self::State, access: &mut FilteredAccess, ); fn matches_component_set( state: &Self::State, set_contains_id: &dyn Fn(ComponentId) -> bool, ) -> bool; } }
关键设计点:
| 关联类型/常量 | 用途 |
|---|---|
Fetch<'w> | 每次切换 Table/Archetype 时构造的临时状态 |
State | 缓存在 QueryState 中的持久状态 |
IS_DENSE | 编译期常量,决定迭代路径 |
Rust 设计亮点:
Fetch<'w>使用了 GAT(Generic Associated Type), 让 Fetch 的生命周期绑定到 World 的借用周期。这避免了用PhantomData或 unsafe 生命周期擦除——编译器直接保证 Fetch 不会比 World 的借用活得更久。
IS_DENSE 常量的设计体现了 Rust 零成本抽象的精髓。它是一个编译期常量,不是运行时变量——这意味着当编译器在 QueryIter 的迭代循环中看到 if IS_DENSE { ... } else { ... } 时,它可以在编译期就消除掉不走的分支,生成纯粹的 Dense 路径或纯粹的 Archetype 路径代码。运行时没有任何分支预测开销。如果将 IS_DENSE 改为运行时 bool,虽然功能等价,但每次迭代都需要一次分支判断——在紧密的内循环中,这个分支可能导致数个百分点的性能下降。shrink_fetch 方法的存在则是为了处理生命周期的协变性——它允许将一个长生命周期的 Fetch 安全地"缩短"为短生命周期,这在 Query 嵌套和 ParamSet(第 7.5 节)等场景中是必要的。
QueryData:数据获取
QueryData 扩展 WorldQuery,增加了 fetch 方法用于实际读取数据:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/fetch.rs:27 (概念) pub unsafe trait QueryData: WorldQuery { type ReadOnly: ReadOnlyQueryData; type Item<'w>; unsafe fn fetch<'w>( fetch: &mut Self::Fetch<'w>, entity: Entity, table_row: TableRow, ) -> Self::Item<'w>; } }
常用的 QueryData 实现:
| 类型 | Item | 说明 |
|---|---|---|
&T | &T | 不可变引用 |
&mut T | Mut<T> | 可变引用(带变更检测) |
Entity | Entity | 实体 ID |
Option<&T> | Option<&T> | 可选组件 |
Ref<T> | Ref<T> | 不可变引用 + 变更 tick |
Has<T> | bool | 是否拥有组件 |
AnyOf<(A, B)> | (Option<A>, Option<B>) | 至少有一个 |
QueryFilter:过滤条件
QueryFilter 专注于筛选,不获取数据:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/filter.rs:84 (简化) pub unsafe trait QueryFilter: WorldQuery { const IS_ARCHETYPAL: bool; unsafe fn filter_fetch( state: &Self::State, fetch: &mut Self::Fetch<'_>, entity: Entity, table_row: TableRow, ) -> bool; } }
IS_ARCHETYPAL 区分两类过滤器:
IS_ARCHETYPAL | 含义 | 例子 |
|---|---|---|
true | 在 Archetype 级别过滤,无需逐行检查 | With<T>, Without<T> |
false | 需要逐行检查数据 | Changed<T>, Added<T> |
Archetypal 过滤器的 filter_fetch 始终返回 true(因为不匹配的 Archetype
已被排除),而 Changed<T> 需要读取每行的 tick 来判断是否通过。
要点:WorldQuery = Fetch + State + IS_DENSE;QueryData 获取数据,QueryFilter 过滤实体。IS_ARCHETYPAL 决定是 Archetype 级还是逐行过滤。
7.2 QueryState:匹配与缓存
QueryState<D, F> 是 Query 的核心状态容器,缓存了匹配结果:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/state.rs:79 (简化) pub struct QueryState<D: QueryData, F: QueryFilter = ()> { world_id: WorldId, pub(crate) archetype_generation: ArchetypeGeneration, pub(crate) matched_tables: FixedBitSet, pub(crate) matched_archetypes: FixedBitSet, pub(crate) component_access: FilteredAccess, pub(super) matched_storage_ids: Vec<StorageId>, pub(super) is_dense: bool, pub(crate) fetch_state: D::State, pub(crate) filter_state: F::State, } }
关键字段解读:
QueryState 缓存结构:
┌─────────────────────────────────────────────────┐
│ archetype_generation: Gen(5) │ ← 上次扫描到的 generation
│ │
│ matched_archetypes: [1, 0, 1, 1, 0, 1, ...] │ ← 位集: 哪些 Archetype 匹配
│ matched_tables: [1, 0, 1, 0, 1, ...] │ ← 位集: 哪些 Table 匹配
│ │
│ matched_storage_ids: [Table(0), Table(2), ...] │ ← Dense 路径: Table ID 列表
│ — OR — [Arch(1), Arch(3), ...] │ ← Archetype 路径: Archetype ID
│ │
│ is_dense: true/false │ ← 编译期确定的迭代策略
│ │
│ component_access: FilteredAccess { ... } │ ← 用于并行安全检测
│ fetch_state: D::State │ ← QueryData 的缓存
│ filter_state: F::State │ ← QueryFilter 的缓存
└─────────────────────────────────────────────────┘
图 7-2: QueryState 缓存结构
增量更新机制
QueryState 不会每帧重新扫描所有 Archetype。它通过 archetype_generation
记录上次检查的位置,只处理新增的 Archetype:
World 当前有 8 个 Archetype (Gen = 8)
QueryState 上次扫描到 Gen = 5
增量更新:
archetypes[5..8] ← 只检查这 3 个新 Archetype
对每个新 Archetype:
if matches_component_set(archetype):
matched_archetypes.set(id)
matched_tables.set(table_id)
matched_storage_ids.push(storage_id)
archetype_generation = 8 ← 更新到最新
图 7-3: QueryState 增量更新流程
is_dense 的决定逻辑
is_dense 在 QueryState 初始化时确定,等于 D::IS_DENSE && F::IS_DENSE:
&T的IS_DENSE:当 T 是 Table 存储时为 trueWith<T>的IS_DENSE:始终 true(Archetypal 过滤不依赖存储)Changed<T>的IS_DENSE:当 T 是 Table 存储时为 true- 元组
(A, B)的IS_DENSE:A::IS_DENSE && B::IS_DENSE
只要 Query 中有任何一个元素需要 SparseSet 存储的数据,整个 Query 就退化为 Archetype 迭代路径。
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/state.rs:51 (概念) // StorageId 是一个 union,根据 is_dense 解释为 TableId 或 ArchetypeId pub(super) union StorageId { pub(super) table_id: TableId, pub(super) archetype_id: ArchetypeId, } }
Rust 设计亮点:
StorageId用union而非enum,省去了判别标签 (discriminant) 的内存和分支开销。由于is_dense在编译期确定且对整个 QueryState 统一,所有 StorageId 的类型一致,不需要逐元素判别。
要点:QueryState 通过 ArchetypeGeneration 增量更新,FixedBitSet 记录匹配结果,is_dense 在编译期决定迭代策略。
7.3 Dense vs Archetype 迭代路径
Query 迭代有两条路径,由 is_dense 在编译期选择:
Dense 路径 (is_dense = true): Archetype 路径 (is_dense = false):
for table_id in matched_storage_ids: for archetype_id in matched_storage_ids:
table = tables[table_id] archetype = archetypes[archetype_id]
set_table(fetch, table) table = tables[archetype.table_id]
for row in 0..table.len(): set_archetype(fetch, archetype, table)
item = fetch(entity, row) for row in archetype.entities:
item = fetch(entity, row)
图 7-4: Dense vs Archetype 两种迭代路径
Dense 路径的优势
当所有查询的组件都存储在 Table 中时,Dense 路径直接遍历 Table 的行。 一张 Table 可能被多个 Archetype 共享(第 6 章),Dense 路径只需遍历 Table 一次,不需要按 Archetype 分组——直接线性扫描整个 Table。
缓存命中率高:同一 Column 中的数据在内存中连续排列。
让我们更精确地分析 Dense 路径的缓存行为。假设你查询 Query<(&Position, &Velocity)>,Position 为 Vec3(12 字节),Velocity 也为 Vec3(12 字节)。在一个 64 字节的 cache line 中,Position 列可以装 5 个元素,Velocity 列同样可以装 5 个。当 CPU 遍历 Table 的第一行时,加载 Position[0] 会将 Position[0..4] 拉入 L1 缓存;接着加载 Velocity[0] 会将 Velocity[0..4] 拉入缓存。后续 4 行的 Position 和 Velocity 访问全部命中 L1,零延迟。硬件预取器还会检测到这种连续的步长访问模式,提前将后续 cache line 从 L2 拉入 L1。这种访问模式下,内存带宽利用率接近理论极限。反观 Archetype 路径:当 Query 涉及 SparseSet 组件时,set_archetype 在每个 Archetype 切换时需要重新定位 SparseSet 的 dense 数组指针,这引入了额外的间接寻址。而且 SparseSet 的 dense 数组中的元素顺序与 Table 行顺序不一定一致——同一个 Archetype 中实体 E0 的 SparseSet 组件可能在 dense[5] 而 E1 的在 dense[2]——这破坏了线性扫描的预取优势。
Archetype 路径
当 Query 涉及 SparseSet 组件时,必须用 Archetype 路径。因为 SparseSet 的数据不在 Table 中,需要通过 Archetype 确定每个实体的 SparseSet 查找路径。
Archetype 路径需要在每个 Archetype 切换时调用 set_archetype,它同时
设置 Table 指针和 SparseSet 查找结构。
性能差异
| 维度 | Dense 路径 | Archetype 路径 |
|---|---|---|
| 遍历粒度 | Table (coarser) | Archetype (finer) |
| 缓存友好度 | 极高 | 较高 |
| 函数调用 | set_table per table | set_archetype per archetype |
| 适用场景 | 纯 Table 组件 | 含 SparseSet 组件 |
要点:Dense 路径直接遍历 Table 行,缓存友好;Archetype 路径按 Archetype 分组遍历,支持 SparseSet 组件。编译期选择,零运行时分支。
7.4 FilteredAccess 与冲突检测
Bevy 的并行调度要求:两个 System 可以并行执行,当且仅当它们的数据访问不冲突。
FilteredAccess 和 FilteredAccessSet 是这个安全保障的核心。
Access:读写追踪
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/access.rs:15 (简化) pub struct Access { read_and_writes: ComponentIdSet, // all accessed components writes: ComponentIdSet, // exclusively accessed components read_and_writes_inverted: bool, // true = "access all except these" writes_inverted: bool, archetypal: ComponentIdSet, // With/Without filters } }
Access 用位集 (FixedBitSet) 追踪每个 ComponentId 的读写状态。
inverted 标志支持 EntityRef/EntityMut 这类"访问几乎所有组件"的查询。
FilteredAccess:带过滤条件的访问
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/access.rs:720 pub struct FilteredAccess { pub(crate) access: Access, pub(crate) required: ComponentIdSet, pub(crate) filter_sets: Vec<AccessFilters>, // DNF form } }
filter_sets 以析取范式 (DNF) 表示过滤条件。例如 (With<A>, Or<(With<B>, Without<C>)>)
展开为 Or<((With<A>, With<B>), (With<A>, Without<C>))>。
冲突检测矩阵
两个系统是否冲突,取决于它们是否可能同时访问同一组件的可变引用。
考虑以下四个系统:
System A: Query<&Pos> — 读 Pos
System B: Query<&mut Vel> — 写 Vel
System C: Query<&mut Pos, With<Enemy>> — 写 Pos (仅 Enemy)
System D: Query<&mut Pos, With<Player>> — 写 Pos (仅 Player)
冲突矩阵:
A B C D
A - OK ✓ 冲突 ✗ 冲突 ✗
B OK ✓ - OK ✓ OK ✓
C 冲突 ✗ OK ✓ - OK ✓ *
D 冲突 ✗ OK ✓ OK ✓ * -
图 7-5: 四系统并行安全冲突矩阵
标注 * 的 C-D 对:虽然都写 Pos,但 With<Enemy> 和 With<Player> 的
过滤条件使它们的 Archetype 集合不相交。FilteredAccess 通过比较 filter_sets
检测这种"条件互斥"。
冲突检测算法
FilteredAccessSet 收集一个系统的所有 FilteredAccess,然后用
is_compatible 方法检查两个系统是否可以并行:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/access.rs:1100 pub struct FilteredAccessSet { combined_access: Access, // union of all accesses filtered_accesses: Vec<FilteredAccess>, } }
combined_access 是所有 FilteredAccess 的合并,用于快速检测"肯定不冲突"。
只有当 combined_access 级别发现潜在冲突时,才深入检查 filter_sets 的互斥性。
FilteredAccess 的位集(bitset)设计值得深入理解。每个 ComponentId 在位集中占一位,读写检查归结为两个位集的交集运算——这在硬件层面是一条 AND 指令加一条比较指令。64 位机器上,一次操作可以同时检查 64 个组件的冲突性。对于拥有数百个组件类型的大型游戏,位集可能扩展到多个 64 位字(FixedBitSet 内部是 Vec<u64>),但交集检查仍然是 O(k/64) 的,k 为组件类型总数。这比朴素的"遍历两个集合找交集"的 O(n*m) 方案快几个数量级。DNF 形式的 filter_sets 支持一种关键的优化:如果两个 System 都写 Position,但一个带 With<Player>、另一个带 With<Enemy>,它们的 filter_sets 包含互斥的 archetypal 条件——同一个 Archetype 不可能同时包含 Player 和 Enemy(假设它们是不同的标记组件)。FilteredAccess 通过比较 filter_sets 中的 with 和 without 集合来检测这种互斥性:如果 System A 的某个 filter 要求 With
Rust 设计亮点:Bevy 将 Rust 的借用规则(共享读 OR 独占写)映射到
Rust 设计亮点:Bevy 将 Rust 的借用规则(共享读 OR 独占写)映射到 运行时的组件访问检测。编译器确保单个 Query 内部安全,FilteredAccess 确保跨 System 安全。两层安全网,零运行时开销(冲突在系统初始化时检测, 不在每帧运行时检查)。
要点:FilteredAccess 用位集追踪读写,DNF 过滤条件支持条件互斥检测。冲突在系统初始化时检测,运行时零开销。
7.5 ParamSet:互斥参数组
当两个 Query 确实会冲突时(例如同一 System 中需要 Query<&mut Pos> 和
Query<&Pos, With<Enemy>>),Bevy 提供 ParamSet 作为解决方案:
#![allow(unused)] fn main() { fn system(mut set: ParamSet<( Query<&mut Pos>, // p0 Query<&Pos, With<Enemy>>, // p1 )>) { // Only one can be active at a time for pos in &mut set.p0() { // ... } // p0 dropped, now safe to use p1 for pos in &set.p1() { // ... } } }
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/system_param.rs:552 pub struct ParamSet<'w, 's, T: SystemParam> { param_states: &'s mut T::State, world: UnsafeWorldCell<'w>, system_meta: SystemMeta, change_tick: Tick, } }
ParamSet 的安全保障:
- 编译期:
p0()返回的借用必须在调用p1()前结束 - 初始化期:ParamSet 向调度器注册所有内部参数的合并访问
- 运行时:每次只有一个参数被激活,不存在同时访问
要点:ParamSet 将互斥的 Query 打包在一起,通过 Rust 借用规则保证同一时刻只有一个 Query 被访问。
7.6 QueryBuilder:运行时动态查询
大多数 Query 在编译期确定,但有时需要在运行时构造查询——例如编辑器、调试工具
或数据驱动的 ECS 逻辑。QueryBuilder 提供这种能力:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/builder.rs:38 (简化) pub struct QueryBuilder<'w, D: QueryData = (), F: QueryFilter = ()> { access: FilteredAccess, world: &'w mut World, or: bool, first: bool, _marker: PhantomData<(D, F)>, } }
使用示例:
#![allow(unused)] fn main() { let mut query = QueryBuilder::<(Entity, &B)>::new(&mut world) .with::<A>() .without::<C>() .build(); let (entity, b) = query.single(&world).unwrap(); }
QueryBuilder 在编译期确定 QueryData 类型((Entity, &B)),但过滤条件
(with/without)在运行时动态添加。最终 build() 生成一个标准的
QueryState,后续迭代与静态 Query 完全相同。
要点:QueryBuilder 支持运行时动态添加过滤条件,build 后生成标准 QueryState,迭代性能无损。
7.7 par_iter:并行迭代与批次分割
对于大量实体的迭代,par_iter 利用多核并行加速:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/par_iter.rs:16 (简化) pub struct QueryParIter<'w, 's, D: IterQueryData, F: QueryFilter> { pub(crate) world: UnsafeWorldCell<'w>, pub(crate) state: &'s QueryState<D, F>, pub(crate) last_run: Tick, pub(crate) this_run: Tick, pub(crate) batching_strategy: BatchingStrategy, } }
批次分割策略
BatchingStrategy 控制如何将工作分配给线程:
假设 3 个匹配的 Table,分别有 100, 50, 200 个实体
BatchingStrategy: min_batch_size = 64
Table 0 (100 entities):
Batch 0: rows [0..64) → Thread A
Batch 1: rows [64..100) → Thread B
Table 1 (50 entities):
Batch 2: rows [0..50) → Thread C (不再分割, < 64)
Table 2 (200 entities):
Batch 3: rows [0..64) → Thread A
Batch 4: rows [64..128) → Thread B
Batch 5: rows [128..192) → Thread C
Batch 6: rows [192..200) → Thread A
图 7-6: par_iter 批次分割示意
使用方式:
#![allow(unused)] fn main() { fn physics_system(query: Query<(&mut Pos, &Vel)>) { query.par_iter_mut().for_each(|(mut pos, vel)| { pos.0 += vel.0; }); } }
par_iter 在 Table 内部分批,每个批次独立交给线程池。由于 Table 的列式存储
保证了同一批次内的数据连续,每个线程的缓存命中率依然很高。
for_each_init:线程本地状态
for_each_init 支持每个线程维护独立的本地状态:
#![allow(unused)] fn main() { query.par_iter().for_each_init( || queue.borrow_local_mut(), |local_queue, item| { **local_queue += 1; }, ); }
init 闭包在每个任务(不是每个线程)调用,返回的状态在任务内复用。
要点:par_iter 按 Table 分批交给线程池,BatchingStrategy 控制批次大小,for_each_init 支持线程本地状态。
本章小结
本章我们深入了 Bevy 的 Query 引擎:
- WorldQuery trait 体系分为 QueryData(获取数据)和 QueryFilter(过滤实体)
- QueryState 通过 ArchetypeGeneration 增量缓存匹配结果,FixedBitSet 记录匹配的 Table/Archetype
- is_dense 在编译期决定 Dense(直接遍历 Table)或 Archetype(按 Archetype 分组)迭代路径
- FilteredAccess 用位集追踪读写,DNF 过滤条件支持条件互斥,冲突在初始化时检测
- ParamSet 解决同系统内的 Query 冲突,通过 Rust 借用规则保证互斥访问
- QueryBuilder 支持运行时动态构造查询,build 后与静态 Query 等效
- par_iter 按 Table 分批并行迭代,BatchingStrategy 控制批次粒度
下一章,我们将揭示 Bevy 最核心的 Rust 魔法:普通函数如何自动变成 System。System trait、FunctionSystem 转换链、SystemParam 的 GAT 设计,以及 all_tuples! 宏的批量实现生成。
第 8 章:System — 函数即系统的魔法
导读:前两章我们理解了数据如何存储(Table/SparseSet)和如何查询(Query)。 本章揭示 Bevy 最核心的 Rust 魔法:一个普通的 Rust 函数,如何自动变成 可被调度器识别、并行执行、访问安全检查的 System。我们将深入 System trait、 FunctionSystem 编译期转换链、SystemParam 的 GAT 设计、all_tuples! 宏的 批量实现生成,以及全部 SystemParam 类型一览。
8.1 System trait:统一接口
所有 System 都实现了 System trait,它是调度器操作系统的唯一接口:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/system.rs:48 (简化) pub trait System: Send + Sync + 'static { type In: SystemInput; type Out; fn name(&self) -> DebugName; fn flags(&self) -> SystemStateFlags; unsafe fn run_unsafe( &mut self, input: SystemIn<'_, Self>, world: UnsafeWorldCell, ) -> Result<Self::Out, RunSystemError>; fn run( &mut self, input: SystemIn<'_, Self>, world: &mut World, ) -> Result<Self::Out, RunSystemError>; fn apply_deferred(&mut self, world: &mut World); fn initialize(&mut self, world: &mut World) -> FilteredAccessSet; fn check_change_tick(&mut self, check: CheckChangeTicks); fn get_last_run(&self) -> Tick; fn set_last_run(&mut self, last_run: Tick); } }
核心方法的职责:
| 方法 | 调用时机 | 职责 |
|---|---|---|
initialize | 系统首次加入 Schedule | 初始化参数状态,返回访问声明 |
run_unsafe | 调度器并行执行 | 接收 UnsafeWorldCell,不应用 deferred |
run | 独占执行 | 接收 &mut World,自动 apply deferred |
apply_deferred | run 后或 ApplyDeferred 节点 | 刷新 Commands 等延迟操作 |
flags | 调度分析 | 返回 EXCLUSIVE / NON_SEND / DEFERRED 标志 |
SystemMeta:系统元数据
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/function_system.rs:33 (简化) pub struct SystemMeta { pub(crate) name: DebugName, flags: SystemStateFlags, pub(crate) last_run: Tick, // tracing spans (feature-gated) } }
SystemStateFlags 是位标志:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/system.rs:24 bitflags! { pub struct SystemStateFlags: u8 { const NON_SEND = 1 << 0; // cannot be sent to other threads const EXCLUSIVE = 1 << 1; // requires exclusive World access const DEFERRED = 1 << 2; // has deferred buffers (Commands) } } }
NON_SEND 系统只能在主线程运行,EXCLUSIVE 系统独占 World,DEFERRED 系统
有延迟缓冲区需要在某个同步点刷新。
System trait 的设计体现了一个重要的架构决策:run_unsafe 和 run 的分离。run_unsafe 接收 UnsafeWorldCell,不自动 apply deferred——这是调度器并行执行时使用的路径,多个 System 同时通过各自的 UnsafeWorldCell 访问 World 的不同部分。run 接收 &mut World,会在执行后自动 apply deferred——这是独占执行或测试时使用的安全路径。为什么不只提供 run?因为并行执行时 World 被 UnsafeWorldCell 包装,没有人持有 &mut World,无法调用 run。为什么不只提供 run_unsafe?因为独占系统和测试场景需要一个安全的入口点,且 apply_deferred 的时机需要由调用者控制——在并行执行中,deferred 操作在所有并行 System 完成后统一 apply(在 ApplyDeferred 同步点),而非每个 System 执行后立即 apply。这种"延迟应用"的设计使得 Commands 等结构性变更不会打断并行执行的流水线。
要点:System trait 是调度器的统一接口,initialize 声明访问,run_unsafe 支持并行,apply_deferred 刷新延迟操作。
8.2 FunctionSystem:编译期转换链
Bevy 的魔法核心:一个普通函数如何变成 System?
#![allow(unused)] fn main() { fn movement(query: Query<(&mut Pos, &Vel)>) { for (mut pos, vel) in &query { pos.0 += vel.0; } } // This "just works": app.add_systems(Update, movement); }
转换链如下:
graph TD
F["普通函数<br/>fn movement(query: Query<..>)"]
F -->|"1. 实现 FnMut"| SPF["impl SystemParamFunction<br/>type Param = (Query<..>,)<br/>fn run(&mut self, input, param)"]
SPF -->|"2. 宏生成 impl IntoSystem"| IS["IntoSystem::into_system()<br/>→ FunctionSystem::new(self, SystemMeta, None)"]
IS -->|"3. 生成 FunctionSystem"| FS["FunctionSystem<Marker, (), (), movement><br/>impl System"]
FS --> INIT["initialize()<br/>→ Param::init_state()"]
FS --> RUN["run_unsafe()<br/>→ Param::get_param() + func.run()"]
FS --> AD["apply_deferred()<br/>→ Param::apply()"]
图 8-1: 函数到 System 的完整转换链
FunctionSystem 结构
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/function_system.rs:503 pub struct FunctionSystem<Marker, In, Out, F> where F: SystemParamFunction<Marker>, { func: F, // the original function state: Option<FunctionSystemState<F::Param>>, // initialized after first run system_meta: SystemMeta, marker: PhantomData<fn(In) -> (Marker, Out)>, } }
FunctionSystemState 包含参数的缓存状态和 World ID:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/function_system.rs:519 struct FunctionSystemState<P: SystemParam> { param: P::State, // cached parameter state world_id: WorldId, // safety check: must match the running World } }
Marker 泛型的作用
Marker 是一个幽灵类型参数。不同参数签名的函数生成不同的 Marker,
避免 trait 实现冲突。例如:
#![allow(unused)] fn main() { // 零参数系统 fn sys_a() {} // Marker = fn() -> () // 一参数系统 fn sys_b(q: Query<&Pos>) {} // Marker = fn(Query<&Pos>) -> () }
Marker = fn($($Param),*) -> Out,每种参数签名都是不同的类型,所以
可以为同一个函数类型实现多个 SystemParamFunction。
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/function_system.rs:576 impl<Marker, In, Out, F> IntoSystem<In, Out, (IsFunctionSystem, Marker)> for F where Marker: 'static, In: SystemInput + 'static, Out: 'static, F: SystemParamFunction<Marker, In: FromInput<In>, Out: IntoResult<Out>>, { type System = FunctionSystem<Marker, In, Out, F>; fn into_system(func: Self) -> Self::System { FunctionSystem::new(func, SystemMeta::new::<F>(), None) } } }
Rust 设计亮点:
Marker类型参数将函数签名编码到类型系统中,让 编译器为每种参数组合生成独立的IntoSystem实现。这是 Rust 的 "零成本抽象"在 ECS 中的极致应用——运行时不存储任何 Marker,它只活在编译期。
Marker 泛型的设计解决了 Rust trait 系统的一个根本限制:同一个类型不能有两个相同 trait 的实现。如果没有 Marker,impl IntoSystem for fn() 和 impl IntoSystem for fn(Query<..>) 会被 Rust 视为同一个 trait 的两个 blanket impl,产生冲突。Marker 将函数签名编码为不同的类型(fn() -> () vs fn(Query<..>) -> ()),使编译器将它们视为不同的 impl。这个技巧在 Rust 生态中被称为"marker pattern"或"tag dispatch",但 Bevy 对它的使用深度远超一般场景——每种参数数量和类型组合都会生成一个独立的 Marker 类型,由 all_tuples! 宏自动完成。
要点:IntoSystem 通过 Marker 泛型消除歧义,将 fn(Param...) 转换为 FunctionSystem,整个过程在编译期完成。
8.3 SystemParam trait:GAT 的精妙运用
SystemParam 是 System 参数的统一 trait,每种可以出现在系统签名中的类型
都必须实现它:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/system_param.rs:217 (简化) pub unsafe trait SystemParam: Sized { type State: Send + Sync + 'static; type Item<'world, 'state>: SystemParam<State = Self::State>; fn init_state(world: &mut World) -> Self::State; fn init_access( state: &Self::State, system_meta: &mut SystemMeta, component_access_set: &mut FilteredAccessSet, world: &mut World, ); fn apply(state: &mut Self::State, system_meta: &SystemMeta, world: &mut World) {} unsafe fn get_param<'world, 'state>( state: &'state mut Self::State, system_meta: &SystemMeta, world: UnsafeWorldCell<'world>, change_tick: Tick, ) -> Result<Self::Item<'world, 'state>, SystemParamValidationError>; } }
GAT 的设计意图
Item<'world, 'state> 是 GAT (Generic Associated Type):关联类型带有
生命周期参数。这解决了一个核心问题——参数的生命周期在获取时才确定:
graph TD
INIT["init_state(world)"] -->|"返回 State<br/>(无生命周期, 持久存储)"| STATE["State"]
STATE -->|"&'s mut State"| GET["get_param(state, world)"]
WORLD["UnsafeWorldCell<'w>"] -->|"'w: borrowed from World"| GET
GET -->|"返回 Item<'w, 's><br/>(生命周期绑定到此次调用)"| ITEM["Item<'w, 's>"]
图 8-2: SystemParam 的生命周期流
没有 GAT,Item 的生命周期必须在 trait 定义时固定——但它应该在
get_param 调用时才绑定。GAT 让 Item<'world, 'state> 推迟到使用点
才确定具体的生命周期。
如果没有 GAT 会怎样?在 GAT 稳定之前(Rust 1.65 之前),Bevy 不得不用一种笨拙的 workaround:将 Item 定义为一个不带生命周期的类型,然后在 get_param 内部用 transmute 将生命周期"擦除"后传出。这种做法依赖于开发者手动保证 transmute 的正确性——任何生命周期标注错误都会导致未定义行为,而编译器完全无法检测。另一种 pre-GAT 方案是用 HRTB(Higher-Ranked Trait Bounds)——for<'w, 's> SystemParam<Item<'w, 's> = ...>——但 HRTB 在当时的 rustc 中有严重的类型推断问题,会导致晦涩难解的编译错误。GAT 的稳定让 Bevy 从根本上解决了这个问题:Item<'world, 'state> 是一个合法的关联类型,编译器完整地追踪其生命周期,任何生命周期违规都会在编译期被捕获。这也是为什么 Bevy 的最低支持 Rust 版本始终跟随 GAT 的稳定版本——这个特性对 ECS 框架的安全性至关重要。
具体实现示例:Query
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/system_param.rs:303 (简化) unsafe impl<D: QueryData + 'static, F: QueryFilter + 'static> SystemParam for Query<'_, '_, D, F> { type State = QueryState<D, F>; type Item<'w, 's> = Query<'w, 's, D, F>; fn init_state(world: &mut World) -> Self::State { unsafe { QueryState::new_unchecked(world) } } fn init_access( state: &Self::State, system_meta: &mut SystemMeta, component_access_set: &mut FilteredAccessSet, world: &mut World, ) { state.init_access( Some(system_meta.name()), component_access_set, world.into(), ); } unsafe fn get_param<'w, 's>( state: &'s mut Self::State, system_meta: &SystemMeta, world: UnsafeWorldCell<'w>, change_tick: Tick, ) -> Result<Self::Item<'w, 's>, SystemParamValidationError> { Ok(unsafe { state.query_unchecked_with_ticks( world, system_meta.last_run, change_tick ) }) } } }
State = QueryState——持久缓存,跨帧复用。
Item<'w, 's> = Query<'w, 's, D, F>——每次 get_param 返回一个带生命周期的 Query。
Rust 设计亮点:GAT
Item<'world, 'state>让 SystemParam 同时拥有 持久状态(State,无生命周期)和临时借用(Item,带生命周期)。这是 Rust 类型 系统表达"初始化一次、每帧获取"模式的优雅方案。在 GAT 稳定之前,Bevy 不得不用 unsafe 绕过这个限制。
要点:SystemParam 用 GAT Item<'w, 's> 将持久 State 与临时借用 Item 分离,init_state 初始化一次,get_param 每帧获取。
8.4 all_tuples! 宏:0 到 16 的批量实现
一个系统可以有 0 到 16 个参数。Bevy 使用 all_tuples! 宏为每种参数数量
生成 trait 实现:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/function_system.rs:950 all_tuples!(impl_system_function, 0, 16, F); }
这一行展开为 17 个 impl 块(0 到 16 个参数)。以 impl_system_function
为例,展开过程:
#![allow(unused)] fn main() { // all_tuples!(impl_system_function, 0, 16, F) 展开: // 0 params impl<Out, Func> SystemParamFunction<fn() -> Out> for Func where Func: Send + Sync + 'static, for<'a> &'a mut Func: FnMut() -> Out, Out: 'static, { type In = (); type Out = Out; type Param = (); fn run(&mut self, _input: (), _param: ()) -> Out { fn call_inner<Out>(mut f: impl FnMut() -> Out) -> Out { f() } call_inner(&mut *self) } } // 1 param impl<Out, Func, F0: SystemParam> SystemParamFunction<fn(F0) -> Out> for Func where Func: Send + Sync + 'static, for<'a> &'a mut Func: FnMut(F0) -> Out + FnMut(SystemParamItem<F0>) -> Out, Out: 'static, { type In = (); type Out = Out; type Param = (F0,); fn run(&mut self, _input: (), param: SystemParamItem<(F0,)>) -> Out { fn call_inner<Out, F0>(mut f: impl FnMut(F0) -> Out, f0: F0) -> Out { f(f0) } let (f0,) = param; call_inner(&mut *self, f0) } } // 2 params impl<Out, Func, F0: SystemParam, F1: SystemParam> SystemParamFunction<fn(F0, F1) -> Out> for Func where ... { type Param = (F0, F1); // ... } // ... up to 16 params }
图 8-3: all_tuples! 宏展开过程 (0, 1, 2 参数)
call_inner 的必要性
注意展开代码中的 call_inner 辅助函数。这不是多余的——直接调用
(&mut *self)(f0) 会让 rustc 无法正确推断类型。call_inner 通过
一个独立的泛型函数边界帮助编译器完成推断。
16 参数限制
16 是一个实用上界。每增加一个参数,编译器需要生成的代码量翻倍 (每种参数组合都是独立的 monomorphization)。16 已经足以覆盖几乎所有 实际场景。
all_tuples! 宏的编译期成本值得认真对待。这个宏为 0 到 16 个参数各生成一套完整的 trait 实现,仅 SystemParamFunction 一个 trait 就产生 17 个 impl 块。但更大的开销来自单态化(monomorphization):当你写一个 fn my_system(q1: Query<..>, q2: Query<..>, res: Res<..>) 时,编译器需要为这个特定的 3 参数组合实例化一套完整的 FunctionSystem、SystemParam::get_param、SystemParam::init_state 等方法。一个中等规模的 Bevy 项目可能有 200-500 个 System,每个都有不同的参数组合——这意味着编译器要生成数百份独立的系统初始化和执行代码。这是 Bevy 项目编译时间较长的主要技术原因之一。Bevy 团队曾考虑过将上限从 16 降低到 12 以减少编译时间,但实际测量表明,大部分编译时间来自用户代码的单态化而非宏展开本身——未使用的参数数量(比如你从不写 14 参数的系统)不会产生额外的编译开销,因为未被实例化的泛型不会被 codegen。因此 16 参数上限的编译成本是"按需付费"的。
当参数超过 16 时,可以用嵌套元组绕过:
#![allow(unused)] fn main() { // 17 params — won't compile directly // fn sys(a: A, b: B, ..., q: Q) {} // Solution: pack into tuple fn sys( (a, b, c, d): (A, B, C, D), (e, f, g, h): (E, F, G, H), (i, j, k, l): (I, J, K, L), (m, n, o, p, q): (M, N, O, P, Q), ) { // 17 params work! } }
因为 (A, B, C, D) 本身实现了 SystemParam(通过 all_tuples! 为元组
生成的实现),它在 16 参数限制中只算一个参数。
要点:all_tuples! 为 0-16 参数批量生成 trait 实现。超过 16 参数时用嵌套元组打包。
8.5 SystemParam 类型一览
Bevy 提供了丰富的 SystemParam 类型,覆盖几乎所有数据访问模式:
| SystemParam | 读/写 | 说明 |
|---|---|---|
Query<D, F> | R/W | 按组件过滤和迭代实体 |
Single<D, F> | R/W | 恰好匹配一个实体的 Query |
Populated<D, F> | R/W | 至少匹配一个实体的 Query |
Res<T> | R | 不可变资源引用 |
ResMut<T> | W | 可变资源引用(带变更检测) |
Commands | W (deferred) | 延迟写入命令队列 |
Local<T> | R/W | 系统本地状态,每个系统实例独立 |
NonSend<T> | R | 非 Send 资源(主线程only) |
NonSendMut<T> | W | 非 Send 资源的可变引用 |
MessageReader<T> | R | 读取消息队列 |
MessageWriter<T> | W | 写入消息队列 |
RemovedComponents<T> | R | 读取本帧移除的组件 |
ParamSet<(P0, P1, ..)> | R/W | 互斥参数组 |
Deferred<T> | W | 延迟应用的缓冲区 |
&World | R | 只读 World 引用(独占调度) |
WorldId | R | 当前 World 的唯一标识 |
Option<Res<T>> | R | 可选资源(不存在时跳过) |
Option<ResMut<T>> | W | 可选可变资源 |
PhantomData<T> | - | 零开销占位符 |
FilteredResources | R | 运行时动态资源访问 |
FilteredResourcesMut | W | 运行时动态可变资源访问 |
&EntityAllocator | R | 实体 ID 分配器的只读访问 |
图 8-4: SystemParam 类型一览表
注意:Entity 只是在 Query 数据中可用,不是独立的 SystemParam;In<T>
属于 SystemInput,也不在此表内。
每个参数的生命周期
对应表格,参数签名中的生命周期标注规则:
#![allow(unused)] fn main() { #[derive(SystemParam)] struct MyParams<'w, 's> { query: Query<'w, 's, &'static Pos>, // 'w = World, 's = State res: Res<'w, GameConfig>, // 'w = World local: Local<'s, u32>, // 's = State commands: Commands<'w, 's>, // both } }
'w:从 World 借用的数据生命周期's:从 SystemParam::State 借用的数据生命周期
要点:Bevy 提供 20+ 种 SystemParam,覆盖查询、资源、命令、消息、本地状态等所有数据访问模式。
8.6 ExclusiveSystem:独占 World 的系统
有些操作需要独占的 &mut World 访问——比如结构性变更、flush commands、
或访问非 Send 资源。ExclusiveFunctionSystem 处理这类情况:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/exclusive_function_system.rs:26 (简化) pub struct ExclusiveFunctionSystem<Marker, Out, F> where F: ExclusiveSystemParamFunction<Marker>, { func: F, param_state: Option<F::Param::State>, system_meta: SystemMeta, marker: PhantomData<fn() -> (Marker, Out)>, } }
ExclusiveSystem 的第一个参数是 &mut World,后续参数是
ExclusiveSystemParam:
#![allow(unused)] fn main() { fn exclusive_system( world: &mut World, // ExclusiveSystemParam types go here ) { // Full World access world.spawn((Pos(0.0), Vel(1.0))); } }
调度器将 ExclusiveSystem 安排在同步点运行——不与任何其他系统并行。
SystemStateFlags::EXCLUSIVE 标志告诉调度器做此安排。
Rust 设计亮点:ExclusiveSystem 和 FunctionSystem 共享同一个
Systemtrait 接口,调度器不需要区分它们。差别体现在flags()的EXCLUSIVE位——这是一个"类型信息到运行时标志"的优雅桥接。
要点:ExclusiveSystem 以 &mut World 为第一参数,独占执行,不与其他系统并行。
8.7 System Combinator:组合系统
Bevy 提供了系统组合器,将两个系统的输出合并:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/combinator.rs:93 (简化) pub trait Combine<A: System, B: System> { type In: SystemInput; type Out; fn combine<T>( input: <Self::In as SystemInput>::Inner<'_>, data: &mut T, a: impl FnOnce(SystemIn<'_, A>, &mut T) -> Result<A::Out, RunSystemError>, b: impl FnOnce(SystemIn<'_, B>, &mut T) -> Result<B::Out, RunSystemError>, ) -> Result<Self::Out, RunSystemError>; } pub struct CombinatorSystem<Func, A, B> { _marker: PhantomData<fn() -> Func>, a: A, b: B, name: DebugName, } }
pipe:管道组合
pipe 将一个系统的输出作为另一个系统的输入:
#![allow(unused)] fn main() { app.add_systems(Update, system_a.pipe(system_b)); // system_a 的返回值成为 system_b 的 In 参数 }
条件组合
CombinatorSystem 更常用于 run conditions 的布尔组合:
#![allow(unused)] fn main() { // XOR combinator example pub type Xor<A, B> = CombinatorSystem<XorMarker, A, B>; impl<A, B> Combine<A, B> for XorMarker where A: System<In = (), Out = bool>, B: System<In = (), Out = bool>, { type In = (); type Out = bool; fn combine<T>( _input: (), data: &mut T, a: impl FnOnce((), &mut T) -> Result<bool, RunSystemError>, b: impl FnOnce((), &mut T) -> Result<bool, RunSystemError>, ) -> Result<bool, RunSystemError> { Ok(a((), data).unwrap_or(false) ^ b((), data).unwrap_or(false)) } } }
CombinatorSystem 实现了 System trait,所以它可以像普通系统一样被调度。
两个子系统的访问声明合并后传给调度器,确保并行安全。
要点:CombinatorSystem 通过 Combine trait 组合两个系统的输入输出,pipe 是最常用的管道组合。
8.8 从函数到调度:完整数据流
最后,让我们用一张图串联从用户函数到调度执行的完整流程:
graph TD
subgraph 用户代码
FN["fn movement(q: Query<..>) { ... }"]
end
subgraph 编译期
INTO["IntoSystem::into_system()"]
FS["FunctionSystem { func, meta, state: None }"]
FN --> INTO --> FS
end
subgraph 运行时
INIT["Schedule.initialize"]
INIT --> SI["System.initialize()"]
SI --> PINI["Param::init_state()"]
SI --> PINA["Param::init_access()"]
SI --> FA["return FilteredAccessSet"]
INIT --> CONF["调度器分析访问冲突<br/>FilteredAccessSet.is_compatible()"]
CONF --> RUN["并行执行: System.run_unsafe(world)"]
RUN --> GP["Param::get_param()<br/>→ QueryState.query_unchecked()"]
RUN --> FR["func.run(param_item)<br/>→ movement(query)"]
RUN --> AD["ApplyDeferred:<br/>System.apply_deferred()<br/>→ Param::apply()<br/>→ Commands flush"]
end
FS --> INIT
图 8-5: 函数到调度的完整数据流
要点:编译期将函数转为 FunctionSystem,运行时 initialize 声明访问,调度器检测冲突后并行执行 run_unsafe,最后在同步点 apply_deferred。
本章小结
本章我们揭示了 Bevy 将函数变成 System 的完整魔法:
- System trait 是调度器的统一接口,核心方法:initialize、run_unsafe、apply_deferred
- FunctionSystem 通过 IntoSystem + Marker 泛型,在编译期将普通函数转为 System
- SystemParam 用 GAT
Item<'w, 's>分离持久 State 和临时借用 Item - all_tuples! 宏为 0-16 参数批量生成 trait 实现,超限用嵌套元组打包
- 20+ 种 SystemParam 覆盖查询、资源、命令、消息、本地状态等全部访问模式
- ExclusiveSystem 以 &mut World 独占执行,不参与并行
- CombinatorSystem 通过 Combine trait 组合系统的输入输出
至此,ECS 的核心三件套——Component/Storage、Archetype/Query、System/Param——已经完整呈现。下一章,我们将进入调度器(Schedule),看这些 System 如何被组织成有序的执行图。
第 9 章:Schedule — 系统编排与自动并行
导读:前两章我们看到了 System 如何从 World 中借取数据、Query 如何高效遍历。 但一个真实的游戏帧可能包含数百个 System——它们的执行顺序如何确定?哪些可以并行? 什么时候需要同步?本章将深入 Schedule 的依赖图构建、拓扑排序、运行时借用检查、 ApplyDeferred 自动插入,以及 FixedUpdate 追赶机制和 Stepping 调试支持。
9.1 Schedule 的核心结构
一个 Schedule 由三层组成:声明层(ScheduleGraph)、编译层(SystemSchedule)和执行层(SystemExecutor):
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/schedule.rs (简化) pub struct Schedule { label: InternedScheduleLabel, graph: ScheduleGraph, // declaration: systems + sets + dependencies executable: SystemSchedule, // compiled: topologically sorted arrays executor: Box<dyn SystemExecutor>, // runtime: single/multi-threaded executor_initialized: bool, } }
ScheduleGraph 存储用户声明的系统、集合和依赖关系——这是一个有向图。当 Schedule 第一次运行(或图发生变化)时,调用 initialize() 将图编译为 SystemSchedule——一组拓扑排序后的并行数组。最后 SystemExecutor 消费这些数组来实际执行系统。
为什么需要三层而不是一个扁平的系统列表?根本原因在于声明与执行的关注点分离。声明层允许用户以自然的方式表达意图——"A 在 B 之前"、"C 属于物理集合"——而不需要关心最终的执行顺序。编译层将这些松散的约束求解为一个确定的拓扑序列,同时预计算并行性所需的冲突矩阵。执行层则专注于运行时调度策略,例如单线程还是多线程。如果只有一个扁平列表,用户每次添加系统都必须手动指定在列表中的确切位置,这在系统数量达到数百个时根本不可行。三层架构让 Bevy 可以在不同层面独立演化——例如替换执行器(从单线程切换到多线程)而不影响声明层的 API,或者优化拓扑排序算法而不改变用户的声明方式。这种分层也使得增量编译成为可能:只有当声明层发生变化时才需要重新编译,而在大多数帧中,编译层的结果可以直接复用。
graph TD
subgraph 声明层
SG["ScheduleGraph<br/>· systems: IndexMap<SystemKey, ScheduleSystem><br/>· system_sets: IndexMap<SetKey, SystemSet><br/>· dependency: DiGraph (before/after 边)<br/>· hierarchy: DiGraph (in_set 边)"]
end
subgraph 编译层
SS["SystemSchedule<br/>· systems: Vec<SystemWithAccess> (按拓扑序)<br/>· system_dependencies: Vec<usize><br/>· system_dependents: Vec<Vec<usize>><br/>· system_conditions / set_conditions"]
end
subgraph 执行层
SE["SystemExecutor<br/>· MultiThreadedExecutor (默认)<br/>· SingleThreadedExecutor (WASM/无std)"]
end
SG -->|"initialize() → 拓扑排序"| SS
SS -->|"executor.run()"| SE
图 9-1: Schedule 三层架构
多个 Schedule 通过 Schedules 资源统一管理,以 ScheduleLabel 为键:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/schedule.rs #[derive(Default, Resource)] pub struct Schedules { inner: HashMap<InternedScheduleLabel, Schedule>, pub ignored_scheduling_ambiguities: BTreeSet<ComponentId>, // ... } }
这种三层架构的代价是首次运行时的编译开销——拓扑排序和冲突矩阵计算需要 O(V+E) 的时间复杂度,其中 V 是系统数量,E 是依赖边数量。然而,由于编译结果被缓存(executable 字段),这个开销只在 Schedule 首次运行或图结构发生变化时才产生。对于系统数量很多的 Schedule,这会带来一次性的初始化成本;但后续每帧的调度只是消费已编译数据,而不是重新排序整张图。如果 Bevy 采用传统的"每帧重新排序"策略,这个编译成本就会被重复支付。
要点:Schedule = ScheduleGraph(声明) + SystemSchedule(编译) + SystemExecutor(执行)。声明层是有向图,编译层是拓扑排序后的并行数组,执行层负责实际的并行/串行调度。
9.2 依赖图:before / after / chain
用户通过 before()、after() 和 chain() 声明系统之间的执行顺序约束:
#![allow(unused)] fn main() { schedule.add_systems(( apply_forces.after(read_input), read_input, render.after(apply_forces), )); // equivalent to: schedule.add_systems(( read_input, apply_forces, render, ).chain()); }
每个约束被转化为 Dependency 存储在 ScheduleGraph 的有向图中:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/graph/mod.rs (简化) pub struct Dependency { pub kind: DependencyKind, // Before | After pub set: InternedSystemSet, } }
chain() 则是对元组中相邻系统自动添加 before → after 约束的语法糖。它通过 ScheduleConfigs::Configs 的 Chain::Chained 模式实现,在图构建阶段展开为一系列 Dependency 边。
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/schedule.rs pub enum Chain { #[default] Unchained, Chained(TypeIdMap<Box<dyn Any>>), } }
Chained 变体中的 TypeIdMap 允许 chain 携带额外配置,例如 IgnoreDeferred 可以指示 ApplyDeferred 不在此 chain 的边上插入同步点。
依赖图的设计选择值得深思:为什么使用显式的 before/after 声明,而不是根据系统参数自动推断顺序?自动推断看似方便,但会导致系统间的隐式耦合——当你修改一个系统的参数时,可能无意间改变了整个 Schedule 的执行顺序。显式声明虽然需要更多代码,但让系统间的依赖关系清晰可见、可追踪、可调试。此外,并非所有逻辑顺序都能从数据访问模式中推断出来——例如"先读取输入再应用力"是一个语义约束,而非数据冲突。chain() 的存在则平衡了简洁性和显式性——对于明确需要串行的系统组,它比手动标注每对 before/after 关系简洁得多。
要点:before/after 声明转化为有向图中的边,chain() 是相邻系统自动添加 before→after 边的语法糖。
9.3 SystemSet:层级分组
SystemSet 是系统的标签分组机制,允许对一组系统统一配置依赖和条件:
#![allow(unused)] fn main() { #[derive(SystemSet, Clone, Debug, PartialEq, Eq, Hash)] enum PhysicsSet { Input, Simulation, Sync, } schedule.configure_sets(( PhysicsSet::Input, PhysicsSet::Simulation.after(PhysicsSet::Input), PhysicsSet::Sync.after(PhysicsSet::Simulation), )); schedule.add_systems(( read_input.in_set(PhysicsSet::Input), apply_forces.in_set(PhysicsSet::Simulation), sync_transforms.in_set(PhysicsSet::Sync), )); }
SystemSet 在 ScheduleGraph 中也是节点(NodeId::Set),与系统节点(NodeId::System)共存于同一个依赖图中。层级关系通过 hierarchy 有向图表达——in_set 创建从 Set 到 System 的层级边。
graph LR
subgraph 依赖图["依赖图 (dependency)"]
I["Input"] --> SIM["Simulation"] --> SY["Sync"]
end
subgraph 层级图["层级图 (hierarchy)"]
PI["PhysicsSet::Input"] --- RI["read_input"]
PS["PhysicsSet::Simulation"] --- AF["apply_forces"]
PY["PhysicsSet::Sync"] --- ST["sync_transforms"]
end
图 9-2: 依赖图与层级图的关系
当 Set 上配置了 run_if 条件时,该条件会传递给 Set 中的所有系统。如果 Set 的条件不满足,其中的所有系统都会被跳过。
Rust 设计亮点:SystemSet 使用
define_label!宏生成——这是 Bevy 标签系统的通用模式。 每个#[derive(SystemSet)]类型被 intern 为InternedSystemSet(一个指向全局分配的&dyn SystemSet的指针), 使得集合比较只需比较指针,而非完整的值。这种 interning 模式在 ScheduleLabel 中也同样使用。
SystemSet 解决了大型项目中的可组合性问题。想象一个物理引擎 Plugin 导出了 20 个系统——没有 SystemSet,其他 Plugin 要在"整个物理引擎之后"运行,就必须逐一列出 20 个系统的 after 依赖。SystemSet 让这 20 个系统归属于一个 PhysicsSet,外部只需声明 after(PhysicsSet) 即可。这种分组也使得条件执行高效化:在 Set 上配置 run_if(in_state(Playing)),Set 中的所有系统共享一次条件求值,而非每个系统独立求值。代价是增加了概念层次——对于小型项目,直接使用 before/after 可能比引入 SystemSet 更直接。但随着项目规模增长,SystemSet 的价值会越来越明显,这也是为什么 Bevy 自身的内置 Plugin 大量使用 SystemSet 来组织系统。
要点:SystemSet 将系统分组为逻辑集合,可以统一配置依赖和条件。Set 和 System 共存于同一个有向图中。
9.4 Run Conditions:条件执行
Run Condition 是一个返回 bool 的只读系统,控制关联的系统或 Set 是否在本帧执行:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/condition.rs pub type BoxedCondition<In = ()> = Box<dyn ReadOnlySystem<In = In, Out = bool>>; pub trait SystemCondition<Marker, In: SystemInput = ()>: IntoSystem<In, bool, Marker, System: ReadOnlySystem> { ... } }
常见的内置 Run Condition 包括:
| Condition | 含义 |
|---|---|
in_state(AppState::Playing) | 当前状态为 Playing 时执行 |
resource_changed::<R> | 资源 R 发生变更时执行 |
on_event::<E> | 有未处理的消息 E 时执行 |
run_once() | 只执行一次 |
Run Condition 可以通过 .and()、.or()、.not() 组合:
#![allow(unused)] fn main() { schedule.add_systems( update_ui .run_if(in_state(AppState::Playing).and(resource_changed::<Score>)), ); }
Run Condition 有一个重要的短路特性:and() 的第二个条件只在第一个为 true 时求值。这意味着如果第二个条件使用了变更检测或 Local<T>,它可能不会在每帧被求值,导致检测行为的微妙不一致。如果需要两个条件都始终求值,使用 and_eager() 替代。
Run Condition 的设计选择了"返回 bool 的 System"而非"独立的条件对象",这意味着条件可以访问任何 ECS 数据——Resource、Query、Local 状态等——与普通 System 完全一致。这种统一性避免了为条件系统引入独立的 API 和学习成本。ReadOnlySystem 约束确保条件求值不会修改 World 状态——条件只是"观察者",不能产生副作用。这是 Rust 类型系统在调度层面的又一次应用:编译器保证条件不会意外地触发变更,调度器可以安全地在并行系统之间求值条件而无需额外同步。如果条件可以修改 World,调度器就必须将条件求值也纳入冲突检测,显著增加调度复杂度。
要点:Run Condition = 返回 bool 的 ReadOnlySystem。注意 and() 的短路行为可能影响变更检测。
9.5 Executor:拓扑排序与并行执行
拓扑排序
当 Schedule::initialize() 被调用时,ScheduleGraph 的依赖图被拓扑排序,生成一个线性执行序列存储在 SystemSchedule 中:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/executor/mod.rs (简化) pub struct SystemSchedule { pub(super) system_ids: Vec<SystemKey>, pub(super) systems: Vec<SystemWithAccess>, pub(super) system_dependencies: Vec<usize>, // predecessor count pub(super) system_dependents: Vec<Vec<usize>>, // successor list pub(super) system_conditions: Vec<Vec<ConditionWithAccess>>, pub(super) set_conditions: Vec<Vec<ConditionWithAccess>>, // ... } }
system_dependencies[i] 存储系统 i 的前驱依赖数量,system_dependents[i] 存储依赖系统 i 的后继系统列表。这是经典的 Kahn 算法所需的数据结构。
为什么选择拓扑排序而非其他调度策略?拓扑排序是有向无环图(DAG)的天然排序方式,它保证了一个关键不变量:如果 A 依赖 B,则 B 在排序结果中总是出现在 A 之前。相比之下,优先级调度(priority scheduling)需要用户手动指定优先级数值,在系统数量增长时极易冲突;时间片轮转(round-robin)则完全忽略了系统间的数据依赖关系。拓扑排序的另一个优势是它天然支持并行——拓扑序中没有前驱的系统可以同时执行,这正是 MultiThreadedExecutor 利用的性质。Kahn 算法的前驱计数数组可以在运行时被直接复用为"剩余依赖计数",当系统完成时递减后继的计数——计数归零的系统立即变为可执行。这种数据结构上的一致性使得编译期和运行期的代码可以自然地衔接,无需额外的转换步骤。
MultiThreadedExecutor
默认的 MultiThreadedExecutor 在运行时使用依赖计数 + 冲突检测来实现最大并行:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/executor/multi_threaded.rs (简化) pub struct MultiThreadedExecutor { state: Mutex<ExecutorState>, system_completion: ConcurrentQueue<SystemResult>, apply_final_deferred: bool, // ... } pub struct ExecutorState { system_task_metadata: Vec<SystemTaskMetadata>, num_dependencies_remaining: Vec<usize>, // decremented at runtime ready_systems: FixedBitSet, // dependencies satisfied running_systems: FixedBitSet, // currently executing completed_systems: FixedBitSet, // finished unapplied_systems: FixedBitSet, // run but deferred not applied // ... } }
执行流程如下:
graph TD
S1["1. 初始化<br/>将所有 dependencies=0 的系统<br/>放入 ready_systems"]
S2["2. 循环: 从 ready_systems 中选取可执行系统<br/>· 检查 can_run(): 资源访问冲突检测<br/>· 检查 should_run(): Run Condition 求值<br/>· 通过则 spawn 到 ComputeTaskPool 线程池"]
S3["3. 系统完成后:<br/>· 递减后继系统的 dependencies_remaining<br/>· remaining == 0 → 加入 ready<br/>· 如果是 ApplyDeferred → flush commands"]
S4{"4. completed_systems<br/>== 全部系统?"}
S1 --> S2 --> S3 --> S4
S4 -->|"否"| S2
S4 -->|"是"| DONE["执行完成"]
图 9-3: MultiThreadedExecutor 执行流程
运行时借用检查(冲突检测)
can_run() 方法是调度器的核心安全保障。它在运行时检查待执行系统与当前正在执行的系统之间是否存在资源访问冲突:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/executor/multi_threaded.rs (简化) fn can_run(&mut self, system_index: usize, conditions: &mut Conditions) -> bool { let system_meta = &self.system_task_metadata[system_index]; // exclusive system: must be the only running system if system_meta.is_exclusive && self.num_running_systems > 0 { return false; } // non-Send system: only one can run at a time (on main thread) if !system_meta.is_send && self.local_thread_running { return false; } // check component access conflicts with running systems if !system_meta.conflicting_systems.is_disjoint(&self.running_systems) { return false; } true } }
在 init() 阶段,执行器预计算每对系统之间的冲突关系(conflicting_systems 位集),基于它们的 FilteredAccessSet:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/executor/multi_threaded.rs // pre-compute conflict matrix during init for index1 in 0..sys_count { for index2 in 0..index1 { if !schedule.systems[index2].access .is_compatible(&schedule.systems[index1].access) { state.system_task_metadata[index1].conflicting_systems.insert(index2); state.system_task_metadata[index2].conflicting_systems.insert(index1); } } } }
冲突规则遵循 Rust 的借用规则:
| 系统 A 访问 | 系统 B 访问 | 是否冲突 | 说明 |
|---|---|---|---|
&T | &T | 无 | 多个不可变引用可共存 |
&T | &mut T | 冲突 | 读写互斥 |
&mut T | &mut T | 冲突 | 多个可变引用不允许 |
Res<A> | Res<B> | 无 | 不同资源,不冲突 |
Res<A> | ResMut<A> | 冲突 | 同一资源,读写互斥 |
Rust 设计亮点:Bevy 的并行调度器本质上是一个运行时借用检查器。编译期的借用规则 保证了单系统内部的安全性,而调度器将这一保证扩展到了系统间。通过
FilteredAccessSet::is_compatible()在初始化阶段预计算冲突矩阵(FixedBitSet), 运行时只需做一次位集is_disjoint检查——O(n/64) 的操作——就能决定系统是否可以并行。
冲突矩阵的预计算开销是 O(n²),其中 n 是系统数量。对于 500 个系统的 Schedule,这意味着约 125,000 次兼容性检查。这看起来不少,但每次检查本质上是两个 FilteredAccessSet 的集合操作,且只在初始化时执行一次。运行时的收益则是直接的:判断一个系统能否与当前所有正在运行的系统并行,只需一次 FixedBitSet::is_disjoint 操作——对于 500 个系统,也只是对少量位集槽位做按位比较。如果没有这个预计算矩阵,运行时就需要对每个候选系统与所有正在运行的系统逐一做 FilteredAccessSet 兼容性检查,调度器的热点路径会明显变重。
值得注意的是,冲突检测是保守的——它可能会将两个实际不冲突的系统标记为冲突(例如两个系统查询不同的 Archetype),但绝不会将冲突的系统标记为安全。这种保守策略保证了正确性,代价是某些并行机会被错过。Bevy 通过 FilteredAccess 中的 with/without 过滤信息来减少误报,但无法完全消除。如果用户需要更精确的控制,可以通过 ambiguity_detection 工具发现这些"假冲突"并手动排序。
要点:执行器预计算系统间的冲突矩阵,运行时通过位集操作实现 O(n/64) 的并行安全检查。读读并行,读写和写写串行。
9.6 ApplyDeferred 自动插入
Commands(第 11 章详述)产生的结构性变更需要在同步点被应用。AutoInsertApplyDeferredPass 是一个 ScheduleBuildPass,在图构建阶段自动在需要的依赖边上插入 ApplyDeferred 同步节点:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/auto_insert_apply_deferred.rs (简化) pub struct AutoInsertApplyDeferredPass { no_sync_edges: BTreeSet<(NodeId, NodeId)>, // edges to skip auto_sync_node_ids: HashMap<u32, SystemKey>, // distance → sync node } }
插入逻辑基于"距离"(distance)计算。从图的起点开始,沿拓扑序遍历,维护每个节点距离上一个同步点的"距离"。当一个 has_deferred() 为 true 的系统被发现,并且它的某个后继有直接依赖关系时,就在该边上插入一个 ApplyDeferred 节点:
graph LR
subgraph 自动插入前
A1["A (Commands)"] --> B1["B"]
end
subgraph 自动插入后
A2["A (Commands)"] --> AD["ApplyDeferred<br/>自动插入的同步点<br/>确保 A 的 Commands<br/>在 B 运行前被应用"]
AD --> B2["B"]
end
图 9-4: ApplyDeferred 自动插入示意
可以通过 IgnoreDeferred 配置选项跳过特定边的同步点插入:
#![allow(unused)] fn main() { // skip sync point on this specific chain schedule.add_systems( (system_a, system_b).chain().chain_ignore_deferred(), ); }
也可以全局关闭自动插入:
#![allow(unused)] fn main() { schedule.set_build_settings(ScheduleBuildSettings { auto_insert_apply_deferred: false, ..default() }); }
自动插入同步点的设计体现了 Bevy 在"正确性优先"与"性能优化"之间的平衡。自动插入保证了正确性——如果系统 A 用 Commands 创建了一个实体,系统 B 依赖 A 并想查询这个新实体,同步点确保 B 运行时新实体已经存在。但同步点有性能代价:它是一个全局屏障(barrier),所有系统必须等待同步完成后才能继续。在一个高度并行的 Schedule 中,过多的同步点会严重限制并行度。这就是 IgnoreDeferred 和 chain_ignore_deferred() 存在的原因——当用户明确知道 B 不需要 A 的 Commands 结果时,可以手动跳过同步点,恢复并行执行。如果 Bevy 不提供自动插入,用户就必须手动分析每对依赖系统之间是否需要同步点——这在系统数量增长时极易出错。自动插入 + 手动跳过的策略让"默认安全,按需优化"成为可能。
要点:AutoInsertApplyDeferredPass 在构建阶段自动在 has_deferred 系统的依赖边上插入同步点,确保 Commands 的结构性变更在被依赖系统运行前完成应用。
9.7 FixedUpdate 追赶机制
物理模拟等需要固定时间步长的系统使用 FixedUpdate Schedule。它嵌入在 Main Schedule 中,以固定间隔运行,并具有追赶 (catch-up) 机制。
当一帧的实际耗时超过了固定步长时间,FixedUpdate 会连续运行多次以"追赶"模拟时间。例如,如果固定步长为 16ms 而上一帧耗时 48ms,FixedUpdate 会在本帧执行 3 次。
这种机制确保物理模拟的确定性:无论帧率如何波动,模拟的时间步长始终一致。
graph LR
F["First"] --> PU["PreUpdate"] --> ST["StateTransition<br/>(默认 feature 集启用)"] --> RFML["RunFixedMainLoop"]
subgraph Fixed["× N 次 (追赶)"]
FPU["FixedPreUpdate"] --> FU["FixedUpdate"] --> FPOST["FixedPostUpdate"]
end
RFML --> FPU
RFML --> U["Update"] --> SS["SpawnScene"] --> POST["PostUpdate"] --> L["Last"]
N = floor(accumulated_time / fixed_timestep) accumulated_time -= N * fixed_timestep 如果 N = 0, FixedUpdate 不运行 (帧太快) 如果 N > 1, FixedUpdate 多次运行 (帧太慢)
图 9-5: FixedUpdate 嵌入 Main Schedule 的追赶机制
为什么选择追赶(catch-up)而非跳过(skip)或插值(interpolation)?跳过策略会丢失模拟步骤,导致物理穿透等问题——想象一个子弹以每步 1 米的速度飞行,如果跳过 3 步,子弹会瞬间跳过 3 米,可能穿过薄墙。追赶策略确保每一步都被完整模拟,碰撞检测不会遗漏。插值策略可以产生更平滑的视觉效果,但增加了一帧的延迟——显示的是上一步和当前步之间的插值,而非最新状态。Bevy 选择追赶作为默认策略,同时在视觉层面(渲染)支持用户自行实现插值。
追赶机制的潜在风险是"死亡螺旋"(death spiral):如果一帧耗时过长导致需要追赶很多步,这些额外步骤本身又会延长帧时间,形成恶性循环。Bevy 通过 Time<Fixed> 资源的 max_delta 参数限制单帧最大追赶次数来防止这种情况。在实际项目中,如果 FixedUpdate 中的系统过于昂贵,考虑使用更大的时间步长或将部分计算移到 Update 中。FixedUpdate 与第 10 章的变更检测深度关联——在 FixedUpdate 中运行的系统仍然可以使用 Changed<T> 和 Added<T>,但由于单帧内可能执行多次,变更检测的行为需要特别注意:第一次执行可以检测到 Update 阶段的变更,但后续的追赶执行只能检测到前一次 FixedUpdate 步骤中的变更。
要点:FixedUpdate 以固定时间步长运行,通过追赶机制保证模拟确定性。一帧中可能执行 0 到多次。
9.8 Stepping:调试支持
Stepping 资源(需启用 bevy_debug_stepping feature)提供了系统级的单步调试能力:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/stepping.rs (简化) #[derive(Resource, Default)] pub struct Stepping { schedule_states: HashMap<InternedScheduleLabel, ScheduleState>, schedule_order: Vec<InternedScheduleLabel>, // ... } }
Stepping 支持四种动作模式:
| 模式 | 行为 |
|---|---|
RunAll | Stepping 未启用,正常运行所有系统 |
Waiting | Stepping 已启用,只运行标记为 AlwaysRun 的系统 |
Step | 执行下一个系统,然后回到 Waiting |
Continue | 连续执行直到遇到 Break 标记的系统或帧结束 |
每个系统可以独立配置为以下行为之一:
| 行为 | Waiting 时 | Step 时 | Continue 时 |
|---|---|---|---|
AlwaysRun | 运行 | 运行 | 运行 |
NeverRun | 跳过 | 跳过 | 跳过 |
Break | 跳过 | 单步 | 在此处停下 |
Continue | 跳过 | 单步 | 运行 |
Stepping 通过在 Schedule::run() 中传入 skip_systems 位集来实现——执行器会跳过被标记的系统:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/schedule.rs pub fn run(&mut self, world: &mut World) { // ... #[cfg(feature = "bevy_debug_stepping")] { let skip_systems = match world.get_resource_mut::<Stepping>() { None => None, Some(mut stepping) => stepping.skipped_systems(self), }; self.executor.run( &mut self.executable, world, skip_systems.as_ref(), error_handler, ); } } }
Rust 设计亮点:Stepping 通过
cfg(feature)条件编译实现零成本抽象——在 release 构建中 完全不存在 Stepping 相关代码。调试功能作为可选 feature,不会给生产构建增加任何开销。 实现上仅传入一个Option<&FixedBitSet>给执行器,对执行器的核心逻辑侵入最小。
要点:Stepping 提供系统级的单步执行和断点支持,通过控制 skip_systems 位集实现,不侵入执行器逻辑。
本章小结
本章我们深入了 Bevy 的系统调度机制:
- Schedule 三层架构:声明层(ScheduleGraph)→ 编译层(SystemSchedule)→ 执行层(SystemExecutor)
- 依赖图:
before/after/chain转化为有向图的边,拓扑排序生成线性执行序列 - SystemSet:层级分组机制,统一配置依赖和条件
- Run Conditions:返回 bool 的只读系统,控制执行与否,注意短路行为
- MultiThreadedExecutor:预计算冲突矩阵,运行时位集检查实现 O(n/64) 并行安全
- 运行时借用检查:读读并行、读写串行——将 Rust 编译期的借用规则扩展到系统间
- ApplyDeferred:自动在有 deferred 参数的依赖边上插入同步点
- FixedUpdate:固定时间步长 + 追赶机制,保证模拟确定性
- Stepping:调试用的单步执行和断点机制
下一章,我们将深入变更检测机制——Schedule 在每帧推进全局 Tick,而变更检测正是基于这个 Tick 来判断数据是否发生了变化。
第 10 章:变更检测 — 零成本追踪
导读:上一章我们看到 Schedule 在每帧推进全局 Tick。本章将深入这个 Tick 机制: 它如何驱动变更检测,使
Added<T>和Changed<T>过滤器成为可能;Mut<T>的DerefMut如何自动标记变更;以及 Tick 溢出保护如何确保长时间运行的应用不会产生 误报。最后,我们将看到变更检测如何被 Transform、UI、Render 等子系统广泛使用, 成为"只在变化时工作"哲学的基石。
10.1 Tick:单调递增的变更时钟
Bevy 的变更检测基于一个全局单调递增的 Tick 计数器:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/tick.rs (简化) #[derive(Copy, Clone, Default, Debug)] pub struct Tick { tick: u32, } }
World 维护一个 AtomicU32 类型的全局 change tick,每次 Schedule 运行时递增:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/mod.rs (简化) pub struct World { pub(crate) change_tick: AtomicU32, pub(crate) last_change_tick: Tick, pub(crate) last_check_tick: Tick, // ... } }
每个组件实例携带两个 Tick:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/tick.rs pub struct ComponentTicks { pub added: Tick, // tick when the component was added pub changed: Tick, // tick when the component was last changed } }
判定逻辑的核心在 Tick::is_newer_than():
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/tick.rs impl Tick { pub fn is_newer_than(self, last_run: Tick, this_run: Tick) -> bool { let ticks_since_insert = this_run.relative_to(self).tick.min(MAX_CHANGE_AGE); let ticks_since_system = this_run.relative_to(last_run).tick.min(MAX_CHANGE_AGE); ticks_since_system > ticks_since_insert } } }
整个判定逻辑可以用时间线来理解:
sequenceDiagram
participant T as 时间轴
Note over T: component.changed
Note over T: ...
Note over T: last_run (系统上次运行)
Note over T: ...
Note over T: this_run (系统本次运行)
rect rgb(200, 255, 200)
Note right of T: ticks_since_system > ticks_since_insert<br/>→ 变更在上次运行之后<br/>→ is_changed() = true
end
rect rgb(255, 200, 200)
Note right of T: ticks_since_system <= ticks_since_insert<br/>→ 变更在上次运行之前<br/>→ is_changed() = false
end
图 10-1: Tick 判定时序图
为什么使用 u32 计数器而非时间戳?时间戳(如 Instant)虽然提供了精确的时间信息,但有几个关键缺陷。首先,获取系统时间需要系统调用,在 WASM 等平台上可能不可用或代价高昂。其次,时间戳比较需要考虑时钟精度和单调性问题——不同平台的时钟分辨率不同,甚至可能回退。最重要的是,变更检测不关心"变更发生在何时",只关心"变更是否发生在我上次运行之后"——这是一个纯粹的顺序关系,不是时间关系。u32 计数器完美地表达了这种顺序语义,每次递增只需一次原子操作,比较只需整数减法。每个组件实例只占用 8 字节(两个 u32 Tick),在百万实体的场景中,这个存储开销相比时间戳的 16 字节(两个 u64/Instant)节省了显著的内存和缓存行。
使用 AtomicU32 存储全局 change tick 是因为多个系统可能并行递增它——每个系统在开始运行时读取当前 tick 作为 this_run,Schedule 在运行前递增全局 tick。原子操作保证了多线程环境下 tick 值的一致性,而无需使用更重的锁机制。
要点:变更检测基于 u32 Tick 的相对差值判定:如果组件变更发生在系统上次运行之后,则视为"已变更"。
10.2 ComponentTicks:added + changed 双追踪
每个组件实例在 Column 中存储两个并行的 Tick 数组(第 5 章已介绍):
Column (Position 类型)
data: [Pos₀] [Pos₁] [Pos₂] ← component values
added_ticks: [T=10] [T=25] [T=30] ← when added
changed_ticks: [T=10] [T=42] [T=30] ← when last changed
added_ticks:组件被首次插入实体时设置,之后不再变化changed_ticks:每次组件被可变访问时更新为当前 Tick
ComponentTicks 提供两个判定方法:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/tick.rs impl ComponentTicks { pub fn is_added(&self, last_run: Tick, this_run: Tick) -> bool { self.added.is_newer_than(last_run, this_run) } pub fn is_changed(&self, last_run: Tick, this_run: Tick) -> bool { self.changed.is_newer_than(last_run, this_run) } } }
这两个判定驱动了 Query 过滤器 Added<T> 和 Changed<T>:
Added<T>:只匹配在上次系统运行后 新添加 的组件Changed<T>:匹配在上次系统运行后 被修改或新添加 的组件
双 Tick 设计的取舍值得关注。将 added 和 changed 分开存储意味着每个组件实例多占 4 字节。对于拥有 100 万个实体、每个实体 10 个组件的大型场景,这额外增加了约 40MB 内存。Bevy 认为这个代价是值得的,因为 Added<T> 过滤器在实际开发中极为常用——初始化系统经常需要在组件首次出现时执行设置逻辑,如果没有 Added<T>,用户就需要自行维护一个 "已初始化" 标志组件,反而增加了更多内存和复杂度。Changed<T> 包含了 Added<T> 的情况——新添加的组件同时被视为"已变更"——这简化了许多系统的逻辑,避免了 Or<(Added<T>, Changed<T>)> 这样的冗余写法。
要点:每个组件实例携带 added + changed 两个 Tick,分别驱动 Added<T> 和 Changed<T> 过滤器。
10.3 Ref<T> 与 Mut<T>:变更检测包装器
Ref<T>:只读访问 + 变更查询
Ref<T> 是不可变引用的变更检测包装器:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/params.rs pub struct Ref<'w, T: ?Sized> { pub(crate) value: &'w T, pub(crate) ticks: ComponentTicksRef<'w>, } }
它实现了 DetectChanges trait,允许查询变更信息而不触发变更标记:
#![allow(unused)] fn main() { fn system(query: Query<Ref<Transform>>) { for transform_ref in &query { if transform_ref.is_changed() { // transform was modified since last system run } if transform_ref.is_added() { // transform was just added } } } }
Mut<T>:可变访问 + 自动标记
Mut<T> 是 Bevy 变更检测的核心——它在 DerefMut 中自动更新 changed tick:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/params.rs pub struct Mut<'w, T: ?Sized> { pub(crate) value: &'w mut T, pub(crate) ticks: ComponentTicksMut<'w>, } }
关键在于 DerefMut 的实现:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/params.rs (通过 macro 生成) impl<'w, T: ?Sized> DerefMut for Mut<'w, T> { fn deref_mut(&mut self) -> &mut Self::Target { self.set_changed(); // automatically mark as changed! self.value } } }
Mut<T> 的 DerefMut 自动标记流程
┌──────────────────┐ ┌──────────────────────────┐
│ fn system( │ │ Mut<T>::deref_mut() │
│ mut q: Query< │ ──→ │ 1. set_changed() │
│ &mut Transform│ │ changed_tick = now │
│ >) │ │ 2. return &mut T │
│ { │ └──────────────────────────┘
│ for mut t in &q │
│ { │ 只要对 Mut<T> 调用 *t = ...
│ t.translation │ 就会触发 DerefMut,
│ .x += 1.0; │ 自动标记 changed
│ } │
│ } │
└──────────────────┘
图 10-2: Mut<T> 的 DerefMut 自动标记流程
如果你只需要读取值而不想触发变更标记,可以通过 Deref(&*mut_ref)只读访问底层数据。如果你确实需要修改数据但不想标记变更(例如避免无限递归的同步),可以使用 bypass_change_detection():
#![allow(unused)] fn main() { fn system(mut query: Query<&mut Transform>) { for mut transform in &mut query { // read without triggering change: use Deref let current = transform.translation; // write without triggering change: bypass transform.bypass_change_detection().translation = current; } } }
Rust 设计亮点:Bevy 巧妙地利用了 Rust 的
Deref/DerefMuttrait 分层。 只读访问走Deref,不修改 tick;可变访问走DerefMut,自动标记变更。 用户无需手动调用任何"标脏"函数——语言层面的可变性语义自动驱动变更检测。 这是 Rust 类型系统与 ECS 变更追踪的完美结合。
这种自动标记机制存在一个重要的注意事项:假阳性(false positive)。当系统获取了 &mut T 但实际上没有修改值时(例如读取值后决定不更改),DerefMut 仍然会标记变更。这意味着下游依赖 Changed<T> 的系统会被不必要地触发。在性能敏感的场景中,这可能导致级联的冗余计算——例如 Transform 传播系统会重新计算一个实际没有移动的实体的 GlobalTransform。解决方案是在可能不修改的情况下先通过 Deref(只读解引用)检查值,确认需要修改后再触发 DerefMut。另一方面,Bevy 的变更检测不存在假阴性(false negative)——如果值确实被修改了,它一定会被检测到,因为 DerefMut 是获取可变引用的唯一途径。这种"宁可多报,不可漏报"的策略保证了依赖变更检测的系统的正确性,虽然可能带来一些不必要的工作。
要点:Mut<T> 在 DerefMut 中自动设置 changed_tick,用户的可变访问自动触发变更标记,无需手动操作。
10.4 Tick 溢出保护
Tick 使用 u32 存储,理论上会在约 42 亿次递增后溢出。Bevy 通过两个常量和定期扫描来防止溢出导致的误报:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/mod.rs // ~1 hour at 1000 ticks/frame × 144 fps pub const CHECK_TICK_THRESHOLD: u32 = 518_400_000; // maximum age before change detection stops working pub const MAX_CHANGE_AGE: u32 = u32::MAX - (2 * CHECK_TICK_THRESHOLD - 1); }
CHECK_TICK_THRESHOLD 约等于 144fps 运行 1 小时的 tick 数。World 在每次 Schedule::run() 时检查是否需要扫描:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/mod.rs (简化) pub fn check_change_ticks(&mut self) -> Option<CheckChangeTicks> { let change_tick = self.read_change_tick(); if change_tick.relative_to(self.last_check_tick).get() < CHECK_TICK_THRESHOLD { return None; // not enough ticks since last scan } self.last_check_tick = change_tick; // clamp all component ticks older than MAX_CHANGE_AGE // ... } }
当扫描触发时,所有超过 MAX_CHANGE_AGE 的组件 tick 被钳位到 MAX_CHANGE_AGE:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/tick.rs pub fn check_tick(&mut self, check: CheckChangeTicks) -> bool { let age = check.present_tick().relative_to(*self); if age.get() > Self::MAX.get() { *self = check.present_tick().relative_to(Self::MAX); true // tick was clamped } else { false } } }
is_newer_than() 中也对差值做了 min(MAX_CHANGE_AGE) 钳位,确保即使在两次扫描之间发生溢出,判定结果也是确定性的:
溢出保护机制
正常情况: 组件变更在 MAX_CHANGE_AGE 以内
┌────────────────────────────────────────┐
│ changed_tick ──── ... ──── this_run │
│ diff < MAX_CHANGE_AGE → 正常判定 │
└────────────────────────────────────────┘
超时情况: 组件变更超过 MAX_CHANGE_AGE
┌────────────────────────────────────────┐
│ changed_tick ── ... (太久) ── this_run │
│ diff > MAX_CHANGE_AGE │
│ → 被 min() 钳位到 MAX_CHANGE_AGE │
│ → is_changed() 返回 false │
│ → 变更"过期",不再被检测到 │
└────────────────────────────────────────┘
定期扫描: 将过旧的 tick 钳位
┌────────────────────────────────────────┐
│ 每 CHECK_TICK_THRESHOLD 次递增后扫描 │
│ 钳位所有 > MAX_CHANGE_AGE 的 tick │
│ → 防止 u32 wrapping 导致误判 │
└────────────────────────────────────────┘
图 10-3: Tick 溢出保护机制
Rust 设计亮点:Bevy 使用
wrapping_sub进行 Tick 差值计算,使得 u32 环绕不会导致错误。 关键洞察是:World 的 change_tick 始终是"最新的",只要组件 tick 和系统 tick 不比 change_tick 老超过u32::MAX(通过定期扫描保证),wrapping 差值就始终是正确的。CheckChangeTicks事件还允许用户的自定义数据结构参与扫描周期。
溢出保护是 u32 Tick 设计的直接后果。Bevy 没有通过更宽的整数类型来回避环绕,而是通过 CHECK_TICK_THRESHOLD 和 MAX_CHANGE_AGE 控制何时触发检查,以及旧 Tick 最多能保留到什么范围。达到阈值后,World 会遍历组件的 change ticks,把过旧的值钳位到安全区间。这样做的取舍很明确:把常态成本留在紧凑的每组件 Tick 存储上,只在阈值达到后支付一次全局检查成本。这种设计体现了 Bevy 在内存效率与偶发维护开销之间的权衡。
要点:Tick 溢出保护通过定期扫描 + 钳位实现。超过 MAX_CHANGE_AGE 的变更不再被检测到,但不会产生误报。
10.5 变更检测在子系统中的应用
变更检测贯穿 Bevy 引擎的几乎所有子系统,是"只在变化时工作"设计哲学的核心。以下是几个关键的应用场景:
Transform 传播
Transform → GlobalTransform 的传播只在 Transform 发生变更时重新计算:
#![allow(unused)] fn main() { // transform propagation only runs for Changed<Transform> fn propagate_transforms( query: Query<(&Transform, &mut GlobalTransform, &Children), Changed<Transform>>, ) { ... } }
在一个有 10 万个实体的场景中,如果只有 100 个发生了移动,传播系统只需处理 100 个而非 10 万个。
UI 布局
UI 布局计算(Taffy/Flexbox)会在节点的布局属性变更时重新计算。当前这些属性直接存放在 Node 组件上:
#![allow(unused)] fn main() { // UI layout only recalculates when node layout data changes fn ui_layout_system( changed_nodes: Query<(Entity, &Node), Changed<Node>>, ) { ... } }
渲染提取
渲染系统通过 Changed<T> 只提取发生变化的材质、网格和光照数据,避免每帧完整重传 GPU 资源:
#![allow(unused)] fn main() { // only extract materials that changed fn extract_materials( materials: Query<(&Handle<Material>, &MeshMaterial), Changed<MeshMaterial>>, ) { ... } }
PBR 光照
PBR 管线中,只有变更的光源才需要重新计算阴影贴图和光源参数。
| 子系统 | 使用的过滤器 | 优化效果 |
|---|---|---|
| Transform 传播 | Changed<Transform> | 只传播移动的实体 |
| UI 布局 | Changed<Node> | 只重算布局属性变化的节点 |
| 渲染提取 | Changed<Material> | 只重传变化的资源 |
| PBR 光照 | Changed<Light> | 只更新变化的光源 |
| Visibility | Changed<Visibility> | 只重算变化的可见性 |
变更检测的规模效应尤为显著。如果一个大场景中每帧只有少量实体的 Transform 发生变化,Changed<Transform> 会让传播系统把工作集中在真正发生变化的那部分实体上,而不是无差别扫描所有匹配项。然而,Changed<T> 过滤器本身也有开销:它需要检查每个匹配 Archetype 中实体的 changed_tick。当变更比例接近 100% 时(例如所有实体每帧都在移动),使用 Changed<T> 反而可能不如直接处理整批数据划算,因为多了一层 Tick 比较。因此,变更检测最适合"稀疏变更"场景——大量静态实体中少量发生变化。对于每帧必然变更的数据(如帧计数器),使用变更检测过滤器反而是一种浪费。这种性能特征与第 15 章的 Transform 传播系统直接相关:静态场景中的大量不变实体是变更检测最大的受益者。
要点:变更检测是 Bevy "只在变化时工作"设计哲学的基石,广泛应用于 Transform、UI、Render、PBR 等子系统,实现了"无变化零成本"的性能特性。
本章小结
本章我们深入了 Bevy 的变更检测机制:
- Tick 是 u32 单调递增计数器,World 每帧递增,通过
AtomicU32保证多线程安全 - ComponentTicks 包含 added + changed 两个 Tick,驱动
Added<T>和Changed<T>过滤器 - Ref<T> 提供只读访问 + 变更查询,不触发变更标记
- Mut<T> 通过
DerefMut自动标记变更——语言层面的可变性语义驱动变更追踪 - 溢出保护 通过
CHECK_TICK_THRESHOLD定期扫描和MAX_CHANGE_AGE钳位实现 - 子系统应用 覆盖 Transform、UI、Render、PBR——"无变化零成本"
下一章,我们将看到 Commands 延迟执行模型——为什么系统在运行时不能直接修改 World,以及 Commands 如何与 ApplyDeferred 同步点配合工作。
第 11 章:Commands — 延迟执行
导读:上一章我们看到变更检测如何追踪组件的修改。但有些操作——比如 spawn/despawn 实体、insert/remove 组件——会改变 Archetype 的结构,导致迭代器失效。这些操作不能 在系统并行执行时直接进行。Commands 正是解决这一问题的延迟执行机制:系统收集命令, 在 ApplyDeferred 同步点统一执行。本章将深入 CommandQueue 的实现、内置与自定义 Command,以及同步点的工作方式。
11.1 为什么需要延迟执行?
考虑以下场景:系统 A 正在遍历所有拥有 Health 组件的实体,同时系统 B 想要为某个实体添加 Health 组件。如果 B 直接修改 World:
- 添加
Health会导致该实体从一个 Archetype 迁移到另一个 - Archetype 中的 Table 会被重新分配(swap_remove + push)
- 系统 A 正在遍历的 Table 被修改——迭代器失效
这与 Rust 中 Vec 遍历时不能插入/删除元素是同一类问题。Rust 的借用规则通过编译器阻止了这种情况,但在 ECS 中,系统 A 和 B 可能并行运行在不同线程上。
graph TD
subgraph 问题["迭代器失效问题"]
PA["System A: 遍历 Query<&Health>"]
PB["System B: insert Health<br/>导致 Archetype 迁移"]
PX["Table 被修改!迭代器失效!"]
PA --> PX
PB --> PX
end
subgraph 方案["解决方案: Commands 延迟执行"]
SA["System A: 遍历 Query<&Health> (安全)"]
SB["System B: commands.entity(e).insert(Health)<br/>→ 命令入队,不修改 World"]
SD["ApplyDeferred: 统一执行所有命令<br/>→ 此时没有系统在运行,修改安全"]
SA --> SD
SB --> SD
end
图 11-1: 迭代器失效问题与 Commands 延迟方案
让我们具体走过一个迭代器失效的场景。假设系统 A 正在遍历 Archetype [Position, Velocity] 的 Table,当前正在访问第 3 行(索引 2)。此时系统 B 为索引 5 的实体添加了 Health 组件——该实体需要从 Archetype [Position, Velocity] 迁移到 [Position, Velocity, Health]。迁移的过程是:从旧 Table 的索引 5 处 swap_remove 该实体的数据(用最后一行填补空缺),然后将数据 push 到新 Table。如果旧 Table 有 8 行,swap_remove 后变为 7 行——但系统 A 可能仍然认为 Table 有 8 行,会尝试访问已被移走的数据,导致未定义行为。更糟糕的是,如果系统 A 恰好在遍历到索引 5 时发生 swap_remove,它会跳过被交换过来的那个实体(原来在末尾的实体),导致逻辑遗漏。这与 Rust 标准库中 Vec::iter() 期间调用 Vec::push() 导致迭代器失效是完全相同的问题,只是发生在不同线程、不同系统之间。
这种延迟执行的代价是可见性延迟——系统 A 用 Commands 创建的实体,在同一帧内的系统 B 中还不可见(除非 A 和 B 之间有 ApplyDeferred 同步点)。这是一个有意识的设计取舍:用可见性延迟换取并行安全性。与第 9 章的 ApplyDeferred 自动插入机制配合,Bevy 尽可能在需要的位置插入同步点,但在不需要的位置保持最大并行度。
要点:结构性变更(spawn/despawn/insert/remove)会导致 Archetype 迁移和 Table 重分配,不能在系统并行执行时进行。Commands 将这些操作延迟到同步点执行。
11.2 CommandQueue:异构命令的密集存储
CommandQueue 是一个类型擦除的异构命令缓冲区,底层使用 Vec<MaybeUninit<u8>> 而非 Vec<Box<dyn Command>> 来最大化性能:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/command_queue.rs (简化) pub struct CommandQueue { pub(crate) bytes: Vec<MaybeUninit<u8>>, // dense command storage pub(crate) cursor: usize, // read position pub(crate) panic_recovery: Vec<MaybeUninit<u8>>, pub(crate) caller: MaybeLocation, } }
每个命令在缓冲区中以 CommandMeta + 命令数据 的方式紧密排列:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/command_queue.rs struct CommandMeta { // function pointer: consume the command, optionally apply to world consume_command_and_get_size: unsafe fn(value: OwningPtr<Unaligned>, world: Option<NonNull<World>>, cursor: &mut usize), } }
CommandQueue 内存布局
bytes: ┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
│ Meta₀ │ Spawn(..)│ Meta₁ │Insert(..)│ Meta₂ │Despawn(..)│
│ fn ptr │ cmd data │ fn ptr │ cmd data │ fn ptr │ cmd data │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘
← dense, no heap allocation per command →
cursor: ─────→ 指向下一个要执行的命令
对比 Vec<Box<dyn Command>>:
┌───┬───┬───┐
│ → │ → │ → │ 每个 Box 都是一次堆分配
└─│─┴─│─┴─│─┘ 指针间接访问,缓存不友好
↓ ↓ ↓
[S] [I] [D] 分散在堆上的命令数据
图 11-2: CommandQueue 密集存储 vs Box<dyn Command> 的对比
为什么选择 Vec<MaybeUninit<u8>> 而非 Vec<Box<dyn Command>>?
- 零分配:命令数据直接写入缓冲区,不需要为每个命令做堆分配
- 缓存友好:命令数据连续排列,顺序执行时有良好的缓存局部性
- 批量执行:
apply()方法顺序扫描缓冲区,逐个执行命令
RawCommandQueue 是 CommandQueue 的裸指针版本,用于避免嵌套可变借用问题(当 World 的命令队列在执行时递归入队新命令):
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/command_queue.rs pub(crate) struct RawCommandQueue { pub(crate) bytes: NonNull<Vec<MaybeUninit<u8>>>, pub(crate) cursor: NonNull<usize>, pub(crate) panic_recovery: NonNull<Vec<MaybeUninit<u8>>>, } }
Rust 设计亮点:CommandQueue 是一个精心设计的类型擦除容器。每个命令通过
CommandMeta中的函数指针实现多态调用——这是"手动 vtable"模式。相比Box<dyn Command>, 它消除了逐个命令的堆分配,在频繁使用 Commands 的场景(如批量 spawn)中性能差异显著。panic_recovery字段则确保即使某个命令 panic,后续命令仍能被正确 drop。
这种内存布局设计直接影响了批量 spawn 的性能。考虑一个初始化系统需要创建 10,000 个实体——使用 Vec<Box<dyn Command>> 方案需要 10,000 次独立的堆分配(每次 Box::new),这些分配分散在堆的各个位置,执行时会产生大量缓存未命中。而 Vec<MaybeUninit<u8>> 方案将所有命令顺序写入同一个连续缓冲区,执行时 CPU 的预取器可以更稳定地预加载后续命令。因此,这种密集存储更适合批量 spawn 一类吞吐敏感场景。代价是实现复杂度更高——需要手动管理类型擦除、对齐和 drop 语义,这也是为什么 CommandQueue 内部充满了 unsafe 代码。
RawCommandQueue 的存在则解决了一个微妙的借用问题。当 CommandQueue 的 apply() 方法正在逐个执行命令时,某个命令可能需要向同一个 World 的命令队列中推入新命令(递归入队)。如果使用普通的 &mut CommandQueue,这会产生嵌套可变借用——违反 Rust 的借用规则。RawCommandQueue 通过裸指针绕过了这个限制,用 unsafe 的方式实现了安全的递归入队。这体现了 Bevy 在"安全的 API 表面"和"高效的内部实现"之间的取舍:对外暴露安全的 Commands 接口,内部使用精心审计的 unsafe 代码实现最佳性能。
要点:CommandQueue 使用 Vec<MaybeUninit<u8>> + 函数指针实现零分配的异构命令存储,每个命令紧密排列在同一个缓冲区中。
11.3 Command trait 与内置命令
Command trait
所有命令必须实现 Command trait:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/commands/command.rs pub trait Command: Send + 'static { type Out: CommandOutput; fn apply(self, world: &mut World) -> Self::Out; } }
Send + 'static 约束确保命令可以跨线程传递,apply 方法接收 &mut World 拥有完整的 World 修改权限。
闭包自动实现了 Command:
#![allow(unused)] fn main() { // closures implement Command automatically commands.queue(|world: &mut World| { world.spawn(SomeBundle::default()); }); }
Commands 系统参数
Commands 是一个系统参数(SystemParam),它持有一个 RawCommandQueue 的引用和实体分配器:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/commands/mod.rs (简化) pub struct Commands<'w, 's> { queue: Deferred<'s, CommandQueue>, entities: &'w Entities, // ... } }
内置命令
Bevy 提供了丰富的内置命令方法:
| 方法 | 操作 | 说明 |
|---|---|---|
commands.spawn(bundle) | 创建新实体 | 预分配 Entity ID,延迟执行 |
commands.entity(e).despawn() | 销毁实体 | 移除所有组件并回收 ID |
commands.entity(e).insert(comp) | 添加组件 | 可能触发 Archetype 迁移 |
commands.entity(e).remove::<T>() | 移除组件 | 可能触发 Archetype 迁移 |
commands.insert_resource(res) | 插入资源 | 全局唯一资源 |
commands.trigger(event) | 触发事件 | 延迟触发 Observer |
commands.run_system(id) | 运行系统 | 延迟执行注册系统 |
spawn 方法特别值得注意:它通过 EntityAllocator 预分配 Entity ID,使得后续代码可以立即引用这个 ID(例如设置父子关系),而实际的 spawn 操作被延迟到 ApplyDeferred:
#![allow(unused)] fn main() { fn setup(mut commands: Commands) { let parent = commands.spawn(Transform::default()).id(); // ID immediately available commands.spawn((Transform::default(), ChildOf(parent))); // can use parent ID // actual spawning happens at next ApplyDeferred } }
Send + 'static 约束的背后是 Bevy 的并行调度模型。由于系统可能在不同线程上执行,它们产生的 Commands 必须能够安全地跨线程传递到主线程的 ApplyDeferred 同步点。'static 约束排除了借用临时数据的命令——这看似限制,但实际上是合理的,因为命令的执行时机是不确定的(可能在当前帧的任意后续同步点),借用的数据在那时可能已经失效。
spawn 的预分配 ID 机制与第 3 章的 Entity 分配器(Entities)深度关联。Commands 持有 &Entities 引用,通过 reserve_entity() 原子地分配 Entity ID,而不需要 &mut World。这使得多个系统可以并行地分配 Entity ID 而不冲突。预分配的 ID 立即可用,但对应的实体在 World 中还不存在——尝试通过 Query 访问这个 ID 会得到空结果。只有在 ApplyDeferred 执行后,实体才真正"诞生"在 World 中。
要点:Command trait = Send + 'static + apply(&mut World)。内置命令覆盖 spawn/despawn/insert/remove 等结构性操作,spawn 预分配 ID 使得命令间可以互相引用。
11.4 自定义 Command
用户可以定义自己的 Command 来封装复杂的 World 操作:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/commands/command.rs (doc example) #[derive(Resource, Default)] struct Counter(u64); struct AddToCounter(u64); impl Command for AddToCounter { type Out = (); fn apply(self, world: &mut World) { let mut counter = world.get_resource_or_insert_with(Counter::default); counter.0 += self.0; } } fn some_system(mut commands: Commands) { commands.queue(AddToCounter(42)); } }
错误处理
Command 可以返回 Result,错误会被传递给错误处理器:
#![allow(unused)] fn main() { struct MyCommand; impl Command for MyCommand { type Out = Result<()>; fn apply(self, world: &mut World) -> Result<()> { let entity = world.get_entity(Entity::PLACEHOLDER)?; // ... do something Ok(()) } } }
默认的错误处理器会 panic,但可以通过 FallbackErrorHandler 资源配置为 warn、忽略等策略。也可以对特定命令指定处理器:
#![allow(unused)] fn main() { commands.queue_handled(MyCommand, |error, context| { warn!("Command failed: {error}"); }); }
自定义 Command 的主要价值在于封装原子性操作。考虑一个"重生"操作需要:移除 Dead 组件、重置 Health 到 100、将位置设为重生点、播放粒子效果。如果在系统中逐一调用 commands.entity(e).remove::<Dead>()、commands.entity(e).insert(Health(100)) 等,这些命令在不同的 ApplyDeferred 同步点之间可能被其他系统的命令打断。将这些操作封装为一个自定义 Command,它们在 apply 方法中作为一个整体执行——拥有 &mut World 的独占访问权保证了操作的原子性。这也使得代码的可测试性更好——自定义 Command 可以在独立的 World 上单独测试,无需搭建完整的 Schedule 环境。
要点:自定义 Command 通过实现 Command trait 封装复杂的 World 操作,支持 Result 返回值和灵活的错误处理。
11.5 ApplyDeferred 同步点
Commands 被收集后,需要在 ApplyDeferred 同步点被执行。如第 9 章所述,AutoInsertApplyDeferredPass 会自动在需要的位置插入同步点。
命令的完整生命周期如下:
graph LR
subgraph S1["System 运行阶段"]
C1["commands.spawn(...)"]
C2["commands.insert(...)"]
C3["commands.despawn(...)"]
CQ["命令入队到 CommandQueue<br/>World 未被修改"]
end
subgraph S2["ApplyDeferred"]
AP["CommandQueue.apply(&mut World)<br/>拥有 &mut World 独占访问<br/>逐个执行命令:<br/>· spawn entity<br/>· insert component<br/>· despawn entity"]
end
subgraph S3["后续 System"]
QR["Query 可以<br/>看到新实体/组件"]
end
S1 --> S2 --> S3
图 11-3: Commands 生命周期:收集 → 同步 → 执行
命令执行的顺序与入队顺序一致。如果一个命令 panic,Bevy 会将未执行的命令移动到 panic_recovery 缓冲区,在 Drop 时安全清理它们。
手动同步点
除了自动插入的同步点,你也可以手动添加 ApplyDeferred 系统:
#![allow(unused)] fn main() { schedule.add_systems(( system_a, ApplyDeferred, system_b, ).chain()); }
这在需要精确控制命令可见性的场景中很有用。
最终同步
默认情况下,Schedule 在所有系统运行完毕后会执行一次最终的 ApplyDeferred,确保最后一个系统的命令也被应用。这个行为可以通过 set_apply_final_deferred(false) 关闭。
ApplyDeferred 同步点的独占 &mut World 访问是整个延迟执行模型的关键保障。在系统并行执行期间,World 被多个系统以只读或部分可变的方式共享访问——调度器通过冲突矩阵(第 9 章)确保不会有两个系统同时可变访问同一数据。但在同步点,所有并行系统都已完成,World 进入"独占"状态——此时可以安全地执行任何结构性变更,包括 Archetype 迁移、Table 重分配等。这种"并行执行 → 独占同步 → 并行执行"的交替模式是 Bevy 调度模型的核心节奏,它在最大化并行度的同时保证了结构性变更的安全性。
命令按入队顺序执行的保证对于正确性至关重要。考虑以下场景:系统 A 先 spawn 一个实体得到 ID e1,然后为 e1 插入组件。如果命令不按序执行,insert 可能在 spawn 之前执行——操作一个不存在的实体。Bevy 的 CommandQueue 的 FIFO 语义确保了这种隐式的因果依赖。然而,不同系统的命令之间没有顺序保证——系统 A 和系统 B 的命令在同一个同步点被执行时,它们的相对顺序取决于系统的拓扑序,而非系统注册顺序。
要点:Commands 在 ApplyDeferred 同步点被执行,拥有 &mut World 独占访问。命令按入队顺序执行,支持 panic 恢复。
11.6 EntityCommands:实体级命令
Commands::entity(id) 返回 EntityCommands,提供针对特定实体的命令接口:
#![allow(unused)] fn main() { fn system(mut commands: Commands, query: Query<Entity, With<Dead>>) { for entity in &query { commands.entity(entity) .remove::<Dead>() .insert(Respawning { timer: 3.0 }) .with_children(|builder| { builder.spawn(ParticleEffect::new()); }); } } }
EntityCommands 上的方法实际上都转化为 EntityCommand trait 的实现:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/commands/entity_command.rs (简化) pub trait EntityCommand: Send + 'static { fn apply(self, entity: EntityWorldMut); } }
与 Command 类似,闭包也自动实现了 EntityCommand。
EntityCommands 的链式调用 API(builder pattern)是用户体验上的重要设计。通过 commands.entity(e).remove::<Dead>().insert(Health(100)).with_children(...) 的链式调用,多个针对同一实体的操作可以在一个表达式中完成,既简洁又确保了所有操作都作用于正确的实体。底层的 EntityCommand trait 接收 EntityWorldMut——这是一个已经定位到特定实体的可变引用,避免了在每个命令中重复查找实体的开销。这种设计与第 4 章的 EntityWorldMut 直接对应——EntityCommand 本质上是延迟版的 EntityWorldMut 操作。
要点:EntityCommands 是面向单个实体的命令接口,底层转化为 EntityCommand trait 调用。
本章小结
本章我们深入了 Bevy 的 Commands 延迟执行机制:
- 延迟动机:结构性变更会导致迭代器失效,不能在并行系统中直接执行
- CommandQueue:使用
Vec<MaybeUninit<u8>>+ 函数指针实现零分配的异构命令存储 - Command trait:
Send + 'static + apply(&mut World),闭包自动实现 - 内置命令:spawn/despawn/insert/remove 等,
spawn预分配 Entity ID - 自定义 Command:封装复杂 World 操作,支持
Result错误处理 - ApplyDeferred:同步点,命令按入队顺序执行,拥有
&mut World独占访问 - EntityCommands:面向单个实体的命令接口
下一章,我们将深入 Bevy 的三种通信模型——Event、Message 和 Observer——它们各自适合什么场景,以及底层实现的关键差异。
第 12 章:Event、Message 与 Observer — 三种通信模型
导读:前一章我们看到 Commands 如何延迟执行结构性变更。但系统间的通信不仅仅是 修改 World——它们还需要传递信号、广播状态变化、响应生命周期事件。Bevy 提供了三种 通信模型:Event(Observer 推送式即时触发)、Message(拉取式批量处理)和 Observer (分布式事件监听)。本章将深入它们的实现机制、5 种生命周期事件、Observer 的三维 监听模型,以及选型指南。
12.1 三种模型概览
Bevy 的三种通信模型满足不同的设计需求:
graph TD
subgraph Push["Event + Observer (推送式)"]
T1["world.trigger(MyEvent)"] -->|"立即执行"| OA["Observer A"]
T1 --> OB["Observer B"]
T1 --> OC["Observer C"]
N1["所有 Observer 在<br/>trigger 调用中同步执行"]
end
subgraph Pull["Message (拉取式)"]
W1["writer.write(MyMessage)"] -->|"写入缓冲区"| BUF["Messages<M><br/>(double buffer)"]
BUF -->|"下一帧/同步点"| R1["reader.read()<br/>批量消费"]
end
图 12-1: Push 模型 (Event+Observer) vs Pull 模型 (Message)
| 特性 | Event + Observer | Message |
|---|---|---|
| 触发方式 | world.trigger() | writer.write() |
| 执行时机 | 立即(同步) | 延迟(下次 reader.read()) |
| 消费方式 | 每个 Observer 独立运行 | MessageReader 批量消费 |
| 多消费者 | 每个 Observer 都会执行 | 每个 Reader 独立追踪进度 |
| 适用场景 | 即时响应、生命周期钩子 | 高吞吐量批量处理 |
| 数据生命期 | 触发期间有效 | 2 帧(双缓冲) |
| 开销 | 每次触发遍历 Observer 列表 | 写入 O(1),读取 O(n) |
为什么 Bevy 需要三种通信机制,而非统一为一种?这是历史演化与设计需求共同作用的结果。最早的 Bevy 只有 Event(现在的 Message),它是一个简单的双缓冲队列。但随着引擎的成熟,开发者发现许多场景需要即时响应——例如组件被添加时立刻执行初始化逻辑,而非等到下一帧才处理。Event 的拉取模型天然无法满足这种需求,因此引入了 Observer 的推送模型。而原来的 Event 被重命名为 Message,专注于高吞吐量的批量场景。三种机制分别优化了不同的性能特征:Observer 优化了延迟(立即触发),Message 优化了吞吐量(批量处理),Event trait 则提供了底层的类型抽象。如果强行统一为一种机制,要么在吞吐量场景付出即时触发的开销,要么在即时响应场景引入不必要的缓冲延迟。
12.2 Event trait 与 Trigger
Event trait
Event 是所有事件类型的基础 trait:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/event/mod.rs pub trait Event: Send + Sync + Sized + 'static { type Trigger<'a>: Trigger<Self>; } }
默认情况下,#[derive(Event)] 使用 GlobalTrigger 作为 Trigger 实现,这意味着事件会触发所有全局 Observer:
#![allow(unused)] fn main() { #[derive(Event)] struct GameOver { score: u32, } // trigger the event world.trigger(GameOver { score: 100 }); }
EntityEvent trait
EntityEvent 是针对特定实体的事件,它额外包含一个目标实体:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/event/mod.rs pub trait EntityEvent: Event { fn event_target(&self) -> Entity; } #[derive(EntityEvent)] struct Damage { entity: Entity, // automatically used as event_target amount: f32, } }
EntityEvent 的 Trigger 默认为 EntityTrigger,它会触发针对特定实体注册的 Observer。如果启用了传播(propagation),事件会沿 Traversal 路径向上冒泡。
Trigger 机制
Trigger trait 定义了事件如何被分发给 Observer:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/event/trigger.rs (简化) pub unsafe trait Trigger<E: Event> { unsafe fn trigger( &mut self, world: DeferredWorld, observers: &CachedObservers, trigger_context: &TriggerContext, event: &mut E, ); } }
Bevy 提供了四种内置 Trigger 实现:
| Trigger | 用途 | 默认使用 |
|---|---|---|
GlobalTrigger | 触发所有全局 Observer | #[derive(Event)] |
EntityTrigger | 触发特定实体的 Observer | #[derive(EntityEvent)] |
PropagateEntityTrigger | 沿 Traversal 路径传播 | EntityEvent + 传播 |
EntityComponentsTrigger | 触发组件级 Observer | 生命周期事件 |
Trigger 机制的分层设计体现了 Bevy 的精确控制哲学。全局事件(GlobalTrigger)类似于传统的"广播"模式,所有监听者都会收到通知;实体级事件(EntityTrigger)则实现了"点对点"通信,只有注册在目标实体上的 Observer 会被触发。传播式事件(PropagateEntityTrigger)借鉴了 DOM 的事件冒泡模型,沿 Relationship 链向上传播。这种分层让开发者可以根据场景选择最精确的分发范围——越精确的范围意味着越少的 Observer 被遍历,性能越好。例如,在一个有 10,000 个实体的场景中,一个 EntityEvent 只需查找注册在目标实体上的少数几个 Observer,而全局 Event 需要遍历所有相关的 Observer 列表。
要点:Event 通过 Trigger 机制分发给 Observer,不同的 Trigger 决定了分发范围:全局、实体级、传播、或组件级。
12.3 五种生命周期事件
Bevy 定义了 5 种组件生命周期事件,它们在组件的不同生命周期阶段自动触发:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/lifecycle.rs pub struct Add { pub entity: Entity } // first insert (new component) pub struct Insert { pub entity: Entity } // every insert (new or replace) pub struct Discard { pub entity: Entity } // before value is discarded (replace or remove) pub struct Remove { pub entity: Entity } // when component is removed pub struct Despawn { pub entity: Entity } // when entity is despawned }
它们的触发时机如下:
graph TD
INSERT1["entity.insert(C) (首次)"] --> ADD["Add<br/>组件第一次出现在此实体上"]
INSERT1 --> INS1["Insert<br/>每次 insert 都触发"]
INSERT2["entity.insert(C) (覆盖已有)"] --> DIS1["Discard<br/>旧值即将被丢弃<br/>(此时仍可访问旧值)"]
INSERT2 --> INS2["Insert<br/>新值已写入"]
REMOVE["entity.remove::<C>()"] --> DIS2["Discard<br/>值即将被丢弃"]
REMOVE --> REM["Remove<br/>组件被移除<br/>(此时仍可访问值)"]
DESPAWN["entity.despawn()"] --> DSP["Despawn<br/>实体即将被销毁<br/>(对每个组件触发)"]
图 12-2: 组件生命周期事件触发时机
这 5 种事件在 Observers 的中心化存储中有专门的缓存字段,避免了 HashMap 查找的开销:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/observer/centralized_storage.rs pub struct Observers { // cached lifecycle observers for high-traffic built-in events add: CachedObservers, insert: CachedObservers, discard: CachedObservers, remove: CachedObservers, despawn: CachedObservers, // all other events go through a HashMap lookup cache: HashMap<EventKey, CachedObservers>, } }
使用生命周期事件的典型场景:
#![allow(unused)] fn main() { // respond when a Health component is added to any entity world.add_observer(|event: On<Add, Health>, query: Query<&Health>| { let health = query.get(event.entity).unwrap(); println!("Entity {:?} now has {} HP", event.entity, health.0); }); // respond when a component is removed world.add_observer(|event: On<Remove, Weapon>| { println!("Entity {:?} lost their weapon!", event.entity); }); }
Rust 设计亮点:生命周期事件的
EventKey使用编译期常量ComponentId(从 0 到 4), 这使得Observers::get_observers_mut()可以通过 match 直接返回对应的缓存引用, 完全避免了 HashMap 查找。对于这些高频事件(每次 spawn/insert/remove 都会触发), 消除 hash 计算的开销至关重要。
5 种生命周期事件的区分看似冗余,但每种都有不可替代的用途。Add 和 Insert 的分离使得"首次初始化"(只在第一次添加时执行)和"每次更新"(包括值覆盖)可以分别处理——例如,一个"装备系统"可能在 Add 时播放装备动画,但在 Insert(覆盖已有装备)时只更新属性面板。Discard 在值即将被丢弃时触发,此时旧值仍可访问——这对于清理与旧值关联的资源(如释放旧材质的 GPU 缓冲区)至关重要。Remove 和 Despawn 的分离则区分了"组件被移除但实体仍存在"和"整个实体被销毁"两种场景。这种细粒度的生命周期控制是传统 ECS 框架中常见的痛点——许多框架只提供 add/remove 两种回调,迫使用户在 remove 回调中自行区分是"组件被移除"还是"实体被销毁"。
要点:5 种生命周期事件覆盖了组件从添加到销毁的完整生命周期。它们有专用的缓存路径,性能比普通事件更高。
12.4 Observer:推送式即时响应
Observer 组件
Observer 是一个特殊的 Component,它包含一个系统和一个描述符:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/observer/distributed_storage.rs (简化) pub struct Observer { pub(crate) system: Box<dyn AnyNamedSystem>, pub(crate) descriptor: ObserverDescriptor, pub(crate) runner: ObserverRunner, pub(crate) last_trigger_id: u32, // ... } }
ObserverDescriptor:三维监听
ObserverDescriptor 定义了 Observer 监听的三个维度:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/observer/distributed_storage.rs pub struct ObserverDescriptor { pub(super) event_keys: Vec<EventKey>, // which events pub(super) components: Vec<ComponentId>, // which components pub(super) entities: Vec<Entity>, // which entities } }
这三个维度组合出不同的监听范围:
ObserverDescriptor 三维监听空间
entities
╱
╱
╱
events ───────────╳──────────── components
╱│
╱ │
╱ │
维度组合:
┌─────────┬────────────┬──────────┬───────────────────────────┐
│ events │ components │ entities │ 含义 │
├─────────┼────────────┼──────────┼───────────────────────────┤
│ [Add] │ [] │ [] │ 任何组件被添加到任何实体 │
│ [Add] │ [Health] │ [] │ Health 被添加到任何实体 │
│ [Add] │ [Health] │ [e1] │ Health 被添加到 e1 │
│ [MyEvt] │ [] │ [] │ MyEvt 被触发 │
│ [MyEvt] │ [] │ [e1] │ MyEvt 针对 e1 被触发 │
└─────────┴────────────┴──────────┴───────────────────────────┘
图 12-3: ObserverDescriptor 三维监听空间
中心化存储与 ArchetypeCache
Observer 的查找通过 CachedObservers 实现,它按照监听范围组织为多层 HashMap:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/observer/centralized_storage.rs (简化) pub struct CachedObservers { // observers watching all entities, all components pub(crate) global_observers: EntityHashMap<ObserverRunner>, // observers watching specific components (any entity) pub(crate) component_observers: HashMap<ComponentId, CachedComponentObservers>, // observers watching specific entities (no component filter) pub(crate) entity_observers: EntityHashMap<EntityHashMap<ObserverRunner>>, } pub struct CachedComponentObservers { // observers for this component on any entity pub(crate) global_observers: EntityHashMap<ObserverRunner>, // observers for this component on specific entities pub(crate) entity_component_observers: EntityHashMap<EntityHashMap<ObserverRunner>>, } }
当 Observer 被注册时,它同时更新 Archetype 上的标志位(ArchetypeFlags),这样在触发生命周期事件时可以快速跳过没有 Observer 的 Archetype:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/observer/centralized_storage.rs pub(crate) fn is_archetype_cached(event_key: EventKey) -> Option<ArchetypeFlags> { match event_key { ADD => Some(ArchetypeFlags::ON_ADD_OBSERVER), INSERT => Some(ArchetypeFlags::ON_INSERT_OBSERVER), // ... } } }
分布式存储
每个 Observer 实体还携带一个 Observer 组件,而被观察的实体携带 ObservedBy 组件,存储指向 Observer 实体的反向引用。这种分布式存储使得:
- Observer 可以被 Query 查询和操作
- 被观察实体的 despawn 可以自动清理 Observer 注册
- Observer 自身的 despawn 可以自动注销
Observer 的推送式执行模型有一个重要的性能考量:每次 trigger() 调用都会同步地执行所有匹配的 Observer。这意味着如果一个高频事件(如碰撞检测每帧触发数千次)注册了多个 Observer,每次触发都会产生函数调用和系统执行的开销。与第 9 章的 Schedule 并行调度不同,Observer 在触发时是串行执行的——它们运行在 DeferredWorld 上,无法利用多线程。因此,对于高频批量事件,Message 的拉取模式通常更高效——写入只是 O(1) 的 push 操作,消费是 O(n) 的批量遍历。Observer 的优势在于低频但需要即时响应的场景,以及需要精确定位到特定实体/组件的场景。
要点:Observer 通过 ObserverDescriptor 的三维空间(事件 × 组件 × 实体)进行精确匹配。中心化缓存优化查找性能,ArchetypeFlags 实现快速跳过。
12.5 Message:拉取式批量处理
Message 是传统的双缓冲事件队列模型,适合高吞吐量的批量处理场景:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/message/mod.rs pub trait Message: Send + Sync + 'static {} }
Messages<M> 双缓冲
Messages<M> 资源使用双缓冲策略存储消息:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/message/messages.rs (简化) pub struct Messages<M: Message> { pub(crate) messages_a: MessageSequence<M>, // older buffer pub(crate) messages_b: MessageSequence<M>, // newer buffer } }
每次 update() 调用时交换缓冲区并清空旧缓冲区:
Messages<M> 双缓冲
帧 N:
messages_a: [m1, m2, m3] ← 上一帧的消息 (即将被清空)
messages_b: [m4, m5] ← 本帧写入的消息
帧 N+1 (调用 update() 后):
messages_a: [m4, m5] ← 上一帧的消息 (从 b 交换过来)
messages_b: [] ← 新缓冲区 (等待新消息)
messages_a 旧内容 [m1, m2, m3] 已被清空
MessageReader 可以读取 messages_a + messages_b
→ 消息在写入后存活最多 2 帧
图 12-4: Messages 双缓冲交换机制
MessageWriter 与 MessageReader
写入和读取分别通过 MessageWriter 和 MessageReader 系统参数完成:
#![allow(unused)] fn main() { // write messages fn produce(mut writer: MessageWriter<Collision>) { writer.write(Collision { a: entity_a, b: entity_b }); } // read messages (with per-system cursor) fn consume(mut reader: MessageReader<Collision>) { for collision in reader.read() { println!("Collision between {:?} and {:?}", collision.a, collision.b); } } }
MessageReader 使用 MessageCursor 追踪每个系统独立的读取进度。这意味着同一条消息可以被多个系统消费,每个系统独立维护自己的消费位置。
双缓冲的 2 帧存活设计是一个精心计算的权衡。如果消息只存活 1 帧(即当前帧写入、当前帧消费),那么在同一帧中 Writer 之后执行的 Reader 可以读到消息,但 Writer 之前执行的 Reader 会错过。2 帧缓冲确保即使 Reader 在 Writer 之前执行,它也能在下一帧读到上一帧的消息。代价是内存使用量翻倍(两个 Vec 而非一个),以及消息的"保质期"增加——如果某个 Reader 在 2 帧内都没有运行(例如被 run_if 条件跳过),消息就会丢失。这种设计假设大多数消费系统每帧都会运行,如果需要更长的消息保留,应使用 Resource 或 Component 而非 Message。
MessageCursor 的独立追踪意味着同一条消息可以被任意数量的 Reader 独立消费——这是"扇出"(fan-out)模式的高效实现。与 Observer 的"每个触发都调用所有 Observer"不同,Message 的每个 Reader 在自己的系统调度时机自主消费,完全融入 Schedule 的并行执行框架。这使得 Message 更适合与第 9 章的 SystemSet 配合使用——不同阶段的系统可以在各自的时机消费同一批消息。
要点:Message 使用双缓冲 + 独立 Cursor 实现高效的拉取式批量消费。消息存活 2 帧,每个 Reader 独立追踪进度。
12.6 选型指南
三种模型各有最佳适用场景:
使用 Event + Observer 当你需要:
- 即时响应:触发时 Observer 立即执行(在同一帧的
trigger调用中) - 生命周期钩子:响应组件的 Add/Insert/Remove/Despawn
- 精确目标:只响应特定实体或特定组件上的事件
- 命令式触发:在确定的代码位置触发事件
#![allow(unused)] fn main() { // good: immediate response to damage world.add_observer(|event: On<Damage>, mut query: Query<&mut Health>| { if let Ok(mut health) = query.get_mut(event.entity) { health.0 -= event.amount; } }); }
使用 Message 当你需要:
- 高吞吐量:大量消息需要批量处理(如碰撞检测结果)
- 多消费者:同一消息需要被多个系统独立消费
- 解耦生产消费:生产者和消费者在不同的 Schedule 阶段
- 帧间缓冲:消息需要跨帧存活(最多 2 帧)
#![allow(unused)] fn main() { // good: batch processing of collision events fn detect_collisions(mut writer: MessageWriter<Collision>) { // ... write thousands of collision messages } fn resolve_physics(mut reader: MessageReader<Collision>) { for collision in reader.read() { // batch process all collisions } } fn play_sounds(mut reader: MessageReader<Collision>) { for collision in reader.read() { // another system independently reads the same messages } } }
对比总结
| 场景 | 推荐 | 原因 |
|---|---|---|
| 组件添加/移除响应 | Observer | 生命周期事件,即时执行 |
| 低频游戏事件 | Observer | 即时响应,清晰的因果关系 |
| 高频批量数据 | Message | 批量处理更高效 |
| 多系统消费同一数据 | Message | 独立 Cursor,无需广播 |
| 实体间交互 | Observer | EntityEvent 精确定位 |
| 跨帧数据传递 | Message | 双缓冲保证 2 帧存活 |
在实际项目中,选型的最大陷阱是将高频事件错误地用 Observer 处理。例如,一个物理引擎每帧产生数千个碰撞事件——如果用 Observer,每个碰撞都会同步触发所有注册的 Observer 系统,总开销与"碰撞数 × Observer 数"成正比。改用 Message,碰撞系统一次性写入所有碰撞事件,各消费系统在后续阶段批量处理,总开销仅与"碰撞数 + 消费系统数"成正比。反过来,将低频的生命周期事件用 Message 处理也不理想——你会失去即时响应能力,初始化逻辑会延迟到下一帧才执行。经验法则是:如果你需要"某事发生时立即做某事",用 Observer;如果你需要"收集所有发生的事再统一处理",用 Message。
要点:Event+Observer 适合即时响应和精确定位,Message 适合高吞吐量批量处理。选择依据是:触发频率、消费模式和时序要求。
本章小结
本章我们深入了 Bevy 的三种通信模型:
- Event trait:
Send + Sync + 'static,通过Trigger机制分发给 Observer - 5 种生命周期事件:Add、Insert、Discard、Remove、Despawn,覆盖组件完整生命周期
- Observer:推送式即时触发,
ObserverDescriptor三维监听(事件 × 组件 × 实体) - CachedObservers:中心化缓存优化查找,ArchetypeFlags 实现快速跳过
- Message:拉取式批量处理,
Messages<M>双缓冲,MessageCursor独立进度追踪 - 选型指南:即时响应用 Observer,高吞吐量用 Message
下一章,我们将深入 Relationship 和 Hierarchy——实体间的结构性纽带,以及 ChildOf/Children 父子关系的双向自动维护机制。
第 13 章:Relationship 与 Hierarchy — 实体间的纽带
导读:前一章我们看到 Observer 如何响应组件的生命周期事件。本章将展示这些生命周期 钩子的一个重要应用:Relationship。Bevy 的 Relationship 系统通过 Component Hooks 自动维护实体间的双向链接,使得插入一个
ChildOf(parent)就能自动在 parent 上 维护Children列表。我们将深入 Relationship trait 的设计、双向维护机制、 ChildOf/Children 父子关系、自定义 Relationship、linked_spawn 级联销毁,以及 Traversal 遍历。
13.1 Relationship trait:单向声明,双向维护
Relationship 是一个 Component,它存储在"源"实体上,指向一个"目标"实体,创建两者之间的关系:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/relationship/mod.rs (简化) pub trait Relationship: Component + Sized { type RelationshipTarget: RelationshipTarget<Relationship = Self>; const ALLOW_SELF_REFERENTIAL: bool = false; fn get(&self) -> Entity; // get the target entity fn from(entity: Entity) -> Self; // create from target entity fn set_risky(&mut self, entity: Entity); // change target (internal) fn on_insert(world: DeferredWorld, ctx: HookContext) { ... } fn on_discard(world: DeferredWorld, ctx: HookContext) { ... } } }
每个 Relationship 都有一个配对的 RelationshipTarget——它存在于"目标"实体上,包含所有指向该目标的"源"实体列表:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/relationship/mod.rs (简化) pub trait RelationshipTarget: Component<Mutability = Mutable> + Sized { const LINKED_SPAWN: bool; type Relationship: Relationship<RelationshipTarget = Self>; type Collection: RelationshipSourceCollection; fn collection(&self) -> &Self::Collection; fn collection_mut_risky(&mut self) -> &mut Self::Collection; fn on_discard(world: DeferredWorld, ctx: HookContext) { ... } fn on_despawn(world: DeferredWorld, ctx: HookContext) { ... } } }
graph LR
A["Entity A<br/>ChildOf(P)<br/>(Relationship)"] -->|"get()"| P["Entity P<br/>Children([A,B,C])<br/>(RelationshipTarget)"]
B["Entity B<br/>ChildOf(P)"] --> P
C["Entity C<br/>ChildOf(P)"] --> P
Relationship = source of truth (源实体的组件) RelationshipTarget = reflection (目标实体的自动维护列表)
图 13-1: Relationship 双向链接模型
在传统 ECS 中,实体间的关系通常通过"在组件中存储 Entity ID"来表达——例如 struct Parent(Entity)。但这种朴素方案有两个严重问题:首先,当被引用的实体被销毁时,持有的 Entity ID 变成了悬空引用(dangling reference),类似于 C 语言的悬空指针。其次,反向查询极其低效——"找出所有以 Entity P 为 Parent 的实体"需要遍历整个 World。Bevy 的 Relationship trait 通过 Component Hooks 自动维护反向索引(RelationshipTarget),使得反向查询变为 O(1) 的组件访问。通过 on_discard Hook 自动清理关系,悬空引用的问题也被根本解决。
这种设计与 Unity 和 Godot 的内置层级系统有本质区别。Unity/Godot 的父子关系是引擎内核的一部分,与 Transform 系统深度耦合——你无法定义一个"不参与 Transform 传播"的父子关系。Bevy 的 Relationship 是一个通用机制,ChildOf/Children 只是它的一个具体实例。你可以定义 EquippedBy/Equipment、FollowedBy/Followers 等任意关系,它们都享受同样的自动双向维护、级联销毁和 Query 友好性。这种将"关系"从引擎内核中解耦出来、变为可组合的 ECS 原语的设计,是 Bevy 在 ECS 建模灵活性上的重要创新。
要点:Relationship 存储在源实体上,是"真相源"。RelationshipTarget 存储在目标实体上,是自动维护的反向引用集合。
13.2 双向维护机制:Component Hooks
Relationship 的双向一致性通过 Component Hooks(on_insert 和 on_discard)自动维护。这些 hooks 在组件被插入或移除时立即执行。
插入时(on_insert)
当 ChildOf(parent) 被插入到实体 child 上时:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/relationship/mod.rs (简化) fn on_insert(mut world: DeferredWorld, ctx: HookContext) { let entity = ctx.entity; let target_entity = world.entity(entity).get::<Self>().unwrap().get(); // 1. reject self-referential if not allowed if !Self::ALLOW_SELF_REFERENTIAL && target_entity == entity { warn!("Self-referential relationship detected, removing..."); world.commands().entity(entity).remove::<Self>(); return; } // 2. add source entity to target's RelationshipTarget if let Ok(mut entity_commands) = world.commands().get_entity(target_entity) { entity_commands .entry::<Self::RelationshipTarget>() .and_modify(move |mut target| { target.collection_mut_risky().add(entity); }) .or_insert_with(move || { let mut target = Self::RelationshipTarget::with_capacity(1); target.collection_mut_risky().add(entity); target }); } else { // target entity doesn't exist, remove invalid relationship world.commands().entity(entity).remove::<Self>(); } } }
移除时(on_discard)
当 ChildOf(parent) 从实体 child 上被移除时:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/relationship/mod.rs (简化) fn on_discard(mut world: DeferredWorld, ctx: HookContext) { let target_entity = world.entity(ctx.entity).get::<Self>().unwrap().get(); if let Ok(mut target_mut) = world.get_entity_mut(target_entity) { if let Some(mut relationship_target) = target_mut.get_mut::<Self::RelationshipTarget>() { relationship_target.collection_mut_risky().remove(ctx.entity); if relationship_target.len() == 0 { // queue removal of empty RelationshipTarget world.commands().queue_silenced(/* remove empty target */); } } } } }
完整的双向维护流程:
graph TD
subgraph 插入["entity.insert(ChildOf(parent))"]
I1["1. ChildOf(parent) 被插入到 entity"]
I2["2. on_insert hook 触发"]
I3["a. 检查 parent != entity (非自引用)"]
I4["b. 检查 parent 实体存在"]
I5{"parent 有 Children?"}
I6["添加 entity 到列表"]
I7["创建 Children([entity])"]
I1 --> I2 --> I3 --> I4 --> I5
I5 -->|"是"| I6
I5 -->|"否"| I7
end
subgraph 移除["entity.remove::<ChildOf>()"]
R1["1. on_discard hook 触发<br/>(旧值仍可访问)"]
R2["a. 获取旧 parent entity"]
R3["b. 从 parent 的 Children 中移除 entity"]
R4{"Children 变空?"}
R5["移除 Children 组件"]
R6["2. ChildOf 组件被移除"]
R1 --> R2 --> R3 --> R4
R4 -->|"是"| R5 --> R6
R4 -->|"否"| R6
end
subgraph 替换["entity.insert(ChildOf(new_parent)) 替换"]
P1["1. on_discard: 从 old_parent 的 Children 中移除 entity"]
P2["2. 新 ChildOf(new_parent) 写入"]
P3["3. on_insert: 在 new_parent 的 Children 中添加 entity"]
P1 --> P2 --> P3
end
图 13-2: Relationship 双向维护流程
Rust 设计亮点:Relationship 的双向维护完全通过 Component Hooks 实现——用户只需操作 源端的
ChildOf组件,目标端的Children会自动保持同步。这种"单写入点,自动同步" 的设计避免了手动维护双向链接时常见的一致性 bug。Hooks 在DeferredWorld上操作, 部分更新通过 Commands 延迟执行(如清理空的RelationshipTarget),兼顾了即时性和安全性。
双向维护的代价是每次关系变更都会触发 Hook 执行,包括访问目标实体的组件并修改其 RelationshipTarget 集合。在批量创建大量关系时(例如一次性 spawn 10,000 个子实体),这些 Hook 调用会成为可观的开销。Bevy 通过 Commands 的延迟执行来缓解这个问题——部分清理操作(如移除空的 RelationshipTarget)被 queue 到 Commands 中异步执行,而非在 Hook 中同步完成。此外,on_insert 使用 entry API 避免了"先检查是否存在再插入"的两次查找开销。然而,如果你的应用场景涉及频繁的关系变更(每帧数千次 reparenting),可能需要考虑批量操作的优化策略,或者评估是否真的需要自动维护的双向关系。
要点:on_insert 在目标实体的 RelationshipTarget 中添加源实体,on_discard 移除。替换操作先 discard 旧值再 insert 新值,自动保持双向一致。
13.3 ChildOf 与 Children:内置父子关系
Bevy 提供了内置的父子关系,由 ChildOf(Relationship)和 Children(RelationshipTarget)组成:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/hierarchy.rs (简化) #[derive(Component)] #[relationship(relationship_target = Children)] pub struct ChildOf(pub Entity); #[derive(Component, Default)] #[relationship_target(relationship = ChildOf, linked_spawn)] pub struct Children(Vec<Entity>); }
创建父子关系
#![allow(unused)] fn main() { // method 1: direct insert let parent = world.spawn_empty().id(); let child = world.spawn(ChildOf(parent)).id(); // method 2: builder pattern let parent = world.spawn_empty().with_children(|p| { let child1 = p.spawn_empty().id(); let child2 = p.spawn_empty().id(); }).id(); // method 3: via Commands commands.spawn_empty().with_children(|p| { p.spawn((Sprite::default(), Transform::default())); p.spawn(Text::new("child")); }); }
Children 的 Deref 访问
Children 实现了 Deref<Target = [Entity]>,可以直接当作切片使用:
#![allow(unused)] fn main() { fn system(query: Query<&Children>) { for children in &query { // Children derefs to &[Entity] for &child in children.iter() { // access each child entity } println!("has {} children", children.len()); } } }
linked_spawn 级联销毁
Children 标记了 linked_spawn,这意味着当父实体被 despawn 时,所有子实体(及其后代)也会被自动 despawn:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/relationship/mod.rs fn on_despawn(mut world: DeferredWorld, ctx: HookContext) { let relationship_target = world.entity(ctx.entity) .get::<Self>().unwrap(); for source_entity in relationship_target.iter() { world.commands().entity(source_entity).try_despawn(); } } }
graph TD
ROOT["despawn(Root)"]
ROOT --> A["despawn(A)"]
ROOT --> B["despawn(B)"]
A --> C["despawn(C)<br/>← 递归销毁"]
B --> D["despawn(D)<br/>← 递归销毁"]
B --> E["despawn(E)<br/>← 递归销毁"]
ROOT -.- RESULT["Root, A, B, C, D, E 全部被销毁"]
如果不想级联销毁, 先 remove ChildOf:
#![allow(unused)] fn main() { entity.remove::<ChildOf>(); // detach from parent parent.despawn(); // only parent is despawned }
图 13-3: linked_spawn 级联销毁递归过程
Bevy 的 ChildOf/Children 设计与 Unity/Godot 的 Transform 层级有一个关键区别:ChildOf 是一个纯粹的拓扑关系,与 Transform 传播逻辑完全解耦。在 Unity 中,设置 parent 会自动影响子对象的世界坐标;在 Bevy 中,ChildOf 仅建立实体间的从属关系,Transform 传播是由独立的系统(第 15 章)根据 ChildOf 关系执行的。这意味着你可以创建不参与 Transform 传播的父子关系——例如一个 UI 面板"拥有"一组逻辑对象,但它们在空间上没有从属关系。这种解耦增加了灵活性,但也意味着用户需要理解"拥有 ChildOf 不等于自动继承 Transform"——如果忘记给子实体添加 Transform 组件,它不会跟随父实体移动。
linked_spawn 的级联销毁是递归的——销毁一个有 1000 个后代的根实体会产生 1000 次 despawn 调用,每次都会触发 Component Hooks 来清理关系。这在深层嵌套的 UI 层级中可能产生可观的开销。如果需要一次性销毁大量实体,考虑先收集所有后代实体 ID,再批量 despawn,避免递归 Hook 的开销。
要点:ChildOf/Children 是内置的父子关系。linked_spawn 属性使得 despawn 父实体时自动级联销毁所有后代。
13.4 自定义 Relationship
用户可以通过 derive 宏定义自己的 Relationship:
#![allow(unused)] fn main() { // custom "EquippedBy" relationship #[derive(Component)] #[relationship(relationship_target = Equipment)] pub struct EquippedBy(pub Entity); #[derive(Component)] #[relationship_target(relationship = EquippedBy)] pub struct Equipment(Vec<Entity>); }
带额外字段的 Relationship
Relationship 可以包含额外字段,但必须用 #[relationship] 标记关系字段:
#![allow(unused)] fn main() { #[derive(Component)] #[relationship(relationship_target = Followers)] pub struct FollowedBy { #[relationship] pub leader: Entity, pub distance: f32, // must impl Default } #[derive(Component)] #[relationship_target(relationship = FollowedBy)] pub struct Followers(Vec<Entity>); }
allow_self_referential
默认情况下,Relationship 不允许实体指向自身。如果需要自引用(如"喜欢自己"),使用 allow_self_referential 属性:
#![allow(unused)] fn main() { #[derive(Component)] #[relationship(relationship_target = PeopleILike, allow_self_referential)] pub struct LikedBy(pub Entity); #[derive(Component)] #[relationship_target(relationship = LikedBy)] pub struct PeopleILike(Vec<Entity>); }
注意:启用自引用后,使用 iter_ancestors 等递归遍历方法时可能导致无限循环。
RelationshipSourceCollection
RelationshipTarget 的内部集合类型通过 RelationshipSourceCollection trait 抽象:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/relationship/relationship_source_collection.rs pub trait RelationshipSourceCollection { type SourceIter<'a>: Iterator<Item = Entity> where Self: 'a; fn add(&mut self, entity: Entity); fn remove(&mut self, entity: Entity); fn iter(&self) -> Self::SourceIter<'_>; fn len(&self) -> usize; fn with_capacity(capacity: usize) -> Self; // ... } }
Vec<Entity> 是最常用的实现,支持一对多关系。对于一对一关系,可以使用单元素的集合类型。
自定义 Relationship 的能力使得 Bevy 的关系模型远超传统 ECS 框架。在大多数 ECS 中,实体间的关系只能通过"存储 Entity ID 的组件"来间接表达——没有自动的反向索引、没有级联销毁、没有一致性保证。Bevy 的 derive 宏将所有这些基础设施打包为一行注解——#[relationship(relationship_target = Equipment)] 就能生成完整的双向维护 Hooks。RelationshipSourceCollection 的抽象进一步增加了灵活性:Vec<Entity> 适合一对多的有序关系(如父子),HashSet<Entity> 适合一对多的无序关系(如标签),甚至可以实现一对一的单元素集合来限制关系的基数。
带额外字段的 Relationship(如 FollowedBy { leader, distance })展示了关系不仅仅是拓扑连接——它可以携带描述这段关系的元数据。这在游戏开发中极为常见:一个"装备在"关系可能需要记录装备的插槽位置,一个"跟随"关系可能需要记录跟随距离。如果没有这种能力,用户就需要创建额外的 Component 来存储关系元数据,并手动与 Relationship 保持同步——正是 Bevy 试图通过自动化来消除的那种一致性维护负担。
要点:自定义 Relationship 通过 derive 宏定义,支持额外字段和自引用。RelationshipSourceCollection 抽象允许不同的集合类型。
13.5 Traversal:层级遍历
Traversal trait 定义了沿 Relationship 链路遍历实体的能力:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/traversal.rs (简化) pub trait Traversal<D: ?Sized>: ReadOnlyQueryData + ReleaseStateQueryData + SingleEntityQueryData { fn traverse(item: Self::Item<'_, '_>, data: &D) -> Option<Entity>; } }
ChildOf 实现了 Traversal,使得事件可以沿父子链向上传播:
#![allow(unused)] fn main() { // EntityEvent with propagation via ChildOf traversal #[derive(EntityEvent)] #[entity_event(traversal = &'static ChildOf, auto_propagate)] struct Click { entity: Entity, } }
当 Click 事件在子实体上触发时,它会自动沿 ChildOf 链向上传播到父实体、祖父实体,直到根节点或被某个 Observer 停止传播。
Bevy 的 hierarchy 模块提供了便利的遍历方法:
#![allow(unused)] fn main() { // iterate ancestors (parent, grandparent, ...) fn system(query: Query<&ChildOf>) { for ancestor in query.iter_ancestors(child_entity) { // visit each ancestor } } // get root ancestor fn system(query: Query<&ChildOf>) { let root = query.root_ancestor(child_entity); } // iterate descendants (breadth-first via Children) fn system(children_query: Query<&Children>) { for descendant in children_query.iter_descendants(parent_entity) { // visit each descendant } } }
Rust 设计亮点:Traversal trait 将"如何沿关系链移动"与"对每个节点做什么"解耦。 这使得事件传播可以复用任何 Relationship 的链路——不仅限于 ChildOf/Children。 你可以定义一个自定义 Relationship(如
SocketOf),让事件沿你的自定义层级传播。 Traversal 本身是 ReadOnlyQueryData,保证遍历过程不会修改 World。
Traversal 的设计将"如何沿关系链移动"与事件传播系统(第 12 章)优雅地连接起来。在 Web 开发中,DOM 事件冒泡只能沿预定义的 parent 链传播。Bevy 的 Traversal 泛型化了这个概念——任何 Relationship 都可以成为传播路径。例如,你可以定义一个 SocketOf Relationship 表示物品插槽关系,然后让 EntityEvent 沿 SocketOf 链传播——当一个宝石被攻击时,事件冒泡到镶嵌它的武器,再冒泡到持有武器的角色。这种组合性是 ECS 架构的核心优势:每个独立的机制(Relationship、Traversal、Observer)本身功能有限,但组合起来可以表达极其丰富的行为。
要点:Traversal trait 允许沿 Relationship 链路遍历实体,支持事件传播。ChildOf 的 Traversal 实现使得事件可以沿父子链向上冒泡。
13.6 RelationshipTarget 的 Clone 行为
当使用 EntityCloner 克隆实体时,RelationshipTarget 需要特殊处理。直接复制 Children 列表会导致重复引用——原始子实体同时被两个父实体引用。
Bevy 通过 clone_relationship_target 函数处理这种情况:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/relationship/mod.rs (简化) pub fn clone_relationship_target<T: RelationshipTarget>( component: &T, cloned: &mut T, context: &mut ComponentCloneCtx, ) { if context.linked_cloning() && T::LINKED_SPAWN { // linked clone: recursively clone all children for entity in component.iter() { cloned.collection_mut_risky().add(entity); context.queue_entity_clone(entity); } } else if context.moving() { // move: update relationship targets to point to new entity for entity in component.iter() { cloned.collection_mut_risky().add(entity); context.queue_deferred(/* update source entity's Relationship */); } } } }
当 linked_cloning 启用(且 LINKED_SPAWN 为 true)时,克隆父实体会递归克隆所有子实体——整个子树被深度复制。
要点:RelationshipTarget 的 clone 行为根据 linked_spawn 和 clone 模式(linked vs move)分别处理,确保关系一致性。
本章小结
本章我们深入了 Bevy 的 Relationship 和 Hierarchy 系统:
- Relationship trait:源实体的组件,指向目标实体,是双向关系的"真相源"
- RelationshipTarget trait:目标实体的自动维护列表,通过 Component Hooks 同步
- 双向维护:
on_insert添加反向引用,on_discard清理反向引用,替换操作先 discard 后 insert - ChildOf/Children:内置父子关系,
linked_spawn实现级联销毁 - 自定义 Relationship:支持额外字段、自引用(
allow_self_referential)、不同集合类型 - Traversal:沿 Relationship 链遍历实体,支持事件传播(冒泡)
- Clone 行为:linked_cloning 递归克隆子树,保证关系一致性
至此,我们完成了 ECS 通信层的全部内容:Commands 延迟执行结构性变更(第 11 章),Event/Message/Observer 实现系统间通信(第 12 章),Relationship/Hierarchy 建立实体间的结构性纽带(本章)。下一部分,我们将进入引擎子系统层——看 ECS 的这些核心机制如何渗透到渲染、物理、UI 等具体领域。
第 14 章:渲染架构 — 双 World 与 Extract 模式
导读:前面 13 章我们深入探索了 ECS 内核:World、Entity、Component、 Archetype、Query、System、Schedule、变更检测、Commands、Event、Message、Observer、 Relationship。从本章开始,我们将看到这些 ECS 机制如何渗透到 Bevy 引擎的 各个子系统中。渲染架构是最能体现 Rust 所有权模型与 ECS 设计哲学融合的子系统。
14.1 为什么需要双 World?
Bevy 的渲染运行在一个独立的 SubApp 中,拥有自己的 World 和 Schedule。这不是偶然的设计选择,而是 Rust 所有权模型的自然推论。
核心矛盾:渲染需要读取游戏数据(Mesh、Material、Transform),而游戏逻辑需要修改这些数据。在 Rust 中,&T 和 &mut T 不能同时存在。如果渲染和游戏逻辑共享同一个 World,要么串行执行(浪费 CPU),要么引入 unsafe(破坏安全性)。
graph LR
subgraph MainWorld["Main World"]
direction TB
GL["Game Logic"]
TU["Transform 更新"]
IP["Input 处理"]
AL["Asset 加载"]
SM["Schedule: Main"]
end
subgraph RenderWorld["Render World"]
direction TB
GPU["GPU 资源准备"]
SB["排序 & 批处理"]
DC["绘制调用"]
SR["Schedule: Render"]
end
MainWorld -- "Extract (拷贝/克隆)" --> RenderWorld
MainWorld -. "Frame N+1 逻辑" .-> Parallel["并行执行"]
RenderWorld -. "Frame N 渲染" .-> Parallel
图 14-1: 双 World 架构与 pipelined rendering 并行示意
解决方案:Main World 拥有数据的 &mut 权限,Render World 拥有自己的副本。两者通过 Extract 阶段做一次单向拷贝,之后各自独立运行。在默认更新路径中,Main Schedule 先执行,随后才进行 Extract 和 RenderApp 更新;如果额外启用 PipelinedRenderingPlugin,渲染还能与后续帧的模拟并行重叠。
#![allow(unused)] fn main() { // 源码: crates/bevy_render/src/lib.rs /// A label for the rendering sub-app. #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, AppLabel)] pub struct RenderApp; }
RenderApp 是一个 SubApp,拥有独立的 World、独立的 Schedule、独立的 Resource。当启用 PipelinedRenderingPlugin 时,Main World 和 Render World 甚至可以在不同线程上并行执行。
Rust 设计亮点:双 World 是所有权驱动的架构决策。Rust 的借用规则 使得 "共享可变" 成为编译错误,Bevy 不是绕过这个限制,而是顺应它—— 通过数据拷贝换取安全的并行执行。这比传统引擎的锁机制更高效、更安全。
双 World 架构的时序值得仔细理解。在默认模式下,Bevy 会先运行 Main App 的默认 Schedule,然后对每个 SubApp 执行 extract,再运行 SubApp 自己的 Schedule。也就是说,Main World 在本帧 PostUpdate 中产生的结果,会在同一轮更新中被提取到 Render World。只有在额外启用 PipelinedRenderingPlugin 时,渲染才会移到不同线程,与第 N+1 帧模拟重叠执行;这时读者可以把它理解为"渲染相对模拟存在约一帧流水线延迟"。因此,"总是落后一帧"不是双 World 本身的普遍结论,而是 pipelined rendering 的时序特征。
如果不使用双 World,Bevy 有哪些替代方案?一种是"加锁"——用 RwLock 保护共享数据,渲染线程读取、游戏线程写入。但锁的竞争会导致两个线程相互等待,实际并行度很低。另一种是 "copy-on-write"——只在数据被修改时才拷贝,节省不变数据的拷贝开销。Bevy 的 ExtractResource 实际上使用了 is_changed() 检测来实现类似的优化——只有变化的数据才会被重新拷贝。完整的 copy-on-write 语义需要更复杂的引用计数或版本号机制,目前 Bevy 选择了更简单直接的"每帧全量拷贝 + 变更检测优化"策略。
要点:Render World 是独立的 SubApp,拥有自己的 World 和 Schedule。双 World 架构是 Rust 所有权模型的自然推论,通过数据拷贝换取安全并行。
14.2 Extract 模式:跨 World 数据同步
Extract 是双 World 之间的桥梁——在每帧开始时,将 Main World 中渲染所需的数据拷贝到 Render World。这个过程由 ExtractSchedule 驱动:
#![allow(unused)] fn main() { // 源码: crates/bevy_render/src/extract_plugin.rs /// Schedule in which data from the main world is 'extracted' into the render world. #[derive(ScheduleLabel, PartialEq, Eq, Debug, Clone, Hash, Default)] pub struct ExtractSchedule; }
ExtractSchedule 有一个特殊配置:auto_insert_apply_deferred 被禁用,Commands 的应用被推迟到 Render Schedule 的 ExtractCommands 阶段。这允许 Extract 阶段快速完成(最小化阻塞 Main World 的时间),而 Commands 在渲染线程上异步执行。
Bevy 提供三种提取方式:
ExtractComponent
将 Main World 中的 Component 提取到 Render World 对应实体上:
#![allow(unused)] fn main() { // 源码: crates/bevy_render/src/extract_component.rs (简化) pub trait ExtractComponent<F = ()>: SyncComponent<F> { type QueryData: ReadOnlyQueryData; type QueryFilter: QueryFilter; type Out: Bundle<Effect: NoBundleEffect>; fn extract_component(item: QueryItem<'_, '_, Self::QueryData>) -> Option<Self::Out>; } }
通过 extract_component 方法,开发者可以在提取时做数据变换——不是简单的 Clone,而是可以裁剪、转换、合并。返回 None 会从 Render World 中移除对应组件。
ExtractResource
将 Main World 中的 Resource 提取到 Render World:
#![allow(unused)] fn main() { // 源码: crates/bevy_render/src/extract_resource.rs (简化) pub trait ExtractResource<F = ()>: Resource { type Source: Resource; fn extract_resource(source: &Self::Source) -> Self; } pub fn extract_resource<R: ExtractResource<F>, F>( mut commands: Commands, main_resource: Extract<Option<Res<R::Source>>>, target_resource: Option<ResMut<R>>, ) { if let Some(main_resource) = main_resource.as_ref() { if let Some(mut target_resource) = target_resource { if main_resource.is_changed() { // Changed 检测! *target_resource = R::extract_resource(main_resource); } } else { commands.insert_resource(R::extract_resource(main_resource)); } } } }
注意内部的 is_changed() 检查——只有 Resource 发生变化时才执行实际拷贝,这是变更检测(第 10 章)在渲染管线中的直接应用。
ExtractInstance
比 ExtractComponent 更高性能的批量提取,避免 ECS 开销:
#![allow(unused)] fn main() { // 源码: crates/bevy_render/src/extract_instances.rs (简化) pub trait ExtractInstance: Send + Sync + Sized + 'static { type QueryData: ReadOnlyQueryData; type QueryFilter: QueryFilter; fn extract(item: QueryItem<'_, '_, Self::QueryData>) -> Option<Self>; } /// Stores all extracted instances in a HashMap Resource, not per-entity Components. #[derive(Resource, Deref, DerefMut)] pub struct ExtractedInstances<EI>(MainEntityHashMap<EI>); }
ExtractInstance 将提取结果存储在一个 HashMap Resource 中,而不是写入每个实体的 Component——减少了实体同步的开销。
提取规模
Bevy 引擎内部有 83+ 次 Extract 操作,覆盖了渲染所需的几乎所有数据:
graph LR
subgraph MW["Main World"]
Camera
Mesh3d
Material
Light
Transform
Fog
Visibility
SSAO
More["... (83+ extractions)"]
end
subgraph RW["Render World"]
ExtractedCamera
ExtractedMesh
PreparedMaterial
ExtractedLight
RenderTransform
ExtractedFog
ExtractedVisib["ExtractedVisibility"]
ExtractedSSAO
More2["..."]
end
Camera -- "ExtractComponent" --> ExtractedCamera
Mesh3d -- "ExtractComponent" --> ExtractedMesh
Material -- "ExtractResource" --> PreparedMaterial
Light -- "ExtractComponent" --> ExtractedLight
Transform -- "ExtractComponent" --> RenderTransform
Fog -- "ExtractResource" --> ExtractedFog
Visibility -- "ExtractComponent" --> ExtractedVisib
SSAO -- "ExtractResource" --> ExtractedSSAO
图 14-2: Extract 数据流示意
Extract 的拷贝开销是双 World 架构的主要性能代价。83+ 次 Extract 操作意味着每帧都有大量的数据复制。在一个拥有 10 万个渲染实体的场景中,仅 Transform 的提取就需要复制 10 万个矩阵(每个 48 字节 Affine3A),总计约 4.8MB 的数据搬运。ExtractResource 的 is_changed() 优化缓解了 Resource 级别的冗余拷贝,但 ExtractComponent 通常每帧都会遍历所有匹配实体。这就是 ExtractInstance 存在的原因——它将结果存储在 HashMap Resource 中而非逐实体写入 Component,避免了在 Render World 中维护实体对应关系的开销。具体成本会随着提取的数据量、平台和启用的渲染特性显著变化,源码本身并没有给出统一的帧时间占比。对于对帧时间极度敏感的应用,可以考虑自定义 Extract 系统,只提取真正变化的数据,利用第 10 章的 Changed<T> 过滤器进一步减少拷贝量。
要点:三种提取方式(Component、Resource、Instance)覆盖不同场景。ExtractResource 利用 Changed 检测避免冗余拷贝。全引擎 83+ 次 Extract 形成完整的数据同步管线。
14.3 RenderSystems:20 个核心阶段的渲染管线
Render World 的主 Schedule 是 Render,其中通过 RenderSystems 枚举定义了顶层阶段和嵌套子阶段,共 20 个 SystemSet:
#![allow(unused)] fn main() { // 源码: crates/bevy_render/src/lib.rs (简化) #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] pub enum RenderSystems { ExtractCommands, // Apply extract commands PrepareAssets, // Prepare GPU resources PrepareMeshes, // Prepare mesh data CreateViews, // Create shadow map views etc. Specialize, // Specialize materials/meshes PrepareViews, // Prepare view data Queue, // Queue drawable entities // QueueMeshes, // - Queue mesh entities // QueueSweep, // - Sweep invisible items PhaseSort, // Sort render phases Prepare, // Prepare GPU data // PrepareResources, // - Init buffers/textures // PrepareResourcesBatchPhases, // PrepareResourcesWritePhaseBuffers, // PrepareResourcesCollectPhaseBuffers, // PrepareResourcesFlush, // PrepareBindGroups, // - Build bind groups Render, // Actual GPU rendering Cleanup, // Cleanup PostCleanup, // Despawn temporary entities } }
这些阶段通过 chain() 串联,形成严格的顺序依赖:
graph TD
EC["ExtractCommands"] --> PA["PrepareAssets"]
PA --> PM["PrepareMeshes"]
PM --> CV["CreateViews"]
CV --> SP["Specialize"]
SP --> PV["PrepareViews"]
PV --> Q["Queue"]
Q --> QM["QueueMeshes"]
Q --> QS["QueueSweep"]
Q --> PS["PhaseSort"]
PS --> P["Prepare"]
subgraph Prepare
PR["PrepareResources"] --> BP["BatchPhases"]
BP --> WPB["WritePhaseBuffers"]
WPB --> CPB["CollectPhaseBuffers"]
CPB --> FL["Flush"]
FL --> PBG["PrepareBindGroups"]
end
P --> PR
PBG --> R["Render"]
R --> CL["Cleanup"]
CL --> PCL["PostCleanup"]
图 14-3: RenderSystems 阶段执行顺序
每个阶段都是一个 SystemSet,开发者可以将自定义渲染 System 插入到任意阶段。这种设计复用了 Schedule 的系统编排能力(第 9 章),让渲染管线的扩展与游戏逻辑的扩展使用完全相同的机制。
Render::base_schedule() 负责构建整个渲染 Schedule 的拓扑结构:
#![allow(unused)] fn main() { // 源码: crates/bevy_render/src/lib.rs impl Render { pub fn base_schedule() -> Schedule { let mut schedule = Schedule::new(Self); schedule.configure_sets( (ExtractCommands, PrepareMeshes, CreateViews, Specialize, PrepareViews, Queue, PhaseSort, Prepare, Render, Cleanup, PostCleanup).chain(), ); // Nested sets within Prepare schedule.configure_sets( (PrepareResources, PrepareResourcesBatchPhases, PrepareResourcesWritePhaseBuffers, PrepareResourcesCollectPhaseBuffers, PrepareResourcesFlush, PrepareBindGroups) .chain().in_set(Prepare), ); schedule } } }
渲染管线之所以需要如此多的有序阶段,是因为 GPU 编程有严格的数据准备顺序。Mesh 数据必须在 Draw Call 之前上传到 GPU 缓冲区;材质必须在绑定 Bind Group 之前被特化(Specialize);排序必须在 Queue 之后、Render 之前完成。这些约束是硬件驱动的,不可重排。通过将每个阶段建模为 SystemSet(第 9 章),Bevy 让自定义渲染代码可以自然地"插入"到管线的任意位置——例如后处理效果在 Render 阶段添加系统,自定义材质在 Specialize 阶段注册。这种统一性是 ECS 架构的巨大优势:游戏开发者不需要学习一套完全不同的渲染管线 API,只需理解 System、SystemSet 和 Schedule 这些已经熟悉的 ECS 概念。
要点:RenderSystems 定义了 20 个有序阶段,使用 SystemSet + chain() 建立依赖关系。渲染管线的扩展机制与游戏逻辑完全一致——都是向 Schedule 添加 System。
14.4 实体同步:SyncToRenderWorld
Main World 和 Render World 中的实体不是同一个——它们有不同的 Entity ID。Bevy 通过 SyncToRenderWorld 标记和 RenderEntity/MainEntity 映射来维护对应关系:
#![allow(unused)] fn main() { // 源码: crates/bevy_render/src/sync_world.rs (概念) #[derive(Component)] pub struct SyncToRenderWorld; // Main World entity marker #[derive(Component)] pub struct MainEntity(Entity); // On Render World entity: points to Main #[derive(Component)] pub struct RenderEntity(Entity); // On Main World entity: points to Render }
当 Main World 中一个带有 SyncToRenderWorld 的实体被创建时,entity_sync_system 会在 Render World 中创建一个对应实体,并建立双向映射。这确保了 Extract 阶段能正确地将数据从 Main World 实体传输到 Render World 对应实体。
临时渲染实体(TemporaryRenderEntity)在每帧的 PostCleanup 阶段被销毁,避免资源泄漏。
实体同步的设计揭示了双 World 架构的一个根本复杂性:两个 World 各自独立分配 Entity ID,因此同一个逻辑对象在两个 World 中有不同的 ID。RenderEntity 和 MainEntity 的双向映射需要在每次实体创建/销毁时维护,增加了一层间接寻址的开销。如果 Bevy 只使用单 World,这个复杂性完全不存在。这是双 World 安全并行的另一个代价——不仅是数据拷贝,还包括实体映射的维护。TemporaryRenderEntity 的帧末销毁则防止了渲染专用实体(如临时粒子效果)在 Render World 中无限积累,确保内存使用保持稳定。
要点:SyncToRenderWorld 标记建立 Main 到 Render 实体映射,Extract 系统通过 RenderEntity/MainEntity 组件维护跨 World 的实体对应关系。
14.5 RenderScheduleOrder 与恢复机制
Render World 的调度顺序由 RenderScheduleOrder Resource 管理:
#![allow(unused)] fn main() { // 源码: crates/bevy_render/src/lib.rs #[derive(Resource, Debug)] pub struct RenderScheduleOrder { pub labels: Vec<InternedScheduleLabel>, } impl RenderScheduleOrder { pub fn insert_after(&mut self, after: impl ScheduleLabel, schedule: impl ScheduleLabel) { ... } pub fn insert_before(&mut self, before: impl ScheduleLabel, schedule: impl ScheduleLabel) { ... } } }
这是 Resource 驱动调度的典型案例——Schedule 的执行顺序本身就是存储在 World 中的数据,可以在运行时动态修改。Plugin 可以通过 insert_after / insert_before 在渲染管线中插入自定义 Schedule。
此外,Bevy 提供了 RenderStartup Schedule 和 RenderState Resource。当渲染设备丢失(如窗口最小化后恢复)时,RenderStartup 会重新执行,重新创建所有 GPU 资源——这是一个 Resource + Schedule 协同实现的优雅恢复机制。
RenderScheduleOrder 将 Schedule 的执行顺序从"硬编码在代码中"提升为"存储在 World 中的数据",这是 ECS 数据驱动哲学的又一体现。传统引擎的渲染管线扩展通常需要修改引擎源码或通过回调注册——前者侵入性强,后者缺乏顺序控制。Bevy 让 Plugin 在初始化阶段通过 insert_after/insert_before 精确地将自定义 Schedule 插入到管线的任意位置,整个过程只是修改一个 Resource 的数据。这种设计也使得渲染管线的调试更容易——你可以在运行时查询 Res<RenderScheduleOrder> 来查看完整的管线结构。
RenderStartup 和 RenderState 的恢复机制展示了 ECS 在处理外部硬件事件时的能力。GPU 设备丢失是一个真实的运行时事件——窗口最小化、系统休眠、GPU 驱动崩溃都可能导致。传统方法是注册一个全局的"设备丢失回调",手动重建所有 GPU 资源。Bevy 将"是否需要重建"存储为 RenderState Resource,将"重建逻辑"组织为 RenderStartup Schedule 中的系统——设备恢复变成了"重新运行一个 Schedule",而非散落在各处的特殊代码。
要点:RenderScheduleOrder 用 Resource 驱动 Schedule 顺序,支持运行时动态插入。RenderStartup 配合 RenderState 实现 GPU 资源的丢失恢复。
本章小结
本章我们从 ECS 视角剖析了 Bevy 的渲染架构:
- 双 World 是 Rust 所有权模型的自然推论——拷贝数据换取安全并行
- Extract 模式 通过三种 trait(Component、Resource、Instance)实现 83+ 次跨 World 数据同步
- ExtractResource 内部利用
is_changed()避免冗余拷贝 - RenderSystems 定义 20 个有序阶段,复用 Schedule 的系统编排能力
- SyncToRenderWorld 建立跨 World 的实体映射
- RenderScheduleOrder 用 Resource 驱动调度顺序
渲染架构是 ECS 设计最极致的体现:连 "如何渲染" 这件事,都被分解为 World、Entity、Component、Resource、System、Schedule 这些 ECS 原语的组合。
下一章,我们将看到渲染中最基础的数据——Transform——如何利用 Hierarchy 和 Changed 检测实现高效的坐标传播。
第 15 章:Transform 系统
导读:Transform 是几乎所有游戏实体都拥有的组件——位置、旋转、缩放。 但在有父子层级的场景中,一个子实体的世界坐标取决于它所有祖先的 Transform。 本章探索 Bevy 如何用 Hierarchy(第 13 章)和 Changed 检测(第 10 章) 实现高效的坐标传播,以及静态场景优化如何跳过不变的子树。
15.1 Transform vs GlobalTransform
Bevy 将变换分为两个组件:
#![allow(unused)] fn main() { // 源码: crates/bevy_transform/src/components/transform.rs (简化) #[derive(Component)] #[require(GlobalTransform, TransformTreeChanged)] pub struct Transform { pub translation: Vec3, pub rotation: Quat, pub scale: Vec3, } // 源码: crates/bevy_transform/src/components/global_transform.rs (简化) #[derive(Component)] pub struct GlobalTransform(Affine3A); }
| Transform | GlobalTransform | |
|---|---|---|
| 语义 | 相对于父实体的局部变换 | 相对于世界原点的全局变换 |
| 谁写 | 用户/游戏逻辑 | 引擎传播系统(只读) |
| 存储 | Vec3 + Quat + Vec3 (TRS) | Affine3A (3x4 仿射矩阵) |
| Required | 自动附带 GlobalTransform | - |
用户只需修改 Transform,Bevy 的传播系统会自动计算 GlobalTransform:
graph TD
Root["World Root"]
Player["Player<br/>Transform: pos=(0,5,0)<br/>GlobalTransform = pos=(0,5,0)"]
Sword["Sword<br/>Transform: pos=(1,0,0)<br/>GlobalTransform = pos=(1,5,0) ← 父 + 本地"]
Shield["Shield<br/>Transform: pos=(-1,0,0)<br/>GlobalTransform = pos=(-1,5,0) ← 父 + 本地"]
Tree["Tree<br/>Transform: pos=(10,0,0)<br/>GlobalTransform = pos=(10,0,0) ← 无父"]
Root --> Player
Root --> Tree
Player --> Sword
Player --> Shield
图 15-1: Transform 传播树
注意 Transform 通过 #[require(GlobalTransform, TransformTreeChanged)] 声明了 Required Components(第 5 章)——spawn 一个带 Transform 的实体时,GlobalTransform 和 TransformTreeChanged 会自动附加。
Rust 设计亮点:GlobalTransform 内部使用
Affine3A(3x4 仿射矩阵)而非 TRS 分量。 这是因为矩阵乘法可以高效地链式组合变换,而 TRS 分量的组合需要先转矩阵再乘再拆—— 存储已经组合好的矩阵避免了重复转换的开销。Affine3A使用 SIMD 对齐(128 位), 在批量运算时充分利用硬件加速。
为什么将变换分为两个组件而非合并为一个?这看似增加了复杂度,但解决了一个根本问题:在有层级关系的场景中,修改父实体的 Transform 应该自动影响所有后代的世界坐标。如果只有一个"世界坐标"组件,用户修改父实体时必须手动更新所有后代——这在有深层嵌套的 UI 或骨骼动画中几乎不可行。分离为局部(Transform)和全局(GlobalTransform)后,用户只需修改局部坐标,引擎自动计算全局坐标。这种设计在几乎所有现代游戏引擎中都有体现——Unity 的 localPosition/position、Godot 的 position/global_position——Bevy 将其映射到 ECS 的组件模型中。
分离设计的代价是存储开销和一致性延迟。每个实体额外存储一个 Affine3A(48 字节),在 10 万个实体的场景中约占 4.8MB。更重要的是,GlobalTransform 只在 PostUpdate 阶段被更新(见 15.4),如果你在 Update 中修改了 Transform 然后立即读取 GlobalTransform,读到的是上一帧的值。这种一帧延迟在大多数场景中不可感知,但在需要精确物理交互的场景中可能导致微妙的位置偏差。propagate_transforms_for 函数提供了按需提前传播的能力来解决特殊场景。
要点:Transform 是局部坐标(用户写),GlobalTransform 是世界坐标(引擎算)。Required Components 确保两者总是成对出现。
15.2 传播系统:Hierarchy × Changed 检测
Transform 传播发生在 PostUpdate 阶段,由两个核心系统组成:
sync_simple_transforms
处理没有父子关系的实体——它们的 GlobalTransform 直接等于 Transform:
#![allow(unused)] fn main() { // 源码: crates/bevy_transform/src/systems.rs (简化) pub fn sync_simple_transforms( mut query: ParamSet<( Query< (&Transform, &mut GlobalTransform), ( Or<(Changed<Transform>, Added<GlobalTransform>)>, Without<ChildOf>, Without<Children>, ), >, // ... orphaned entities handling )>, mut orphaned: RemovedComponents<ChildOf>, ) { query.p0().par_iter_mut() .for_each(|(transform, mut global_transform)| { *global_transform = GlobalTransform::from(*transform); }); } }
关键过滤条件:Changed<Transform> 或 Added<GlobalTransform>,同时 Without<ChildOf> 且 Without<Children>。这意味着:
- 只处理变化的实体(Changed 检测)
- 只处理没有层级关系的实体(交给专门的传播系统处理有层级的)
- 使用
par_iter_mut并行执行
propagate_parent_transforms
处理有父子关系的实体——需要从根到叶递归传播:
传播算法
对每个根实体 (Without<ChildOf>):
global = Transform → GlobalTransform
对每个子实体 (递归):
global = parent.global * child.transform
传播系统利用 ChildOf Relationship(第 13 章)遍历层级树,利用 Changed<Transform> 检测跳过未变化的子树。
ParamSet 的使用值得关注——它解决了 Query 之间的别名冲突问题(第 7 章),让同一个系统可以安全地在不同过滤条件下访问相同组件。
两个系统的分工是性能优化的直接体现。在大多数游戏中,大部分实体是"独立"的——它们没有父子关系。对于这些实体,GlobalTransform 就是 Transform 的直接转换,不需要矩阵乘法。sync_simple_transforms 利用 Without<ChildOf> 和 Without<Children> 过滤器精确命中这些实体,并使用 par_iter_mut 进行并行处理——在 10 万个独立实体中,如果只有 1000 个发生了移动,Changed<Transform> 过滤器将工作量缩减到 1%,再由多核并行执行,效率极高。
层级传播的成本则显著更高。propagate_parent_transforms 必须从根到叶递归遍历层级树,对每个有父子关系的实体执行矩阵乘法:child.global = parent.global * child.transform。这个过程是天然串行的——子实体的 GlobalTransform 依赖父实体的 GlobalTransform,无法完全并行化。在一棵有 10 层深度的 UI 层级树中,每一层都必须等待上一层计算完成。这就是为什么第 15.3 节的 StaticTransformOptimizations 如此重要——通过脏位向上传播,可以跳过整棵不变的子树,将 O(所有层级实体) 降低到 O(变化子树的实体)。
要点:两个系统分工明确——simple 处理无层级实体,propagate 处理有层级实体。Changed 检测避免每帧重新计算所有 GlobalTransform。
15.3 StaticTransformOptimizations:脏位传播
在大型静态场景中(如建筑、地形),绝大多数实体的 Transform 每帧都不变。传播系统仍然需要遍历整棵层级树来检查 Changed——这在上万个静态实体时会成为性能瓶颈。
StaticTransformOptimizations 通过脏位 (dirty bit) 向上传播解决这个问题:
#![allow(unused)] fn main() { // 源码: crates/bevy_transform/src/systems.rs #[derive(Resource, Debug, Default, PartialEq, Eq)] pub enum StaticTransformOptimizations { #[default] Enabled, Disabled, } }
核心机制由 mark_dirty_trees 系统实现:
#![allow(unused)] fn main() { // 源码: crates/bevy_transform/src/systems.rs (简化) pub fn mark_dirty_trees( changed: Query<Entity, Or<(Changed<Transform>, Changed<ChildOf>, Added<GlobalTransform>)>>, mut transforms: Query<&mut TransformTreeChanged>, parents: Query<&ChildOf>, static_optimizations: Res<StaticTransformOptimizations>, ) { // For each changed entity, walk UP the tree marking ancestors as dirty for entity in changed.iter() { let mut next = entity; while let Ok(mut tree) = transforms.get_mut(next) { if tree.is_changed() && !tree.is_added() { break; // Already marked, stop climbing } tree.set_changed(); if let Ok(parent) = parents.get(next).map(ChildOf::parent) { next = parent; } else { break; } } } } }
工作流程:
graph TD
subgraph Before["Frame N: 变化前"]
R1["Root"]
P1["Player"]
S1["Sword ⚡changed"]
Sh1["Shield"]
T1["Tree"]
R1 --> P1
P1 --> S1
P1 --> Sh1
R1 --> T1
end
subgraph After["标记后"]
R2["Root 🔴dirty"]
P2["Player 🔴dirty"]
S2["Sword 🔴dirty ← 起点"]
Sh2["Shield"]
T2["Tree ← 整棵子树跳过!"]
R2 --> P2
P2 --> S2
P2 --> Sh2
R2 --> T2
end
Before -- "脏位向上传播" --> After
图 15-2: 脏位向上传播优化静态场景
在 std 环境下,mark_dirty_trees 使用并行实现——通过 AtomicU64 位集和多线程 channel 协同标记脏位,确保在大场景中依然高效:
graph LR
P["Producer (single)<br/>找到 changed 实体"] --> TW["Traversal Workers (many)<br/>爬树 + atomic OR 标记位集"]
TW --> C["Consumer (single)<br/>调用 set_changed()"]
图 15-3: 并行脏位标记流水线
这种三阶段流水线设计(producer-traversal-consumer)利用了 ComputeTaskPool(第 23 章),实现了标记过程的完全并行化。
脏位策略的核心洞察是:在一个典型的大场景中,绝大多数实体每帧都不变。一个有 10 万个实体的城市场景中,也许每帧只有 50 个角色在移动。如果没有脏位优化,传播系统需要遍历全部 10 万个实体来检查 Changed<Transform>——即使 99.95% 的实体没有变化。脏位向上传播将问题转化为:只从变化的叶节点开始,向上标记到根节点。传播系统在遍历时检查根节点是否被标记——如果没有,整棵子树直接跳过。
为什么脏位需要"向上"传播而非"向下"?因为传播系统是从根到叶遍历的——它首先需要知道"根节点的某个后代是否发生了变化",才能决定是否进入这棵子树。如果脏位向下传播,传播系统仍然需要从根开始检查每个节点,无法实现子树级别的跳过。向上传播让根节点成为"哨兵"——一个干净的根意味着整棵子树都是干净的。
AtomicU64 位集的使用反映了脏位标记在大场景中的规模挑战。当数百个实体同时发生变化时,它们可能在不同线程上并行地标记各自的祖先链。如果使用普通的 set_changed() 调用,多个线程同时修改同一个祖先的 TransformTreeChanged 组件会导致数据竞争。原子位集允许多个线程无锁地并行标记,然后由单一消费者线程将位集转换为实际的组件变更标记。这种三阶段流水线(producer-traversal-consumer)的设计直接借鉴了并行图算法中的常见模式。
要点:StaticTransformOptimizations 通过脏位向上传播,让传播系统跳过整棵不变子树。大场景下使用 atomic 位集 + 多线程流水线并行标记。默认启用。
15.4 TransformSystems 与执行时机
Transform 传播在 PostUpdate 阶段执行,由 TransformSystems SystemSet 组织:
#![allow(unused)] fn main() { // 源码: crates/bevy_transform/src/plugins.rs (概念) #[derive(SystemSet)] pub enum TransformSystems { MarkDirtyTrees, // Step 1: mark dirty trees Propagate, // Step 2: propagate transforms } }
执行顺序:MarkDirtyTrees → Propagate。
这意味着如果你在 PostUpdate 或之后修改 Transform,GlobalTransform 的更新会延迟一帧。这是有意为之的设计——所有游戏逻辑(Update)完成后,统一传播一次,避免在同一帧内多次重复计算。
propagate_transforms_for<F> 是一个泛型辅助系统,允许对特定过滤条件的实体提前传播——例如在同一帧内移动并渲染相机:
#![allow(unused)] fn main() { // 源码: crates/bevy_transform/src/systems.rs pub fn propagate_transforms_for<F: QueryFilter + 'static>( tf_helper: TransformHelper, mut query: Query<(Entity, &mut GlobalTransform), F>, ) { for (entity, mut gtf) in query.iter_mut() { if let Ok(computed) = tf_helper.compute_global_transform(entity) { *gtf = computed; } } } }
"PostUpdate 统一传播"的设计体现了批处理优化的哲学。如果允许 Transform 变更在任意时刻立即传播 GlobalTransform,那么一个实体在同一帧内被移动三次就会触发三次传播——前两次是浪费。通过推迟到 PostUpdate,无论 Transform 被修改了多少次,传播只执行一次,使用最终值。这与第 10 章的变更检测配合得很好——Changed<Transform> 不关心"变了几次",只关心"是否变了"。这种设计假设游戏逻辑在 Update 阶段完成所有 Transform 修改,渲染在 PostUpdate 之后读取 GlobalTransform。如果某个系统需要在 Update 中读取最新的 GlobalTransform(例如将相机对准刚移动的目标),可以使用 TransformHelper::compute_global_transform() 按需计算,或者使用 propagate_transforms_for 提前传播特定实体。
要点:Transform 传播在 PostUpdate 统一执行,游戏逻辑应在 Update 中修改 Transform。propagate_transforms_for 提供按需提前传播的能力。
本章小结
本章我们探索了 Bevy Transform 系统的 ECS 设计:
- Transform 是局部坐标(用户写),GlobalTransform 是世界坐标(引擎算),通过 Required Components 自动配对
- 两个系统分工:sync_simple_transforms 处理无层级实体,propagate_parent_transforms 处理有层级实体
- Changed 检测避免每帧重算所有 GlobalTransform
- StaticTransformOptimizations 通过脏位向上传播,跳过整棵不变子树
- 并行脏位标记使用 atomic 位集 + 三阶段流水线
- 传播在 PostUpdate 统一执行,确保一帧内只算一次
Transform 系统是 ECS 各机制协同工作的典范:Component(Transform/GlobalTransform)、Relationship(ChildOf 层级)、Changed 检测、ParamSet、并行迭代、SystemSet 排序——全部无缝配合。
下一章,我们将看到另一个核心子系统——Asset 系统如何用 Handle
第 16 章:Asset 系统
导读:游戏引擎需要管理大量外部资源——纹理、模型、音频、字体。Bevy 的 Asset 系统将这些资源建模为 ECS 原语:AssetServer 是 Resource,Handle
用 PhantomData 实现编译期类型安全,Asset 事件驱动响应链, AssetLoader/Processor/Saver 构成完整的资产管线。
16.1 Handle:PhantomData 的强类型设计
Handle<T> 是 Asset 系统的核心类型——它是一个对特定类型 Asset 的引用:
#![allow(unused)] fn main() { // 源码: crates/bevy_asset/src/handle.rs (简化) #[derive(Reflect)] pub enum Handle<A: Asset> { /// Strong reference: keeps the asset alive until all handles are dropped. Strong(Arc<StrongHandle>), /// Uuid reference: does not keep the asset alive. Uuid(Uuid, PhantomData<fn() -> A>), } }
StrongHandle 内部没有类型参数,它存储 TypeId 和 AssetIndex:
#![allow(unused)] fn main() { // 源码: crates/bevy_asset/src/handle.rs pub struct StrongHandle { pub(crate) index: AssetIndex, pub(crate) type_id: TypeId, // Runtime type identifier pub(crate) asset_server_managed: bool, pub(crate) path: Option<AssetPath<'static>>, pub(crate) meta_transform: Option<MetaTransform>, pub(crate) drop_sender: Sender<DropEvent>, // Drop notification } }
类型安全完全由 Handle<A> 的 PhantomData<fn() -> A> 保证——在 Uuid 变体中,PhantomData 让编译器区分 Handle<Image> 和 Handle<Mesh>,但不占用运行时内存。
Handle<T> 类型安全
Handle<Image> ──→ AssetId<Image> ──→ Assets<Image> ✓
Handle<Mesh> ──→ AssetId<Mesh> ──→ Assets<Mesh> ✓
Handle<Image> ──→ AssetId<Image> ──→ Assets<Mesh> ✗ 编译错误!
PhantomData<fn() -> A> 在编译期阻止类型混用
图 16-1: Handle
Rust 设计亮点:Handle 在 Strong 变体中使用
Arc<StrongHandle>实现引用计数, 在 Uuid 变体中使用PhantomData<fn() -> A>>——注意是fn() -> A而非A。 使用函数指针类型作为 PhantomData 参数,使 Handle 对 A 既不拥有 (own) 也不借用 (borrow),避免了不必要的 Drop、Send/Sync 约束传播。这是 Rust 中 PhantomData 的最佳实践用法。
引用计数与自动卸载
StrongHandle 实现了 Drop——当最后一个 Strong Handle 被丢弃时,它通过 drop_sender 通知 AssetServer,触发资产卸载:
#![allow(unused)] fn main() { // 源码: crates/bevy_asset/src/handle.rs impl Drop for StrongHandle { fn drop(&mut self) { let _ = self.drop_sender.send(DropEvent { index: ErasedAssetIndex::new(self.index, self.type_id), asset_server_managed: self.asset_server_managed, }); } } }
这是 RAII 模式的直接应用——资源的生命周期由 Handle 的所有权决定,无需手动管理。
Handle 的引用计数策略是一个深思熟虑的设计选择。使用 Arc(原子引用计数)而非 Rc(非原子引用计数)是因为 Handle 需要在多线程环境中安全传递——系统可能在不同线程上运行,共享同一个 Handle。原子操作的开销在每次 clone/drop 时约为几纳秒,对于 Handle 的使用频率而言完全可以接受。与之对比,如果使用垃圾回收(GC)来管理 Asset 生命周期,虽然可以避免引用计数的开销,但会引入不确定的 GC 停顿——这在需要稳定帧时间的游戏中是不可接受的。
Strong Handle 的 drop_sender 通道设计值得注意:当最后一个 Handle 被 drop 时,它不是直接释放 Asset,而是发送一个 DropEvent 通知 AssetServer。这种间接释放有两个原因:首先,Handle 的 drop 可能发生在任意线程、任意时刻,直接释放 Asset 可能导致正在使用该 Asset 的渲染线程出现数据竞争;其次,AssetServer 可以在统一的时间点(如帧末)批量处理释放请求,避免频繁的碎片化释放。这种"通知式卸载"模式与 Rust 的所有权系统完美契合——Handle 拥有对 Asset 的"逻辑所有权",但"物理释放"由 AssetServer 统一管理。
要点:Handle
16.2 AssetServer:Resource 驱动的异步加载
AssetServer 是一个全局 Resource,负责协调资产的加载、跟踪和卸载:
#![allow(unused)] fn main() { // 源码: crates/bevy_asset/src/server/mod.rs (概念) // AssetServer is backed by an Arc, so clones share state. // It can be freely used in parallel. }
典型的加载流程:
graph TD
AS["AssetServer (Resource)"] -- "load()" --> IO["IoTaskPool (async)"]
IO -- "AssetReader" --> FS["文件系统 / 网络"]
FS -- "bytes" --> AL["AssetLoader<br/>trait: load(bytes) → Asset"]
AL -- "Asset" --> AT["Assets<T> (Resource)<br/>HashMap<AssetId, T>"]
AT --> AE["AssetEvent<T>::Added<br/>Message 通知"]
AS -- "立即返回" --> H["Handle<T><br/>(asset loads async)"]
图 16-2: Asset 加载生命周期
关键设计点:
- 立即返回 Handle:
load()立即返回一个 Handle,资产在后台异步加载。System 可以检查加载状态 - IoTaskPool 异步执行:磁盘/网络 I/O 不阻塞 ECS 主循环
- Assets
Resource :每种 Asset 类型有独立的Assets<T>Resource,本质是HashMap<AssetId, T>
AssetServer 作为 Resource 存储在 World 中,这意味着它完全融入 ECS 的访问控制框架。系统通过 Res<AssetServer> 只读访问它来发起加载请求,通过 ResMut<Assets<T>> 可变访问来手动创建或修改 Asset。调度器(第 9 章)确保对 Assets<Image> 的可变访问不会与对同一 Resource 的只读访问并行——这比传统引擎中用锁保护 AssetManager 更安全,因为冲突在调度层面被阻止,而非在运行时通过锁等待。立即返回 Handle 的设计也与 Rust 的异步模型一致——Handle 类似于一个 "Future",它代表了一个尚未完成的加载操作,但可以被存储、传递和引用。
16.3 AssetLoader / Processor / Saver 管线
Asset 管线由三个 trait 组成:
#![allow(unused)] fn main() { // 源码: crates/bevy_asset/src/loader.rs (简化) pub trait AssetLoader: TypePath + Send + Sync + 'static { type Asset: Asset; type Settings: Settings + Default + Serialize + Deserialize; type Error: Into<BevyError>; fn load( &self, reader: &mut dyn Reader, settings: &Self::Settings, load_context: &mut LoadContext, ) -> impl ConditionalSendFuture<Output = Result<Self::Asset, Self::Error>>; fn extensions(&self) -> &[&str] { &[] } } // 源码: crates/bevy_asset/src/saver.rs (简化) pub trait AssetSaver: TypePath + Send + Sync + 'static { type Asset: Asset; type Settings: Settings; type OutputLoader: AssetLoader; // Loader for the saved format type Error: Into<BevyError>; // ... } }
graph TD
SRC["源文件 (.png, .gltf, .ogg)"] --> AL["AssetLoader<br/>load(bytes)"]
AL -- "开发期" --> PR["Processor (可选)<br/>process() — 压缩/转换"]
PR --> SV["AssetSaver<br/>save()"]
SV --> OUT["处理后的资产文件 (.processed)"]
图 16-3: AssetLoader / Processor / Saver 三阶段管线
Processor 和 Saver 主要用于开发期的资产预处理——将源文件转换为运行时优化的格式。运行时只使用 AssetLoader。
16.4 Asset 事件与 Changed 检测链
Asset 系统通过 AssetEvent<T> Message 通知其他系统资产状态变化:
#![allow(unused)] fn main() { // 源码: crates/bevy_asset/src/event.rs #[derive(Message, Reflect)] pub enum AssetEvent<A: Asset> { Added { id: AssetId<A> }, Modified { id: AssetId<A> }, Removed { id: AssetId<A> }, Unused { id: AssetId<A> }, LoadedWithDependencies { id: AssetId<A> }, } }
这些事件驱动了一条响应链:
graph TD
A["AssetEvent<Image>::Modified"] --> B["Material 系统检测到依赖的纹理变了"]
B --> C["重新准备 GPU 纹理 (PrepareAssets)"]
C --> D["标记使用该材质的实体需要重新渲染"]
AssetChanged<T> Query 过滤器进一步简化了响应链的编写——它让 System 直接查询 "哪些实体的 Asset Handle 指向的资产发生了变化":
#![allow(unused)] fn main() { // Usage in a system fn react_to_material_changes( query: Query<&MeshMaterial3d<StandardMaterial>, AssetChanged<StandardMaterial>>, ) { for material_handle in &query { // This entity's material asset was modified } } }
热重载 (Hot Reload)
热重载建立在 Asset 事件之上:
AssetSource监听文件系统变更(AssetSourceEvent)- 文件变更触发重新加载(通过 AssetLoader)
- 重新加载完成后发出
AssetEvent::Modified - 依赖该资产的系统自动响应
整个链路不需要特殊的热重载代码——已有的事件响应机制自然支持热重载。
热重载的边缘情况值得深入讨论。最常见的问题是"部分加载"——当文件系统事件通知文件已更改时,文件可能还在被编辑器写入,此时读取会得到不完整的数据。Bevy 的 AssetSource 通过可配置的"去抖动"(debounce)延迟来缓解这个问题——等待文件变更事件停止一段时间后才触发重新加载。另一个边缘情况是"依赖链重载"——当一个 GLTF 模型引用的纹理被修改时,GLTF 本身没有变化,但渲染结果应该更新。Bevy 通过 LoadedWithDependencies 事件和 AssetLoader 的依赖追踪机制来处理这种情况:AssetLoader 在加载时声明依赖的子资产,当子资产变更时,父资产也会收到重新加载通知。
AssetChanged<T> 过滤器是变更检测(第 10 章)在 Asset 领域的创造性扩展。普通的 Changed<Handle<T>> 只能检测 Handle 本身是否被替换——例如将实体的材质从 A 换为 B。但 AssetChanged<T> 还能检测 Handle 指向的 Asset 内容是否被修改——例如材质 A 的颜色属性被热重载改变了。这种深层变更检测需要跨越 Handle → AssetId → Assets
要点:AssetEvent 驱动完整的变更响应链。AssetChanged
16.5 Assets 的 ECS 存储模式
每种 Asset 类型都有独立的 Assets<T> Resource:
#![allow(unused)] fn main() { // 源码: crates/bevy_asset/src/assets.rs (概念) #[derive(Resource)] pub struct Assets<A: Asset> { // Internal storage: dense array indexed by AssetIndex // ... handle_provider: AssetHandleProvider, } }
Assets<T> 作为 Resource 存储在 World 中,System 通过 Res<Assets<Image>>、ResMut<Assets<Mesh>> 等方式访问。这意味着对 Assets<Image> 和 Assets<Mesh> 的访问互不冲突——调度器可以将访问不同 Asset 类型的 System 并行执行。
Rust 设计亮点:
Assets<T>利用泛型实现了按类型分片的存储。每个T生成 独立的 Resource 类型(单态化),使得 ECS 调度器可以精确判断 System 之间是否存在 数据竞争。这比 "一个大 AssetManager" 的设计更细粒度,带来更好的并行性。
这种按类型分片的存储模式与第 5 章 Component 的列式存储(Column)设计思路一脉相承——不同类型的数据存储在不同的容器中,使得对不同类型的访问互不干扰。在规模上,一个典型的大型游戏可能有数十种 Asset 类型、数千个 Asset 实例。按类型分片意味着一个系统处理 Image 资产时,不会阻塞另一个系统处理 AudioSource 资产。如果使用传统的"一个 AssetManager 管理所有类型"的设计,对任意 Asset 的访问都会相互排斥,并行度大幅降低。代价是每种 Asset 类型都需要注册独立的 Resource,增加了 World 中的 Resource 数量,但这种注册通常由 Plugin 自动完成,对用户透明。
要点:每种 Asset 类型有独立的 Assets
本章小结
本章我们从 ECS 视角探索了 Bevy 的 Asset 系统:
- Handle
用 PhantomData<fn() -> A>实现零成本编译期类型安全 - Strong Handle 通过
Arc + Drop实现 RAII 式自动卸载 - AssetServer 作为 Resource 驱动异步加载,立即返回 Handle
- AssetLoader/Processor/Saver 构成三阶段管线
- AssetEvent 驱动变更响应链,热重载自然建立在事件机制之上
- Assets
按类型分片存储,最大化 System 并行度
Asset 系统展示了 ECS 的另一个维度:Resource 不仅存储状态,还驱动异步工作流。Handle 的 PhantomData 设计是 Rust 类型系统在游戏引擎中的精妙应用。
下一章,我们将看到 Input 系统如何在 Resource 和 Entity+Component 两种模式之间选择最合适的 ECS 建模方式。
第 17 章:Input 系统
导读:输入系统是游戏引擎中最直接面向用户的子系统。Bevy 的 Input 系统 展示了 ECS 建模的两种典型策略:键盘/鼠标用 Resource(全局唯一), 手柄用 Entity + Component(多设备多实例)。此外,Pointer 抽象统一了 鼠标和触摸输入,common_conditions 则展示了如何将输入状态转化为系统条件。
17.1 ButtonInput:泛型 Resource 模式
键盘和鼠标按键的状态存储在全局 Resource 中:
#![allow(unused)] fn main() { // 源码: crates/bevy_input/src/button_input.rs (简化) #[derive(Resource)] pub struct ButtonInput<T: Clone + Eq + Hash + Send + Sync + 'static> { pressed: HashSet<T>, just_pressed: HashSet<T>, just_released: HashSet<T>, } }
三个 HashSet 分别记录当前帧的状态:
| 方法 | 含义 | 持续时间 |
|---|---|---|
pressed(key) | 按键被按住 | 从按下到释放 |
just_pressed(key) | 按键刚被按下 | 仅一帧 |
just_released(key) | 按键刚被释放 | 仅一帧 |
通过泛型参数 T,同一个数据结构同时服务于键盘和鼠标:
ButtonInput 的泛型实例化
ButtonInput<KeyCode> ← Res<ButtonInput<KeyCode>> 键盘
ButtonInput<MouseButton> ← Res<ButtonInput<MouseButton>> 鼠标
ButtonInput<GamepadButton> ← 由 Gamepad Entity 持有 手柄 (见 17.2)
图 17-1: ButtonInput
为什么选择 Resource?键盘和鼠标都是全局唯一设备——整个系统只有一个键盘状态、一个鼠标状态。Resource 是 ECS 中表达 "全局唯一数据" 的正确方式。
每帧的更新流程:
graph TD
A["平台事件 (winit/gilrs)"] --> B["KeyboardInput / MouseButtonInput<br/>← Message"]
B --> C["更新 ButtonInput<KeyCode> Resource<br/>← PreUpdate"]
C --> D["用户 System 读取 Res<ButtonInput<KeyCode>><br/>← Update"]
D --> E["帧末清除 just_pressed / just_released<br/>← PostUpdate"]
图 17-2: 输入事件处理流水线
Rust 设计亮点:
ButtonInput<T>的泛型约束T: Clone + Eq + Hash + Send + Sync + 'static精确表达了 "可以用作 HashSet 键" 且 "可以跨线程安全访问" 的需求。这比面向对象 的继承层级更精确——不需要T是某个 "Input" 基类的子类,只需满足数学上的约束。
为什么键盘使用 Resource 而手柄使用 Entity?这个选择背后是 ECS 建模的一个核心原则:实例数量决定存储方式。一台电脑只有一个键盘状态——即使连接了多个物理键盘,操作系统也将它们合并为一个逻辑设备。Resource 是 ECS 中"全局唯一数据"的正确抽象——它不需要 Entity ID,不参与 Archetype 组织,不会出现在 Query 中。如果将键盘状态建模为 Entity + Component,会引入不必要的复杂性:需要分配 Entity ID、需要用 Query 查找(但永远只有一个结果)、需要处理键盘实体被意外销毁的情况。Resource 的直接访问(Res<ButtonInput<KeyCode>>)比 Query 的间接访问(Query<&ButtonInput<KeyCode>, With<Keyboard>>)在语义和性能上都更优。
三个 HashSet 的设计也值得讨论。just_pressed 和 just_released 只在一帧内有效——它们在帧末被清空。这种"边沿触发"(edge-triggered)语义与"电平触发"(level-triggered)的 pressed 互补:游戏中的跳跃通常只需在按下瞬间触发一次(just_pressed),而移动需要在按住期间持续生效(pressed)。如果没有 just_pressed,用户就需要自行维护上一帧的按键状态来检测边沿,这是一个常见的错误来源。HashSet 的选择而非位数组(BitSet)是因为 KeyCode 枚举的值域较大且稀疏——大多数帧中只有少数几个键被按下,HashSet 的空间效率优于位数组。
要点:ButtonInput
17.2 Gamepad:Entity + Component 模式
手柄与键盘鼠标不同——一台电脑可以连接多个手柄。Bevy 将每个手柄建模为一个 Entity:
#![allow(unused)] fn main() { // 源码: crates/bevy_input/src/gamepad.rs (概念) // Each connected gamepad is an Entity with these components: // - Name component: "Xbox Controller" // - GamepadSettings component: deadzone, threshold // - ButtonInput<GamepadButton> component (per-gamepad) // - Axis<GamepadInput> component (per-gamepad) }
Gamepad 的 Entity + Component 建模
Entity A (Gamepad 1): Entity B (Gamepad 2):
┌────────────────────────────┐ ┌────────────────────────────┐
│ Name("Player 1 Pad") │ │ Name("Player 2 Pad") │
│ GamepadSettings │ │ GamepadSettings │
│ ButtonInput<GamepadButton> │ │ ButtonInput<GamepadButton> │
│ Axis<GamepadInput> │ │ Axis<GamepadInput> │
└────────────────────────────┘ └────────────────────────────┘
每个手柄是独立 Entity,拥有自己的输入状态
图 17-3: Gamepad 的 Entity + Component 建模
这种设计的优势:
- 多设备支持:每个手柄有独立的状态和配置
- Query 友好:
Query<(&Name, &ButtonInput<GamepadButton>)>直接遍历所有手柄 - 灵活配置:每个手柄可以有不同的死区设置
手柄事件系统处理连接/断开和输入更新:
#![allow(unused)] fn main() { // 源码: crates/bevy_input/src/gamepad.rs (概念) pub struct GamepadConnectionEvent { pub gamepad: Entity, // The gamepad entity pub connection: GamepadConnection, } pub enum GamepadConnection { Connected(GamepadInfo), Disconnected, } }
连接时创建 Entity,断开时销毁——实体的生命周期直接映射到物理设备的生命周期。
Gamepad 的 Entity 建模展示了 ECS 在处理动态数量对象时的优雅。在传统面向对象设计中,你可能需要一个 Vec<Gamepad> 或 HashMap<GamepadId, Gamepad> 来管理多个手柄。但在 ECS 中,每个手柄就是一个 Entity,它的状态和配置是 Components——所有已有的 ECS 基础设施(Query 遍历、Changed 检测、Commands 创建/销毁、Observer 生命周期事件)自动适用。当手柄断开时,despawn 对应实体即可,所有引用它的系统在下一帧自然不再查询到它。这比手动维护容器和清理引用要安全得多——不存在悬空引用的风险,因为 Entity 的生命周期由 World 统一管理。
这种建模也使得多玩家本地游戏的实现变得自然——每个手柄 Entity 可以与一个玩家 Entity 建立 Relationship(第 13 章),系统通过 Query 同时处理所有玩家的输入,无需特殊的多玩家分发逻辑。如果手柄是 Resource,就需要为每个手柄分配一个独立的 Resource 类型或使用索引——这在手柄数量不确定时非常笨拙。
要点:Gamepad 用 Entity + Component 建模多设备。每个手柄是独立 Entity,拥有自己的输入状态和配置。实体生命周期映射到设备生命周期。
17.3 Pointer 抽象:统一鼠标与触摸
bevy_picking 模块提供了 Pointer 抽象,统一了鼠标和触摸输入:
graph LR
Mouse["鼠标"] --> P0["Pointer (id=0)"]
Touch["触摸"] --> PN["Pointer (id=N)"]
Pen["手写笔"] --> PM["Pointer (id=M)"]
P0 --> PI["PointerInput 事件"]
PN --> PI
PM --> PI
PI --> US["上层系统<br/>只处理 Pointer,不关心具体输入来源"]
图 17-4: Pointer 统一抽象层
Pointer 也被建模为 Entity——每个指针(鼠标光标或触摸点)是一个独立实体。这使得多指触控自然地映射到多个 Entity,与单一鼠标指针使用完全相同的 Query 模式。
这种抽象在 UI 系统(第 19 章)中发挥重要作用——UI 的 Picking 后端只需处理 Pointer Entity,不需要分别处理鼠标和触摸。
Pointer 抽象的设计哲学是"输入归一化"——将物理上不同的输入设备映射到统一的逻辑模型中。这个概念在 Web 平台上已经被 PointerEvent API 验证过(统一了 mouse、touch、pen),Bevy 将其移植到了 ECS 的 Entity 模型中。与 Web 的区别在于,Bevy 的每个 Pointer 都是一个可查询的 Entity——你可以为 Pointer 附加自定义 Component(例如 PointerStyle 改变光标样式),通过 Query 同时处理所有 Pointer,甚至用 Observer(第 12 章)监听 Pointer Entity 的创建和销毁。这种 Entity 化的抽象比传统的"输入事件回调"更灵活——Pointer 有持续的状态(位置、压力等),可以被其他系统随时查询,而非仅在事件发生时才可访问。
要点:Pointer 将鼠标、触摸、手写笔统一为 Entity,上层系统无需关心具体输入来源。多指触控自然映射为多个 Entity。
17.4 common_conditions:输入状态转系统条件
bevy_input 提供了一组系统条件 (System Condition),将输入状态转化为 run_if 条件:
#![allow(unused)] fn main() { // 源码: crates/bevy_input/src/common_conditions.rs (简化) pub fn input_toggle_active<T>( default: bool, input: T, ) -> impl FnMut(Res<ButtonInput<T>>) -> bool + Clone where T: Clone + Eq + Hash + Send + Sync + 'static, { let mut active = default; move |inputs: Res<ButtonInput<T>>| { active ^= inputs.just_pressed(input.clone()); active } } pub fn input_just_pressed<T>( input: T, ) -> impl FnMut(Res<ButtonInput<T>>) -> bool + Clone where T: Clone + Eq + Hash + Send + Sync + 'static, { move |inputs: Res<ButtonInput<T>>| inputs.just_pressed(input.clone()) } }
使用示例:
#![allow(unused)] fn main() { app.add_systems(Update, pause_menu.run_if(input_toggle_active(false, KeyCode::Escape)) ); }
这些条件是有状态的闭包——input_toggle_active 内部用 mut active 维护切换状态。这是 Bevy System 条件机制(第 9 章)的实际应用:条件本身也是 System,可以拥有 Local 状态。
graph TD
A["input_just_pressed(KeyCode::Space)"] --> B["impl FnMut(Res<ButtonInput<KeyCode>>) -> bool"]
B --> C[".run_if(...)<br/>← Schedule 条件系统 (第 9 章)"]
C -- "返回 true" --> D["执行关联的 System"]
C -- "返回 false" --> E["跳过"]
图 17-5: common_conditions 工作流程
common_conditions 展示了 ECS 系统条件机制(第 9 章)的实际威力。在传统的游戏循环中,暂停功能通常需要在每个相关系统中添加 if !paused { ... } 检查。使用 run_if(input_toggle_active(false, KeyCode::Escape)),暂停逻辑被提取到 Schedule 层面——系统本身不知道也不关心暂停的存在,条件求值在系统执行之前完成。这种关注点分离使得暂停功能可以从一个中心位置配置,而非分散在数十个系统中。
input_toggle_active 中的 mut active 状态是通过闭包捕获实现的——每个使用此条件的系统都有自己独立的状态副本。这与 System 的 Local<T> 参数(第 8 章)在概念上等价:每个系统实例拥有自己的本地状态,不同系统间互不影响。条件本身也是一个 System(ReadOnlySystem),它参与 Schedule 的调度——Bevy 的调度器会确保条件系统对 Res<ButtonInput<KeyCode>> 的读取不与其他系统的写入冲突。
要点:common_conditions 将输入状态转化为 run_if 条件,是有状态闭包与 System 条件机制的结合。input_toggle_active 展示了条件系统可以维护跨帧状态。
17.5 输入系统的 ECS 建模选择
回顾整个 Input 系统的 ECS 建模,可以提炼出一个通用的选择准则:
| 场景 | ECS 建模 | 原因 |
|---|---|---|
| 全局唯一 (键盘、鼠标) | Resource | 只有一个实例,无需 Entity |
| 多实例 (手柄、Pointer) | Entity + Component | 每个设备独立,Query 友好 |
| 事件流 (按键事件) | Message | 瞬时事件,帧内消费 |
| 条件过滤 | System Condition | 决定 System 是否执行 |
这种选择准则不仅适用于 Input,也适用于引擎的其他子系统。
这个选择准则可以扩展为一个更通用的 ECS 建模决策树。当你面对一个新的数据或概念需要在 ECS 中表达时,首先问:"它有几个实例?"如果全局唯一,用 Resource;如果有多个实例且需要独立状态,用 Entity + Component。然后问:"它是持续存在还是瞬时的?"持续状态用 Resource 或 Component,瞬时事件用 Message 或 Observer。最后问:"它需要驱动系统执行还是仅被系统消费?"如果需要控制系统是否执行,用 System Condition。Input 系统完美地展示了这个决策树的每个分支:键盘(全局唯一→Resource)、手柄(多实例→Entity+Component)、按键事件(瞬时→Message)、暂停切换(控制执行→Condition)。掌握这个决策树是在 Bevy 中进行高效 ECS 建模的关键技能。
要点:全局唯一用 Resource,多实例用 Entity + Component,瞬时数据用 Message,条件过滤用 System Condition。
本章小结
本章我们从 ECS 视角分析了 Bevy 的 Input 系统:
- ButtonInput
用泛型 Resource 统一键盘和鼠标,三个 HashSet 追踪按键状态 - Gamepad 用 Entity + Component 建模多设备,实体生命周期映射到设备连接状态
- Pointer 统一鼠标、触摸、手写笔为 Entity,上层无需关心输入来源
- common_conditions 将输入状态转化为 run_if 条件,支持有状态的条件闭包
- ECS 建模选择遵循 "唯一性决定存储方式" 原则
Input 系统虽然简单,但清晰地展示了 ECS 不同原语(Resource vs Entity+Component vs Message)的适用场景。
下一章,我们将看到 State 系统如何用 ECS 实现有限状态机,以及 OnEnter/OnExit 如何成为独立 Schedule。
第 18 章:State 系统
导读:几乎所有游戏都需要状态机——主菜单、游戏中、暂停、结算。Bevy 的 State 系统将有限状态机完全建立在 ECS 之上:状态值是 Resource,状态转换 触发独立的 Schedule(OnEnter/OnExit),DespawnWhen 利用状态转换 Message 自动 清理实体,run_if(in_state(...)) 让系统只在特定状态下运行。
18.1 States trait:有限状态机的基石
States trait 定义了什么可以作为状态:
#![allow(unused)] fn main() { // 源码: crates/bevy_state/src/state/states.rs pub trait States: 'static + Send + Sync + Clone + PartialEq + Eq + Hash + Debug { const DEPENDENCY_DEPTH: usize = 1; } }
约束很简洁:Clone + PartialEq + Eq + Hash + Debug + Send + Sync + 'static——本质上就是 "可以比较、可以哈希、可以跨线程、可以持久存储" 的值类型。
为什么 Bevy 选择将状态建模为 Schedule 驱动的系统,而非简单的布尔标志或枚举检查?在 Unity 中,状态管理通常依赖于 MonoBehaviour 中的 if (state == X) 条件判断,这导致状态逻辑分散在无数个组件中,难以追踪状态转换的完整链路。Godot 的状态机虽然更结构化,但仍然是面向对象的——状态节点持有自身的进入/退出回调,与场景树紧密耦合。Bevy 的设计哲学截然不同:状态值只是一个 Resource,状态转换触发独立的 Schedule 执行。这意味着状态系统完全复用 ECS 已有的原语(Resource、Schedule、Message、run_if),而非引入新的运行时机制。这种设计的代价是概念密度较高——初学者需要理解 Schedule 才能理解 State——但收益是状态系统与 ECS 的其他部分完全正交,不存在任何特殊路径。
使用 derive 宏定义状态:
#![allow(unused)] fn main() { #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, States)] enum GameState { #[default] MainMenu, InGame, Paused, GameOver, } }
状态值存储在两个 Resource 中:
#![allow(unused)] fn main() { // 源码: crates/bevy_state/src/state/resources.rs (概念) #[derive(Resource)] pub struct State<S: States>(S); // Current state #[derive(Resource)] pub struct NextState<S: States>(Option<S>); // Queued next state }
状态转换流程:
graph TD
A["System 写入 NextState<br/>next_state.set(GameState::InGame)"] --> B["StateTransition Schedule<br/>(PreUpdate 后执行)"]
B --> C["1. 读取 NextState<GameState>"]
C --> D{"2. 有值?"}
D -- "是" --> E["OnExit(GameState::MainMenu)<br/>← 独立 Schedule"]
E --> F["OnTransition<br/>exited: MainMenu, entered: InGame"]
F --> G["OnEnter(GameState::InGame)<br/>← 独立 Schedule"]
G --> H["3. 更新 State<GameState> = InGame"]
H --> I["4. 清空 NextState<GameState> = None"]
I --> J["5. 发出 StateTransitionEvent"]
图 18-1: 状态转换流程
这种双 Resource 设计(State + NextState)暗含了一个重要的时序保证:在同一帧中,多个 System 可以各自调用 next_state.set(),但只有最后一次写入生效,且状态转换推迟到 StateTransition Schedule 统一执行。这避免了帧中间状态不一致的问题——如果状态转换是即时的,一个 System 刚切换状态,后续 System 可能还在读取旧状态的数据,导致逻辑错误。在默认主循环顺序下,StateTransition 位于 PreUpdate 之后、Update 之前,因此 OnEnter/OnExit 中通过 Commands 生成的实体通常会在同一帧后续的 Update / PostUpdate 中可见;真正被避免的是“在更早阶段读到半完成的状态切换结果”这类时序问题。
要点:States trait 约束简洁明确。状态值存储在 State 和 NextState 两个 Resource 中。转换由 StateTransition Schedule 驱动。
18.2 三种状态类型
Bevy 提供三种状态类型,覆盖从简单到复杂的需求:
States (基础状态)
用户直接控制,通过 NextState::set() 手动触发转换:
#![allow(unused)] fn main() { fn handle_input(mut next_state: ResMut<NextState<GameState>>) { if escape_pressed { next_state.set(GameState::Paused); } } }
SubStates (子状态)
只在父状态满足特定条件时才存在:
#![allow(unused)] fn main() { // 源码: crates/bevy_state/src/state/sub_states.rs (概念) #[derive(SubStates, Clone, PartialEq, Eq, Hash, Debug, Default)] #[source(AppState = AppState::InGame)] // Only exists when InGame enum GamePhase { #[default] Setup, Battle, Conclusion, } }
当 AppState 不是 InGame 时,State<GamePhase> Resource 不存在。
ComputedStates (计算状态)
从一个或多个源状态自动派生,不能手动修改:
#![allow(unused)] fn main() { // 源码: crates/bevy_state/src/state/computed_states.rs (概念) pub trait ComputedStates: 'static + Send + Sync + Clone + PartialEq + Eq + Hash + Debug { type SourceStates: StateSet; fn compute(sources: Self::SourceStates) -> Option<Self>; } // Example #[derive(Clone, PartialEq, Eq, Hash, Debug)] struct InGame; impl ComputedStates for InGame { type SourceStates = AppState; fn compute(sources: AppState) -> Option<Self> { match sources { AppState::InGame { .. } => Some(InGame), _ => None, // State resource removed } } } }
graph TD
AS["AppState<br/>(States — 手动控制)"] -- "source" --> GP["GamePhase<br/>(SubStates — 条件存在 + 手动控制)"]
GP -- "source" --> IG["InGame<br/>(ComputedStates — 自动派生)"]
NS1["NextState::set()"] -.-> AS
NS2["NextState::set()<br/>(仅当 InGame)"] -.-> GP
AC["自动计算,不可手动修改"] -.-> IG
图 18-2: 三种状态类型的层级关系
ComputedStates 的设计哲学值得深思。在传统游戏引擎中,派生状态通常通过事件监听或回调实现——当源状态变化时,手动更新派生状态。这种命令式方式容易出错:忘记注册回调、回调执行顺序不确定、多个源状态变化时的组合爆炸。ComputedStates 借鉴了响应式编程的理念——compute 函数是一个纯函数,从源状态到派生状态的映射是声明式的。框架保证每次源状态变化后自动重新计算,开发者只需要描述"派生状态应该是什么",而非"何时更新派生状态"。这种设计的代价是每次源状态变化都会重新计算所有依赖的 ComputedStates,但由于状态转换频率极低(通常每秒不到一次),这个开销完全可以忽略。
Rust 设计亮点:ComputedStates 的
compute函数返回Option<Self>—— 返回None表示该状态不应存在,对应的State<S>Resource 会被移除。 这优雅地利用了 Rust 的 Option 类型来表达 "状态可能不存在" 的语义, 而不是引入额外的 "Invalid" 状态值。
要点:States 手动控制,SubStates 条件存在 + 手动控制,ComputedStates 自动派生。三者通过 SourceStates 形成依赖链。
18.3 OnEnter/OnExit:独立 Schedule
OnEnter 和 OnExit 不是普通的系统集合——它们是独立的 Schedule:
#![allow(unused)] fn main() { // 源码: crates/bevy_state/src/state/transitions.rs #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash, Default)] pub struct OnEnter<S: States>(pub S); #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash, Default)] pub struct OnExit<S: States>(pub S); #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash, Default)] pub struct OnTransition<S: States> { pub exited: S, pub entered: S, } }
每个状态值对应一个独立的 Schedule 实例——OnEnter(GameState::InGame) 和 OnEnter(GameState::Paused) 是两个完全不同的 Schedule。
状态转换的执行顺序由 StateTransitionSystems 控制:
#![allow(unused)] fn main() { // 源码: crates/bevy_state/src/state/transitions.rs #[derive(SystemSet)] pub enum StateTransitionSystems { DependentTransitions, // Apply state changes ExitSchedules, // Run OnExit (leaf → root order) TransitionSchedules, // Run OnTransition EnterSchedules, // Run OnEnter (root → leaf order) } }
graph TD
subgraph ST["StateTransition Schedule"]
direction TB
DT["1. DependentTransitions<br/>Apply NextState changes"]
DT --> EX["2. ExitSchedules (leaf → root)"]
EX --> EX1["OnExit(GamePhase::Battle) ← 子状态先"]
EX1 --> EX2["OnExit(AppState::InGame) ← 父状态后"]
EX2 --> TR["3. TransitionSchedules<br/>OnTransition { exited, entered }"]
TR --> EN["4. EnterSchedules (root → leaf)"]
EN --> EN1["OnEnter(AppState::MainMenu) ← 父先"]
EN1 --> EN2["OnEnter(GamePhase::Setup) ← 子后"]
end
图 18-3: 状态转换调度顺序
Exit 从叶到根、Enter 从根到叶——这保证了子状态的清理在父状态之前,子状态的初始化在父状态之后。
将 OnEnter/OnExit 设计为独立 Schedule 而非普通 System 集合,有深远的架构含义。独立 Schedule 意味着状态转换时的初始化/清理逻辑与正常帧更新的 System 完全隔离——它们不参与 Update Schedule 的拓扑排序,不受 before/after 约束影响,也不会与游戏逻辑系统产生数据访问冲突。这使得状态转换成为一个"原子"操作——要么完整执行,要么不执行。如果 OnEnter 是普通 System,它会被插入 Update 的并行调度中,可能与读取旧状态数据的系统同时运行,产生不一致性。独立 Schedule 的代价是多了一次调度开销,但状态转换每秒最多发生几次,这个代价微不足道。
要点:OnEnter/OnExit 是独立 Schedule,每个状态值一个。Exit 从叶到根,Enter 从根到叶。这保证了层级状态的正确初始化/清理顺序。
18.4 DespawnWhen:StateTransition Message 驱动的实体清理
状态转换时通常需要清理上一个状态的实体(如从 InGame 退出时销毁所有游戏实体)。Bevy 提供了 DespawnWhen 组件:
#![allow(unused)] fn main() { // 源码: crates/bevy_state/src/state_scoped.rs (简化) #[derive(Component)] pub struct DespawnWhen<S: States> { pub state_transition_evaluator: Box<dyn Fn(&StateTransitionEvent<S>) -> bool + Sync + Send + 'static>, } }
以及便捷的 DespawnOnExit 和 DespawnOnEnter:
#![allow(unused)] fn main() { // Usage commands.spawn(( DespawnOnExit(GameState::InGame), Player, Transform::default(), )); // When GameState exits InGame, this entity is automatically despawned. }
实现原理:DespawnWhen 并不依赖 Observer。状态系统会写入 StateTransitionEvent<S> Message,despawn_entities_when_state 系统随后通过 MessageReader<StateTransitionEvent<S>> 读取最新的状态转换,再遍历所有带有 DespawnWhen 组件的实体,执行评估函数,匹配则销毁。DespawnOnExit 和 DespawnOnEnter 也是同一模式的特化版本。
graph TD
A["StateTransition:<br/>1. Apply NextState → State changes"] --> B["2. Send StateTransitionEvent<GameState>"]
B --> C["System 读取 MessageReader"]
C --> D["遍历 Query<(Entity, &DespawnWhen<GameState>)>"]
D --> E{"evaluator(event) == true?"}
E -- "是" --> F["commands.entity(entity).despawn()"]
E -- "否" --> G["跳过"]
图 18-4: DespawnWhen 基于状态转换 Message 的实体清理
这不是 Observer 模式(第 12 章)的直接复用,而是 Message + 普通 System 的声明式封装——无需在 OnExit Schedule 中手动编写清理 System,只需在 spawn 时声明 "何时销毁"。
如果没有 DespawnWhen,开发者需要在每个 OnExit Schedule 中手动编写清理逻辑:查询所有属于当前状态的实体,逐一销毁。这种命令式清理容易遗漏——新增一种实体类型时忘记更新清理系统是常见错误,导致"幽灵实体"在状态转换后残留。DespawnWhen 将清理意图内聚到实体的 spawn 点——创建实体时就声明它的生命周期边界。这种"声明式生命周期"模式在 ECS 中特别有价值:实体的创建和销毁逻辑共同定位,审查代码时不需要在 spawn 和 despawn 系统之间来回跳转。
要点:DespawnWhen/DespawnOnExit 通过 StateTransitionEvent Message + Reader 系统自动清理状态关联实体。声明式而非命令式。
18.5 run_if(in_state(...)):条件系统
in_state 是最常用的状态条件,让 System 只在特定状态下运行:
#![allow(unused)] fn main() { // 源码: crates/bevy_state/src/condition.rs (简化) pub fn in_state<S: States>(state: S) -> impl FnMut(Option<Res<State<S>>>) -> bool + Clone { move |current_state: Option<Res<State<S>>>| { matches!(current_state, Some(s) if *s == State(state.clone())) } } }
使用示例:
#![allow(unused)] fn main() { app.add_systems(Update, ( player_movement.run_if(in_state(GameState::InGame)), menu_navigation.run_if(in_state(GameState::MainMenu)), // These systems never conflict — they run in different states )); }
还有其他状态条件:
#![allow(unused)] fn main() { // State exists? my_system.run_if(state_exists::<GameState>) // State just changed? cleanup_system.run_if(state_changed::<GameState>) }
这些条件本质上是读取 Res<State<S>> Resource 的普通 System——它们完全建立在 ECS 的 Resource 和 System 条件机制之上。
in_state 的设计揭示了一个重要的性能特征:被 run_if(in_state(...)) 守卫的系统在条件不满足时完全不执行——连参数获取(Query 遍历、Resource 读取)都不会发生。这意味着在 MainMenu 状态下,所有标记为 in_state(GameState::InGame) 的系统的开销为零,而非"执行但立即返回"。对于拥有数百个系统的大型游戏,这种差异很重要:条件检查只需要读取一个 Resource 的值并做一次比较,而跳过的系统可能原本需要遍历数千个实体。与第 9 章的 System Condition 机制(run_if)结合,状态系统实现了高效的"系统级别的条件编译"——运行时版本。
要点:in_state 将 State Resource 转化为 run_if 条件。多个状态互斥的系统自然不会冲突。
本章小结
本章我们从 ECS 视角分析了 Bevy 的 State 系统:
- States =
Clone + Eq + Hash + Debug + Send + Sync + 'static的值类型,存储为 Resource - 三种状态类型:States(手动)、SubStates(条件存在)、ComputedStates(自动派生)
- OnEnter/OnExit 是独立 Schedule,Exit 叶→根,Enter 根→叶
- DespawnWhen 利用状态转换 Message 自动清理状态关联实体
- in_state 将 Resource 转化为 System 条件
State 系统的优雅之处在于它完全由已有的 ECS 原语组合而成——Resource(状态值)、Schedule(OnEnter/OnExit)、Message(StateTransitionEvent)、System Condition(in_state)以及普通清理系统。没有引入任何新的运行时机制。
下一章,我们将看到 UI 系统如何将每个 UI 元素建模为 Entity + Component,实现全 ECS 的用户界面。
第 19 章:UI 系统
导读:很多游戏引擎的 UI 系统是一个独立的子框架,有自己的数据模型和 事件机制。Bevy 不同——它的 UI 完全构建在 ECS 之上:每个 UI 元素是 Entity, 布局属性是 Component,布局计算在 PostUpdate 运行,交互通过 Picking + Observer 实现,Widget 是 Component 的组合。
19.1 Node Entity:全 ECS 的 UI 模型
Bevy UI 中,每个 UI 元素都是一个普通的 ECS Entity,通过 Component 组合描述外观和行为:
#![allow(unused)] fn main() { // A simple button in Bevy UI commands.spawn(( Node { width: Val::Px(200.0), height: Val::Px(50.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, BackgroundColor(Color::srgb(0.2, 0.2, 0.8)), Button, // Marker component Interaction::None, // Tracks hover/press state )); }
Node 是 UI 实体的核心 Component,包含所有布局属性(CSS Flexbox/Grid 的子集)。其他外观和行为通过额外的 Component 叠加:
UI Entity 的 Component 组合
Entity (Button)
┌──────────────────────────────────┐
│ Node ← 布局属性 │
│ ComputedNode ← 计算后的尺寸/位置│
│ BackgroundColor ← 背景色 │
│ BorderColor ← 边框色 │
│ Button ← 标记:这是按钮 │
│ Interaction ← 交互状态 │
│ UiGlobalTransform ← UI 变换 │
│ Visibility ← 可见性 │
│ children![] ← 子 Entity (文本等) │
└──────────────────────────────────┘
图 19-1: UI Entity 的 Component 组合
这种 "全 ECS" 模型的直接好处:
- Query 即查询:
Query<&Interaction, With<Button>>直接获取所有按钮的交互状态 - System 即逻辑:UI 逻辑与游戏逻辑使用完全相同的 System 编写方式
- Component 即扩展:添加自定义 Component 即可扩展 UI 功能
- Hierarchy 即布局:父子关系直接决定布局嵌套
为什么 Bevy 选择全 ECS UI 而非 egui 这样的即时模式 UI?即时模式 UI(immediate mode)的优势是简洁直观——调用 ui.button("Click") 立刻返回是否被点击。但即时模式的根本问题是它与 ECS 的数据模型不兼容:即时模式 UI 没有持久化的实体,无法被 Query 查询,无法参与 Change Detection,无法被 Observer 监听。如果 Bevy 采用即时模式 UI,开发者就需要在两套完全不同的编程模型之间来回切换——写游戏逻辑用 System/Component,写 UI 用回调/闭包。全 ECS UI 消除了这种割裂:一个按钮就是一个 Entity,它的点击状态就是一个 Component,检测点击就是一个 Query。当然,全 ECS UI 的代价是更多的样板代码——创建一个按钮需要组合多个 Component,而非一行函数调用。但这种代价换来的是 UI 与游戏逻辑的完全统一,以及 ECS 带来的自动并行和 Change Detection 优化。
Rust 设计亮点:Bevy 的全 ECS UI 模型意味着 UI 不需要独立的事件系统、 布局引擎接口或组件层次。Query、Observer、Component 组合、Hierarchy—— 这些 ECS 原语同时服务于游戏逻辑和 UI 逻辑。这消除了传统引擎中 "游戏代码" 和 "UI 代码" 之间的 impedance mismatch。
要点:UI 元素 = Entity + Component 组合。不存在独立的 "UI 框架"——UI 完全是 ECS 数据。
19.2 布局系统:PostUpdate 的 Flexbox/Grid
UI 布局由 ui_layout_system 驱动,运行在 PostUpdate 阶段:
graph TD
subgraph PreUpdate
F["UiSystems::Focus<br/>← 更新交互状态"]
end
subgraph Update
US["用户 System<br/>← 响应 Interaction 变化"]
end
subgraph PostUpdate
PR["UiSystems::Prepare"]
PG["UiSystems::Propagate<br/>← 传播 UI 属性"]
LY["UiSystems::Layout<br/>← Flexbox/Grid 布局计算"]
PL["UiSystems::PostLayout"]
ST["UiSystems::Stack<br/>← 计算绘制顺序"]
RD["UiSystems::Render<br/>← 准备渲染数据"]
PR --> PG --> LY --> PL --> ST --> RD
end
PreUpdate --> Update --> PostUpdate
图 19-2: UI 系统在 Schedule 中的执行阶段
布局引擎使用 taffy(一个纯 Rust 的 CSS 布局库),支持 Flexbox 和 CSS Grid。Node Component 中的属性直接映射到 taffy 的布局属性:
#![allow(unused)] fn main() { // Node component (simplified from crates/bevy_ui/src/ui_node.rs) pub struct Node { pub display: Display, // Flex, Grid, Block, None pub position_type: PositionType, // Relative, Absolute pub width: Val, pub height: Val, pub flex_direction: FlexDirection, pub justify_content: JustifyContent, pub align_items: AlignItems, // ... CSS-like properties } }
布局计算的结果写入 ComputedNode Component:
#![allow(unused)] fn main() { // 源码: crates/bevy_ui/src/ui_node.rs (简化) #[derive(Component)] pub struct ComputedNode { pub stack_index: u32, // Drawing order pub size: Vec2, // Computed size in physical pixels pub content_size: Vec2, // Content area size pub border: BorderRect, // Resolved border pub border_radius: ResolvedBorderRadius, pub padding: BorderRect, // Resolved padding // ... } }
Node → ComputedNode 的过程就是 "声明式布局属性" → "计算后的绝对值"。这是 ECS 中 Input Component → Computed Component 模式的典型应用。
布局计算为什么放在 PostUpdate 而非 Update?这是一个深思熟虑的时序决策。Update 阶段是用户 System 修改 UI 属性的时机——改变按钮文本、切换面板可见性、调整列表项。如果布局计算也在 Update 中运行,就必须保证布局系统在所有 UI 修改系统之后执行,这需要繁琐的 ordering 约束。将布局推迟到 PostUpdate 确保了所有 Update 中的 UI 修改都已完成,布局只需计算一次就能得到最终结果。这种"先收集所有修改,再统一计算"的模式还有性能好处:如果一帧中多个 System 修改了同一个 Node 的不同属性,布局只计算一次而非多次。在默认渲染路径中,这些 PostUpdate 里的结果会在同一轮更新后被提取并渲染;只有在启用 pipelined rendering 等额外流水线机制时,读者才可能观察到额外的帧级延迟。
要点:布局在 PostUpdate 执行,用 taffy 引擎计算 Flexbox/Grid。Node 声明布局属性,ComputedNode 存储计算结果。
19.3 Interaction 与 Picking
UI 交互由 Interaction Component 追踪:
#![allow(unused)] fn main() { // 源码: crates/bevy_ui/src/focus.rs (简化) #[derive(Component, Copy, Clone, Eq, PartialEq)] pub enum Interaction { Pressed, Hovered, None, } }
传统的交互检测由 ui_focus_system 在 PreUpdate 阶段完成。但 Bevy 同时提供了更强大的 Picking 集成:
#![allow(unused)] fn main() { // 源码: crates/bevy_ui/src/picking_backend.rs (概念) pub struct UiPickingPlugin; // This plugin runs hit tests on UI nodes using Pointer entities (Ch17) }
UI Picking 后端利用了第 17 章的 Pointer 抽象——它对 UI 节点树做射线检测,根据 ComputedNode 的位置和大小判断哪些节点被 Pointer 命中。命中结果通过 Picking 事件系统传播,触发 Observer。
graph TD
A["Pointer Entity (鼠标/触摸)"] --> B["UiPickingBackend: 命中检测<br/>hit test against ComputedNode"]
B --> C["Picking 事件<br/>(Over, Out, Click, Drag...)"]
C --> D["Observer<br/>(on_click, on_hover, ...)"]
D --> E["Widget 逻辑"]
图 19-3: UI 交互链
Picking 在 UI 中的集成展示了 Bevy 架构的一个核心优势:子系统间的复用。第 17 章介绍的 Pointer Entity 和 Picking 后端是为 3D 场景设计的,但它们的抽象足够通用,可以直接用于 UI 命中检测。UI Picking 后端只需将 ComputedNode 的矩形区域注册为 Pickable 目标,其余的事件分发、冒泡、Observer 触发全部由 Picking 框架处理。这避免了 UI 系统重新发明一套事件系统,也意味着 3D 世界中的点击和 UI 按钮的点击使用完全相同的事件管线。对于需要混合 3D 和 2D UI 交互的场景(如 3D 场景中的可点击物体),这种统一性尤其有价值。
要点:Interaction Component 追踪基本交互状态。Picking 后端提供更精确的命中检测,结果通过 Observer 传播。
19.4 Observer 在 Widget 中的应用
Bevy 的 UI Widget(bevy_ui_widgets crate)大量使用 Observer 模式。在以下 7 个 Widget 文件中都使用了 On<> Observer:
| Widget 文件 | Observer 用途 |
|---|---|
button.rs | 点击事件处理 |
checkbox.rs | 选中/取消选中状态切换 |
radio.rs | 单选组互斥选择 |
slider.rs | 拖动值变更 |
scrollbar.rs | 滚动位置更新 |
editable_text.rs | 文本输入处理 |
menu.rs | 菜单展开/收起 |
interaction_states.rs 中的 Observer 展示了 Component 生命周期钩子在 UI 中的应用:
#![allow(unused)] fn main() { // 源码: crates/bevy_ui/src/interaction_states.rs (简化) pub(crate) fn on_add_disabled(add: On<Add, InteractionDisabled>, mut world: DeferredWorld) { let mut entity = world.entity_mut(add.entity()); if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() { accessibility.set_disabled(); } } pub(crate) fn on_remove_disabled(remove: On<Remove, InteractionDisabled>, mut world: DeferredWorld) { let mut entity = world.entity_mut(remove.entity()); if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() { accessibility.clear_disabled(); } } }
当 InteractionDisabled Component 被添加或移除时,Observer 自动更新对应的无障碍属性。这是 响应式 UI 的 ECS 实现——状态变化自动触发副作用,无需轮询。
Observer 在 UI Widget 中的大量使用揭示了一个重要的设计权衡。传统的轮询式 UI(每帧检查 if interaction == Pressed)虽然简单,但随着 Widget 数量增长,每帧需要检查的条件呈线性增长。Observer 模式将开销从"每帧 O(n)"降低到"事件发生时 O(1)"——只有当交互真正发生时才执行逻辑。对于一个包含数百个 UI 元素的复杂界面,大部分元素在大部分帧中都没有交互,Observer 的优势就非常明显。此外,Observer 的声明式风格让 Widget 的行为定义更加内聚——交互逻辑与 Widget 定义紧密绑定,而非分散在独立的 System 中。这与第 12 章介绍的 Observer 设计哲学一脉相承:将因果关系编码在数据附近,而非分散在全局系统中。
更多交互状态 Component 也通过 Observer 维护一致性:
#![allow(unused)] fn main() { // 源码: crates/bevy_ui/src/interaction_states.rs #[derive(Component)] pub struct Pressed; // Button held down #[derive(Component)] pub struct Checkable; // Can be checked #[derive(Component)] pub struct Checked; // Is checked // Observers ensure a11y attributes stay in sync with component state }
要点:7 个 Widget 文件使用 Observer 处理交互事件。Component Add/Remove 钩子实现响应式 UI——状态变化自动触发副作用。
19.5 Focus 与 Accessibility
Focus 系统管理键盘焦点:
#![allow(unused)] fn main() { // 源码: crates/bevy_ui/src/focus.rs (概念) #[derive(Component)] pub enum FocusPolicy { Block, // Blocks focus from passing through Pass, // Allows focus to pass through } }
UiStack Resource 维护了所有 UI 节点的绘制顺序(z-order),Focus 系统使用它来确定哪个节点应该接收键盘焦点。
无障碍 (Accessibility) 通过 AccessibilityNode Component 和 bevy_a11y crate 实现。Observer 确保 UI 状态变化(Disabled、Checked 等)自动同步到无障碍树,使屏幕阅读器等辅助工具获得正确的语义信息。
graph LR
ID["InteractionDisabled Component"] -- "Add" --> O1["Observer"] -- "set_disabled()" --> AN1["AccessibilityNode"]
ID -- "Remove" --> O2["Observer"] -- "clear_disabled()" --> AN2["AccessibilityNode"]
CK["Checked Component"] -- "Add" --> O3["Observer"] -- "set_toggled(True)" --> AN3["AccessibilityNode"]
CK -- "Remove" --> O4["Observer"] -- "set_toggled(False)" --> AN4["AccessibilityNode"]
图 19-4: Accessibility 同步链
要点:FocusPolicy 控制焦点传播。Observer 自动同步 UI 状态到无障碍树,确保辅助工具获得正确语义。
本章小结
本章我们从 ECS 视角分析了 Bevy 的 UI 系统:
- 全 ECS 模型:每个 UI 元素是 Entity + Component 组合,不存在独立 UI 框架
- 布局系统在 PostUpdate 用 taffy 计算 Flexbox/Grid,结果写入 ComputedNode
- Interaction + Picking 提供两层交互检测,Picking 复用 Pointer Entity 抽象
- 7 个 Widget 使用 Observer 处理交互,Component 生命周期钩子实现响应式 UI
- Accessibility 通过 Observer 自动同步 UI 状态到无障碍树
UI 系统是 "ECS 即 UI 框架" 理念的最佳证明——布局是 Component,交互是 Observer,Widget 是 Component 组合,焦点是 Resource。传统 UI 框架的概念在 ECS 中都找到了自然的映射。
下一章,我们将看到 PBR 渲染如何利用 Change Detection 驱动 Shader 编译,以及如何用 Plugin 链组织复杂的渲染功能。
第 20 章:PBR 渲染
导读:PBR (Physically Based Rendering) 是现代 3D 游戏的视觉基础。 本章不深入图形学数学,而是从 ECS 视角分析 Bevy PBR 的架构: StandardMaterial 如何利用 Change Detection 驱动 Shader 编译, Light Clustering 如何作为 per-view Component 工作,24+ 个 Plugin 如何组织,以及 ExtendedMaterial 如何用 trait 组合实现材质扩展。
20.1 StandardMaterial:Change Detection 驱动 Shader
StandardMaterial 是 Bevy 内置的 PBR 材质,实现了 Material trait:
#![allow(unused)] fn main() { // 源码: crates/bevy_pbr/src/pbr_material.rs (概念) #[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] pub struct StandardMaterial { pub base_color: Color, pub base_color_texture: Option<Handle<Image>>, pub metallic: f32, pub perceptual_roughness: f32, pub normal_map_texture: Option<Handle<Image>>, pub emissive: LinearRgba, pub alpha_mode: AlphaMode, // ... 20+ fields } }
StandardMaterial 是一个 Asset(存储在 Assets<StandardMaterial> Resource 中),通过 Handle<StandardMaterial> 被实体引用。当材质属性变化时,需要重新编译对应的 Shader 变体——这个过程由 Change Detection 驱动。
graph TD
A["用户修改 StandardMaterial<br/>material.alpha_mode = AlphaMode::Mask(0.5)"] --> B["Assets<StandardMaterial> 被标记为 Changed"]
B --> C["Material 系统检测到变化<br/>AssetEvent<StandardMaterial>::Modified"]
C --> D["Shader Key 变化<br/>MeshPipelineKey 包含 alpha_mode 位"]
D --> E["PipelineCache: 编译/缓存新的 Shader 变体"]
图 20-1: Material Change Detection 到 Shader 编译的链路
Bevy 使用 Shader Specialization 模式——不同的材质配置(Alpha 模式、纹理开关、法线贴图等)生成不同的 Shader 变体。MeshPipelineKey 是一个 bitflag,编码了影响 Shader 的所有配置:
#![allow(unused)] fn main() { // 概念 (crates/bevy_pbr/src/render/) bitflags! { pub struct MeshPipelineKey: u64 { const ALPHA_MASK = 1 << 0; const BLEND_ALPHA = 1 << 1; const NORMAL_MAP = 1 << 2; const EMISSIVE = 1 << 3; // ... many more flags } } }
只有当 PipelineKey 变化时才需要编译新的 Shader——大多数帧中,材质不变,Shader 直接从缓存读取。这是 Change Detection 在渲染管线中的精确应用。
如果没有 Change Detection 驱动的 Shader Specialization,替代方案是什么?最朴素的做法是为所有可能的材质配置组合预编译 Shader 变体——但 MeshPipelineKey 是一个 64 位 bitflag,理论上有数十亿种组合,预编译显然不可行。另一种做法是每帧都重新判断需要哪些 Shader 变体,但这意味着即使没有任何材质变化也要做大量冗余工作。Change Detection 提供了第三条路:只在材质属性真正变化时才触发 PipelineKey 的重新计算和 Shader 编译。在典型的运行时场景中,绝大多数帧的材质属性不变,Shader 编译开销为零。只有在编辑器中动态调整材质属性或加载新场景时,才会触发编译。这种"惰性编译 + 缓存"策略让 Bevy 在保持灵活性的同时避免了运行时的编译风暴。
要点:StandardMaterial 变化通过 AssetEvent 传播。MeshPipelineKey 编码材质配置,只在 Key 变化时触发 Shader 重编译。
20.2 Light Clustering:per-view Component
在一个有数十个点光源的场景中,每个片元都需要判断哪些光源影响它。Bevy 使用 Light Clustering 将视锥体划分为 3D 网格,每个网格单元记录影响它的光源列表:
Light Clustering
视锥体被划分为 3D 网格 (Cluster Grid):
┌───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ ← 每个 Cluster 记录
├───┼───┼───┼───┤ 影响它的光源列表
│ 4 │ 5 │ 6 │ 7 │
├───┼───┼───┼───┤ Camera A 和 Camera B
│ 8 │ 9 │10 │11 │ 各有独立的 Clustering
└───┴───┴───┴───┘
(一层,实际有多层深度)
图 20-2: Light Clustering 3D 网格
Clustering 是 per-view 的——每个 Camera 有自己的 Cluster 数据。在 ECS 中,这自然地建模为 Camera Entity 上的 Component:
#![allow(unused)] fn main() { // 概念: Light Clustering as per-view Component // Camera entity: // - Camera // - Transform // - GlobalClusterSettings ← Clustering configuration // - ClusterConfig ← Cluster grid dimensions }
GlobalClusterSettings 允许全局配置 Cluster 参数(网格大小、最大光源数等),但实际的 Cluster 数据是每个视图独立计算的。
多摄像机场景(如分屏多人游戏、小地图渲染)中,每个 Camera Entity 独立维护自己的 Cluster 数据——这是 ECS per-entity 数据模型的自然结果。
将 Clustering 建模为 per-view Component 而非全局 Resource 的设计选择反映了 ECS 思维的核心:数据应该存储在最自然的位置。全局 Resource 意味着所有 Camera 共享同一个 Cluster 配置和数据,多摄像机场景需要特殊处理。而 per-view Component 让多摄像机支持变成了"免费"功能——每个 Camera Entity 独立计算,互不干扰。这与第 7 章介绍的 Query 模型完美契合:渲染系统只需 Query<(&Camera, &ClusterData)> 就能遍历所有视图并各自处理。这种建模方式的缺点是可能存在重复计算——如果两个 Camera 看到完全相同的场景,它们的 Cluster 数据会被独立计算两次。但在实践中,不同 Camera 几乎总是有不同的视角和参数,独立计算是正确的默认行为。
要点:Light Clustering 数据作为 per-view Component 存储在 Camera Entity 上。多摄像机自然支持独立 Clustering。
20.3 24+ 个 Plugin 的注册链
PbrPlugin 是 Bevy PBR 渲染的入口,它注册了大量子 Plugin 形成完整的 PBR 功能栈:
#![allow(unused)] fn main() { // 源码: crates/bevy_pbr/src/lib.rs (简化) impl Plugin for PbrPlugin { fn build(&self, app: &mut App) { app.add_plugins(( MeshRenderPlugin { ... }, MaterialsPlugin { ... }, MaterialPlugin::<StandardMaterial> { ... }, ScreenSpaceAmbientOcclusionPlugin, FogPlugin, ExtractResourcePlugin::<DefaultOpaqueRendererMethod>::default(), SyncComponentPlugin::<ShadowFilteringMethod, Self>::default(), LightmapPlugin, LightProbePlugin, GpuMeshPreprocessPlugin { ... }, VolumetricFogPlugin, ScreenSpaceReflectionsPlugin, ScreenSpaceTransmissionPlugin, ClusteredDecalPlugin, ContactShadowsPlugin, )) .add_plugins(( decal::ForwardDecalPlugin, SyncComponentPlugin::<DirectionalLight, Self>::default(), SyncComponentPlugin::<PointLight, Self>::default(), SyncComponentPlugin::<SpotLight, Self>::default(), SyncComponentPlugin::<RectLight, Self>::default(), SyncComponentPlugin::<AmbientLight, Self>::default(), )) .add_plugins(( ScatteringMediumPlugin, AtmospherePlugin, GpuClusteringPlugin, )); } } }
graph TD
PBR["PbrPlugin"]
PBR --> MRP["MeshRenderPlugin<br/>网格渲染"]
PBR --> MTP["MaterialsPlugin<br/>材质管理"]
PBR --> MSP["MaterialPlugin<StdMat><br/>StandardMaterial"]
PBR --> SSAO["SSAOPlugin<br/>屏幕空间环境光遮蔽"]
PBR --> FOG["FogPlugin<br/>距离雾"]
PBR --> LM["LightmapPlugin<br/>光照贴图"]
PBR --> LP["LightProbePlugin<br/>光探针"]
PBR --> GPU["GpuMeshPreprocessPlugin<br/>GPU 网格预处理"]
PBR --> VF["VolumetricFogPlugin<br/>体积雾"]
PBR --> SSR["SSRPlugin<br/>屏幕空间反射"]
PBR --> TR["TransmissionPlugin<br/>透射"]
PBR --> CD["ClusteredDecalPlugin<br/>簇贴花"]
PBR --> CS["ContactShadowsPlugin<br/>接触阴影"]
PBR --> FD["ForwardDecalPlugin<br/>前向贴花"]
PBR --> SC["SyncComponent x6<br/>光源与阴影同步"]
PBR --> SM["ScatteringMediumPlugin<br/>散射介质"]
PBR --> AT["AtmospherePlugin<br/>大气效果"]
PBR --> GC["GpuClusteringPlugin<br/>GPU Clustering"]
图 20-3: PbrPlugin 的子 Plugin 注册链
注意 SyncComponentPlugin 在当前源码中出现了 6 次:其中 5 次为不同光源类型注册 Main World → Render World 的组件同步(第 14 章 Extract 模式),另 1 次同步 ShadowFilteringMethod。每个 Plugin 负责一个独立的渲染功能,通过 ECS 的 System/Resource/Component 与其他 Plugin 协作。
这种 Plugin 链设计体现了一个重要的架构哲学:渲染管线的每个功能应该是可独立开关的。传统的单体渲染器(如 Unity 的内置渲染管线)将所有渲染功能编译在一起,用户只能通过参数调整行为,不能移除不需要的功能。Bevy 的 Plugin 链让开发者可以精确控制渲染功能集——不需要体积雾?不注册 VolumetricFogPlugin。不需要屏幕空间反射?移除 SSRPlugin。这不仅减少了二进制大小,更重要的是消除了不需要的 System 的调度开销。每个 Plugin 只注册自己的 System 和 Resource,通过 ECS Schedule 的 ordering 约束声明与其他 Plugin 的执行顺序关系。这种设计的挑战在于 Plugin 之间的依赖管理——某些 Plugin 依赖其他 Plugin 的 Resource 存在,如果用户禁用了被依赖的 Plugin,可能导致运行时错误。Bevy 通过 Plugin 的 ready 方法和可选的 Resource 查询来缓解这个问题。
Rust 设计亮点:Plugin 链体现了 Rust 的 组合优于继承 原则。 PbrPlugin 不是一个巨大的单体渲染器,而是 24+ 个独立 Plugin 的组合。 每个 Plugin 只注册自己需要的 System 和 Resource,通过 ECS 的 Schedule 排序机制与其他 Plugin 协调执行顺序。用户可以选择性地禁用任何 Plugin。
要点:PbrPlugin 由 24+ 个子 Plugin 组合而成。每个 Plugin 独立注册 System/Resource。SyncComponentPlugin 为 5 种光源和 1 个阴影过滤配置注册同步。
20.4 ExtendedMaterial:trait 组合扩展材质
当 StandardMaterial 不够用时(如需要自定义 Shader 效果),Bevy 提供 ExtendedMaterial<B, E> 的 trait 组合模式:
#![allow(unused)] fn main() { // 源码: crates/bevy_pbr/src/extended_material.rs (简化) pub trait MaterialExtension: Asset + AsBindGroup + Clone + Sized { fn vertex_shader() -> ShaderRef { ShaderRef::Default } fn fragment_shader() -> ShaderRef { ShaderRef::Default } fn alpha_mode() -> Option<AlphaMode> { None } fn enable_prepass() -> bool { true } fn enable_shadows() -> bool { true } // ... } // ExtendedMaterial<StandardMaterial, MyExtension> // combines base material with extension }
使用示例:
#![allow(unused)] fn main() { #[derive(Asset, AsBindGroup, Reflect, Clone)] struct MyEffect { #[uniform(100)] color_shift: LinearRgba, } impl MaterialExtension for MyEffect { fn fragment_shader() -> ShaderRef { "shaders/my_effect.wgsl".into() } } // Register as Material: app.add_plugins(MaterialPlugin::<ExtendedMaterial<StandardMaterial, MyEffect>>::default()); }
ExtendedMaterial 组合
┌────────────────────────────────────┐
│ ExtendedMaterial<B, E> │
│ │
│ Base Material (B = StandardMat): │
│ - base_color, metallic, ... │
│ - PBR shader │
│ │
│ Extension (E = MyEffect): │
│ - color_shift │
│ - custom fragment shader │
│ │
│ Combined: base bindings + ext bindings │
│ Shader: base functions + ext overrides │
└────────────────────────────────────┘
图 20-4: ExtendedMaterial trait 组合
MaterialExtension 的方法都有默认实现——返回 ShaderRef::Default 表示使用基础材质的 Shader。只需覆盖想要自定义的部分。这是 开放-封闭原则 在 Rust trait 中的体现。
ExtendedMaterial 的设计解决了一个游戏开发中的经典难题:如何在不修改引擎源码的情况下扩展渲染效果?传统引擎的解决方案是材质继承(如 Unreal 的 Material Instance),但继承层级过深会导致维护困难和性能问题。Bevy 的 trait 组合方案更加扁平——Extension 直接叠加在 Base 之上,没有多层继承的复杂性。更重要的是,Extension 的 Shader binding 起始索引(如 #[uniform(100)])与 Base 的 binding 不冲突,这是通过约定(而非编译器强制)实现的分离。这种设计让开发者可以为 StandardMaterial 添加自定义效果(如溶解、轮廓、全息)而不影响 PBR 的核心 Shader 逻辑。
Rust 设计亮点:ExtendedMaterial 利用 Rust 的泛型组合
ExtendedMaterial<B, E>而非继承来扩展材质。B是基础材质(通常是 StandardMaterial),E是扩展。 由于 Rust 没有继承,这种 trait 组合是唯一的方式——但它比继承更灵活: 同一个 Extension 可以与不同的 Base 组合,同一个 Base 可以有多种 Extension。
要点:ExtendedMaterial<B, E> 用泛型组合(非继承)扩展材质。MaterialExtension trait 的默认实现实现按需覆盖。
本章小结
本章我们从 ECS 视角分析了 Bevy 的 PBR 渲染:
- StandardMaterial 变化通过 AssetEvent 传播,MeshPipelineKey 编码配置,Change Detection 驱动 Shader 重编译
- Light Clustering 数据作为 per-view Component 存储在 Camera Entity 上
- PbrPlugin 由 24+ 个子 Plugin 组合,体现组合优于继承
- ExtendedMaterial<B, E> 用 trait 组合扩展材质,比继承更灵活
PBR 渲染展示了 ECS 架构在复杂渲染管线中的可扩展性——每个渲染功能是一个独立 Plugin,通过共享的 ECS 数据和 Schedule 排序协同工作。
下一章,我们将看到三个较小但同样精彩的子系统:动画图、BSN 场景宏、文本管线。
第 21 章:动画、场景与文本
导读:本章涵盖三个子系统,它们各自独立但都展示了 ECS 的精妙应用: AnimationGraph 用 Resource + Component 混合模式实现动画混合, BSN 宏用 proc macro 构建声明式场景 DSL,Text Pipeline 集成 Parley 排版引擎与 Font Atlas 字形图集。每个子系统约 800-1000 字。
21.1 AnimationGraph:Resource + Component 混合模式
动画图的结构
Bevy 的动画系统以 AnimationGraph 为核心——一个有向无环图 (DAG),描述多个动画剪辑如何混合:
#![allow(unused)] fn main() { // 源码: crates/bevy_animation/src/graph.rs (简化) #[derive(Asset, Reflect)] pub struct AnimationGraph { graph: DiGraph<AnimationGraphNode, ()>, // petgraph DAG root: NodeIndex, // mask groups, parameters, etc. } pub enum AnimationNodeType { Blend, // Blend children by weight Add, // Additive blending Clip(Handle<AnimationClip>), // Leaf node: play a clip } }
graph LR
Idle["Idle (Clip, weight=1.0)"]
Run["Run (Clip)"]
Walk["Walk (Clip)"]
Blend1["Blend (w=0.5)"]
Root["Root (Blend)"]
Run --> Blend1
Walk --> Blend1
Blend1 --> Root
Idle --> Root
图 21-1: AnimationGraph DAG 结构 — Run+Walk 以 0.5 权重混合后,再与 Idle 混合
Resource + Component 混合
AnimationGraph 是一个 Asset——它可以从文件加载,存储在 Assets<AnimationGraph> Resource 中。但使用它的是 AnimationPlayer Component,挂载在需要动画的实体上:
#![allow(unused)] fn main() { // 源码: crates/bevy_animation/src/lib.rs (简化) #[derive(Component, Reflect)] pub struct AnimationPlayer { // Active animations with weights and timing // ... } // 源码: crates/bevy_animation/src/graph.rs #[derive(Component, Reflect)] pub struct AnimationGraphHandle(pub Handle<AnimationGraph>); }
Resource + Component 混合模式
Assets<AnimationGraph> (Resource):
┌──────────────────────────────────┐
│ Graph A: Idle/Walk/Run blend │ ← 多个实体共享
│ Graph B: Attack combo │ ← Asset, 可热重载
└──────────────────────────────────┘
│ Handle
▼
Entity (Character):
┌──────────────────────────────────┐
│ AnimationGraphHandle(Handle<A>) │ ← Component: 引用哪个图
│ AnimationPlayer { ... } │ ← Component: 播放状态
│ Transform │ ← 被动画驱动
└──────────────────────────────────┘
图 21-2: AnimationGraph 的 Resource + Component 混合存储
AnimationGraph 作为有向无环图的设计选择并非偶然。早期的动画系统通常使用简单的状态机——每个状态播放一个动画,状态间通过条件触发转换。状态机的问题在于扩展性:当需要混合多个动画层(如上半身攻击 + 下半身行走)时,状态数量会组合爆炸。DAG 结构将动画混合建模为数据流图——每个节点可以是剪辑播放、混合、叠加等操作,数据从叶节点流向根节点。这种建模方式天然支持多层混合和权重控制,无需为每种组合定义状态。Bevy 选择 petgraph 库实现 DAG 而非自研数据结构,体现了 Rust 生态复用的理念——petgraph 提供了经过充分测试的图算法实现,让 Bevy 可以专注于动画语义而非图数据结构的细节。
这种模式的好处:
- 共享:多个实体可以引用同一个 AnimationGraph Asset
- 独立状态:每个实体的 AnimationPlayer 维护独立的播放进度和权重
- 热重载:修改 Graph Asset 文件会通过 AssetEvent 自动更新所有引用实体
动画系统在 PostUpdate 的 AnimationSystems 中执行,在 Transform 传播之前——确保动画修改的 Transform 值能在同一帧被传播。这种时序安排是 Resource + Component 混合模式的直接延伸:AnimationGraph(Asset/Resource)定义了动画的结构和混合规则,AnimationPlayer(Component)在每帧更新播放进度并写入 Transform。将动画执行放在 PostUpdate 而非 Update 可以确保所有游戏逻辑(如角色速度变化影响行走/跑步混合权重)已经完成,动画系统读取到的是本帧最终的游戏状态。这与 UI 布局放在 PostUpdate 的设计理念一致——"先完成所有修改,再统一计算结果"。
Mask 与 Transition
AnimationGraph 支持 Mask——一个位域,控制哪些动画目标(骨骼)受节点影响。典型用例是让角色握住物品时,屏蔽手部的动画。
AnimationTransition 实现了动画之间的平滑过渡:
#![allow(unused)] fn main() { // 源码: crates/bevy_animation/src/transition.rs (概念) // Transitions smoothly blend from old animation to new over a duration }
要点:AnimationGraph 是 Asset (Resource 共享),AnimationPlayer 是 Component (per-entity 状态)。这种混合模式平衡了数据共享和独立状态的需求。
21.2 BSN 宏:proc macro 构建声明式场景
Scene trait
Bevy 的场景系统基于 Scene trait——一个描述 "Entity 应该长什么样" 的抽象:
#![allow(unused)] fn main() { // 源码: crates/bevy_scene/src/scene.rs (简化) pub trait Scene: Send + Sync + 'static { fn resolve( &self, context: &mut ResolveContext, scene: &mut ResolvedScene, ) -> Result<(), ResolveSceneError>; fn register_dependencies(&self, _dependencies: &mut SceneDependencies) {} } }
Scene 是组合式的——多个 Scene 可以叠加到同一个 ResolvedScene。一个 Scene 可以:
- 添加 Component(Template)
- 添加子实体(通过 Relationship)
- 继承另一个 Scene(通过
.bsn文件) - 修改(Patch)已有的属性
bsn! 宏
bsn! 是一个 proc macro,提供声明式的场景描述 DSL:
#![allow(unused)] fn main() { // 使用示例 world.spawn_scene(bsn! { #Player // Entity name Score(0) // Component with value Children [ // Child entities (Relationship) Sword, // Child 1: just a component Shield, // Child 2: just a component ] }); // 带继承的场景 world.queue_spawn_scene(bsn! { :"player.bsn" // Inherit from asset file #Player Score(0) Children [ Sword, Shield, ] }); }
bsn! 宏在编译期将这段 DSL 转换为实现了 Scene trait 的类型。
为什么 Bevy 需要一个专用的场景 DSL?在 bsn! 之前,创建复杂的实体层级需要大量的 commands.spawn().with_child().with_child() 链式调用,代码冗长且层级关系不直观。JSON 或 YAML 等通用数据格式可以描述层级结构,但它们是运行时解析的,无法在编译期检测类型错误——比如拼错了 Component 名称或传入了错误类型的值。bsn! 的设计目标是两全其美:像声明式数据格式一样清晰直观地描述实体层级,同时像 Rust 代码一样在编译期进行完整的类型检查。这种设计还有一个重要的性能特征:bsn! 在编译期就确定了需要哪些 Component,生成的代码可以直接调用类型化的 insert 方法,避免了运行时反序列化和类型查找的开销。代价是 proc macro 增加了编译时间,且 DSL 语法与标准 Rust 不同,需要额外的学习成本。
Rust 设计亮点:
bsn!利用 proc macro 实现了一个领域特定语言 (DSL), 在编译期验证语法正确性。它不是运行时解释的脚本——而是编译为 Rust 代码, 享受 Rust 的类型检查和零成本抽象。这是 Rust 宏系统在游戏引擎中的精妙应用: 用户写声明式的场景描述,编译器生成高效的 Entity spawn 代码。
Template 与实体克隆
场景系统内部使用 Template 机制——每个 Component 类型可以实现 FromTemplate trait,定义如何从模板值创建实际组件:
#![allow(unused)] fn main() { // 源码概念: crates/bevy_ecs/src/template.rs pub trait Template: Send + Sync + 'static { // Define how to contribute to an entity } pub trait FromTemplate: Component { // Create component from template value } }
当场景被 spawn 时,Template 被解析为实际的 Component 值,然后 insert 到 Entity 上。场景继承通过覆盖合并实现——子场景的属性覆盖父场景的同名属性。
Template 机制的设计动机来自游戏开发中的一个常见需求:预制体(Prefab)的变体。一个"基础士兵"场景定义了通用属性(模型、动画、碰撞体),而"弓箭手"和"骑兵"场景继承基础士兵并覆盖特定属性(武器、移动速度)。传统引擎用类继承实现这种关系,但 ECS 没有类继承。Template 的覆盖合并是 ECS 中实现"预制体变体"的惯用方式——它在数据层面实现了组合式继承,每个 Component 可以独立定义自己的合并策略。
ResolvedScene 内部可以包含多个相关实体(通过 Relationship),spawn 时会一起创建并建立关系。
spawn_scene 与 queue_spawn_scene
#![allow(unused)] fn main() { // 源码: crates/bevy_scene/src/spawn.rs (概念) pub trait WorldSceneExt { // Spawn immediately (dependencies must be loaded) fn spawn_scene<S: Scene>(&mut self, scene: S) -> Result<EntityWorldMut<'_>, SpawnSceneError>; // Queue for spawning (waits for dependencies to load) fn queue_spawn_scene<S: Scene>(&mut self, scene: S) -> EntityWorldMut<'_>; } }
queue_spawn_scene 处理有外部依赖的场景(如继承自 .bsn 文件)——它会等待所有依赖加载完成后再 spawn,利用了 Asset 系统(第 16 章)的异步加载能力。
要点:bsn! 宏在编译期将声明式 DSL 转换为 Scene trait 实现。Template 机制支持场景继承和属性覆盖。queue_spawn_scene 集成 Asset 异步加载。
21.3 Text Pipeline:Parley + Font Atlas
文本排版:Parley 集成
Bevy 的文本排版由 Parley 库驱动——一个现代的纯 Rust 排版引擎:
#![allow(unused)] fn main() { // 源码: crates/bevy_text/src/pipeline.rs (简化) #[derive(Resource, Default)] pub struct TextPipeline { sections_buffer: Vec<TextSectionView<'static>>, text_buffer: String, } impl TextPipeline { pub fn update_buffer( &mut self, fonts: &Assets<Font>, text_spans: impl Iterator<Item = (Entity, usize, &str, &TextFont, Color, ...)>, linebreak: LineBreak, justify: Justify, bounds: TextBounds, scale_factor: f32, computed: &mut ComputedTextBlock, font_system: &mut FontCx, layout_cx: &mut LayoutCx, // ... ) -> Result<(), TextError> { ... } } }
TextPipeline 是一个 Resource,维护排版过程中的缓存。文本渲染的流程:
graph TD
A["Text Component<br/>(spans + style)"] --> B["TextPipeline::update_buffer()<br/>Parley: shaping + line breaking + layout"]
B --> C["ComputedTextBlock Component<br/>positioned glyphs with sizes"]
C --> D["FontAtlas<br/>rasterize glyphs → texture atlas"]
D --> E["GPU: render textured quads"]
图 21-3: Text 渲染管线
Font Atlas:字形图集
每个字形只需光栅化一次——结果被缓存到 FontAtlas:
#![allow(unused)] fn main() { // 源码: crates/bevy_text/src/font_atlas.rs (简化) pub struct FontAtlas { pub dynamic_texture_atlas_builder: DynamicTextureAtlasBuilder, pub glyph_to_atlas_index: HashMap<GlyphCacheKey, GlyphAtlasLocation>, pub texture_atlas: TextureAtlasLayout, pub texture: Handle<Image>, } }
FontAtlas 字形图集
┌─────────────────────────────────┐
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │ C │ │ D │ │
│ └───┘ └───┘ └───┘ └───┘ │
│ ┌───┐ ┌───┐ ┌───────┐ │
│ │ a │ │ b │ │ W │ │
│ └───┘ └───┘ └───────┘ │
│ ┌───┐ │
│ │ . │ (空闲空间) │
│ └───┘ │
└─────────────────────────────────┘
↑ GPU Texture (Rgba8Unorm)
GlyphCacheKey → GlyphAtlasLocation (rect in atlas)
图 21-4: FontAtlas 字形图集布局
FontAtlasSet 为每个 (字体, 字号) 组合维护一个 FontAtlas。当遇到新字形时:
- 用
swash库光栅化字形 - 通过
DynamicTextureAtlasBuilder打包到图集纹理 - 记录
GlyphCacheKey → GlyphAtlasLocation映射 - 如果当前图集满了,创建新的图集纹理
FontAtlas 中的 texture 是一个 Handle<Image>——字形图集本身也是 Asset,自然参与 Bevy 的资源管理和 GPU 上传流程。
ECS 中的文本组件
文本在 ECS 中的建模:
#![allow(unused)] fn main() { // 概念性展示 // Root text entity: // - Text component (root content) // - TextLayout component (justify, linebreak) // - TextBounds component (max width/height) // - ComputedTextBlock component (layout cache) // - TextLayoutInfo component (glyph positions) // - TextFont / TextColor components (root style) // // Child span entities (optional): // - TextSpan component (appended content) // - TextFont / TextColor components (per-span style) }
ComputedTextBlock 是 Parley 排版结果的缓存。根 Text / Text2d 的变化会触发重建,而 TextFont、TextLayout、LineHeight、LetterSpacing、Children 等变化也会把它标记为需要重新排版或重渲染。这又是 Changed 检测(第 10 章)在子系统中的应用。
选择 Parley 而非自研排版引擎是一个重要的架构决策。文本排版是一个看似简单实则极其复杂的领域:Unicode 双向文本、复杂脚本的字形连接(如阿拉伯文)、行折断算法、字距调整。Parley 作为纯 Rust 实现的排版引擎,处理了这些复杂性,让 Bevy 可以专注于文本与 ECS 的集成而非排版算法本身。Font Atlas 的设计同样值得关注——它将字形缓存建模为 Asset(Handle<Image>),这意味着字形图集自动参与 Bevy 的 GPU 资源管理和热重载流程。当字体文件被修改时,Font Atlas 会被重建,所有引用该字体的文本实体自动更新显示。这种设计的代价是字形图集占用额外的 GPU 内存,但对于游戏中常见的有限字符集,图集大小通常在几 MB 以内。
文本变更检测
Text/TextFont Changed?
│
Yes ─┤─→ TextPipeline::update_buffer() → ComputedTextBlock
│
No ──┤─→ 跳过排版,使用缓存的 ComputedTextBlock
要点:TextPipeline 集成 Parley 排版引擎,FontAtlas 缓存光栅化字形。ComputedTextBlock 利用 Changed 检测避免重复排版。Font Atlas 本身也是 Asset。
本章小结
本章我们从 ECS 视角分析了三个子系统:
动画
- AnimationGraph 是 Asset(共享),AnimationPlayer 是 Component(独立状态)
- 动画图是 DAG,支持混合、叠加、Mask
- 动画在 PostUpdate 执行,在 Transform 传播之前
场景
- bsn! 宏 在编译期将声明式 DSL 转换为 Scene trait 实现
- Template 机制支持场景继承和属性覆盖
- queue_spawn_scene 集成 Asset 异步加载
文本
- TextPipeline 集成 Parley 排版,Changed 检测避免重复排版
- FontAtlas 缓存光栅化字形到 GPU 纹理图集
- 字形图集本身是 Asset,参与引擎资源管理
三个子系统虽然功能各异,但都遵循相同的 ECS 模式:数据是 Component/Resource,逻辑是 System,变更驱动更新,Asset 管理资源生命周期。这再次印证了 ECS 架构的统一性——理解了 ECS 原语,就理解了引擎的每个子系统。
第 22 章:Reflect — ECS 的运行时镜像
导读:Rust 语言没有内置反射 (Reflection),但 Bevy 通过 derive 宏和 TypeRegistry 构建了一套完整的运行时类型内省系统。本章从 TypeRegistry 注册机制出发,讲解 reflect_path 运行时字段访问、World→Reflect→serde 序列化链路、Reflect Functions 动态函数调用,以及 bevy_remote 调试协议。 理解反射系统,是理解场景保存、编辑器工具和远程调试的基础。
22.1 Rust 的反射困境与 Bevy 的方案
Rust 是一门强调零成本抽象和编译期确定性的语言。与 Java 或 C# 不同,Rust 编译器不会在二进制中保留类型的字段名、方法签名等元数据。这意味着在运行时,你无法"询问"一个值它有哪些字段。
Bevy 的解决方案是通过 #[derive(Reflect)] 宏在编译期生成反射元数据代码:
#![allow(unused)] fn main() { // 源码: crates/bevy_reflect/src/lib.rs (用法示例) #[derive(Reflect)] struct Player { name: String, health: f32, position: Vec3, } }
derive 宏为 Player 生成以下 trait 实现:
PartialReflect— 动态内省的基础 traitReflect— 完整反射,支持 downcast 到具体类型Struct/TupleStruct/Enum— 按类型结构分类的子 traitTyped— 提供编译期TypeInfoGetTypeRegistration— 注册到 TypeRegistryFromReflect— 从动态值重构具体类型
Rust 设计亮点:derive 宏在编译期生成反射元数据代码,而非依赖运行时 自省。这意味着只有标注了
#[derive(Reflect)]的类型才参与反射,没有 全局运行时开销。这是"opt-in reflection"——你选择加入,编译器为你生成 代码。这种方式完美契合 Rust 的零成本抽象哲学。
为什么 Rust 不像 Java 或 C# 那样提供内置反射?根本原因在于 Rust 的设计哲学:只为你使用的功能付出代价(零成本抽象)。Java 的反射要求 JVM 在运行时保留所有类的元数据——字段名、方法签名、注解——即使这些信息从未被使用。C# 的反射同样依赖 CLR 的元数据表。这些运行时元数据不仅增加了二进制大小,还阻碍了编译器的优化——因为编译器不知道哪些字段会被反射访问,无法安全地移除"未使用"的代码。Rust 编译为原生二进制,没有虚拟机,编译后的代码中不保留类型名和字段名。Bevy 的 derive 宏方案是对这个限制的精妙回应:只有标注了 #[derive(Reflect)] 的类型才生成反射代码,未标注的类型没有任何开销。这是"opt-in"设计——开发者显式选择哪些类型需要反射能力,编译器为这些类型生成等价于手写的内省代码。
要点:Bevy Reflect 通过 derive 宏在编译期生成反射代码,弥补 Rust 语言缺乏内置反射的不足。
22.2 PartialReflect 与 Reflect:双层 trait 设计
反射系统的核心是两个 trait 层次:
#![allow(unused)] fn main() { // 源码: crates/bevy_reflect/src/reflect.rs (简化) pub trait PartialReflect: DynamicTypePath + Send + Sync { fn reflect_ref(&self) -> ReflectRef; fn reflect_mut(&mut self) -> ReflectMut; fn try_apply(&mut self, value: &dyn PartialReflect) -> Result<(), ApplyError>; fn try_as_reflect(&self) -> Option<&dyn Reflect>; fn reflect_clone(&self) -> Result<Box<dyn PartialReflect>, ReflectCloneError>; // ... } pub trait Reflect: PartialReflect { fn into_any(self: Box<Self>) -> Box<dyn Any>; fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; fn into_reflect(self: Box<Self>) -> Box<dyn Reflect>; fn as_reflect(&self) -> &dyn Reflect; fn as_reflect_mut(&mut self) -> &mut dyn Reflect; fn set(&mut self, value: Box<dyn Reflect>) -> Result<(), Box<dyn Reflect>>; } }
为什么需要两层?
| trait | 角色 | downcast | 典型使用 |
|---|---|---|---|
PartialReflect | 动态数据模型 | 不能直接 downcast | DynamicStruct、序列化中间值 |
Reflect | 完整反射 | 可以 downcast 到 T | 具体类型的运行时操作 |
DynamicStruct 实现了 PartialReflect 但不实现 Reflect,因为它是"动态构建的结构体",不对应任何具体的 Rust 类型。通过 FromReflect::from_reflect,可以将一个 dyn PartialReflect 转换为具体类型。
双层 trait 设计的深层动机在于反序列化流程中的类型安全问题。当从 JSON 反序列化一个结构体时,解析器首先构建一个 DynamicStruct——一个键值对的集合。这个 DynamicStruct 实现了 PartialReflect(可以按字段名访问数据),但它不实现 Reflect,因为它不是任何具体 Rust 类型的实例。只有通过 FromReflect::from_reflect 将 DynamicStruct 转换为具体类型(如 Player)后,才获得 Reflect 能力。这种分层设计防止了一类常见错误:在类型不确定时就尝试 downcast。如果只有单一的 Reflect trait,DynamicStruct 也需要实现它,downcast 到具体类型时只能在运行时失败。双层设计将这个约束提升到了编译期——持有 dyn PartialReflect 的代码在类型层面就被阻止了 downcast 操作。
要点:PartialReflect 是动态数据操作的基础,Reflect 在此之上提供具体类型的 downcast 能力。
22.3 TypeRegistry:运行时类型元数据库
TypeRegistry 是反射系统的中枢数据库,存储所有已注册类型的元数据:
#![allow(unused)] fn main() { // 源码: crates/bevy_reflect/src/type_registry.rs pub struct TypeRegistry { registrations: TypeIdMap<TypeRegistration>, // TypeId → 注册信息 short_path_to_id: HashMap<&'static str, TypeId>, // 短路径查找 type_path_to_id: HashMap<&'static str, TypeId>, // 完整路径查找 ambiguous_names: HashSet<&'static str>, // 名称冲突记录 } }
每个 TypeRegistration 包含一个类型的所有反射元数据:
TypeRegistry
┌──────────────────────────────────────────────┐
│ TypeId(Player) → TypeRegistration { │
│ type_info: StructInfo { │
│ fields: ["name", "health", "position"] │
│ } │
│ type_data: { │
│ ReflectComponent → (insert/reflect fn) │
│ ReflectDefault → (default fn) │
│ ReflectSerialize → (serialize fn) │
│ } │
│ } │
│ │
│ TypeId(Vec3) → TypeRegistration { ... } │
│ TypeId(String) → TypeRegistration { ... } │
└──────────────────────────────────────────────┘
图 22-1: TypeRegistry 结构
TypeRegistry 与 ECS Component 的集成
Bevy 的 App::register_type::<T>() 将类型注册到 AppTypeRegistry 资源中(一个 TypeRegistryArc)。当类型同时实现 Component 和 Reflect 时,ReflectComponent 类型数据会被自动注入:
#![allow(unused)] fn main() { // 注册后,可以通过 TypeRegistry 动态操作 Component let registry = world.resource::<AppTypeRegistry>().read(); let registration = registry.get(TypeId::of::<Player>()).unwrap(); let reflect_component = registration.data::<ReflectComponent>().unwrap(); // 动态读取实体上的 Player 组件 let reflected: &dyn Reflect = reflect_component.reflect(world, entity).unwrap(); }
ReflectComponent 提供了 insert、reflect、reflect_mut、remove 等方法,使得编辑器工具可以在不知道具体类型的情况下操作组件数据。类似的类型数据还有 ReflectResource、ReflectDefault、ReflectSerialize 等。
TypeRegistry 的设计哲学是将类型元数据集中管理而非分散存储。替代方案是让每个 Component 类型自身持有反射能力(如通过 vtable),但这会导致反射功能与组件类型紧耦合——每添加一种新的运行时操作(如序列化、编辑器显示、远程调试),都需要修改 Component 的 trait 要求。TypeRegistry 的 TypeData 机制将这些"附加能力"外挂到注册信息上,而非嵌入类型本身。这意味着 ReflectComponent、ReflectSerialize、ReflectDefault 可以独立添加和移除,不同的工具(编辑器、调试器、场景保存器)只查询自己需要的 TypeData。这种解耦对引擎的可扩展性至关重要:第三方 Plugin 可以注册新的 TypeData 种类,无需修改 Bevy 核心代码。
要点:TypeRegistry 通过 TypeData 机制将反射与 ECS 集成——ReflectComponent 让工具可以动态操作任意已注册的组件类型。
22.4 ReflectPath:运行时字段访问
GetPath trait 支持用字符串路径访问嵌套字段:
#![allow(unused)] fn main() { // 源码: crates/bevy_reflect/src/path/mod.rs (简化) pub trait ReflectPath<'a>: Sized { fn reflect_element(self, root: &dyn PartialReflect) -> Result<&dyn PartialReflect, ReflectPathError<'a>>; fn reflect_element_mut(self, root: &mut dyn PartialReflect) -> Result<&mut dyn PartialReflect, ReflectPathError<'a>>; } }
路径语法支持三种访问方式:
| 语法 | 含义 | 示例 |
|---|---|---|
.field | 命名字段 | ".position" |
[index] | 索引访问 | "[0]" |
#variant | 枚举变体 | "#Some" |
这些可以组合使用:
#![allow(unused)] fn main() { // 路径示例 player.reflect_path(".position.x") // 访问 Player.position.x list.reflect_path("[2].name") // 访问 list[2].name option.reflect_path("#Some.0") // 访问 Option::Some 的第一个字段 }
路径解析器将字符串解析为 Access 序列,逐级导航到目标字段。这在编辑器属性面板和序列化路径中非常有用——你可以用字符串引用任意深度的字段。
要点:reflect_path 支持用字符串路径(.field、[index]、#variant)在运行时访问任意深度的嵌套字段。
22.5 序列化链路:World → Reflect → serde
Bevy 的场景序列化遵循三层转换链路:
graph TD
A["World (ECS 数据)"] -- "ReflectComponent::reflect()" --> B["dyn Reflect (反射表示)"]
B -- "ReflectSerializer" --> C["serde::Serialize (序列化格式)"]
C -- "serde_json / serde_ron" --> D["JSON / RON 文本"]
图 22-2: World → Reflect → serde 序列化链路
序列化
#![allow(unused)] fn main() { // 源码: crates/bevy_reflect/src/serde/ (简化) // ReflectSerializer 将 dyn PartialReflect 转换为 serde Serialize pub struct ReflectSerializer<'a> { value: &'a dyn PartialReflect, registry: &'a TypeRegistry, } }
ReflectSerializer 需要 TypeRegistry 来查找类型的路径名(作为 JSON/RON 中的类型标识符)。序列化时,结构体被展开为字段名→值的映射,枚举被编码为变体名→数据的格式。
反序列化
#![allow(unused)] fn main() { // TypedReflectDeserializer 已知目标类型,直接反序列化 pub struct TypedReflectDeserializer<'a> { registration: &'a TypeRegistration, registry: &'a TypeRegistry, } }
反序列化产生 Box<dyn PartialReflect>(通常是 DynamicStruct),然后通过 FromReflect::from_reflect 转换为具体类型,最后通过 ReflectComponent::insert 写回 World。
这条序列化链路揭示了 Bevy 反射系统与 serde 生态整合的核心挑战。serde 的设计假设序列化/反序列化的类型在编译期已知——#[derive(Serialize)] 生成的代码直接操作具体类型的字段。但场景序列化需要在运行时处理任意类型的组件,编译期不知道场景文件中包含哪些组件类型。ReflectSerializer 通过在 JSON/RON 输出中嵌入类型路径(如 "bevy_transform::Transform")来解决这个问题——反序列化时,先解析类型路径,在 TypeRegistry 中查找对应的 TypeRegistration,获取反序列化所需的元数据。这种"类型路径 + Registry 查找"的模式是静态类型语言实现动态序列化的标准做法,但它引入了一个脆弱点:如果类型被重命名或模块路径发生变化,旧的序列化文件将无法加载。Bevy 通过 #[reflect(type_path = "...")] 属性提供了路径迁移机制。
完整的场景保存/加载流程:
保存: World → 遍历实体 → ReflectComponent::reflect()
→ ReflectSerializer → RON/JSON 文件
加载: RON/JSON 文件 → TypedReflectDeserializer → DynamicStruct
→ FromReflect::from_reflect → ReflectComponent::insert → World
要点:场景序列化是 World→Reflect→serde 的三层链路,TypeRegistry 贯穿始终,提供类型标识和反序列化所需的元数据。
22.6 Reflect Functions:动态函数调用
bevy_reflect 的 func 模块支持将 Rust 函数转换为可动态调用的形式:
#![allow(unused)] fn main() { // 源码: crates/bevy_reflect/src/func/mod.rs (用法示例) use bevy_reflect::func::{DynamicFunction, IntoFunction, ArgList}; fn add(a: i32, b: i32) -> i32 { a + b } // 将普通函数转换为 DynamicFunction let func: DynamicFunction = add.into_function(); // 用 ArgList 传入参数并调用 let args = ArgList::default().with_owned(25_i32).with_owned(75_i32); let result = func.call(args).unwrap(); assert_eq!(result.unwrap_owned().try_downcast_ref::<i32>(), Some(&100)); }
核心类型:
| 类型 | 说明 |
|---|---|
DynamicFunction | 不可变函数/闭包的动态表示 |
DynamicFunctionMut | 可变闭包的动态表示 |
IntoFunction | 将 Fn 转为 DynamicFunction 的 trait |
IntoFunctionMut | 将 FnMut 转为 DynamicFunctionMut 的 trait |
ArgList | 动态参数列表 |
Return | 动态返回值 |
支持的函数签名包括:
(...) -> R— 普通函数for<'a> (&'a arg, ...) -> &'a R— 返回引用的函数for<'a> (&'a mut arg, ...) -> &'a mut R— 返回可变引用的函数
函数也可以注册到 FunctionRegistry 中,供远程调试协议按名称调用。
要点:Reflect Functions 通过 IntoFunction trait 将 Rust 函数转为动态可调用对象,支持运行时参数传递和返回值提取。
22.7 bevy_remote:基于反射的调试协议
bevy_remote crate 基于 JSON-RPC 2.0 协议,通过 HTTP 暴露 World 的反射接口:
外部工具 (编辑器/调试器) Bevy 应用
┌────────────────────┐ ┌────────────────────┐
│ JSON-RPC Client │◀── HTTP ──▶│ RemotePlugin │
│ │ │ ├─ BrpRequest │
│ world.query │ │ ├─ TypeRegistry │
│ world.get_components │ └─ World │
│ world.insert_components │ │
└────────────────────┘ └────────────────────┘
图 22-3: bevy_remote 架构
内置方法
#![allow(unused)] fn main() { // 源码: crates/bevy_remote/src/builtin_methods.rs pub const BRP_GET_COMPONENTS_METHOD: &str = "world.get_components"; pub const BRP_QUERY_METHOD: &str = "world.query"; pub const BRP_SPAWN_ENTITY_METHOD: &str = "world.spawn_entity"; pub const BRP_INSERT_COMPONENTS_METHOD: &str = "world.insert_components"; pub const BRP_REMOVE_COMPONENTS_METHOD: &str = "world.remove_components"; pub const BRP_DESPAWN_COMPONENTS_METHOD: &str = "world.despawn_entity"; pub const BRP_REPARENT_ENTITIES_METHOD: &str = "world.reparent_entities"; }
一个典型的请求-响应示例:
// 请求:获取实体 4294967298 的 Transform 组件
{
"method": "world.get_components",
"id": 0,
"params": {
"entity": 4294967298,
"components": ["bevy_transform::components::transform::Transform"]
}
}
// 响应:组件数据以反射序列化的 JSON 返回
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"bevy_transform::components::transform::Transform": {
"translation": { "x": 0.0, "y": 0.5, "z": 0.0 },
"rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 },
"scale": { "x": 1.0, "y": 1.0, "z": 1.0 }
}
}
}
这个协议的核心依赖是反射系统——组件通过 ReflectComponent 读取,通过 ReflectSerializer 序列化为 JSON。没有反射,就不可能实现这种通用的远程查询能力。
要点:bevy_remote 通过 JSON-RPC 2.0 协议暴露 World 的反射接口,是编辑器和调试工具的基础通信协议。
本章小结
本章我们深入了 Bevy 的反射系统:
- derive 宏 在编译期为类型生成反射代码,弥补 Rust 无内置反射的限制
- PartialReflect / Reflect 双层 trait 设计分离了动态数据模型和具体类型 downcast
- TypeRegistry 是反射元数据的中枢,通过 TypeData(如
ReflectComponent)与 ECS 集成 - reflect_path 支持字符串路径访问嵌套字段
- 序列化链路 是 World → Reflect → serde 的三层转换
- Reflect Functions 支持动态函数调用
- bevy_remote 基于反射实现 JSON-RPC 2.0 调试协议
下一章,我们将深入 Bevy 的并发模型——TaskPool 三池架构和调度器的并行决策算法。
第 23 章:并发模型
导读:Bevy 引擎的高性能很大程度上来自其并发设计。本章从 TaskPool 三池 架构出发,讲解调度器的并行决策算法、par_iter 数据并行、单线程与多线程 模式的差异,以及 Send/Sync 约束在 ECS 中的角色。本章与第 9 章 (Schedule) 关系密切——第 9 章讲的是"如何编排系统",本章讲的是"如何并行执行系统"。
23.1 TaskPool 三池架构
Bevy 将异步任务分为三个独立的线程池,每个池针对不同类型的工作负载优化:
#![allow(unused)] fn main() { // 源码: crates/bevy_tasks/src/usages.rs taskpool! { (COMPUTE_TASK_POOL, ComputeTaskPool) } taskpool! { (ASYNC_COMPUTE_TASK_POOL, AsyncComputeTaskPool) } taskpool! { (IO_TASK_POOL, IoTaskPool) } }
三个池通过 OnceLock 全局单例管理,在 App 启动时初始化:
┌──────────────────────────────────────────────────────┐
│ Bevy 任务池架构 │
│ │
│ ┌─────────────────┐ 必须在当前帧完成 │
│ │ ComputeTaskPool │ 线程数 = 剩余线程预算 │
│ │ (同步计算) │ 用途: System 并行执行 │
│ └─────────────────┘ │
│ │
│ ┌──────────────────┐ 可跨帧完成 │
│ │AsyncComputeTaskPool│ 默认约 25%,最多 4 线程 │
│ │ (异步计算) │ 用途: 寻路、AI 决策 │
│ └──────────────────┘ │
│ │
│ ┌─────────────────┐ I/O 密集 │
│ │ IoTaskPool │ 默认约 25%,最多 4 线程 │
│ │ (异步 I/O) │ 用途: 文件加载、网络请求 │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────┘
图 23-1: TaskPool 三池架构
| 线程池 | 典型用途 | 任务特征 |
|---|---|---|
ComputeTaskPool | System 并行执行、par_iter | 必须在当前帧完成 |
AsyncComputeTaskPool | 寻路、程序生成、AI 决策 | 可跨帧执行 |
IoTaskPool | Asset 加载、网络请求 | I/O 等待为主,CPU 使用少 |
TaskPool 内部结构
每个 TaskPool 是对底层执行器的封装:
#![allow(unused)] fn main() { // 源码: crates/bevy_tasks/src/task_pool.rs (简化) pub struct TaskPoolBuilder { num_threads: Option<usize>, // 默认 = 逻辑核心数 stack_size: Option<usize>, thread_name: Option<String>, on_thread_spawn: Option<Arc<dyn Fn() + Send + Sync + 'static>>, on_thread_destroy: Option<Arc<dyn Fn() + Send + Sync + 'static>>, } }
TaskPool::scope 方法是并行执行的核心入口——它创建一个作用域,在其中可以 spawn 多个任务,所有任务在 scope 结束时保证完成:
#![allow(unused)] fn main() { compute_pool.scope(|scope| { for chunk in work.chunks(BATCH_SIZE) { scope.spawn(async move { process(chunk); }); } }); // 到这里,所有 spawn 的任务都已完成 }
主线程 tick
每帧主线程会 tick 三个池的 local executor,处理需要在主线程执行的任务:
#![allow(unused)] fn main() { // 源码: crates/bevy_tasks/src/usages.rs pub fn tick_global_task_pools_on_main_thread() { // 每个池的 local_executor 处理最多 100 个任务 COMPUTE_TASK_POOL.get().unwrap().with_local_executor(|exec| { for _ in 0..100 { exec.try_tick(); } }); // AsyncCompute 和 IO 同理 } }
为什么是三个池而非一个统一的线程池?这个设计源于游戏引擎工作负载的独特特征。游戏帧循环有严格的时间预算(16.6ms @60fps),帧内的计算任务(System 执行、物理模拟)必须在这个预算内完成——它们是"阻塞式"的,主线程会等待它们全部完成才进入下一帧。如果将这些帧内任务与 I/O 任务(资源加载、网络请求)放在同一个池中,I/O 任务的线程可能阻塞在磁盘读取上,占用了本应执行帧内计算的线程。将 ComputeTaskPool 与 IoTaskPool 分离确保了帧内计算不会被 I/O 阻塞"挤占"。AsyncComputeTaskPool 则处于中间地带——寻路或 AI 决策等任务可能需要多帧才能完成,它们不应阻塞主帧循环,但也不是 I/O 密集型。三池架构让每种工作负载在自己的线程池中运行,互不干扰。当前默认实现不是"每个池都拿满 CPU 核心数",而是先取一个总线程预算,再按 TaskPoolOptions 分配:IO 默认约 25% 且最多 4 线程,Async Compute 也是 25% 且最多 4 线程,Compute 使用剩余线程。
要点:三个 TaskPool 分别优化同步计算、异步计算和 I/O 工作负载。ComputeTaskPool 负责 System 的并行执行,AsyncComputeTaskPool 处理可跨帧的长任务。
23.2 多线程调度器:并行决策算法
第 9 章我们了解了 Schedule 如何对系统进行拓扑排序。本节深入 MultiThreadedExecutor——它决定哪些系统可以在同一时刻并行运行。
核心数据结构
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/executor/multi_threaded.rs (简化) struct SystemTaskMetadata { conflicting_systems: FixedBitSet, // 数据访问冲突的系统集合 condition_conflicting_systems: FixedBitSet, // 条件评估冲突的系统 dependents: Vec<usize>, // 依赖于本系统的下游系统 is_send: bool, // 是否可以在非主线程运行 is_exclusive: bool, // 是否需要独占 World } }
conflicting_systems 基于第 7 章和第 9 章介绍的 FilteredAccessSet 计算——如果两个系统的组件访问存在读写冲突且不能通过 Archetype 过滤证明不相交,它们就是冲突的。
can_run 决策流程
调度器在每次尝试启动一个系统时,都会执行以下决策流程:
graph TD
Start["系统 S 是否可以运行?"] --> Q1{"1. S 是 exclusive?"}
Q1 -- "是" --> Q1a{"有任何系统在运行?"}
Q1a -- "是" --> NO1["不可运行 ✗"]
Q1a -- "否" --> Q2
Q1 -- "否" --> Q2{"2. S 是 !Send?"}
Q2 -- "是" --> Q2a{"主线程已被占用?"}
Q2a -- "是" --> NO2["不可运行 ✗"]
Q2a -- "否" --> Q3
Q2 -- "否" --> Q3{"3. set conditions 有冲突?"}
Q3 -- "是" --> NO3["不可运行 ✗"]
Q3 -- "否" --> Q4{"4. 自身 conditions 有冲突?"}
Q4 -- "是" --> NO4["不可运行 ✗"]
Q4 -- "否" --> Q5{"5. 数据访问与运行中系统冲突?"}
Q5 -- "是" --> NO5["不可运行 ✗"]
Q5 -- "否" --> YES["可以运行 ✓"]
图 23-2: can_run 并行决策流程
对应源码:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/executor/multi_threaded.rs fn can_run(&mut self, system_index: usize, conditions: &mut Conditions) -> bool { let system_meta = &self.system_task_metadata[system_index]; // 1. Exclusive 系统需要独占 if system_meta.is_exclusive && self.num_running_systems > 0 { return false; } // 2. !Send 系统只能在主线程 if !system_meta.is_send && self.local_thread_running { return false; } // 3-4. Condition 冲突检查 (用 FixedBitSet::is_disjoint) for set_idx in conditions.sets_with_conditions_of_systems[system_index] .difference(&self.evaluated_sets) { if !self.set_condition_conflicting_systems[set_idx] .is_disjoint(&self.running_systems) { return false; } } // 5. 数据访问冲突检查 if !system_meta.conflicting_systems.is_disjoint(&self.running_systems) { return false; } true } }
FixedBitSet::is_disjoint 是一个 O(n/64) 的位运算操作——对于 100 个系统,只需比较约 2 个 u64 就能判断是否冲突。
多线程调度器的并行决策算法本质上是在运行时模拟一个简化版的借用检查器。它面临一个 NP 难的调度优化问题:给定一组系统和它们之间的冲突关系,找到最大化并行度的执行方案。Bevy 没有追求最优解,而是采用贪心策略——按拓扑排序的顺序依次尝试启动系统,只要 can_run 检查通过就立刻启动。这种贪心策略可能不是全局最优的(某些情况下推迟启动一个系统可能让更多系统并行),但它的决策开销极低——每个系统的 can_run 检查只需几次位运算。在实践中,ECS 系统的冲突图通常是稀疏的(大多数系统访问不同的组件),贪心策略已经能达到接近最优的并行度。更重要的是,调度决策发生在每帧的关键路径上,任何算法复杂度的增加都会直接影响帧时间。
执行循环
调度器的主循环逻辑:
- 将所有依赖已满足的系统加入
ready_systems队列 - 按优先级遍历 ready_systems
- 对每个系统调用
can_run检查 - 通过检查的系统 spawn 到 ComputeTaskPool
- 系统完成后,通知 dependents 减少依赖计数
- 重复直到所有系统完成
Rust 设计亮点:
FixedBitSet用位运算高效表示系统间的冲突关系。 判断两个系统是否可以并行,只需要一次 bitwise AND + 判零操作。 这比 HashMap 查找或遍历列表快得多,让调度决策开销可以忽略不计。
要点:多线程调度器通过 FixedBitSet 冲突矩阵和 5 步 can_run 决策流程,在微秒级别判断系统是否可以并行运行。
23.3 FilteredAccess:编译期借用到运行时调度
这是连接 Rust 借用检查和 ECS 并行调度的关键抽象(在第 7 章和第 9 章首次出现):
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/access.rs pub struct FilteredAccess { pub(crate) access: Access, // 读写了哪些 ComponentId pub(crate) required: ComponentIdSet, // 必须存在的组件 pub(crate) filter_sets: Vec<AccessFilters>, // With/Without 过滤条件 } }
每个 System 在注册时通过 SystemParam::init_access 声明自己的数据访问模式。调度器在 build 阶段计算所有系统对的冲突关系:
System A: Query<&mut Position, With<Player>>
→ access: { write: [Position] }
→ filter: { with: [Player] }
System B: Query<&Position, With<Enemy>>
→ access: { read: [Position] }
→ filter: { with: [Enemy] }
冲突判断:
A write Position ∩ B read Position = 冲突?
→ 检查 filter: With<Player> ∩ With<Enemy> 是否可能重叠
→ Player 和 Enemy 互斥 → 不冲突 → 可以并行 ✓
这就是 Bevy 将 Rust 的编译期借用规则(&T vs &mut T 不共存)转换到运行时并行调度的方式。编译器无法在系统级别做这个推断,所以 Bevy 在运行时(但仅在构建阶段,不在每帧)用 FilteredAccess 做等效的检查。
FilteredAccess 中的 filter_sets 扮演着至关重要的角色——它让调度器能够识别"虽然访问同一组件,但操作不同实体集合"的系统对。没有 filter_sets,两个分别查询 Query<&mut Position, With<Player>> 和 Query<&mut Position, With<Enemy>> 的系统会被判定为冲突(因为都写 Position),无法并行。filter_sets 让调度器看到 With<Player> 和 With<Enemy> 是互斥过滤条件,从而判定两个系统操作不同的实体子集,可以安全并行。这种基于 Archetype 过滤的冲突消解是 Bevy 并行性能的关键优化之一——它将 ECS 的列式存储和 Archetype 分组信息融入调度决策,最大化了并行度。
要点:FilteredAccess 将编译期借用规则提升到运行时,让调度器能安全地判断系统间的数据访问是否冲突。
23.4 par_iter:数据并行
除了系统级并行,Bevy 还支持单个系统内部的数据并行——通过 par_iter 将 Query 的遍历分散到多个线程:
#![allow(unused)] fn main() { // 使用示例 fn movement_system(mut query: Query<(&mut Position, &Velocity)>) { query.par_iter_mut().for_each(|(mut pos, vel)| { pos.0 += vel.0 * DELTA_TIME; }); } }
par_iter 的工作原理:
Query 匹配了 1000 个实体 (分布在 3 个 Table 中)
Table 0 (400 entities) Table 1 (350 entities) Table 2 (250 entities)
┌──────┬──────┬──────┐ ┌──────┬──────┐ ┌──────┬──────┐
│Batch1│Batch2│Batch3│ │Batch4│Batch5│ │Batch6│Batch7│
│ 128 │ 128 │ 144 │ │ 175 │ 175 │ │ 125 │ 125 │
└──┬───┴──┬───┴──┬───┘ └──┬───┴──┬───┘ └──┬───┴──┬───┘
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
Thread1 Thread2 Thread3 Thread1 Thread2 Thread3 Thread1
(ComputeTaskPool)
图 23-3: par_iter 数据并行分批
批次大小由 BatchingStrategy 控制:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/batching.rs (概念) pub struct BatchingStrategy { pub batch_size_limits: Range<usize>, // 默认 1..=usize::MAX pub batches_per_thread: usize, // 默认 1 } }
par_iter 内部使用 ComputeTaskPool::scope 来分发批次任务。每个批次内的实体仍然是顺序处理的(保持缓存局部性),只是不同批次在不同线程上并行。
par_iter 的批次划分策略值得深入理解。每个批次内的实体是连续存储在同一个 Table 中的,这意味着批次内的遍历享有极好的缓存局部性——CPU 缓存行预取的数据正好是下一个要处理的实体。如果批次太小,线程创建和同步的开销会超过并行收益;如果批次太大,负载不均衡——某些线程很快完成,另一些还在忙碌。Bevy 的 ComputeTaskPool 内部使用 work-stealing 机制来缓解负载不均衡:空闲的线程会从其他线程的任务队列中"偷"任务。这意味着即使批次大小不完美,work-stealing 也能在运行时动态平衡负载。但 work-stealing 本身有开销(需要原子操作和可能的缓存失效),所以合理的初始批次划分仍然重要。
注意:par_iter 不适合所有场景。如果每个实体的处理逻辑非常轻量(如简单加法),线程调度的开销可能超过并行带来的收益。一般当实体数超过 1000 且处理逻辑中等复杂度时,par_iter 才有明显优势。
要点:par_iter 将 Query 遍历按批次分发到 ComputeTaskPool,实现系统内部的数据并行。
23.5 单线程模式
Bevy 支持在不启用 multi_threaded feature 的情况下以单线程模式运行。这在 WASM 环境中尤为重要:
#![allow(unused)] fn main() { // 源码: crates/bevy_tasks/src/lib.rs cfg::conditional_send! { if { // WASM 平台: Send 约束被移除 pub trait ConditionalSend {} impl<T> ConditionalSend for T {} } else { // 原生平台: 保留 Send 约束 pub trait ConditionalSend: Send {} impl<T: Send> ConditionalSend for T {} } } }
单线程模式下的关键差异:
| 特性 | 多线程模式 | 单线程模式 |
|---|---|---|
| System 执行 | 并行 (MultiThreadedExecutor) | 顺序 (SingleThreadedExecutor) |
| TaskPool | 真正的线程池 | 相同 TaskPool API,但底层是单线程后端 |
| Future | 必须 Send | 不要求 Send |
| par_iter | 多线程分批 | 退化为普通 iter |
| NonSend | 限制主线程 | API 仍保留,但通常不再影响线程分派 |
#![allow(unused)] fn main() { // 源码: crates/bevy_tasks/src/single_threaded_task_pool.rs (简化) // 单线程模式下,公开类型仍然叫 TaskPool,只是后端实现退化为本地执行 pub struct TaskPool {} impl TaskPool { pub fn scope<'env, F, T>(&self, f: F) -> Vec<T> where F: for<'scope> FnOnce(&'scope Scope<'scope, 'env, T>), { // 直接在当前线程执行,无线程切换 } } }
要点:单线程模式通过 ConditionalSend trait 放松 Send 约束;Schedule 默认切到 SingleThreadedExecutor,TaskPool API 保持不变,但底层执行退化为单线程后端。
23.6 Send/Sync 在 ECS 中的角色
Bevy ECS 的并行能力建立在 Rust 的类型系统之上——Send 和 Sync 是关键约束:
Component: Send + Sync + 'static
Resource: Send + Sync + 'static
System fn: Send + Sync + 'static (每个参数也必须满足)
─────────────────────────────────────────────────
Send → 数据可以从一个线程移动到另一个线程
Sync → 数据的引用可以被多个线程共享
─────────────────────────────────────────────────
结果:
→ 多个系统读同一组件 (&T where T: Sync) → 安全 ✓
→ 一个系统写组件 (&mut T) → 调度器保证独占 → 安全 ✓
→ 组件在线程间移动 (T: Send) → 安全 ✓
NonSend:逃生舱口
有些平台 API(如窗口句柄、OpenGL 上下文)不满足 Send。Bevy 通过 NonSend<T> 和 NonSendMut<T> 提供逃生舱口:
#![allow(unused)] fn main() { // NonSend 系统只能在主线程运行 fn platform_system(window: NonSend<PlatformWindow>) { window.set_title("Bevy"); } }
调度器中的处理:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/executor/multi_threaded.rs // SystemTaskMetadata.is_send 标记 if !system_meta.is_send && self.local_thread_running { return false; // 主线程已被占用,NonSend 系统必须等待 } }
NonSend 系统被调度到 ThreadExecutor(主线程执行器),不会被 spawn 到 ComputeTaskPool 的工作线程上。
Rust 设计亮点:Bevy 将 Rust 的 Send/Sync trait 约束从编译期 类型检查延伸到运行时调度策略。
!Send类型自动被限制在主线程—— 不是通过文档约定,而是通过类型系统强制执行。
要点:Send + Sync 是 ECS 并行执行的基石。NonSend 提供了 !Send 类型的安全逃生舱口,自动被限制在主线程执行。
本章小结
本章我们深入了 Bevy 的并发模型:
- TaskPool 三池架构:Compute(帧内同步计算)、AsyncCompute(跨帧异步计算)、IO(I/O 密集)
- 多线程调度器:通过 FixedBitSet 冲突矩阵和 5 步 can_run 决策,在微秒级判断并行安全性
- FilteredAccess:将编译期借用规则提升到运行时,驱动并行调度
- par_iter:系统内部的数据并行,按批次分发到 ComputeTaskPool
- 单线程模式:通过 ConditionalSend 放松约束,适配 WASM 平台
- Send/Sync:ECS 并行的类型系统基石,NonSend 是
!Send类型的安全通道
下一章,我们将看看 Bevy 提供的诊断和调试工具——DiagnosticsStore、Gizmos 和 Stepping。
第 24 章:诊断与调试
导读:一个复杂的 ECS 引擎需要强大的诊断工具。本章介绍五套互补的 调试机制(DiagnosticsStore、Gizmos、Schedule Stepping、bevy_remote、 Schedule 可视化)与一个开发体验增强(Subsecond 代码热补丁)。它们共同 构成 Bevy 从性能观测、调试到快速迭代的完整开发闭环。
24.1 DiagnosticsStore:性能指标体系
bevy_diagnostic crate 提供了一套轻量级的性能指标收集框架:
DiagnosticPath 与 Diagnostic
#![allow(unused)] fn main() { // 源码: crates/bevy_diagnostic/src/diagnostic.rs pub struct DiagnosticPath { path: Cow<'static, str>, // 如 "fps" 或 "frame_time/cpu" hash: u64, // FNV-1a 哈希,快速查找 } pub struct Diagnostic { path: DiagnosticPath, suffix: Cow<'static, str>, // 单位后缀,如 "ms" history: VecDeque<DiagnosticMeasurement>, // 测量历史 sum: f64, // 累加值 (用于平均) ema: f64, // 指数移动平均 ema_smoothing_factor: f64, // EMA 平滑因子 max_history_length: usize, // 最大历史长度 is_enabled: bool, } }
Diagnostic 维护一个 VecDeque 历史队列,每次添加测量值时同时更新指数移动平均 (EMA):
#![allow(unused)] fn main() { // 源码: crates/bevy_diagnostic/src/diagnostic.rs fn add_measurement(&mut self, measurement: DiagnosticMeasurement) { if let Some(previous) = self.measurement() { let delta = (measurement.time - previous.time).as_secs_f64(); let alpha = (delta / self.ema_smoothing_factor).clamp(0.0, 1.0); self.ema += alpha * (measurement.value - self.ema); } else { self.ema = measurement.value; } // ... 维护 history 和 sum } }
DiagnosticsStore
DiagnosticsStore 是一个 Resource,作为所有 Diagnostic 的容器:
DiagnosticsStore (Resource)
┌──────────────────────────────────────────────┐
│ "fps" → Diagnostic { ema: 59.8 } │
│ "frame_time" → Diagnostic { ema: 16.7 } │
│ "entity_count" → Diagnostic { ema: 1024 } │
│ "process/cpu_usage" → Diagnostic { ema: 45.2 } │
└──────────────────────────────────────────────┘
图 24-1: DiagnosticsStore 结构
常用诊断插件
| 插件 | 路径 | 说明 |
|---|---|---|
FrameTimeDiagnosticsPlugin | fps, frame_time, frame_count | 帧时间和 FPS |
EntityCountDiagnosticsPlugin | entity_count | 实体总数 |
SystemInformationDiagnosticsPlugin | system/cpu_usage, system/mem_usage, process/cpu_usage, process/mem_usage | CPU/内存使用(需 sysinfo_plugin feature) |
LogDiagnosticsPlugin | (输出) | 周期性打印诊断到日志 |
Deferred 写入模式
诊断数据通过 Diagnostics SystemParam(一个 Deferred<DiagnosticsBuffer>)写入,延迟到 apply_deferred 时才真正更新 DiagnosticsStore。这避免了在 System 执行期间对 DiagnosticsStore 的写锁竞争。
需要注意的是,DiagnosticsPlugin 本身只负责初始化 DiagnosticsStore(以及在相关 feature 打开时初始化 SystemInfo Resource)。具体的 FPS、实体数、系统信息等指标,仍然要通过对应的诊断插件显式注册。
为什么选择 EMA(指数移动平均)而非简单平均或中位数?游戏性能指标有一个独特特征:最近的测量值比历史值更有意义。简单平均对所有历史值一视同仁,当性能突然变化时(如加载新场景后帧率下降),需要很多帧才能让平均值反映实际情况。EMA 通过 smoothing factor 给近期值更高的权重,能更快速地响应性能变化,同时仍然平滑掉逐帧的随机抖动。Deferred 写入模式的设计与第 11 章的 Commands 思路一致——在并行执行阶段只收集数据到线程本地缓冲区,在 apply_deferred 时才统一写入 DiagnosticsStore。这避免了对 DiagnosticsStore Resource 的写锁争用,保证了诊断数据的收集不会成为并行执行的瓶颈。这种"采集零开销,汇总集中处理"的设计在监控系统中是标准实践,Bevy 将其自然地映射到了 ECS 的 Deferred 机制上。
要点:DiagnosticsStore 基于 DiagnosticPath 哈希查找,使用 EMA 平滑测量值,通过 Deferred 模式避免并行写锁冲突。
24.2 Gizmos:调试可视化
bevy_gizmos 提供了即时模式 (immediate mode) 的调试绘制 API:
#![allow(unused)] fn main() { // 使用示例 fn debug_draw(mut gizmos: Gizmos) { gizmos.line(start, end, Color::RED); gizmos.circle(center, radius, Color::GREEN); gizmos.sphere(position, radius, Color::BLUE); gizmos.ray(origin, direction, Color::YELLOW); } }
Gizmos SystemParam
#![allow(unused)] fn main() { // 源码: crates/bevy_gizmos/src/gizmos.rs (简化) pub struct Gizmos<'w, 's, Config = DefaultGizmoConfigGroup, Clear = ()> { buffer: &'w mut GizmoBuffer<Config, Clear>, // ... } }
Gizmos 是一个 SystemParam,内部向 GizmoBuffer 写入绘制指令。这些指令在渲染阶段被提取并转换为 GPU draw call。
设计特点
graph LR
subgraph System["System 阶段"]
GL["gizmos.line"]
GC["gizmos.circle"]
GS["gizmos.sphere"]
end
subgraph Buffer["GizmoBuffer"]
L["lines"]
C["circles"]
S["spheres"]
end
GL -- "写入" --> L
GC -- "写入" --> C
GS -- "写入" --> S
L -- "提取" --> GPU["GPU 批量渲染"]
C -- "提取" --> GPU
S -- "提取" --> GPU
图 24-2: Gizmos 数据流 (每帧自动清除)
- 即时模式:每帧重新提交绘制指令,无需管理生命周期
- 配置分组:通过泛型
Config参数支持多组 Gizmo 配置(如不同颜色主题) - 条件绘制:结合
run_if可以在运行时开关调试可视化
Gizmos 适合用于:碰撞箱可视化、路径调试、物理射线显示、AI 导航网格预览等。
为什么 Gizmos 采用即时模式而非 ECS 实体模式?如果将每条调试线段建模为 Entity + Component,开发者需要管理这些实体的创建和销毁——忘记销毁会导致调试图形永久残留,销毁时机不对会导致闪烁。即时模式消除了这个生命周期管理问题:每帧的调试绘制都是全新的,上一帧的绘制自动消失。这种设计特别适合调试场景——开发者可以在任意 System 中随时添加绘制指令,不需要考虑清理逻辑。从性能角度看,即时模式避免了每帧创建和销毁大量临时 Entity 的开销——GizmoBuffer 只是一个内存缓冲区,写入和清除都是 O(1) 操作。配置分组(通过泛型 Config 参数)则提供了批量控制的能力——可以一次性开关整组调试可视化,而不是逐个管理。这与第 19 章 UI 系统选择全 ECS 模型形成了有趣的对比:UI 需要持久化的交互状态,适合 Entity 模型;调试绘制是临时的,适合即时模式。
要点:Gizmos 是即时模式的调试绘制 API,每帧写入 GizmoBuffer,在渲染阶段批量提交 GPU。
24.3 Schedule Stepping:系统级单步调试
Stepping 资源允许开发者像调试器单步执行代码一样,逐个系统地推进 Schedule 执行:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/stepping.rs #[derive(Resource, Default)] pub struct Stepping { schedule_states: HashMap<InternedScheduleLabel, ScheduleState>, schedule_order: Vec<InternedScheduleLabel>, cursor: Cursor, // 当前暂停位置 action: Action, // 本帧的执行动作 updates: Vec<Update>, // 待应用的配置更新 } }
使用模式
#![allow(unused)] fn main() { // 启用 Stepping app.add_plugins(SteppingPlugin); // 在运行时控制 fn stepping_control(mut stepping: ResMut<Stepping>, input: Res<ButtonInput<KeyCode>>) { if input.just_pressed(KeyCode::F10) { stepping.step_frame(); // 执行到下一帧 } if input.just_pressed(KeyCode::F11) { stepping.step(); // 执行下一个系统 } if input.just_pressed(KeyCode::F5) { stepping.continue_(); // 继续正常运行 } } }
Stepping 决策流程
graph TD
Start["每个 System 执行前"] --> A{"1. 检查 Stepping.action"}
A -- "RunAll" --> RUN["正常执行"]
A -- "Step" --> STEP["只执行 cursor 指向的"]
A -- "Continue" --> CONT["执行到断点"]
RUN --> B{"2. 检查系统级行为"}
STEP --> B
CONT --> B
B -- "AlwaysRun" --> SKIP["跳过 stepping,直接执行"]
B -- "NeverRun" --> NEVER["始终跳过"]
B -- "Break" --> PAUSE["暂停在此系统"]
B -- "Default" --> C["3. 更新 cursor 位置"]
图 24-3: Stepping 决策流程
Stepping 特别适合调试系统执行顺序问题——当多个系统间存在微妙的依赖关系时,单步执行可以精确观察每个系统的效果。
Schedule Stepping 的设计灵感来自传统调试器的断点机制,但它操作的是 ECS 系统而非代码行。在传统调试器中,你可以在源代码的某一行设置断点,程序执行到那里就暂停。Stepping 将这个概念提升到了 ECS 层面——你可以在某个 System 上设置"断点",Schedule 执行到那个 System 时暂停。这对于调试 ECS 特有的问题特别有价值:System 执行顺序导致的数据不一致、Change Detection 的触发时机、Observer 的意外触发等。这些问题在传统调试器中很难诊断,因为它们不是某一行代码的 bug,而是系统间交互的 emergent behavior。Stepping 的 AlwaysRun 和 NeverRun 选项则提供了细粒度的控制——某些基础设施 System(如 Time 更新)即使在 stepping 模式下也需要运行,以保持引擎的基本功能。
要点:Stepping 资源提供系统级单步调试,支持 step(逐系统)、step_frame(逐帧)、continue(继续)等操作。
24.4 bevy_remote:远程 World 查询
第 22 章介绍了 bevy_remote 的反射基础。本节补充其作为调试工具的实际用法。
启用远程调试
#![allow(unused)] fn main() { app.add_plugins(( RemotePlugin::default(), RemoteHttpPlugin::default(), // 默认监听 127.0.0.1:15702 )); }
调试操作一览
| 方法 | 说明 | 用途 |
|---|---|---|
world.query | 查询匹配组件的实体 | 查找特定类型的实体 |
world.get_components | 获取实体的组件数据 | 检查实体状态 |
world.insert_components | 动态插入组件 | 运行时修改数据 |
world.remove_components | 移除组件 | 运行时清理 |
world.spawn_entity | 创建新实体 | 动态创建调试实体 |
world.despawn_entity | 销毁实体 | 清理测试实体 |
world.reparent_entities | 修改实体层级 | 调整场景结构 |
数据流
sequenceDiagram
participant Tool as 外部工具 (curl / 编辑器)
participant Plugin as RemoteHttpPlugin
participant ES as exclusive system
Tool->>Plugin: HTTP POST (JSON-RPC)
Plugin->>Plugin: BrpRequest 解析
Plugin->>ES: 调用 exclusive system
ES->>ES: World 访问
ES->>ES: TypeRegistry 查找
ES->>ES: Reflect 序列化
ES-->>Plugin: 结果
Plugin-->>Tool: JSON-RPC Response
图 24-4: bevy_remote 数据流
BRP 请求被收集后,在一个 exclusive system 中处理——这保证了对 World 的安全独占访问。响应通过 ReflectSerializer 将组件数据转为 JSON。
使用 exclusive system 处理远程请求是一个深思熟虑的设计选择。替代方案是使用普通 System 配合 Query 来处理请求,但远程调试协议需要能查询和修改任意类型的组件——这需要运行时确定的 Query,而不是编译期固定的 Query。Exclusive system 拥有对 World 的完整访问权限,可以通过 TypeRegistry 和 ReflectComponent 动态操作任意已注册的组件。代价是 exclusive system 无法与其他系统并行执行——但远程调试通常只在开发阶段使用,偶尔的排他性访问不会对游戏性能产生实质影响。这种设计与第 22 章的反射系统形成了完整的工具链:Reflect 提供类型内省能力,TypeRegistry 提供元数据查找,bevy_remote 将两者包装为网络可访问的 API。
要点:bevy_remote 通过 HTTP + JSON-RPC 2.0 提供远程 World 查询,请求在 exclusive system 中处理,确保线程安全。
24.5 Schedule 可视化
bevy_dev_tools 提供了 Schedule 的可视化工具,帮助理解系统执行顺序和依赖关系:
#![allow(unused)] fn main() { // 使用 Bevy 内置的 schedule graph 导出 app.add_plugins(ScheduleDebugPlugin); }
Schedule 可视化生成的数据包括:
- 系统执行顺序(拓扑排序后的线性序列)
- 系统间的依赖边(before/after 约束)
- SystemSet 分组信息
- 并行执行的系统组
这对于理解复杂应用中系统的实际执行顺序非常有帮助——尤其是当多个 Plugin 各自添加系统时,全局的执行顺序可能不如预期。
FPS Overlay
bevy_dev_tools 还提供了 FPS 叠加显示:
#![allow(unused)] fn main() { // 源码: crates/bevy_dev_tools/src/fps_overlay.rs app.add_plugins(FpsOverlayPlugin { config: FpsOverlayConfig { text_config: TextFont { font_size: 20.0, ..default() }, ..default() }, }); }
这是一个轻量级的帧率显示,直接在游戏画面上叠加 FPS 数值。
要点:Schedule 可视化帮助理解系统执行顺序,FPS Overlay 提供即时的帧率监控。
24.6 Subsecond 热补丁:ECS 与代码替换的天然契合
前面几节讨论的都是"观测"工具。本节讨论一个更激进的开发体验优化:在不重启进程的前提下替换 System 函数体。Bevy 0.17+ 提供可选的 hotpatching feature,把 Dioxus Labs 开发的 Subsecond 接进 App 运行时:底层热函数支持位于 bevy_ecs,而与 Dioxus CLI 的连接和补丁通知桥接由 bevy_app::hotpatch::HotPatchPlugin 完成。
核心机制
Subsecond 的核心 API 是 subsecond::call(|| { ... })。在 debug 构建里,它把闭包装成 HotFn,通过全局 JumpTable 查询当前函数指针,并在需要时通过 unwind/retry 重新进入最新版本;在非 debug 构建里则直接 return f(),也就是不会走跳转表调用路径(源码: packages/subsecond/subsecond/src/lib.rs:250)。JumpTable 本身保存补丁动态库路径、old->new 的 AddressMap(底层是 HashMap<u64, u64>)以及 ASLR 锚点(源码: packages/subsecond/subsecond-types/src/lib.rs:9;packages/subsecond/subsecond/src/lib.rs:273)。外部工具编译变化的代码生成单元后发送新 JumpTable,运行时只替换表项,不直接改写进程代码页。
Bevy 的集成:把热补丁信号变成变更检测
Bevy 的类型层支持位于 bevy_ecs,但真正把热补丁接进调度循环的是 bevy_app::hotpatch::HotPatchPlugin:它先连接 Dioxus CLI,再用 subsecond::register_handler 把补丁到达转换为 channel 信号;Last 阶段的系统读到信号后写入 HotPatched,并对 HotPatchChanges 调用 set_changed()(源码: crates/bevy_app/src/hotpatch.rs:24)。集成中最核心的两个 ECS 对象仍然是:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/lib.rs:143 #[cfg(feature = "hotpatching")] #[derive(Message, Default)] pub struct HotPatched; // 源码: crates/bevy_ecs/src/lib.rs:155 #[cfg(feature = "hotpatching")] #[derive(Resource, Default)] pub struct HotPatchChanges; }
HotPatched 是开发者可订阅的补丁 Message。HotPatchChanges 是一个零字段 Resource,存在的唯一目的是借用第 10 章的 Tick 机制充当"热补丁时钟"。Bevy 不会在补丁到达的瞬间直接改写所有 System;而是先在 Last 标记它已变化,随后 Executor 在后续调度点读取 last_changed(),只在 hotpatch_tick 晚于 system.last_run 时调用 refresh_hotpatch() 刷新缓存函数指针:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/schedule/executor/multi_threaded.rs:480 #[cfg(feature = "hotpatching")] if hotpatch_tick.is_newer_than( system.get_last_run(), context.environment.world_cell.change_tick(), ) { system.refresh_hotpatch(); } }
信号传播完全复用既有机制——外部通知先转成 Resource 的 change tick,再由调度器按需刷新函数指针;不需要额外的全局状态机。
flowchart TD
A["外部工具<br/>发送新跳转表"] --> B["Subsecond 运行时<br/>原子替换 AddressMap"]
B --> C["HotPatchPlugin handler<br/>发送 channel 信号"]
C --> D["Last 阶段系统<br/>write HotPatched<br/>set_changed(HotPatchChanges)"]
D --> E["后续调度点<br/>Executor 读取 hotpatch_tick"]
E --> F{"hotpatch_tick<br/>newer than<br/>system.last_run?"}
F -- "Yes" --> G["system.refresh_hotpatch()"]
F -- "No" --> H["复用缓存指针"]
G --> I["运行新版函数体"]
H --> I
图 24-5: Subsecond 热补丁在 Bevy Executor 中的触发流程
为什么 ECS 的架构优势在这里显现
Unreal 的 Live Coding 和 Unity 的 Assembly Reload 早已证明 OOP 引擎可以热重载。ECS 的优势不是"让热补丁成为可能",而是让它几乎不需要额外设计。三个特征共同作用:System 的数据依赖通过 SystemParam 在函数签名中显式声明(第 8 章),持久状态绝大多数放在 Component/Resource 中而非闭包字段(Local<T> 是例外,但它由 FunctionSystem 包装器持有、不随补丁变化);数据与行为彻底分离(第 1 章)让补丁只需替换函数指针,World 中的实体数据无需迁移;调度器在 System/Observer 派发前提供天然同步点,让函数指针刷新发生在既有执行边界上,无需 stop-the-world。
Rust 设计亮点:在
bevy_ecs层,Bevy 的 Subsecond 支持只新增一个 Message、一个空 Resource、一个 trait 方法;运行时桥接则由bevy_app::hotpatch::HotPatchPlugin负责把外部补丁信号转成 ECS 世界里的 change tick。HotPatchChanges本身不存数据,只复用既有变更检测机制充当热补丁时钟。
启动方式与已知陷阱
启用步骤:打开 Bevy 的 hotpatching feature,然后用 Dioxus CLI 的 dx serve --hot-patch 运行应用(--hotpatch 只是别名;源码: packages/cli/src/cli/serve.rs:57)。几个必须提前知道的陷阱:
- 仅补丁 tip crate(
main.rs所在 crate)。跨 crate 修改不支持——rustc 构建图非确定性,且修改转发泛型的函数会引发级联 codegen 变更。 - System 签名与数据结构布局不能变。修改
Query过滤器、参数类型、Component/Resource字段布局都需要完整重启。 - Tip crate 的 thread-local 每次补丁后重置,Subsecond 官方警告"复杂场景可能 crash 或 segfault"。
- 开发模式专用:Subsecond 的
call()在非 debug 构建里会直接执行原闭包,但 Bevy 的 hotpatching 路径包含unsafe,Executor 注释也明确要求此 feature 不应在 release 开启。
实际使用中最频繁的改动(System 函数体内的数值调整、分支逻辑、bug 修复)恰好是 Subsecond 完美支持的场景。
要点:Subsecond 通过 JumpTable 实现代码热补丁;Bevy 则先在 bevy_app 层把外部补丁通知桥接进 ECS 世界,再用 HotPatchChanges 的变更检测 tick 把刷新信号传播到后续调度点。
本章小结
本章我们了解了 Bevy 的五套诊断调试工具和一个开发体验增强:
- DiagnosticsStore:基于 EMA 的性能指标体系;FPS、实体数、系统信息等指标由各诊断插件按需注册
- Gizmos:即时模式调试绘制,零管理成本的可视化调试
- Stepping:系统级单步调试,精确控制 Schedule 执行
- bevy_remote:基于反射的远程 World 查询,JSON-RPC 2.0 协议
- Schedule 可视化:系统执行顺序和依赖关系图
- Subsecond 热补丁:亚秒级代码热替换;通过
HotPatchedMessage +HotPatchChangesResource 复用变更检测 Tick 机制
下一章,我们将讨论 Bevy 的跨平台支持——从 no_std ECS 核心到 WASM 单线程模式。
第 25 章:跨平台与 no_std
导读:Bevy 引擎的跨平台能力不仅仅是"编译到不同目标"——它深入到 ECS 核心的
no_std支持、Web 平台的单线程适配、以及平台差异的抽象。 本章从bevy_platformcrate 出发,分析 Bevy 如何在保持核心 API 一致 的同时,适配从嵌入式到浏览器的多种运行环境。
25.1 bevy_platform:平台抽象层
bevy_platform 是 Bevy 的平台兼容层,本身是 #![no_std]:
#![allow(unused)] fn main() { // 源码: crates/bevy_platform/src/lib.rs #![no_std] //! Platform compatibility support for first-party Bevy engine crates. cfg::std! { extern crate std; pub mod dirs; } cfg::alloc! { extern crate alloc; pub mod collections; } pub mod cell; // SyncUnsafeCell 等 pub mod cfg; // 条件编译宏 pub mod future; // 平台无关的 Future 工具 pub mod hash; // 确定性哈希 pub mod sync; // Arc, OnceLock 等同步原语 pub mod thread; // 线程抽象 pub mod time; // 时间抽象 (Instant) }
这个 crate 的设计理念:将所有平台差异封装在一处,上层 crate 通过 bevy_platform 的 API 而非直接依赖 std。
cfg 宏系统
bevy_platform::cfg 提供了一组条件编译宏,统一各平台的 feature gate:
#![allow(unused)] fn main() { // 源码: crates/bevy_platform/src/cfg.rs (概念) define_alias! { #[cfg(feature = "std")] => { std } // 有标准库 #[cfg(feature = "alloc")] => { alloc } // 有堆分配 #[cfg(target_arch = "wasm32")] => { web } // Web 平台 } }
上层 crate 使用这些宏而非裸 #[cfg]:
#![allow(unused)] fn main() { // 在 bevy_tasks 中 cfg::std! { extern crate std; } cfg::web! { if { // WASM 特化代码 } else { // 原生平台代码 } } }
将平台差异集中在一个 crate 中而非分散在各个子系统中,是一个有代价的架构决策。集中式的好处显而易见:新增平台支持只需修改 bevy_platform,上层 crate 无需改动。但代价是 bevy_platform 必须预见所有上层 crate 可能需要的平台抽象——如果某个子系统需要的平台 API 尚未被 bevy_platform 封装,就需要先扩展 bevy_platform,再由子系统使用。这种"先抽象,再使用"的流程增加了开发的间接性。替代方案是让每个 crate 自行处理 #[cfg] 条件编译,这更直接但会导致平台逻辑分散在数十个 crate 中,维护成本随 crate 数量线性增长。Bevy 选择了集中式方案,因为游戏引擎的平台差异(时间 API、线程模型、同步原语)是有限且稳定的,一个精心设计的抽象层可以覆盖绝大多数需求。
要点:bevy_platform 是 no_std 的平台抽象层,通过 cfg 宏系统统一条件编译,避免上层 crate 分散处理平台差异。
25.2 ECS 核心的 no_std 支持
Bevy ECS 的核心数据结构(World、Entity、Component、Archetype、Query)不依赖标准库:
graph TD
subgraph L1["bevy_app (需要 std: 线程、I/O)"]
APP["App, Plugin, SubApp"]
end
subgraph L2["bevy_ecs (核心: no_std + alloc)"]
ECS1["World, Entity, Component"]
ECS2["Query, System, Schedule"]
ECS3["Commands, Events"]
end
subgraph L3["bevy_platform (no_std)"]
BP1["sync: Arc, OnceLock"]
BP2["collections: HashMap"]
BP3["hash: 确定性哈希"]
end
subgraph L4["core + alloc (Rust 基础库)"]
CORE["core, alloc"]
end
L1 --> L2
L2 --> L3
L3 --> L4
图 25-1: ECS no_std 依赖层次
no_std 可用的 ECS 核心
在 no_std 环境下(只需要 alloc),以下 ECS 功能可用:
| 模块 | no_std 可用 | 依赖 |
|---|---|---|
| World | 是 | alloc |
| Entity | 是 | core |
| Component | 是 | alloc |
| Archetype | 是 | alloc |
| Query | 是 | alloc |
| System (基础) | 是 | alloc |
| Schedule (单线程) | 是 | alloc |
| Commands | 是 | alloc |
| Events | 是 | alloc |
| 多线程 Executor | 否 | std (thread) |
| TaskPool | 否 | std (thread) |
| Diagnostics | 否 | std (time) |
实际用例
Bevy 的 no_std 支持使 ECS 可以用在:
- 嵌入式系统:单片机上的游戏逻辑
- 自定义运行时:无操作系统的裸机环境
- 库集成:将 ECS 作为纯数据管理层嵌入其他框架
// no_std 环境使用 ECS (概念示例) #![no_std] extern crate alloc; use bevy_ecs::prelude::*; #[derive(Component)] struct Position(f32, f32); fn main_loop(world: &mut World, schedule: &mut Schedule) { schedule.run(world); }
在游戏引擎中支持 no_std 是一个不寻常的选择——大多数游戏引擎假设运行在完整的操作系统上。Bevy 追求 no_std 支持的动机不仅仅是"可以在单片机上运行 ECS"(虽然这确实有趣),更深层的原因是 no_std 约束迫使代码保持最小依赖。当 ECS 核心不依赖 std 时,它自然地避免了对文件系统、网络、线程等重量级 API 的隐式依赖。这使得 ECS 成为一个真正纯粹的数据管理层——它只做实体-组件-系统的管理,不做任何 I/O。这种纯粹性带来了可测试性和可嵌入性的好处:ECS 核心可以在没有操作系统的环境中运行测试,也可以作为库嵌入到其他框架(如将 Bevy ECS 用于服务端的游戏状态管理)。代价是所有需要 std 功能的代码(多线程调度、诊断、资源加载)必须放在上层 crate 中,增加了架构的层次复杂度。
要点:Bevy ECS 核心基于 core + alloc 构建,不依赖 std,可在嵌入式和裸机环境使用。
25.3 Web 平台:单线程 ECS
WASM 环境是 Bevy 跨平台的重要场景。浏览器的 JavaScript 主线程不支持传统的多线程模型(Web Workers 存在但有严格限制)。
ConditionalSend 适配
第 23 章介绍了 ConditionalSend——在 WASM 平台移除 Send 约束:
#![allow(unused)] fn main() { // 源码: crates/bevy_tasks/src/lib.rs cfg::conditional_send! { if { // WASM: 一切都在主线程,无需 Send pub trait ConditionalSend {} impl<T> ConditionalSend for T {} } else { pub trait ConditionalSend: Send {} impl<T: Send> ConditionalSend for T {} } } }
单线程 TaskPool 后端
#![allow(unused)] fn main() { // 源码: crates/bevy_tasks/src/single_threaded_task_pool.rs (概念) // WASM / 非 multi_threaded 配置下,公开类型仍然是 TaskPool, // 只是内部后端不再创建真实工作线程 pub struct TaskPool {} impl TaskPool { pub fn scope<'env, F, T>(&self, f: F) -> Vec<T> { // 直接在当前线程执行 // 没有线程创建开销 } } }
Web 环境的关键差异
原生平台 Web 平台 (WASM)
┌──────────────────────┐ ┌──────────────────────┐
│ MultiThreadedExecutor│ │ SingleThreadedExecutor│
│ ├─ Thread 1 │ │ └─ 主线程顺序执行 │
│ ├─ Thread 2 │ │ │
│ └─ Thread N │ │ TaskPool │
│ │ │ └─ 同步执行 │
│ T: Send + Sync 必须 │ │ │
│ NonSend = 主线程限制 │ │ T: 无 Send 要求 │
│ │ │ NonSend = API 保留 │
│ Instant = std::time │ │ Instant = web_time │
└──────────────────────┘ └──────────────────────┘
图 25-2: 原生 vs Web 平台差异
Web 平台使用 web_time crate 替代 std::time::Instant,因为浏览器的时间 API 与系统时间 API 不同。
渲染适配
Web 环境使用 WebGPU 或 WebGL2 作为渲染后端。bevy_render 通过 wgpu 的跨平台抽象自动选择后端,上层代码无需修改。
ConditionalSend 的设计是 Bevy 跨平台架构中最精妙的 trait 技巧之一。问题的根源在于:Bevy 的系统函数和异步任务在原生平台上需要满足 Send 约束(因为它们会被发送到其他线程执行),但在 WASM 上 Send 约束毫无意义(只有一个线程)。如果 API 统一要求 Send,WASM 用户就无法使用 !Send 的浏览器 API(如 DOM 操作)。如果 API 不要求 Send,原生平台的类型安全就被削弱。ConditionalSend 通过条件编译在两个平台上切换定义:原生平台上它等价于 Send,WASM 上它是一个对所有类型自动实现的空 trait。上层代码只需要约束 T: ConditionalSend 而非 T: Send,就能在两个平台上都正确工作。这种设计的代价是引入了一个额外的 trait,增加了概念复杂度;但它优雅地解决了"一套 API 同时服务多线程和单线程环境"这个本质难题。
要点:Web 平台通过 ConditionalSend 移除 Send 约束;Schedule 默认切到 SingleThreadedExecutor,TaskPool API 保持一致但底层退化为单线程执行,并通过主线程 tick 推进本地 executor。
25.4 NonSend:平台差异的统一处理
不同平台的 !Send 资源有不同的来源:
| 平台 | 常见 NonSend 类型 | 原因 |
|---|---|---|
| Windows/macOS/Linux | 平台窗口/显示后端对象 | 宿主窗口系统具有线程上下文约束 |
| iOS | UIKit 宿主对象 | 主线程 UI 限制 |
| Android | GameActivity / NativeActivity 宿主 | JNI 与宿主线程限制 |
| Web | 浏览器宿主对象 | 单线程环境 |
Bevy 通过 NonSend<T> 和 NonSendMut<T> 提供统一的 API。调度器自动处理线程约束——NonSend 系统被标记为 is_send = false,只会被调度到主线程执行器。
// 跨平台的主线程约束(各平台代码一致) fn main_thread_only_system(_main_thread: NonSendMarker) { // 调度器保证此系统只能在主线程运行 }
在单线程模式下(如 WASM),NonSend 约束变得无意义——所有代码都在主线程。但 API 保持一致,使得同一份代码可以在多线程和单线程环境下编译和运行。
NonSend 的跨平台统一性体现了 Bevy 的一个重要设计原则:平台差异应该在引擎层面被吸收,而非泄漏到用户代码中。用户写的 NonSend<T> / NonSendMut<T> 或 NonSendMarker 系统在所有平台上都使用同一套 API:在多线程平台上,调度器据此把系统固定到主线程;在单线程平台上,这个约束通常只是显式表达“此系统依赖主线程上下文”。窗口底层句柄在引擎内部更常以 RawHandleWrapper 之类的封装组件存在,而不是直接作为 NonSend<RawWindowHandle> 暴露给普通系统。
要点:NonSend 是跨平台 !Send 资源的统一 API。不同平台的线程约束通过调度器自动适配。
25.5 Android/iOS Plugin 差异
移动平台的主要差异集中在应用生命周期和窗口管理:
Android
Android 平台的关键点是:Bevy 当前默认面向 GameActivity,NativeActivity 仍可选但已是 legacy 路径。除此之外,Android 还需要:
- 通过
winit的 Android 后端管理窗口和事件循环 - 资产 (Asset) 通过 Android 的
AssetManager加载,而非文件系统 - 宿主生命周期与窗口/渲染后端协同工作
iOS
iOS 侧同样存在宿主生命周期、图形后端和资产 I/O 的平台约束。对 Bevy 用户最重要的事实不是这些细节本身,而是:
- Plugin API 不因平台而变化
- 窗口、输入、渲染和资产后端差异由底层平台集成吸收
- 用户层仍通过同一套
DefaultPlugins/Plugin入口组织应用
统一的 Plugin 接口
尽管平台细节不同,Plugin 接口保持统一:
#![allow(unused)] fn main() { // 所有平台使用相同的 Plugin API impl Plugin for MyGamePlugin { fn build(&self, app: &mut App) { app.add_systems(Update, game_logic); } } }
平台差异被封装在 DefaultPlugins 内部——不同平台的 WindowPlugin、RenderPlugin 实现不同,但对用户透明。
要点:Android/iOS 的差异主要落在宿主生命周期、窗口后端和资产 I/O;对用户暴露的 Plugin API 保持统一,平台分歧主要由 DefaultPlugins 和底层平台集成吸收。
本章小结
本章我们了解了 Bevy 的跨平台架构:
- bevy_platform:
no_std平台抽象层,通过 cfg 宏统一条件编译 - ECS no_std:核心数据结构基于
core + alloc,可在嵌入式/裸机环境使用 - Web 单线程:通过 ConditionalSend +
SingleThreadedExecutor+ 单线程后端的TaskPool适配 WASM - NonSend:跨平台
!Send资源的统一 API,调度器自动处理线程约束 - 移动平台:Android/iOS 差异封装在 DefaultPlugins 中,Plugin API 保持统一
下一章是全书的收束——我们将总结 Bevy 中 10 种重要的 Rust 设计模式。
第 26 章:Bevy 中的 Rust 设计模式总结
导读:本章是全书的收束。在前 25 章中,我们反复遇到 Bevy 对 Rust 语言 特性的精妙运用。本章将这些设计模式提炼为 10 个主题,每个主题用 Bevy 源码 中的真实案例说明,解释为什么这样设计而非仅仅怎样设计。 读完本章,你会理解为什么 Rust 特别适合构建游戏引擎。
26.1 unsafe 边界的艺术
首次出现:第 3 章 (World)、第 5 章 (BlobArray)
Bevy 大量使用 unsafe,但遵循一个严格原则:unsafe 实现,safe 接口。 内部的 unsafe 代码被精心封装在边界之内,用户永远不需要写 unsafe。
最典型的案例是 UnsafeWorldCell:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/unsafe_world_cell.rs pub struct UnsafeWorldCell<'w> { ptr: *mut World, #[cfg(debug_assertions)] allows_mutable_access: bool, _marker: PhantomData<(&'w World, &'w UnsafeCell<World>)>, } }
UnsafeWorldCell 将 &mut World 拆分为多个并发的、受限访问的视图。调度器可以同时给多个系统各自一个 UnsafeWorldCell——每个系统只能访问自己声明的组件。
另一个案例是 BlobArray(第 5 章):
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/storage/blob_array.rs pub struct BlobArray { item_layout: Layout, data: NonNull<u8>, // 裸指针 capacity: usize, drop: Option<unsafe fn(OwningPtr<Aligned>)>, } }
BlobArray 内部用指针算术管理类型擦除的内存,但对外只暴露 Column 和 Table 的安全 API。用户通过 Query<&Position> 读取数据时,完全不需要知道底层是裸指针操作。
为什么这样设计:Rust 的类型系统无法在编译期表达"这两个系统访问不同组件"这种约束。unsafe 在此处是必要的桥梁——它让运行时调度器承担编译器做不到的安全验证,同时保持外部 API 的完全安全。
这对游戏引擎意味着什么:游戏引擎是 unsafe 边界艺术的极端考验。引擎需要在每帧处理数万个实体的数据,性能要求排除了"安全但低效"的方案(如每次访问都加锁)。同时,引擎是被成百上千个游戏开发者使用的基础设施,用户 API 的安全性直接决定了整个生态的可靠性。Bevy 的策略是将 unsafe 集中在少数经过严格审计的底层模块(BlobArray、UnsafeWorldCell、Table),由引擎核心开发者维护,然后在这些模块之上构建完全安全的 Query、System、Commands API 供所有用户使用。这种分层策略让 unsafe 的审计范围保持可控——需要关注的 unsafe 代码量不随用户代码的增长而增加。
26.2 all_tuples! 宏元编程
首次出现:第 8 章 (System)
Bevy 需要让任意参数数量的函数成为 System。Rust 没有可变参数泛型 (variadic generics),所以 Bevy 用 all_tuples! 宏为 0 到 16 个参数生成 trait 实现:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/function_system.rs all_tuples!(impl_system_function, 0, 16, F); // 展开后相当于: impl<F, R> SystemParamFunction<()> for F where F: Fn() -> R { ... } impl<F, R, P0> SystemParamFunction<(P0,)> for F where F: Fn(P0) -> R, P0: SystemParam { ... } impl<F, R, P0, P1> SystemParamFunction<(P0, P1)> for F where F: Fn(P0, P1) -> R { ... } // ... 一直到 16 个参数 }
类似地,SystemParam 的元组实现也是宏生成的:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/system_param.rs all_tuples!(impl_system_param_tuple, 0, 16, P); }
这使得 (Query<&A>, Res<B>, MessageWriter<C>) 自动成为合法的 SystemParam。
为什么这样设计:Rust 的 trait 系统是单态化的——编译器需要为每种参数组合生成具体代码。宏是在语言不支持可变参数泛型之前的唯一解决方案。16 的上限是实践中的平衡——超过 16 个参数的系统几乎不存在,但编译时间随参数数量指数增长。
这对游戏引擎意味着什么:游戏引擎的核心 API 需要同时满足两个看似矛盾的需求——灵活性(支持任意参数组合的系统函数)和人体工程学(用户无需显式实现 trait 或注册类型)。all_tuples! 宏让普通的 Rust 函数直接成为 ECS 系统,用户不需要实现任何 trait,不需要装箱,不需要类型注册——只需要写一个参数类型正确的函数。这种"零仪式"的 API 风格对游戏开发者至关重要:游戏代码迭代速度极快,任何额外的样板代码都会显著降低开发效率。代价是每次增加 all_tuples! 的上限都会显著增加编译时间,因为需要为更多的参数组合生成代码。16 这个数字是 Bevy 社区在 API 灵活性和编译时间之间反复权衡的结果。
26.3 GAT 生命周期参数化
首次出现:第 8 章 (SystemParam)
SystemParam trait 使用 Generic Associated Types (GAT) 来处理生命周期的参数化:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/system/system_param.rs pub unsafe trait SystemParam: Sized { type State: Send + Sync + 'static; type Item<'world, 'state>: SystemParam<State = Self::State>; fn init_state(world: &mut World) -> Self::State; fn init_access( state: &Self::State, system_meta: &mut SystemMeta, component_access_set: &mut FilteredAccessSet, world: &mut World, ); // ... } }
关键是 type Item<'world, 'state> —— 它是一个带生命周期参数的关联类型。这允许 Query<'w, 's, &Position> 在每次系统调用时获得新的生命周期,而 State 持久存活:
System 第一次调用:
State (长期存活) → Item<'w1, 's1> (本次调用的借用)
System 第二次调用:
State (同一个) → Item<'w2, 's2> (新的借用)
为什么这样设计:没有 GAT,SystemParam 的 Item 类型无法拥有独立的生命周期参数。Bevy 需要将"持久状态"(如 QueryState)和"临时借用"(如对 World 数据的引用)分离。GAT 正是 Rust 类型系统中实现这种分离的机制。
这对游戏引擎意味着什么:游戏引擎的系统函数在生命周期管理上面临独特挑战。系统函数每帧被调用一次,每次调用借用 World 中的数据,但系统的"内部状态"(如 QueryState 中缓存的 Archetype 匹配信息)需要跨帧持久存活。没有 GAT,这两种生命周期(帧内借用 vs 跨帧状态)无法在类型系统中区分,只能退而使用 unsafe 或运行时检查。GAT 让 Bevy 在完全安全的 API 层面表达这种"短期借用 + 长期状态"的模式。这也是为什么 Bevy 等待 GAT 稳定后才重构 SystemParam 的原因——在此之前的实现需要更多的 unsafe 代码和更弱的类型保证。
26.4 PhantomData 幽灵类型
首次出现:第 3 章 (UnsafeWorldCell)、第 16 章 (Handle)
PhantomData<T> 在 Bevy 中有两种用途:
1. 生命周期标记
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/world/unsafe_world_cell.rs pub struct UnsafeWorldCell<'w> { ptr: *mut World, _marker: PhantomData<(&'w World, &'w UnsafeCell<World>)>, } }
裸指针 *mut World 不携带生命周期信息。PhantomData 告诉编译器这个类型在语义上借用了 'w 生命周期的 World,从而启用借用检查器的保护。
2. 类型标签
#![allow(unused)] fn main() { // 源码: crates/bevy_asset/src/handle.rs pub enum Handle<A: Asset> { Strong(Arc<StrongHandle>), Uuid(Uuid, PhantomData<fn() -> A>), // PhantomData 使 Handle<Mesh> ≠ Handle<Image> } // 源码: crates/bevy_time/src/time.rs pub struct Time<T: Default = ()> { context: T, wrap_period: Duration, delta: Duration, // ... } }
Handle<Mesh> 和 Handle<Image> 在运行时的底层数据完全相同(都是一个 ID),但类型系统阻止你把 Handle<Mesh> 传给期望 Handle<Image> 的函数。Time<Fixed> 和 Time<Virtual> 同理——通过类型参数区分不同的时间语义。
为什么这样设计:PhantomData 实现了零成本的类型安全。编译后没有任何运行时开销,但在编译期提供了完整的类型检查。这是 Rust 零成本抽象的典范——你在编译期获得安全保证,运行时不付出任何代价。
这对游戏引擎意味着什么:游戏引擎管理着大量不同类型的资源——网格、纹理、音频、材质——它们在底层都是 ID + 引用计数,但在逻辑上绝对不能混用。PhantomData 让引擎可以用相同的底层实现服务所有资源类型,同时在编译期阻止类型混淆。在一个中等规模的游戏中,可能有数千个 Handle 在系统间传递——如果没有类型标签,把 Handle<Mesh> 误传给需要 Handle<Image> 的函数是一个极难追踪的 bug(它不会崩溃,只是渲染出错误的东西)。PhantomData 将这类错误从"运行时视觉 bug"提升为"编译期类型错误",极大地提升了大型游戏项目的可维护性。
26.5 trait object vs 静态分发
首次出现:第 2 章 (Plugin)、第 8 章 (System)
Bevy 在 Plugin 和 System 上做了不同的分发选择:
| 概念 | 分发方式 | 原因 |
|---|---|---|
| Plugin | 动态分发 (Box<dyn Plugin>) | 数量少,注册时调用一次 |
| System | 静态分发 (泛型 impl IntoSystemConfigs) | 数量多,每帧调用 |
#![allow(unused)] fn main() { // Plugin: 动态分发 — 数量少、调用频率低 pub trait Plugin: Downcast + Any + Send + Sync { fn build(&self, app: &mut App); } // App 存储 Box<dyn Plugin> // System: 静态分发 — 数量多、调用频率高 pub trait IntoSystemConfigs<Marker> { fn into_configs(self) -> SystemConfigs; } // 编译器为每种系统函数签名单态化 }
Plugin 使用动态分发因为:
- Plugin 数量通常在 10-50 个
build()只在启动时调用一次- 需要存储在异构集合中
System 使用静态分发因为:
- System 数量可能达数百个
- 每帧调用,性能敏感
- 单态化允许编译器内联和优化
为什么这样设计:这不是"动态好还是静态好"的问题,而是根据调用频率和性能需求做出的工程权衡。Bevy 在同一个引擎中混合使用两种分发方式,各取所长。
这对游戏引擎意味着什么:游戏引擎的性能瓶颈通常集中在"热路径"——每帧执行数百次的代码。System 的执行就是最核心的热路径,静态分发通过单态化让编译器可以内联系统函数体、消除虚表查找、优化缓存预取模式。对于每帧执行一次的系统,虚函数调用的开销(约 5-10ns)可能不显著;但对于包含 par_iter 的系统,内联允许编译器将循环体优化为 SIMD 指令,性能差异可达数倍。Plugin 是"冷路径"——只在启动时调用,使用动态分发换来的是异构集合的灵活性和更短的编译时间。这种"热路径静态分发、冷路径动态分发"的策略是高性能 Rust 软件的通用模式。
26.6 所有权驱动的架构:双 World
首次出现:第 14 章 (Render 架构)
Bevy 渲染架构使用两个独立的 World——Main World 和 Render World:
#![allow(unused)] fn main() { // 概念: Extract 阶段 (简化) fn extract_phase(main_world: &mut World, render_world: &mut World) { // 从 main_world 提取数据到 render_world // 两个 World 各自拥有自己的数据 } }
graph LR
subgraph MW["Main World (游戏逻辑)<br/>拥有所有权"]
GS["Game Systems<br/>(可以并行执行)"]
end
subgraph RW["Render World (渲染逻辑)<br/>拥有所有权"]
RS["Render Systems<br/>(可以并行执行)"]
end
MW -- "Extract (数据复制)" --> RW
这是 Rust 所有权系统在架构层面的应用。两个 World 各自独立拥有数据,Extract 阶段通过值复制而非引用共享来传递数据。这消除了主线程和渲染线程之间的数据竞争——不需要锁,不需要原子操作。
为什么这样设计:传统引擎用共享内存 + 锁来实现主线程与渲染线程的通信。Bevy 选择数据复制是因为 Rust 的所有权模型让"复制比共享更安全"成为自然选择——而 ECS 的列式存储使得批量 memcpy 非常高效。
这对游戏引擎意味着什么:游戏渲染管线的核心挑战是主线程(游戏逻辑)和渲染线程需要访问同一份数据——但修改时机不同。传统引擎用共享内存 + 锁来协调这两类工作,而锁会把线程调度和等待时机引入帧时间路径。Bevy 的双 World 架构改成了"先提取、再独立运行":Extract 阶段把渲染需要的数据从 Main World 复制到 Render World,之后两个 World 在各自的调度中前进。默认更新路径里,Main App 先执行,再对 Render SubApp 做 extract 和 update;只有额外启用 pipelined rendering 时,渲染线程才会与下一轮模拟重叠,表现出典型的流水线式帧间错位。ECS 的列式存储又让这类批量复制保持较好的顺序访问特性,因此 Bevy 能在不引入锁共享的前提下完成逻辑世界到渲染世界的数据同步。
26.7 编译期借用到运行时调度
首次出现:第 7 章 (Query)、第 9 章 (Schedule)、第 23 章 (并发)
Rust 编译器通过借用检查保证 &T 和 &mut T 不共存。但 ECS 系统是独立函数,编译器看不到系统间的数据关系。Bevy 将这个检查移到了运行时:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/query/access.rs pub struct FilteredAccess { pub(crate) access: Access, // read_set + write_set pub(crate) required: ComponentIdSet, pub(crate) filter_sets: Vec<AccessFilters>, } }
每个 System 注册时声明 FilteredAccess,调度器在 build 阶段计算冲突矩阵:
graph LR
subgraph Compile["编译期"]
BC["&T 和 &mut T 不能共存<br/>(借用检查器)"]
end
subgraph Runtime["运行时 (Schedule::build)"]
FA["FilteredAccess<br/>read_set ∩ write_set = 冲突?"]
end
Compile -- "≈ 等价映射" --> Runtime
这种"编译期规则的运行时映射"是 Bevy 最重要的设计模式之一。它让 Bevy 能在保持 Rust 安全性的同时,实现编译器无法独立推断的跨系统并行。
为什么这样设计:Rust 编译器是基于词法作用域的——它能检查一个函数内部的借用安全,但无法跨函数推断。ECS 的系统间数据访问模式是动态的,只能在运行时确定。FilteredAccess 是编译期安全规则到运行时的忠实翻译。
这对游戏引擎意味着什么:这个模式是 Bevy 能够安全并行执行数百个系统的基石。在没有 ECS 的传统引擎中,并行化游戏逻辑需要开发者手动标注线程安全性、手动管理锁、手动避免数据竞争——这是一个巨大的心智负担,也是 bug 的温床。Bevy 的 FilteredAccess 将这个负担转移给了框架:开发者只需要声明系统参数的类型(Query<&mut Position> vs Query<&Position>),框架自动推断并行安全性。当两个系统确实冲突时,调度器自动串行化它们——不需要开发者做任何额外工作。这种"声明式并行"的模式让游戏开发者可以专注于游戏逻辑,而非线程安全问题。
26.8 Deref/DerefMut 透明拦截
首次出现:第 10 章 (变更检测)
Mut<T> 通过实现 DerefMut 在用户无感知的情况下自动标记变更:
#![allow(unused)] fn main() { // 源码: crates/bevy_ecs/src/change_detection/params.rs pub struct Mut<'w, T: ?Sized> { pub(crate) value: &'w mut T, pub(crate) ticks: ComponentTicksMut<'w>, } // 源码: crates/bevy_ecs/src/change_detection/traits.rs impl<T: ?Sized> DerefMut for Mut<'_, T> { #[track_caller] fn deref_mut(&mut self) -> &mut Self::Target { self.set_changed(); // 自动标记变更! self.ticks.changed_by.assign(MaybeLocation::caller()); self.value } } }
当你写 *position = new_pos; 时,编译器自动调用 deref_mut(),触发变更标记。这使得 Changed<Position> 过滤器能正确工作——而用户完全不需要手动调用任何变更通知方法。
Deref 的只读版本直接返回引用,不标记变更:
#![allow(unused)] fn main() { impl<T: ?Sized> Deref for Mut<'_, T> { type Target = T; fn deref(&self) -> &Self::Target { self.value // 只读访问不标记变更 } } }
为什么这样设计:手动变更通知(如 Unity 的 SetDirty())容易遗忘。Bevy 利用 Rust 的 Deref trait 将变更检测嵌入到赋值操作本身。#[track_caller] 还记录了修改发生的源码位置,便于调试。
这对游戏引擎意味着什么:变更检测是游戏引擎性能优化的核心技术之一。渲染系统不需要每帧重新计算所有网格的变换矩阵——只需要更新 Transform 发生变化的实体。UI 系统不需要每帧重新布局——只需要重新布局 Node 属性变化的元素。如果变更通知依赖开发者手动调用(如 Unity 的 SetDirty),遗忘调用会导致渲染不更新(难以发现的 bug),冗余调用会导致不必要的重计算(性能问题)。Deref 拦截将变更检测变成了"不可绕过"的——只要通过 &mut 访问组件数据,变更就被自动记录。这消除了一整类由"忘记标记变更"导致的 bug,同时保证了变更信息的精确性。
26.9 derive macro 突破无反射限制
首次出现:第 22 章 (Reflect)
#[derive(Reflect)] 是 Bevy 反射系统的核心——它在编译期生成运行时需要的类型元数据:
#![allow(unused)] fn main() { // 用户代码 #[derive(Reflect)] struct Player { name: String, health: f32, } // derive 宏生成 (概念) impl PartialReflect for Player { fn reflect_ref(&self) -> ReflectRef { ReflectRef::Struct(self) } // ... } impl Struct for Player { fn field(&self, name: &str) -> Option<&dyn PartialReflect> { match name { "name" => Some(&self.name), "health" => Some(&self.health), _ => None, } } fn field_len(&self) -> usize { 2 } fn field_at(&self, index: usize) -> Option<&dyn PartialReflect> { match index { 0 => Some(&self.name), 1 => Some(&self.health), _ => None, } } } impl Typed for Player { fn type_info() -> &'static TypeInfo { static CELL: NonGenericTypeInfoCell = NonGenericTypeInfoCell::new(); CELL.get_or_set(|| TypeInfo::Struct(StructInfo::new::<Self>(&[ NamedField::new::<String>("name"), NamedField::new::<f32>("health"), ]))) } } }
宏将字段名、字段类型、字段数量等信息编码为 Rust 代码。运行时通过 trait 方法访问这些信息——不依赖任何运行时元数据格式。
为什么这样设计:Java/C# 的反射依赖虚拟机在编译产物中保留元数据。Rust 编译后是原生二进制,没有虚拟机。derive 宏是 Rust 的编译期代码生成机制——它将"应该在运行时可用的信息"在编译期转化为"具体的 trait 实现代码"。
这对游戏引擎意味着什么:游戏编辑器和调试工具需要在运行时检查和修改任意组件的属性——显示 Transform 的 x/y/z 滑块、修改材质颜色、查看实体的组件列表。在 C# 引擎(Unity)中,这些功能依赖运行时反射,开发者无需额外工作。Rust 引擎必须显式选择哪些类型参与反射,这增加了一个"注册"步骤(#[derive(Reflect)]),但带来的好处是反射代码经过编译器优化,运行时性能与手写代码相当。更重要的是,derive 宏生成的反射代码在编译期就确定了字段布局,不需要运行时的哈希表查找——field("health") 被编译为一个 match 语句,与手写的字段访问代码一样高效。
26.10 Builder 模式
首次出现:第 2 章 (App)、第 9 章 (Schedule)
Bevy 的 App 和 Schedule 使用 Builder 模式组装复杂对象:
#![allow(unused)] fn main() { // App builder App::new() .add_plugins(DefaultPlugins) .add_systems(Startup, setup) .add_systems(Update, (movement, collision).chain()) .insert_resource(GameConfig::default()) .run(); // Schedule builder let mut schedule = Schedule::default(); schedule.add_systems((system_a, system_b).chain()); schedule.set_executor_kind(ExecutorKind::MultiThreaded); }
Builder 模式的核心特征是 方法链 和 延迟构建:
#![allow(unused)] fn main() { // 源码概念: App 的 builder 方法返回 &mut Self impl App { pub fn add_plugins(&mut self, plugins: impl Plugins) -> &mut Self { // ... 注册 plugin self } pub fn add_systems(&mut self, schedule: impl ScheduleLabel, systems: impl IntoSystemConfigs) -> &mut Self { // ... 注册 system self } pub fn run(&mut self) { // 最终构建并启动 } } }
Bevy 选择 &mut Self 返回而非 Self(不转移所有权),因为 App 是一个大型容器,每次方法调用都移动会产生不必要的开销。run() 是终结方法,消费 App 并启动主循环。
为什么这样设计:Builder 模式让初始化代码具有声明式风格——你描述"应用包含什么",而不是命令式地"先做这个,再做那个"。方法链保持了代码的连贯性,类型系统保证了只有合法的配置组合才能编译通过。
这对游戏引擎意味着什么:游戏应用的初始化是一个复杂的组装过程——注册数十个 Plugin、配置数百个 System、插入初始 Resource、设置渲染参数。如果没有 Builder 模式,这些操作会分散在多个初始化函数中,依赖调用顺序,难以一目了然地理解应用的完整配置。Builder 的 &mut Self 链式调用将所有配置集中在一处,形成一个"应用的声明式描述"。延迟构建更是关键——App 在 run() 被调用之前不会真正初始化任何资源,这意味着 Plugin 可以在 build 阶段自由地添加或修改配置,而不用担心初始化顺序。这种"描述优先,执行延后"的模式让复杂的游戏配置变得可组合、可测试、可审查。
本章小结
| # | 模式 | 核心机制 | 首次出现 |
|---|---|---|---|
| 1 | unsafe 边界 | unsafe 实现, safe 接口 | 第 3, 5 章 |
| 2 | all_tuples! 宏 | 为 0-16 参数生成 trait 实现 | 第 8 章 |
| 3 | GAT 生命周期 | type Item<'w, 's> 分离状态与借用 | 第 8 章 |
| 4 | PhantomData | 零成本类型标签和生命周期标记 | 第 3, 16 章 |
| 5 | trait object vs 静态分发 | Plugin 用 dyn,System 用泛型 | 第 2, 8 章 |
| 6 | 所有权驱动架构 | 双 World 消除数据竞争 | 第 14 章 |
| 7 | 编译期借用→运行时调度 | FilteredAccess 映射借用规则 | 第 7, 9 章 |
| 8 | Deref/DerefMut 拦截 | 赋值自动标记变更 | 第 10 章 |
| 9 | derive macro 反射 | 编译期生成运行时元数据 | 第 22 章 |
| 10 | Builder 模式 | 方法链声明式配置 | 第 2, 9 章 |
这 10 种模式贯穿全书,展示了 Rust 语言特性与游戏引擎需求之间的深度契合。Bevy 不仅仅是"用 Rust 写的游戏引擎",更是 Rust 类型系统、所有权模型和宏系统的集大成者。
附录 A:ECS 术语对照表
本附录列出 Bevy ECS 中的核心术语,提供中英文对照和简要说明。
| 英文 | 中文 | 说明 | 首见章节 |
|---|---|---|---|
| Entity | 实体 | 轻量级身份标识,本质是一个带 generation 的整数 ID | 第 4 章 |
| Component | 组件 | 挂载到 Entity 上的数据,Send + Sync + 'static | 第 5 章 |
| System | 系统 | 操作组件数据的函数,自动并行调度 | 第 8 章 |
| World | 世界 | 所有 ECS 数据的容器,拥有所有 Entity、Component 和 Resource | 第 3 章 |
| Resource | 资源 | 全局唯一的共享数据,不属于任何 Entity | 第 3 章 |
| Archetype | 原型 | 共享相同组件组合的 Entity 集合的索引 | 第 6 章 |
| Table | 表 | 列式存储后端,一个 Table 包含多个 Column | 第 5 章 |
| Column | 列 | 存储同一类型组件的连续数组,含变更检测 Tick | 第 5 章 |
| SparseSet | 稀疏集合 | sparse→dense 双数组存储,优化组件增删 | 第 5 章 |
| Query | 查询 | 高效遍历满足条件的 Entity 及其 Component | 第 7 章 |
| Filter | 过滤器 | 限制 Query 匹配范围的条件(With、Without、Changed 等) | 第 7 章 |
| Schedule | 调度 | 系统的编排和执行计划,自动处理依赖和并行 | 第 9 章 |
| SystemSet | 系统集 | 一组 System 的逻辑分组,用于配置顺序约束 | 第 9 章 |
| Commands | 命令 | 延迟执行的 World 操作队列(spawn、insert、despawn 等) | 第 11 章 |
| Event | 事件 | 通过 trigger 立即分发给 Observer 的触发对象 | 第 12 章 |
| Message | 消息 | 双缓冲消息队列,通过 MessageWriter / MessageReader 批量传递 | 第 12 章 |
| Observer | 观察者 | 响应特定触发的回调系统 | 第 12 章 |
| Relationship | 关系 | Entity 间的类型化关联(如 ChildOf) | 第 13 章 |
| Plugin | 插件 | 模块化的功能包,向 App 注册 System、Resource 等 | 第 2 章 |
| App | 应用 | Bevy 应用的入口,Builder 模式组装 Plugin 和 System | 第 2 章 |
| Tick | 变更计数 | 全局递增的变更标记,驱动 Changed/Added 检测 | 第 10 章 |
| StorageType | 存储类型 | Component 的存储策略选择:Table 或 SparseSet | 第 5 章 |
| BlobArray | 类型擦除数组 | 用 Layout + 裸指针管理的类型擦除内存容器 | 第 5 章 |
| FilteredAccess | 过滤访问 | 记录 System 的组件读写模式,驱动并行决策 | 第 7 章 |
| SystemParam | 系统参数 | System 函数参数的抽象 trait(Query、Res、Commands 等) | 第 8 章 |
| Extract | 提取 | 从 Main World 复制数据到 Render World 的阶段 | 第 14 章 |
| Reflect | 反射 | 运行时类型内省,通过 derive 宏生成元数据 | 第 22 章 |
| TypeRegistry | 类型注册表 | 存储所有已注册类型的反射元数据 | 第 22 章 |
| TaskPool | 任务池 | 线程池,分为 Compute、AsyncCompute、IO 三种 | 第 23 章 |
| NonSend | 非发送 | !Send 类型的特殊存储通道,限主线程访问 | 第 5 章 |
| Stepping | 单步调试 | 逐系统推进 Schedule 执行的调试工具 | 第 24 章 |
| Gizmos | 调试绘制 | 即时模式的调试可视化 API | 第 24 章 |
附录 B:SystemParam 速查
本附录列出当前 Bevy 源码树中最常用、也最容易混淆的 SystemParam 类型。
查询与资源
| SystemParam | 读/写 | 说明 |
|---|---|---|
Query<'w, 's, D, F> | 读写 | 查询满足条件的实体及组件。最常用的参数 |
Single<'w, 's, D, F> | 读写 | 恰好匹配一个实体的 Query,不满足时系统被跳过 |
Populated<'w, 's, D, F> | 读写 | 至少匹配一个实体的 Query |
Res<'w, T> | 只读 | 读取全局 Resource T |
ResMut<'w, T> | 读写 | 读写全局 Resource T,自动标记变更 |
Option<Res<'w, T>> | 只读 | 可选 Resource,不存在时返回 None |
Option<ResMut<'w, T>> | 读写 | 可选可变 Resource |
NonSend<'w, T> | 只读 | 读取 !Send 资源,限主线程 |
NonSendMut<'w, T> | 读写 | 读写 !Send 资源,限主线程 |
命令、本地状态与组合
| SystemParam | 读/写 | 说明 |
|---|---|---|
Commands<'w, 's> | 写(延迟) | 延迟执行的 World 操作队列 |
Local<'s, T> | 读写 | 系统局部状态,每个系统实例独立持久化 |
ParamSet<'w, 's, (P0, P1, ...)> | 读写 | 多个冲突参数的安全包装,一次只访问一个 |
Deferred<'s, T> | 写(延迟) | 延迟写入的缓冲区,在 apply_deferred 时刷新 |
(P0, P1, ..., P15) | 继承 | 元组自动实现 SystemParam,最多 16 个元素 |
PhantomData<T> | 无 | 占位符,不访问任何数据 |
消息与生命周期
| SystemParam | 读/写 | 说明 |
|---|---|---|
MessageReader<'w, 's, M> | 只读 | 读取缓冲消息,追踪各系统自己的读取进度 |
PopulatedMessageReader<'w, 's, M> | 只读 | 仅在存在消息时运行系统 |
MessageWriter<'w, M> | 写 | 写入缓冲消息 |
RemovedComponents<'w, 's, T> | 只读 | 读取本帧被移除的组件 T 对应实体列表 |
World 与动态访问
| SystemParam | 读/写 | 说明 |
|---|---|---|
&World | 只读 | 对整个 World 的只读引用(exclusive system 中常见) |
WorldId | 只读 | 当前 World 的唯一标识 |
FilteredResources<'w, 's> | 只读 | 运行时动态声明资源访问集合 |
FilteredResourcesMut<'w, 's> | 读写 | 运行时动态声明可变资源访问集合 |
&EntityAllocator | 只读 | 访问实体 ID 分配器 |
诊断与调试
| SystemParam | 读/写 | 说明 |
|---|---|---|
Diagnostics<'w, 's> | 写(延迟) | 写入诊断数据,延迟更新到 DiagnosticsStore |
Gizmos<'w, 's, Config> | 写 | 即时模式调试绘制 |
相关但不是 SystemParam 的类型
| 类型 | 类别 | 说明 |
|---|---|---|
In<T> | SystemInput | 系统输入参数,不属于 SystemParam |
EntityCommands | Commands 返回值 | 通过 Commands::spawn / Commands::entity 获取 |
DeferredWorld<'w> | 低层 API | 常见于 hooks 或内部实现,不是普通 system 参数 |
读写模式说明
| 模式 | 含义 | 对并行的影响 |
|---|---|---|
| 只读 | 不修改数据 | 可与其他只读系统并行 |
| 读写 | 可能修改数据 | 与访问相同组件/资源的系统互斥 |
| 写(延迟) | 修改操作缓冲,不立即生效 | 不阻塞并行(实际写入在 apply_deferred) |
提示:当系统参数超过 16 个时,可以将多个参数打包为
#[derive(SystemParam)]的自定义结构体,或使用嵌套元组。
附录 C:内置 Schedule / SystemSet 一览
本附录列出 Bevy 内置的所有 Schedule 标签和主要 SystemSet。
主循环 Schedule
主循环每帧按以下顺序执行 Schedule。默认 feature 集启用 bevy_state 时会包含 StateTransition;如果裁掉该 feature,这个阶段不存在。
启动 (仅执行一次):
┌──────────────────────────────────────────────┐
│ StateTransition (OnEnter 初始状态) │
│ PreStartup → Startup → PostStartup │
└──────────────────────────────────────────────┘
每帧循环:
┌──────────────────────────────────────────────┐
│ First │
│ ├─ 消息更新、时间更新 │
│ PreUpdate │
│ ├─ 输入处理、窗口事件 │
│ StateTransition │
│ ├─ 状态转换 (OnExit / OnEnter) │
│ RunFixedMainLoop │
│ ├─ FixedFirst → FixedPreUpdate → FixedUpdate │
│ │ → FixedPostUpdate → FixedLast │
│ Update │
│ ├─ 用户游戏逻辑 (主要写 System 的地方) │
│ SpawnScene │
│ ├─ 执行场景实例化 │
│ PostUpdate │
│ ├─ Transform 传播、渲染提取 │
│ Last │
│ ├─ 帧末清理 │
└──────────────────────────────────────────────┘
图 C-1: 主循环 Schedule 执行顺序
Schedule 标签详解
启动阶段 (仅运行一次)
| Schedule | 用途 | 典型内容 |
|---|---|---|
PreStartup | 启动前的准备 | 底层系统初始化 |
Startup | 应用启动 | 场景加载、初始实体 spawn |
PostStartup | 启动后处理 | 依赖 Startup 数据的初始化 |
每帧阶段
| Schedule | 用途 | 典型内容 |
|---|---|---|
First | 帧开头 | 消息更新、时间更新 |
PreUpdate | 更新前 | 输入事件处理、窗口事件 |
StateTransition | 状态切换 | 运行 OnExit/OnTransition/OnEnter |
RunFixedMainLoop | 固定步驱动 | 根据累计时间决定 FixedMain 执行次数 |
Update | 主更新 | 用户游戏逻辑 |
SpawnScene | 场景实例化 | 执行场景生成请求 |
PostUpdate | 更新后 | Transform 传播、可见性计算、渲染数据提取 |
Last | 帧末尾 | 清理、诊断收集 |
固定时间步
| Schedule | 用途 | 说明 |
|---|---|---|
FixedFirst | 固定步开头 | 固定步时间更新 |
FixedPreUpdate | 固定步前处理 | 物理引擎预处理 |
FixedUpdate | 固定步主循环 | 物理模拟、确定性逻辑 |
FixedPostUpdate | 固定步后处理 | 物理后处理 |
FixedLast | 固定步末尾 | 固定步清理 |
渲染阶段
| Schedule | 用途 | 说明 |
|---|---|---|
ExtractSchedule | 数据提取 | Main World → Render World 复制 |
Render | 渲染执行 | GPU 指令提交 |
状态转换
| Schedule | 用途 | 说明 |
|---|---|---|
StateTransition | 状态机转换 | 运行 OnExit/OnTransition/OnEnter |
OnEnter(S) | 进入状态 S | 状态初始化逻辑 |
OnExit(S) | 离开状态 S | 状态清理逻辑 |
OnTransition { exited, entered } | 状态转换 | 从一个状态到另一个状态的过渡逻辑 |
内置 SystemSet
引擎核心 SystemSet
| SystemSet | Schedule | 说明 |
|---|---|---|
TransformSystem::TransformPropagate | PostUpdate | Transform 层级传播 |
VisibilitySystem::* | PostUpdate | 可见性计算 |
AnimationSystem::* | PostUpdate | 动画评估 |
输入 SystemSet
| SystemSet | Schedule | 说明 |
|---|---|---|
InputSystem | PreUpdate | 输入事件处理 |
渲染 SystemSet
| SystemSet | Schedule | 说明 |
|---|---|---|
RenderSet::ExtractCommands | Render | 提取命令执行 |
RenderSet::Prepare | Render | GPU 资源准备 |
RenderSet::Queue | Render | 渲染队列构建 |
RenderSet::Render | Render | GPU 指令提交 |
RenderSet::Cleanup | Render | 渲染资源清理 |
顺序约束示例
#![allow(unused)] fn main() { // 用户系统添加到 Update app.add_systems(Update, ( input_handler, movement.after(input_handler), collision.after(movement), )); // 固定时间步物理 app.add_systems(FixedUpdate, ( apply_forces, integrate_velocity.after(apply_forces), detect_collisions.after(integrate_velocity), )); // 状态系统 app.add_systems(OnEnter(GameState::Playing), setup_game); app.add_systems(OnExit(GameState::Playing), cleanup_game); }
附录 D:StorageType 选择指南
本附录提供一个决策树,帮助选择 Component 的存储类型:Table(默认)或 SparseSet。
决策树
你的 Component 会在运行时频繁 insert / remove 吗?
│
├─ 否 (大多数组件)
│ │
│ └─ 使用 Table (默认)
│ 性能: 遍历 O(n) 缓存友好
│ 代价: insert/remove 触发 Archetype 迁移 O(组件数)
│
└─ 是 (频繁增删)
│
├─ 这个 Component 会被 Query 频繁遍历吗?
│ │
│ ├─ 否 → SparseSet ✓
│ │ insert/remove O(1),遍历较少无所谓
│ │
│ └─ 是 → 权衡
│ 遍历性能 (Table) vs 增删性能 (SparseSet)
│ 测量后决定
│
└─ 同时有大量实体频繁增删? → SparseSet ✓
图 D-1: StorageType 决策树
性能对比
| 操作 | Table | SparseSet | 说明 |
|---|---|---|---|
| 顺序遍历 | O(n) 缓存友好 | O(n) 指针跳转 | Table 更适合批量顺序遍历 |
| 随机访问 | O(1) | O(1) | 相当 |
| 添加组件 | O(组件数) 迁移 | O(1) | SparseSet 快得多 |
| 删除组件 | O(组件数) 迁移 | O(1) swap-remove | SparseSet 快得多 |
| 内存占用 | 紧凑连续 | sparse 数组有空洞 | Table 更省内存 |
典型选择
适合 Table(默认)的组件
#![allow(unused)] fn main() { #[derive(Component)] struct Position(Vec3); // 每帧遍历,很少增删 #[derive(Component)] struct Velocity(Vec3); // 每帧遍历,很少增删 #[derive(Component)] struct Health(f32); // 频繁读取,很少增删 #[derive(Component)] struct Name(String); // 基本不增删 }
适合 SparseSet 的组件
#![allow(unused)] fn main() { #[derive(Component)] #[component(storage = "SparseSet")] struct Stunned; // 状态标记,频繁切换 #[derive(Component)] #[component(storage = "SparseSet")] struct AnimationPlaying { // 动画播放中,频繁添加/移除 clip: Handle<AnimationClip>, } #[derive(Component)] #[component(storage = "SparseSet")] struct DebugHighlight; // 调试标记,随时添加/移除 #[derive(Component)] #[component(storage = "SparseSet")] struct DamageOverTime { // 临时效果,持续几秒后移除 dps: f32, remaining: f32, } }
Archetype 迁移的代价
Table 存储组件的 insert/remove 会触发 Archetype 迁移——实体的所有 Table 组件数据被 memcpy 到新的 Table:
Entity 有 [A, B, C] (Table 组件)
insert D:
┌───────────────────┐ ┌─────────────────────┐
│ Table [A, B, C] │ │ Table [A, B, C, D] │
│ Row N: 复制出 ──┼────▶│ Row M: 复制入 │
└───────────────────┘ └─────────────────────┘
A, B, C 三个组件各 memcpy 一次
如果实体有 10 个 Table 组件,每次 insert/remove 就要做 10 次 memcpy。SparseSet 组件不参与 Archetype——它们的增删是独立的 O(1) 操作。
经验法则
- 默认用 Table——大多数组件都是"创建后长期存在,每帧遍历"
- 标记组件优先 SparseSet——空结构体(ZST)标记如
Stunned、Selected,频繁切换 - 临时效果用 SparseSet——buff/debuff、粒子状态、动画触发器
- 测量驱动——如果不确定,先用 Table,发现瓶颈后再切换
附录 E:常见 ECS 反模式
本附录列出使用 Bevy ECS 时常见的反模式及其修正方法。
反模式 1:上帝 System
问题:一个 System 承担过多职责,参数接近 16 个上限。
#![allow(unused)] fn main() { // 反模式: 一个系统做所有事情 fn game_system( mut players: Query<(&mut Position, &mut Health, &Velocity, &Inventory)>, mut enemies: Query<(&mut Position, &mut Health, &mut AI), Without<Player>>, mut score: ResMut<Score>, time: Res<Time>, input: Res<ButtonInput<KeyCode>>, mut commands: Commands, mut messages: MessageWriter<GameMessage>, asset_server: Res<AssetServer>, // ... 还在增加 ) { // 200 行逻辑 } }
修正:拆分为职责单一的小系统。
#![allow(unused)] fn main() { // 正确: 职责分离 fn movement_system(mut query: Query<(&mut Position, &Velocity)>, time: Res<Time>) { ... } fn combat_system(mut query: Query<(&mut Health, &Attack)>, mut messages: MessageWriter<DamageMessage>) { ... } fn input_system(input: Res<ButtonInput<KeyCode>>, mut commands: Commands) { ... } fn scoring_system(mut score: ResMut<Score>, mut messages: MessageReader<ScoreMessage>) { ... } }
为什么:小系统更容易并行(数据访问冲突更少),更容易测试,更容易复用。
反模式 2:用 Component 存储引用
问题:尝试在 Component 中存储对其他 Entity 的引用。
#![allow(unused)] fn main() { // 反模式: 存储 &Entity 或裸 Entity 而不使用 Relationship #[derive(Component)] struct Parent { children: Vec<Entity>, // 手动维护,容易失效 } }
修正:使用 Bevy 内置的 Relationship 或 ChildOf。
#![allow(unused)] fn main() { // 正确: 使用 Relationship commands.spawn(Player).with_child(Sword); // 或使用 ChildOf commands.spawn((Sword, ChildOf(player_entity))); }
为什么:手动维护 Entity 引用容易出现悬空引用(Entity 已 despawn 但引用未清理)。Relationship 自动处理生命周期。
反模式 3:过度使用 Changed 过滤器
问题:依赖 Changed<T> 做业务逻辑,忽略首帧和系统顺序问题。
#![allow(unused)] fn main() { // 反模式: Changed 在首帧不触发(如果组件是初始值) fn react_to_health(query: Query<&Health, Changed<Health>>) { for health in &query { // 首帧可能遗漏,系统顺序不对也可能遗漏 } } }
修正:结合 Added 处理首次添加,或使用 Message / Observer 明确通知。
#![allow(unused)] fn main() { // 正确: 用 Message 替代 Changed 做明确通知 fn damage_system( mut health: Query<&mut Health>, mut messages: MessageWriter<HealthChanged>, ) { // 修改 health 时明确发送消息 messages.write(HealthChanged { entity, old, new }); } }
为什么:Changed<T> 的语义是"本 Tick 内 DerefMut 被调用过"——它不区分值是否真的变了,也可能因为系统执行顺序而遗漏。对于重要的业务逻辑,明确的 Message 或显式触发的 Event/Observer 更可靠。
反模式 4:在 System 中 spawn 并立即查询
问题:在一个 System 中 spawn Entity,期望同一帧内另一个 System 能查询到它。
#![allow(unused)] fn main() { // 反模式: Commands 是延迟的,spawn 不会立即生效 fn spawn_system(mut commands: Commands) { commands.spawn((Player, Position(Vec3::ZERO))); } fn setup_system(query: Query<&Position, Added<Player>>) { // 如果与 spawn_system 在同一帧的同一 apply_deferred 之前 // 这里查不到新 spawn 的实体! } }
修正:确保系统顺序正确,或使用 apply_deferred 隔离。
#![allow(unused)] fn main() { // 正确: 明确顺序约束 app.add_systems(Update, ( spawn_system, apply_deferred, // 显式插入 apply_deferred setup_system, ).chain()); }
为什么:Commands 是延迟执行的——spawn、insert、despawn 操作在 apply_deferred 时才真正应用到 World。理解这个延迟模型是正确使用 Bevy ECS 的关键。
反模式 5:Resource 代替 Component
问题:将本应挂载到 Entity 上的数据存为全局 Resource。
#![allow(unused)] fn main() { // 反模式: 用 Resource 存储特定实体的数据 #[derive(Resource)] struct PlayerHealth(f32); #[derive(Resource)] struct PlayerPosition(Vec3); #[derive(Resource)] struct PlayerInventory(Vec<Item>); }
修正:使用 Component,让数据跟随 Entity。
#![allow(unused)] fn main() { // 正确: 数据挂载到 Entity #[derive(Component)] struct Health(f32); #[derive(Component)] struct Inventory(Vec<Item>); // spawn 时组合 commands.spawn((Player, Health(100.0), Position::default(), Inventory::default())); }
为什么:Resource 是全局单例。当你需要第二个玩家(或第二个相同类型的实体)时,Resource 方案就崩溃了。Component 天然支持多实体。
反模式 6:忽略 StorageType 选择
问题:对所有组件都使用默认的 Table 存储,即使某些组件频繁增删。
#![allow(unused)] fn main() { // 反模式: 频繁增删的组件使用默认 Table #[derive(Component)] struct Burning; // 几秒后 remove #[derive(Component)] struct Selected; // 每帧可能切换 // 每次 insert/remove 都触发 Archetype 迁移 }
修正:频繁增删的组件标记 SparseSet。
#![allow(unused)] fn main() { // 正确: 频繁增删用 SparseSet #[derive(Component)] #[component(storage = "SparseSet")] struct Burning; #[derive(Component)] #[component(storage = "SparseSet")] struct Selected; }
为什么:Table 组件的 insert/remove 触发 Archetype 迁移,所有 Table 组件都要 memcpy。SparseSet 的增删是 O(1),不影响其他组件。详见附录 D。
反模式 7:系统间共享可变状态
问题:通过 Arc<Mutex<T>> 或全局变量在系统间共享可变状态。
#![allow(unused)] fn main() { // 反模式: 绕过 ECS 的资源管理 static SHARED_STATE: Mutex<GameState> = Mutex::new(GameState::default()); fn system_a() { let mut state = SHARED_STATE.lock().unwrap(); state.score += 1; } }
修正:使用 Resource 或 Event 在 ECS 框架内通信。
#![allow(unused)] fn main() { // 正确: 用 Resource #[derive(Resource)] struct GameScore(u32); fn system_a(mut score: ResMut<GameScore>) { score.0 += 1; // ECS 管理并发安全 } }
为什么:ECS 调度器能看到 Resource 的读写模式并安全并行。Mutex 绕过了调度器,可能导致死锁,且调度器无法对其优化。
总结
| # | 反模式 | 正确做法 | 关键原因 |
|---|---|---|---|
| 1 | 上帝 System | 拆分为小系统 | 更好的并行和可维护性 |
| 2 | Component 存引用 | 使用 Relationship | 自动生命周期管理 |
| 3 | 过度用 Changed | Message / 显式 Event/Observer 通知 | Changed 语义可能遗漏 |
| 4 | spawn 后立即查询 | chain + apply_deferred | Commands 是延迟的 |
| 5 | Resource 代替 Component | 数据挂载到 Entity | 支持多实体 |
| 6 | 忽略 StorageType | 频繁增删用 SparseSet | 避免 Archetype 迁移 |
| 7 | 共享可变状态 | Resource / Message | 保持调度器可见性 |
附录 F:Bevy ECS vs 其他 ECS
本附录比较 Bevy ECS 与 Rust 生态中其他主流 ECS 库,以及跨语言的 flecs。
对比总览
| 特性 | Bevy ECS | specs | legion | hecs | flecs (C) |
|---|---|---|---|---|---|
| 语言 | Rust | Rust | Rust | Rust | C/C++ |
| 存储模型 | Table + SparseSet | BitSet + DenseVecStorage | Archetype | Archetype | Archetype + SparseSet |
| 状态 | 活跃维护 | 维护模式 | 维护模式 | 维护中 | 活跃维护 |
| 调度器 | 内置自动并行 | 内置 (shred) | 内置 | 无 | 内置 |
| 变更检测 | Tick 级别 | 手动 FlaggedStorage | 无 | 无 | 有 |
| 反射 | 完整 (bevy_reflect) | 无 | 无 | 无 | 有 (内置) |
| 关系 (Relationship) | 有 (Relationship) | 无 | 无 | 无 | 有 (核心特性) |
| no_std | 支持 | 否 | 否 | 支持 | 否 |
| 内置命令系统 | Commands | 无 (需 LazyUpdate) | CommandBuffer | CommandBuffer | 有 |
详细比较
Bevy ECS
架构特色:
- Table 列式存储 + SparseSet 稀疏存储,双策略
- 自动并行调度(MultiThreadedExecutor),基于 FilteredAccess 冲突检测
- 零成本变更检测(Tick 级别 Added/Changed/RemovedComponents)
- 完整的反射和序列化系统
- GAT 驱动的 SystemParam 抽象
优势:功能最全面,与 Bevy 引擎深度集成,活跃社区
劣势:编译时间较长(all_tuples 宏展开)
specs
架构特色:
- Storage 层面更灵活:DenseVecStorage、VecStorage、HashMapStorage 等
- 使用 shred 进行系统调度
- BitSet 用于实体追踪
优势:成熟稳定,Storage 策略最灵活
劣势:维护模式,API 较旧,无内置变更检测
legion
架构特色:
- 纯 Archetype 存储(无 SparseSet 选项)
- World 使用分代 Archetype(可以有多个 World 合并)
- SystemBuilder 模式声明资源访问
优势:API 简洁,Archetype 实现高效
劣势:维护模式,无变更检测,无反射
hecs
架构特色:
- 极简 Archetype ECS,约 2000 行代码
- 不含调度器——只做 World + Query
- 支持 no_std
优势:极简、易理解、编译快、可嵌入
劣势:无调度器、无变更检测、无命令系统
flecs (C/C++)
架构特色:
- C 语言实现,最全面的 ECS 功能集
- Relationship 是核心特性(pair-based)
- 内置 REST API 和 Web 调试器
- Query DSL 支持复杂关系查询
优势:功能最全面(尤其是 Relationship),性能极高,跨语言
劣势:C 语言实现,Rust binding 非原生
存储模型对比
Bevy ECS (Table + SparseSet):
┌─────────────────────────────────────────┐
│ Table 存储 (默认): 列式连续内存 │
│ [A₀ A₁ A₂] [B₀ B₁ B₂] [C₀ C₁ C₂] │
│ │
│ SparseSet 存储 (可选): 稀疏→稠密映射 │
│ sparse[e] → dense_index │
└─────────────────────────────────────────┘
specs (多种 Storage):
┌─────────────────────────────────────────┐
│ DenseVecStorage: 类似 SparseSet │
│ VecStorage: 用 Option<T> 直接索引 │
│ HashMapStorage: HashMap<Entity, T> │
└─────────────────────────────────────────┘
hecs / legion (纯 Archetype):
┌─────────────────────────────────────────┐
│ 所有组件都在 Archetype Table 中 │
│ 无 SparseSet 选项 │
└─────────────────────────────────────────┘
查询 API 对比
#![allow(unused)] fn main() { // Bevy ECS fn system(query: Query<(&Position, &Velocity), With<Player>>) { for (pos, vel) in &query { /* ... */ } } // hecs let mut world = hecs::World::new(); for (_, (pos, vel)) in world.query::<(&Position, &Velocity)>().iter() { // ... } // specs impl<'a> System<'a> for MovementSystem { type SystemData = (WriteStorage<'a, Position>, ReadStorage<'a, Velocity>); fn run(&mut self, (mut pos, vel): Self::SystemData) { for (pos, vel) in (&mut pos, &vel).join() { /* ... */ } } } }
选择建议
| 场景 | 推荐 | 理由 |
|---|---|---|
| Bevy 引擎游戏开发 | Bevy ECS | 原生集成,功能完整 |
| 需要最简 ECS 嵌入 | hecs | 极简,no_std,无额外依赖 |
| 需要复杂 Relationship | flecs | Relationship 是核心特性 |
| 已有 specs 项目 | specs | 迁移成本高,维持现状 |
| 学习 ECS 概念 | hecs | 代码量少,容易阅读源码 |
| 高性能 + C 互操作 | flecs | C 实现,FFI 友好 |