双缓冲与清理时机
把链子故意接反,让 DJ 跑在车手前面。系统沿用 Listing 7-1 的两位,只动两处:直道加长到 8 格——隔一帧才撞一次,错拍才看得清;以及 chain 里的次序倒过来:
fn main() {
let mut app = App::new();
app.add_message::<RailHit>()
.add_systems(Startup, spawn_car)
// 故意接反:DJ 先跑,车手后跑
.add_systems(Update, (play_sound, drive).chain());
for frame in 1..=5 {
println!("—— 第 {frame} 帧 ——");
app.update();
}
}Listing 7-4:DJ 排在车手之前——音效慢一拍,但一声不少
cargo run -p ch07-messages --example listing-07-04—— 第 1 帧 ——
—— 第 2 帧 ——
阿莱:撞上护栏!
—— 第 3 帧 ——
DJ:砰!
—— 第 4 帧 ——
阿莱:撞上护栏!
—— 第 5 帧 ——
DJ:砰!撞声和砰声整整齐齐地错开一帧。不难理解:第 2 帧轮到 DJ 时消息还没写下,他扑了个空;等阿莱撞上去,本帧已经没有 DJ 的戏份。真正值得追问的是第 3 帧——为什么消息还在?谁把它留到了下一帧,又是谁保证它不会在第 4 帧再响一次?
缓冲的内部构造
答案在 Messages<M> 的结构里。它内部是两个仓位:一个收本帧写入的消息,一个存上一帧的存货。add_message 注册的那个清理系统,每帧在最早的 First 调度(第 6 章的“引擎备料区”)里做一套固定动作:倒掉旧仓,把新仓整体挪作旧仓,腾出空仓收本帧。
照这个节奏推演一条消息的一生:第 N 帧写入新仓;第 N+1 帧开头被挪进旧仓,整帧仍然可读;第 N+2 帧开头随旧仓一起倒掉。每条消息恰好可见两帧——这就是双缓冲(double buffering)。read() 的可见范围由此完全确定:两个仓位里、自己的游标还没扫过的全部消息。
拿 Listing 7-4 对一遍账:第 2 帧的撞击在第 3 帧仍可读(已挪进旧仓),DJ 补上一声;响过之后他的游标越过了这条消息,第 4 帧就算它还躺在仓里也不会重读。双缓冲管寿命,游标管去重,两件事互不越界。
于是读者的勤快程度直接决定收成:
- 每帧都读:一条不丢。这是消息系统的设计前提。
- 每两帧读一次:贴着寿命的边缘,能否收齐取决于你和写者的相对顺序——不要依赖它。
- 更慢:必然丢消息,而且是静默丢弃——没有警告,没有错误。
口说无凭。记分员守了一天累了,开始打瞌睡,每三帧才醒来记一次账;阿莱可不会等他,照旧每帧一撞。消息带上序号,丢没丢一对便知:
fn main() {
let mut app = App::new();
app.add_message::<RailHit>().add_systems(Startup, spawn_car);
app.add_systems(
Update,
(
drive,
// 记分员打瞌睡:每三帧才醒来读一次
drowsy_scorer.run_if(|mut frame: Local<u32>| {
*frame += 1;
frame.is_multiple_of(3)
}),
)
.chain(),
);
for frame in 1..=9 {
println!("—— 第 {frame} 帧 ——");
app.update();
}
}fn spawn_car(mut commands: Commands) {
// 还是那条 4 格直道:阿莱每一帧都在撞
commands.spawn(Car {
pos: 0,
velocity: 4,
});
}
/// 写者:每次撞护栏,消息编号递增
fn drive(mut car: Single<&mut Car>, mut hits: MessageWriter<RailHit>, mut count: Local<u32>) {
car.pos += car.velocity;
if car.pos == 0 || car.pos == 4 {
car.velocity = -car.velocity;
*count += 1;
println!("阿莱:第 {} 次撞护栏", *count);
hits.write(RailHit { n: *count });
}
}
/// 读者:醒来时把缓冲里还剩的消息一次读完
fn drowsy_scorer(mut hits: MessageReader<RailHit>) {
let heard: Vec<u32> = hits.read().map(|hit| hit.n).collect();
println!("记分员醒来:记下第 {heard:?} 次");
}Listing 7-5:错过即丢——打瞌睡的记分员每三帧才醒一次
cargo run -p ch07-messages --example listing-07-05—— 第 1 帧 ——
阿莱:第 1 次撞护栏
—— 第 2 帧 ——
阿莱:第 2 次撞护栏
—— 第 3 帧 ——
阿莱:第 3 次撞护栏
记分员醒来:记下第 [2, 3] 次
—— 第 4 帧 ——
阿莱:第 4 次撞护栏
—— 第 5 帧 ——
阿莱:第 5 次撞护栏
—— 第 6 帧 ——
阿莱:第 6 次撞护栏
记分员醒来:记下第 [5, 6] 次
—— 第 7 帧 ——
阿莱:第 7 次撞护栏
—— 第 8 帧 ——
阿莱:第 8 次撞护栏
—— 第 9 帧 ——
阿莱:第 9 次撞护栏
记分员醒来:记下第 [8, 9] 次撞了 9 次,账上只有 6 笔。第 1、4、7 次无声无息地蒸发了——每次醒来,仓里只剩“昨天和今天”的消息,更早的已随旧仓倒掉。这不是 bug 而是设计:缓冲不清理就会无限膨胀,Bevy 选择了固定两帧的寿命,把“勤快地读”定为读者的本分。输出还顺带验证了上一节的两条规则:被 run_if 拦下的帧里游标原地不动,所以每次醒来读到的是两条而不是一条;而游标再勤快,也追不回已经倒掉的仓。
FixedUpdate 的特殊照顾
两帧寿命有一个明显的受害者:第 6 章说过,FixedUpdate 按自己的时钟走,高帧率下可能连续好几帧一轮都不跑。要是清理照常进行,放在 FixedUpdate 里的读者岂不是注定丢消息?
Bevy 替你想过了。带 TimePlugin 的 App(MinimalPlugins 和 DefaultPlugins 都含)会协调清理节奏:每轮固定更新结束时发一个信号,First 里的清理系统只在收到信号后才真正动手。也就是说,FixedUpdate 一轮没跑的帧,消息一条也不清;规则从“可见两帧”放宽成“至少活到 FixedUpdate 看过一眼”。读者放在 Update 还是 FixedUpdate 里因此都安全,前提不变:每次轮到自己时把消息读掉。固定时间步的全貌在第 18 章。
本章的实验全部用裸
App::new(),没有TimePlugin,清理系统每帧照跑——所以 Listing 7-5 才能呈现教科书式的两帧寿命。换成MinimalPlugins再跑一遍,丢消息的位置就会随固定时钟漂移,不再可复现。
把本节收拢成一句话:消息是通知,不是存档。读到之后需要留底的信息,写进 Resource 或 Component;拿消息当长期状态用,迟早栽在寿命上。
寿命规则清楚了。回到碰碰车场——生意越来越好,三辆车同时上场,新的问题跟着来了。