Skip to content

过滤器与变更检测

现在轮到 Query<D, F> 的第二个槽位。过滤器只做一件事:决定哪些行进入结果集,但不把数据交到你手上With<T> 从第 2 章用到现在;它的同伴一共两类——按“有没有某列”筛的结构过滤器,和按“这列动没动过”筛的变更过滤器。

与、或、非

结构过滤器有三个基本件:

  • With<T>:必须有 T 这一列;
  • Without<T>:必须没有 T 这一列;
  • Or<(...)>:括号里的条件满足任意一个即可。

组合规则也只有一条:过滤器元组是“且”,Or 里面是“或”,两者任意嵌套(With<A>, Without<B>) 读作“有 A 且没有 B”;Or<(With<A>, With<B>)> 读作“有 A 或有 B”。牧场入夜,三条点名规则各查一遍:

rust
fn night_census(
    // 羊,或牧羊犬——在牧场过夜的住户
    residents: Query<&Name, Or<(With<Sheep>, With<Sheepdog>)>>,
    // 羊,且没戴铃铛
    unbelled_sheep: Query<&Name, (With<Sheep>, Without<Bell>)>,
    // (羊或牧羊犬),且没戴铃铛——过滤器可以任意嵌套
    unbelled_residents: Query<&Name, (Or<(With<Sheep>, With<Sheepdog>)>, Without<Bell>)>,
) {
    let list = |names: Vec<&str>| names.join("、");
    println!("过夜的住户:{}", list(residents.iter().map(Name::as_str).collect()));
    println!("没铃铛的羊:{}", list(unbelled_sheep.iter().map(Name::as_str).collect()));
    println!("没铃铛的住户:{}", list(unbelled_residents.iter().map(Name::as_str).collect()));
}

Listing 4-4(节选):元组套 Or,Or 套 With——过滤器的组合代数

牧场上有戴铃铛的小白、没铃铛的小黑和卷卷、戴铃铛的牧羊犬阿黄,以及狼灰背。运行:

console
cargo run -p ch04-systems-queries --example listing-04-04
text
过夜的住户:阿黄、小黑、卷卷、小白
没铃铛的羊:小黑、卷卷
没铃铛的住户:小黑、卷卷

逐条核对:第一条 Or 收下了所有羊和牧羊犬,唯独狼不在;第二条在羊里排除了戴铃铛的小白;第三条嵌套——住户当中没铃铛的,阿黄因为铃铛出局。三个查询读的都是 Name,互不冲突,安安稳稳挤在同一个系统里。

一个值得养成的习惯:不需要读数据时,用 With<T> 当过滤器,而不是把 &T 写进 D 槽位。两者筛出的行相同,但 &T 会登记一份对 T 的读访问——本章开头说过访问集合决定并行,多余的声明会无谓地挡住别的系统。With/Without/Or 不登记任何访问,意图也更清楚:我只是按这列筛行,不碰它。

Changed 与 Added:只看动过的行

游戏代码里有个高频需求:只处理变了的东西。血量变了才刷新血条,位置变了才重算碰撞。每帧全量扫一遍当然也能写,但浪费;Bevy 把“变没变”做成了过滤器:

  • Added<T>T 这一列是新挂上的——来自 spawninsert
  • Changed<T>T 被挂上或被写过——AddedChanged 的子集。

“自什么时候以来”是理解它们的关键:窗口是本系统上一次运行到这一次运行之间。每个系统按自己的节奏看世界,A 系统漏不掉的变更,B 系统也各自看得见,互不干扰。由此推出一个重要细节:系统第一次运行时,从未看过任何东西——第一帧里一切皆“新”

还有一条规则必须刻在脑子里:Bevy 不比较值,写访问本身就是变更。通过 &mut 解引用了组件——哪怕写回去的是原值——这一行就被标记为 Changed。眼见为实,两只羊三帧:

rust
/// 给贪吃的羊喂食——真实的修改
fn feed_greedy(mut greedy: Query<&mut Hunger, With<Greedy>>) {
    for mut hunger in &mut greedy {
        hunger.0 -= 2;
    }
}

/// 盘点员:第 2 帧把其余羊的饥饿值"重新登记"一遍——写回原值
fn recount(mut others: Query<&mut Hunger, Without<Greedy>>, mut day: Local<u32>) {
    *day += 1;
    if *day == 2 {
        for mut hunger in &mut others {
            let value = hunger.0;
            hunger.0 = value; // 值没变,但这是一次写访问
        }
        println!("〔盘点员把没加餐的羊重新登记了一遍〕");
    }
}
rust
/// 哨兵:只报告 Hunger 发生过变更的羊
fn monitor(changed: Query<(&Name, &Hunger), Changed<Hunger>>) {
    for (name, hunger) in &changed {
        println!("{name} 的饥饿值有变动:{}", hunger.0);
    }
}

/// 登记员:只报告 Hunger 是新挂上的羊
fn register(added: Query<&Name, Added<Hunger>>) {
    for name in &added {
        println!("名册新增:{name}");
    }
}
rust
fn main() {
    let mut app = App::new();
    app.add_systems(Startup, spawn_flock).add_systems(
        Update,
        (feed_greedy, recount, monitor, register).chain(),
    );

    for day in 1..=3 {
        println!("—— 第 {day} 帧 ——");
        app.update();
    }
}

Listing 4-5:两个写入者、两个观察者,三帧对照

小白和小黑都有 Hunger;小黑额外带 Greedy 标记,每帧被喂食。盘点员只在第 2 帧出手,把没加餐的羊(也就是小白)的饥饿值原样写回。运行:

console
cargo run -p ch04-systems-queries --example listing-04-05
text
—— 第 1 帧 ——
小黑 的饥饿值有变动:8
小白 的饥饿值有变动:10
名册新增:小黑
名册新增:小白
—— 第 2 帧 ——
〔盘点员把没加餐的羊重新登记了一遍〕
小黑 的饥饿值有变动:6
小白 的饥饿值有变动:10
—— 第 3 帧 ——
小黑 的饥饿值有变动:4

三帧三个知识点:

  1. 第 1 帧:两只羊全员上榜——既在 Changed 名单也在 Added 名单。monitorregister 都是第一次运行,启动时生成的组件对它们而言全是新的。实际项目里这个“首帧全新”经常被用来做初始化,但没意识到它存在时也经常造成“为什么第一帧全都触发了”的困惑——现在你有免疫力了。
  2. 第 2 帧:小白的饥饿值还是 10,却被报告“有变动”——盘点员的写回触发了 DerefMut,仅此一下就足够。真想“值没变就别标记”,用 set_if_neq(它先比较再写,需要组件实现 PartialEq);变更检测的更多机关在第 11 章。
  3. 第 3 帧:盘点员歇工,只剩真正被喂食的小黑。Added 名单从第 2 帧起一直是空的——没有新组件挂上。

两条边界说明,免得日后踩坑。其一,Commands 的延迟语义在这里依然成立:insert 排队产生的“新”,要等命令在同步点应用之后才被看见。其二,Changed/Added 是逐行检查而非按子表跳过——查询匹配一百万行时,过滤器要一行行验过去,它省的是你的处理逻辑,不是遍历本身。

过滤器到此配齐。最后剩下那个悬而未决的问题:系统内部的两个查询打起来怎么办?