Skip to content

同步点与命令应用时机

第 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_deferredbefore_ignore_deferredafter_ignore_deferred

rust
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——只要顺序,不要同步点

console
cargo run -p ch06-schedules --example listing-06-08
text
—— 第 1 天 ——
征兵处:征召 1 名新兵
点名官:现役 0 人
—— 第 2 天 ——
征兵处:征召 1 名新兵
点名官:现役 1 人
—— 第 3 天 ——
征兵处:征召 1 名新兵
点名官:现役 2 人

顺序如约——点名永远在征兵之后;同步点如约缺席——每天的新兵都是下一天才被点到(帧末清算,规则一兜底)。把 after_ignore_deferred 换回 after,输出立刻变成 1、2、3:规则二接管,当天入伍当天点名。

两个版本都对,分别对应两种需求。值得记住的反而是这个对比揭示的事实:chainbefore/after 不只排顺序,还顺手替你买了数据可见性——这是它们比表面上更强的地方,也是它们偶尔比你预期更贵的地方。

还有一件压箱底的工具:ApplyDeferred 本身是个可注册的系统,add_systems(Update, ApplyDeferred.after(a).before(b)) 可以在任意位置手工钉一个同步点。自动规则够用时不必想起它。

拼起来:皇家铸币厂的六天

本章全部工具上岗——集合排工序、run_if 把关、王令走 Commands,而它“当天生效”的玄机正是规则二:

rust
//! 第 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)

console
cargo run -p ch06-schedules
text
—— 第 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 一行定下 ProduceProcessSettle,六个系统各自 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)只跑一次;之后每帧 FirstPreUpdateRunFixedMainLoopUpdateSpawnScenePostUpdateLast。引擎在 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 变体可以只要顺序不要同步

练习

  1. 固定时钟:把 Listing 6-2 的 ManualDuration 从 30 毫秒改成 80 毫秒,先笔算每帧 FixedUpdate 该跑几次,再运行验证——你应该能看到“一帧补跑两次”。再把步长改回默认(删掉 Time::<Fixed> 那行),算算 80 毫秒能补几次 15.625 毫秒的课。
  2. 消灭歧义:用两种方式让 Listing 6-5 的警告闭嘴——先加 before/after 真正定序,再换成 ambiguous_with 按下警告。思考:这个例子里审计员数到 0 和数到 3 都“不崩溃”,哪种处理才算对?
  3. 命令时机:把 Listing 6-9 国王的 .before(MintStage::Produce) 改成 .after(MintStage::Settle),先预测赶工令哪天生效、第 2 天矿工挖几块,再运行验证。然后把约束整个删掉多跑几次:挖矿的数字为什么纹丝不动,国王的台词却在一天的输出里上下漂移?(提示:一个由同步点规则决定,一个由执行顺序决定。)

下一章拆下一件解耦利器:系统之间不再靠共享资源传话,而是互发 Message——写进缓冲、双帧可读、自动清理,碰撞与计分从此不必相识。