第 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 单线程模式。