过滤器与变更检测
现在轮到 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”。牧场入夜,三条点名规则各查一遍:
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——过滤器的组合代数
牧场上有戴铃铛的小白、没铃铛的小黑和卷卷、戴铃铛的牧羊犬阿黄,以及狼灰背。运行:
cargo run -p ch04-systems-queries --example listing-04-04过夜的住户:阿黄、小黑、卷卷、小白
没铃铛的羊:小黑、卷卷
没铃铛的住户:小黑、卷卷逐条核对:第一条 Or 收下了所有羊和牧羊犬,唯独狼不在;第二条在羊里排除了戴铃铛的小白;第三条嵌套——住户当中没铃铛的,阿黄因为铃铛出局。三个查询读的都是 Name,互不冲突,安安稳稳挤在同一个系统里。
一个值得养成的习惯:不需要读数据时,用 With<T> 当过滤器,而不是把 &T 写进 D 槽位。两者筛出的行相同,但 &T 会登记一份对 T 的读访问——本章开头说过访问集合决定并行,多余的声明会无谓地挡住别的系统。With/Without/Or 不登记任何访问,意图也更清楚:我只是按这列筛行,不碰它。
Changed 与 Added:只看动过的行
游戏代码里有个高频需求:只处理变了的东西。血量变了才刷新血条,位置变了才重算碰撞。每帧全量扫一遍当然也能写,但浪费;Bevy 把“变没变”做成了过滤器:
Added<T>:T这一列是新挂上的——来自spawn或insert;Changed<T>:T被挂上或被写过——Added算Changed的子集。
“自什么时候以来”是理解它们的关键:窗口是本系统上一次运行到这一次运行之间。每个系统按自己的节奏看世界,A 系统漏不掉的变更,B 系统也各自看得见,互不干扰。由此推出一个重要细节:系统第一次运行时,从未看过任何东西——第一帧里一切皆“新”。
还有一条规则必须刻在脑子里:Bevy 不比较值,写访问本身就是变更。通过 &mut 解引用了组件——哪怕写回去的是原值——这一行就被标记为 Changed。眼见为实,两只羊三帧:
/// 给贪吃的羊喂食——真实的修改
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!("〔盘点员把没加餐的羊重新登记了一遍〕");
}
}/// 哨兵:只报告 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}");
}
}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 帧出手,把没加餐的羊(也就是小白)的饥饿值原样写回。运行:
cargo run -p ch04-systems-queries --example listing-04-05—— 第 1 帧 ——
小黑 的饥饿值有变动:8
小白 的饥饿值有变动:10
名册新增:小黑
名册新增:小白
—— 第 2 帧 ——
〔盘点员把没加餐的羊重新登记了一遍〕
小黑 的饥饿值有变动:6
小白 的饥饿值有变动:10
—— 第 3 帧 ——
小黑 的饥饿值有变动:4三帧三个知识点:
- 第 1 帧:两只羊全员上榜——既在
Changed名单也在Added名单。monitor和register都是第一次运行,启动时生成的组件对它们而言全是新的。实际项目里这个“首帧全新”经常被用来做初始化,但没意识到它存在时也经常造成“为什么第一帧全都触发了”的困惑——现在你有免疫力了。 - 第 2 帧:小白的饥饿值还是 10,却被报告“有变动”——盘点员的写回触发了
DerefMut,仅此一下就足够。真想“值没变就别标记”,用set_if_neq(它先比较再写,需要组件实现PartialEq);变更检测的更多机关在第 11 章。 - 第 3 帧:盘点员歇工,只剩真正被喂食的小黑。
Added名单从第 2 帧起一直是空的——没有新组件挂上。
两条边界说明,免得日后踩坑。其一,Commands 的延迟语义在这里依然成立:insert 排队产生的“新”,要等命令在同步点应用之后才被看见。其二,Changed/Added 是逐行检查而非按子表跳过——查询匹配一百万行时,过滤器要一行行验过去,它省的是你的处理逻辑,不是遍历本身。
过滤器到此配齐。最后剩下那个悬而未决的问题:系统内部的两个查询打起来怎么办?