同步点与命令应用时机
第 3 章给同步点(sync point)下过定义:调度中一个可以独占 World 的位置,攒下的命令在这里统一应用。当时承诺“完整规则放在第 6 章”——现在工具齐了,三条规则一次说清。
规则一:调度跑完,命令必然已全部应用。每个调度的末尾固定有一次清算,谁也逃不过。这就是 Startup 里 spawn 的实体在 Update 里必然可见的原因,也是上一章双倍卡“下一枪生效”的底层依据。推论:调度边界就是天然的同步点——PreUpdate 攒的命令,进 Update 前一定落地。
规则二:显式排序边 + 上游攒着命令 = 自动同步点。当你用 chain/before/after 声明“A 先于 B”,而 A 的签名里有 Commands(或其他延迟参数),调度器就在这条边上自动插入一个同步点,保证 B 看见 A 的全部成果。第 3 章 Listing 3-3 里“下一个系统看见了增援”,正是这条规则的现场。
规则三:没有排序边,就没有同步点。两个系统之间不存在约束时,调度器绝不会主动替它们同步——命令一律等到规则一的帧末清算。
为什么不慷慨一点、每个系统跑完都同步?因为贵。同步点要独占 World,意味着所有并行中的系统必须先收工、流水线整个停下来,应用完命令再重新开张。Bevy 的策略和排序一样:只在你声明了依赖的地方买单。
只要顺序,不要同步点
规则二有个隐含的代价:after 一个带 Commands 的系统,就自动背上了一个同步点——哪怕你只想要顺序、不需要看见它的命令。比如点名官只想“在征兵处下班后再点名”(免得打印交错),至于今天的新兵明天再算数,完全可以接受。这时用 _ignore_deferred 变体,三件排序工具各有一个:chain_ignore_deferred、before_ignore_deferred、after_ignore_deferred。
fn main() {
let mut app = App::new();
app.add_systems(
Update,
(
recruit,
// 点名保证在征兵之后运行,但拒绝为这条边插同步点
headcount.after_ignore_deferred(recruit),
),
);
for day in 1..=3 {
println!("—— 第 {day} 天 ——");
app.update();
}
}Listing 6-8:after_ignore_deferred——只要顺序,不要同步点
cargo run -p ch06-schedules --example listing-06-08—— 第 1 天 ——
征兵处:征召 1 名新兵
点名官:现役 0 人
—— 第 2 天 ——
征兵处:征召 1 名新兵
点名官:现役 1 人
—— 第 3 天 ——
征兵处:征召 1 名新兵
点名官:现役 2 人顺序如约——点名永远在征兵之后;同步点如约缺席——每天的新兵都是下一天才被点到(帧末清算,规则一兜底)。把 after_ignore_deferred 换回 after,输出立刻变成 1、2、3:规则二接管,当天入伍当天点名。
两个版本都对,分别对应两种需求。值得记住的反而是这个对比揭示的事实:chain 和 before/after 不只排顺序,还顺手替你买了数据可见性——这是它们比表面上更强的地方,也是它们偶尔比你预期更贵的地方。
还有一件压箱底的工具:
ApplyDeferred本身是个可注册的系统,add_systems(Update, ApplyDeferred.after(a).before(b))可以在任意位置手工钉一个同步点。自动规则够用时不必想起它。
拼起来:皇家铸币厂的六天
本章全部工具上岗——集合排工序、run_if 把关、王令走 Commands,而它“当天生效”的玄机正是规则二:
//! 第 6 章综合示例:皇家铸币厂的六天
//! 工序集合排顺序,run_if 把关,王令走 Commands——当天生效靠自动同步点
use bevy::prelude::*;
// —— 集合定义 ——
/// 铸币厂的三道工序
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum MintStage {
Produce,
Process,
Settle,
}
// —— 资源定义 ——
/// 厂房库存:矿石与锭
#[derive(Resource, Default)]
struct Stockpile {
ore: u32,
ingots: u32,
}
/// 金库
#[derive(Resource, Default)]
struct Treasury(u32);
/// 王室赶工令:在场时矿工加倍干活
#[derive(Resource)]
struct RushOrder;
fn main() {
let mut app = App::new();
app.init_resource::<Stockpile>()
.init_resource::<Treasury>()
.add_systems(Startup, opening)
// 三道工序按 chain 排定先后;系统随后各自入伙
.configure_sets(
Update,
(MintStage::Produce, MintStage::Process, MintStage::Settle).chain(),
)
// 国王先于全部工序发话;他的 Commands 会在排序边上触发自动同步点——王令当天生效
.add_systems(Update, royal_decree.before(MintStage::Produce))
.add_systems(Update, dig.in_set(MintStage::Produce))
// 凑满 3 块矿石才开炉,否则整帧歇着
.add_systems(
Update,
smelt
.in_set(MintStage::Process)
.run_if(|pile: Res<Stockpile>| pile.ore >= 3),
)
.add_systems(
Update,
(
mint_coins,
// 金库没动静的天,账房不出声
report.after(mint_coins).run_if(resource_changed::<Treasury>),
)
.in_set(MintStage::Settle),
);
for day in 1..=6 {
println!("—— 第 {day} 天 ——");
app.update();
}
}
// —— Startup:开张 ——
fn opening() {
println!("皇家铸币厂开张!");
}
// —— Update:一天一轮 ——
/// 国王:第 2 天颁布赶工令,第 4 天收回
fn royal_decree(mut commands: Commands, mut day: Local<u32>) {
*day += 1;
if *day == 2 {
println!("国王:颁布赶工令!");
commands.insert_resource(RushOrder);
}
if *day == 4 {
println!("国王:赶工令收回。");
commands.remove_resource::<RushOrder>();
}
}
/// 矿工:平日 +1 矿石,赶工 +3
fn dig(mut pile: ResMut<Stockpile>, rush: Option<Res<RushOrder>>) {
let mined = if rush.is_some() { 3 } else { 1 };
pile.ore += mined;
println!("矿工:+{mined} 矿石(存 {})", pile.ore);
}
/// 冶炼炉:3 块矿石出 1 根锭——run_if 保证开炉时矿石必然够数
fn smelt(mut pile: ResMut<Stockpile>) {
pile.ore -= 3;
pile.ingots += 1;
println!("冶炼炉:出锭 1 根(余矿 {})", pile.ore);
}
/// 铸币机:有锭才开机,金库才被写
fn mint_coins(mut pile: ResMut<Stockpile>, mut treasury: ResMut<Treasury>) {
if pile.ingots > 0 {
treasury.0 += pile.ingots;
println!("铸币机:+{} 金币", pile.ingots);
pile.ingots = 0;
}
}
/// 账房:金库变了才记一笔
fn report(treasury: Res<Treasury>) {
println!("账房:金库 {} 枚金币", treasury.0);
}Listing 6-9:完整示例——皇家铸币厂的六天(src/main.rs)
cargo run -p ch06-schedules—— 第 1 天 ——
皇家铸币厂开张!
矿工:+1 矿石(存 1)
账房:金库 0 枚金币
—— 第 2 天 ——
国王:颁布赶工令!
矿工:+3 矿石(存 4)
冶炼炉:出锭 1 根(余矿 1)
铸币机:+1 金币
账房:金库 1 枚金币
—— 第 3 天 ——
矿工:+3 矿石(存 4)
冶炼炉:出锭 1 根(余矿 1)
铸币机:+1 金币
账房:金库 2 枚金币
—— 第 4 天 ——
国王:赶工令收回。
矿工:+1 矿石(存 2)
—— 第 5 天 ——
矿工:+1 矿石(存 3)
冶炼炉:出锭 1 根(余矿 0)
铸币机:+1 金币
账房:金库 3 枚金币
—— 第 6 天 ——
矿工:+1 矿石(存 1)对着输出清点本章的工具:
- 三道工序靠集合排序:
configure_sets一行定下Produce→Process→Settle,六个系统各自in_set入伙,互不点名; - 王令当天生效:国王
.before(MintStage::Produce)制造了排序边,他的Commands触发规则二的自动同步点——第 2 天颁布、第 2 天矿工就 +3。对比第 5 章摊主的“下一枪生效”(摊主排在射手后面,命令只能等帧末清算):同一套Commands,落地时机全由调度结构决定; run_if双岗:冶炼炉的闭包条件让它在矿石不足 3 块的第 1、4、6 天整帧歇工;账房挂着resource_changed::<Treasury>,金库没动静的天不出声——第 4、6 天输出干干净净;- 第 1 天账房报了 0:首帧一切皆新,
Treasury刚插入即算“变过”——第 5 章的口径,在调度层同样成立。
小结
- Main 调度全家:启动三件套(
PreStartup/Startup/PostStartup)只跑一次;之后每帧First→PreUpdate→RunFixedMainLoop→Update→SpawnScene→PostUpdate→Last。引擎在Pre/Post备料善后,Update留给你;执行顺序与注册顺序无关 FixedUpdate跟固定时钟走(默认 64 Hz):攒够步长补课,一帧可能跑零到多次;物理与规则结算放这里,逐帧呈现的逻辑留在Update。全貌在第 18 章- 同一调度内默认无序,顺序是声明出来的:一串自己人
.chain(),和别人对齐before/after;约束有传递性,跨调度的约束被静默忽略。“冲突数据 + 无序”是歧义,用ambiguity_detection让调度器点名 - SystemSet 成组排序:
configure_sets排工序、.in_set()入伙;集合间有序、集合内自由并行;粗排靠集合、细排靠点名 run_if在系统即将运行前评估,false 则整帧跳过;条件就是只读、返回bool的系统,闭包即写即用;common_conditions备好了常用件,.and()/.or()/not()自由组合;跳过的帧变更检测不丢账- 同步点三规则:调度末尾必清算;排序边 + 上游有命令 → 自动插入;没有边 → 绝不插入。同步点独占 World,
_ignore_deferred变体可以只要顺序不要同步
练习
- 固定时钟:把 Listing 6-2 的
ManualDuration从 30 毫秒改成 80 毫秒,先笔算每帧FixedUpdate该跑几次,再运行验证——你应该能看到“一帧补跑两次”。再把步长改回默认(删掉Time::<Fixed>那行),算算 80 毫秒能补几次 15.625 毫秒的课。 - 消灭歧义:用两种方式让 Listing 6-5 的警告闭嘴——先加
before/after真正定序,再换成ambiguous_with按下警告。思考:这个例子里审计员数到 0 和数到 3 都“不崩溃”,哪种处理才算对? - 命令时机:把 Listing 6-9 国王的
.before(MintStage::Produce)改成.after(MintStage::Settle),先预测赶工令哪天生效、第 2 天矿工挖几块,再运行验证。然后把约束整个删掉多跑几次:挖矿的数字为什么纹丝不动,国王的台词却在一天的输出里上下漂移?(提示:一个由同步点规则决定,一个由执行顺序决定。)
下一章拆下一件解耦利器:系统之间不再靠共享资源传话,而是互发 Message——写进缓冲、双帧可读、自动清理,碰撞与计分从此不必相识。