丢拍与重复:FixedUpdate 里的输入
第 17 章末尾欠了一句话的账:“just_pressed 以帧为粒度,而 FixedUpdate 和帧不是一对一——在那里面读它会丢拍或重拍。”机制现在凑齐了,先把案情推演一遍:
- 第 17 章说过,输入快照每帧在
PreUpdate刷新一次,just_pressed只在按下的那一帧为真,下一帧就翻篇; - 上一节刚验过,
FixedUpdate每帧跑零到多轮。
两条规矩一碰头,病灶自现:just_pressed 为真的那一帧里恰好没有鼓点,这次按下就永远没人看见——丢拍;那一帧里塞了好几拍,每一拍读到的都是同一份“刚按下”的快照——同一招收好几遍,重复。
Figure 18-7:快照按帧翻篇,鼓点不按帧落——没踩进那一帧就丢,踩进几次就重几次
升堂对账
推演要当庭验证。让场记和鼓师记同一本账:两个系统问的是一字不差的同一句话,区别只有住址——场记在 Update(帧帧在看),鼓师在 FixedUpdate(只在鼓点上抬头):
/// 场记的账:Update 每帧跑一次,一次按下就是一招
fn clerk_counts(keyboard: Res<ButtonInput<KeyCode>>, mut tally: ResMut<Tally>) {
if keyboard.just_pressed(KeyCode::Space) {
tally.seen += 1;
println!("场记:第 {} 招。", tally.seen);
}
}
/// 鼓师的账:同一句问话搬进 FixedUpdate——本帧没鼓点就看不见,一帧几拍就看几遍
fn drummer_counts(keyboard: Res<ButtonInput<KeyCode>>, mut tally: ResMut<Tally>) {
if keyboard.just_pressed(KeyCode::Space) {
tally.caught += 1;
println!("鼓师:鼓点上接到第 {} 招!", tally.caught);
}
}Listing 18-7(其一):同一句 just_pressed,两个住址两本账(examples/listing-18-07.rs)
案发现场要两处:慢板把鼓点拧到 4 拍/秒——鼓点比帧稀,专演丢拍;拖戏让鼓点回到默认的 64 拍/秒,但每帧硬卡 150 毫秒——帧比鼓点稀,专演重复。拖戏不是胡闹,它模拟的是任何机器都会遇到的卡顿帧和低配机器的常态:
/// 换场:慢板拧鼓点,拖戏拧帧率
fn switch_scene(
keyboard: Res<ButtonInput<KeyCode>>,
mut fixed: ResMut<Time<Fixed>>,
mut dragging: ResMut<Dragging>,
) {
if keyboard.just_pressed(KeyCode::Digit1) {
fixed.set_timestep_hz(4.0);
dragging.0 = false;
println!("老雷:慢板——鼓点 4 拍/秒,帧照常跑。");
}
if keyboard.just_pressed(KeyCode::Digit2) {
fixed.set_timestep_hz(64.0);
dragging.0 = true;
println!("老雷:拖戏——鼓点恢复 64 拍/秒,每帧硬卡 150 毫秒。");
}
}
/// 拖戏:把一帧人为拖长,下一帧鼓师就得连补好几拍
fn drag(dragging: Res<Dragging>) {
if dragging.0 {
std::thread::sleep(Duration::from_millis(150));
}
}Listing 18-7(其二):两个案发现场——慢板拧鼓点,拖戏拖帧
cargo run -p ch18-time --example listing-18-07开场是慢板。攒足手速快快地点八下空格:
老雷:验招——场记帧帧盯着,鼓师只在鼓点上抬头。都按空格记账。
鼓师:鼓点上接到第 1 招!
场记:第 1 招。
场记:第 2 招。
场记:第 3 招。
…
场记:第 8 招。场记齐齐整整记了八招,鼓师只逮着一招——还是开场撞了运气(这一行排在场记前头不是乱序:第 6 章的调度表里固定主循环跑在 Update 之前,同一帧里鼓师先开口)。能不能逮着全看按键那一瞬有没有鼓点路过:快照窗口只有一帧宽,每秒只来 4 拍,十有八九扑空——帧率越高,窗口越窄,漏得越狠。再按 2 切到拖戏,点两下:
老雷:拖戏——鼓点恢复 64 拍/秒,每帧硬卡 150 毫秒。
鼓师:鼓点上接到第 2 招!
鼓师:鼓点上接到第 3 招!
…
鼓师:鼓点上接到第 11 招!
场记:第 9 招。一招进账,鼓师连喊十声——150 毫秒的帧攒下约十拍,十拍读到的是同一份快照。两个现场合在一起,账面荒唐:

Figure 18-8:同一个键,两本账——慢板几乎全漏,拖戏每招翻十倍
把出剑、跳跃、换弹这类瞬时动作直接写进 FixedUpdate,上面就是玩家的真实体验:平时偶尔吞招,一卡顿就连发。坑的形状看清了,填法也就显然了。
接拍:把瞬时攒成意图
病根在“瞬时的真假与拍子对不上号”,药方就是别让鼓师直接看快照:找一个每帧必跑的系统把瞬时输入收集下来、攒进一个资源,鼓师在拍子上消费这份积攒,消费完清零。攒下的账不会翻篇——鼓点再稀也漏不掉;消费即清零——一帧再多拍也重不了。第 17 章的意图层在这里严丝合缝地对上了:那时为“多设备”立的中间层,正是为“跨调度”要的缓冲。
/// 场记收招:just_pressed 还是每帧问,但只往账上记,不当场办
fn collect_strikes(
keyboard: Res<ButtonInput<KeyCode>>,
mut queued: ResMut<Queued>,
mut tally: ResMut<Tally>,
) {
if keyboard.just_pressed(KeyCode::Space) {
queued.strikes += 1;
tally.seen += 1;
println!("场记:记下第 {} 招,候拍。", tally.seen);
}
}
/// 鼓师结账:每拍把攒下的招一笔结清,结完清零——后头的拍子自然没账可重复
fn drummer_settles(mut queued: ResMut<Queued>, mut tally: ResMut<Tally>) {
if queued.strikes == 0 {
return;
}
tally.caught += queued.strikes;
println!(
"鼓师:这一拍结清 {} 招——共接 {} 招。",
queued.strikes, tally.caught
);
queued.strikes = 0;
}Listing 18-8(其一):收招只记账,结账一笔清(examples/listing-18-08.rs)
收招系统的住址有讲究。放 Update 行不行?行——但回看第 6 章的调度表:固定主循环排在 Update 前面,本帧 Update 收的招要等下一帧的鼓点才被结算,平白慢一拍。官方惯用法是挂进 RunFixedMainLoop 调度的 RunFixedMainLoopSystems::BeforeFixedMainLoop 系统集——每帧必跑、又赶在本帧鼓点之前,输入当帧就能进账:
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
.insert_resource(ClearColor(Color::srgb(0.10, 0.10, 0.14)))
.insert_resource(Time::<Fixed>::from_hz(4.0))
.init_resource::<Queued>()
.init_resource::<Tally>()
.init_resource::<Dragging>()
.add_systems(Startup, setup)
// 收招放在固定主循环之前:每帧必跑一次,鼓点再稀也漏不掉
.add_systems(
RunFixedMainLoop,
collect_strikes.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
)
.add_systems(FixedUpdate, drummer_settles)
.add_systems(Update, (switch_scene, drag, hud))
.run();
}Listing 18-8(其二):收招站在鼓点前——BeforeFixedMainLoop,每帧恰好一次
同样的两个现场再验一遍:
老雷:再验一遍招——这回场记只管记账,鼓师按拍结账。
场记:记下第 1 招,候拍。
鼓师:这一拍结清 1 招——共接 1 招。
场记:记下第 2 招,候拍。
鼓师:这一拍结清 1 招——共接 2 招。
场记:记下第 3 招,候拍。
场记:记下第 4 招,候拍。
鼓师:这一拍结清 2 招——共接 4 招。
…
老雷:拖戏——鼓点恢复 64 拍/秒,每帧硬卡 150 毫秒。
场记:记下第 7 招,候拍。
鼓师:这一拍结清 1 招——共接 7 招。
场记:记下第 8 招,候拍。
鼓师:这一拍结清 1 招——共接 8 招。慢板下两招攒在一起、一拍结清(“结清 2 招”那行就是证据);拖戏下一帧十拍,第一拍消费、后九拍无账可结——两头的账都两清了。多提一句火候:这里攒的是次数,因为“出招八次”八次都该作数;如果攒的是“本帧按住的方向”这类持续状态,收集系统直接覆写就行,不用累加。
哪些输入要缓存,哪些不用?只有
just_pressed/just_released这类“瞬时事实”有帧粒度问题。pressed是持续状态,按住期间帧帧为真,鼓点哪一拍来问都问得到,在FixedUpdate里直接读是安全的(丢不了,顶多迟零点几拍感知不到)。Message也是安全的——第 7 章“FixedUpdate 的特殊照顾”说过,带TimePlugin的 App 会推迟消息清理,直到固定调度看过一眼,MessageReader放在FixedUpdate里一条不丢。真正需要你动手缓冲的,就是快照上的瞬时三问。官方示例examples/movement/physics_in_fixed_timestep.rs里有这套模式的完整版(含“鼓点跑过之后才清账”的开关),值得通读。
输入的账清了。但鼓师还背着最后一桩公案——把走位搬上鼓点之后,台下看客说阿燕走路一顿一顿,像踩在棉花上。压轴的《赶月》,下一节开演。