鼓点之间:渲染插值
上一节结案时落下的公案:老雷按 18.4 节的口诀把阿燕的走位结算搬上鼓点,排练时用慢板(4 拍/秒)看分解动作——结果台下齐声喊晕。先复现现场。走位逻辑本身无可指摘:速度乘 delta、到台口折返,在 FixedUpdate 里 Res<Time> 自动是固定钟,账目分毫不差:
/// 鼓点上结算走位:Res<Time> 在这里是固定钟,delta 恒为 0.25 秒
/// 位置直接写进 Transform——逻辑没错,错的是观感:一秒只挪四次
fn advance(time: Res<Time>, mut walkers: Query<(&mut Transform, &mut Walker, &mut Sprite)>) {
for (mut transform, mut walker, mut sprite) in &mut walkers {
transform.translation.x += walker.velocity * time.delta_secs();
if transform.translation.x.abs() > LIMIT {
transform.translation.x = transform.translation.x.clamp(-LIMIT, LIMIT);
walker.velocity = -walker.velocity;
}
sprite.flip_x = walker.velocity < 0.0;
}
}Listing 18-9:账没错,观感错了——位置直接写进 Transform,一秒只挪四次(examples/listing-18-09.rs)
cargo run -p ch18-time --example listing-18-09跑起来一看就明白看客在喊什么:阿燕的腿是顺的(帧动画在 Update 里照常拨片),人却一顿一顿地蹦——每秒整整齐齐四跳,每跳 45 个单位。病根上一节其实已经写在 Figure 18-7 里了:鼓点不和帧对齐。两拍之间往往隔着好几帧,这些帧里 Transform 纹丝不动;拍子一落,它瞬移一大步。把鼓点换回默认 64 Hz 毛病会变细,但不会变没——144 Hz 的屏幕上一秒 144 帧只有 64 次移动,挑剔的眼睛照样看得出抖。
退回 Update?那就把 18.4 节的两条命(稳定、确定)又交出去了。鱼与熊掌的正解是分家:账本归鼓点,画面归帧——物理位置按拍结算,屏幕上的位置每帧从账本里“估”出来。
两本账与一个零头
估的办法干净得出奇。让账本多记一笔:每拍结算时,先把“这一拍的位置”挪去“上一拍的位置”,再走新的一步——任何时刻账上都有前后两拍的位置:
/// 巡台的人:速度与“账上的位置”——上一拍在哪、这一拍在哪
#[derive(Component)]
struct Walker {
velocity: f32,
}
#[derive(Component)]
struct StagePos {
current: f32,
previous: f32,
}
/// 替身:鼓点上挪一次(直写 Transform)
#[derive(Component)]
struct Direct;
/// 阿燕:每帧在两拍之间找步子(插值)
#[derive(Component)]
struct Interpolated;Listing 18-10(其一):兵分两路——StagePos 是账本,Transform 从此只管画(src/main.rs)
而“现在”走到两拍之间的几分几厘,鼓师的账本上早就有了——18.4 节那笔没花完的零头!overstep 除以步长就是进度比例,Time<Fixed> 直接给了现成方法 overstep_fraction():零头刚清空时是 0(刚结算完一拍),快攒满下一拍时逼近 1。拿它在两本账之间做线性插值(lerp),就是本帧该画的位置:
Figure 18-9:两拍之间找步子——alpha = overstep_fraction(),画面落在前后两拍的连线上
压轴《赶月》把对照组直接摆上台:替身与阿燕共用同一套鼓点结算(同一个 advance 系统、同一份 StagePos 账本),只在“怎么画”上分道扬镳——替身把账面直接拍上 Transform,阿燕按零头插值:
/// 鼓点上的结算:先把“这一拍”挪进“上一拍”,再走新的一步——两本账都是给插值留的
fn advance(
time: Res<Time>,
mut walkers: Query<(&mut StagePos, &mut Walker, Option<&Interpolated>)>,
mut laps: ResMut<Laps>,
) {
for (mut pos, mut walker, interpolated) in &mut walkers {
pos.previous = pos.current;
pos.current += walker.velocity * time.delta_secs();
if pos.current.abs() > LIMIT {
pos.current = pos.current.clamp(-LIMIT, LIMIT);
walker.velocity = -walker.velocity;
if interpolated.is_some() {
laps.0 += 1;
println!("场记:第 {} 趟走完,折返。", laps.0);
}
}
}
}
/// 替身:账上是多少,画面就是多少——一秒只动四次
fn snap_direct(mut walkers: Query<(&mut Transform, &StagePos), With<Direct>>) {
for (mut transform, pos) in &mut walkers {
transform.translation.x = pos.current;
}
}
/// 阿燕:离下一拍还差几分(overstep_fraction),步子就迈到两拍之间的几分处
fn glide(
fixed: Res<Time<Fixed>>,
mut walkers: Query<(&mut Transform, &StagePos), With<Interpolated>>,
) {
let alpha = fixed.overstep_fraction();
for (mut transform, pos) in &mut walkers {
transform.translation.x = pos.previous.lerp(pos.current, alpha);
}
}Listing 18-10(其二):三个系统讲完全部机制——结算、直写、插值
各就各位是关键,三个住址对应三种节奏:
advance在FixedUpdate——按拍结算账本,每秒恰好四次;snap_direct也在FixedUpdate——替身的画法,账变它才变;glide挂在RunFixedMainLoop的AfterFixedMainLoop系统集——每帧恰好一次、又保证排在本帧所有鼓点之后,拿到的是最新账面和最新零头。上一节收输入用BeforeFixedMainLoop,这一节铺画面用AfterFixedMainLoop,一前一后正好把固定主循环夹在中间——这正是官方physics_in_fixed_timestep.rs示例的骨架。
完整代码如下,HUD 与中场/换挡沿用 18.2 节的零件:
//! Listing 18-10:《赶月》——同一套鼓点结算,替身直写 Transform,阿燕在两拍之间插值
//! 空格中场/开戏,↑↓ 给戏台钟换挡(慢放时替身顿得更凶,阿燕照样丝滑)。
use std::time::Duration;
use bevy::prelude::*;
use bevy::sprite::Anchor;
use bevy::time::common_conditions::on_real_timer;
const FLOOR: f32 = -270.0;
const BACK_LANE: f32 = -40.0;
const LIMIT: f32 = 420.0;
const SPEED: f32 = 180.0;
// ANCHOR: components
/// 巡台的人:速度与“账上的位置”——上一拍在哪、这一拍在哪
#[derive(Component)]
struct Walker {
velocity: f32,
}
#[derive(Component)]
struct StagePos {
current: f32,
previous: f32,
}
/// 替身:鼓点上挪一次(直写 Transform)
#[derive(Component)]
struct Direct;
/// 阿燕:每帧在两拍之间找步子(插值)
#[derive(Component)]
struct Interpolated;
// ANCHOR_END: components
#[derive(Component)]
struct FrameClock(Timer);
#[derive(Component)]
struct Hud;
/// 折返的趟数(场记的台账)
#[derive(Resource, Default)]
struct Laps(u32);
// ANCHOR: app
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::<Laps>()
.add_systems(Startup, setup)
// 鼓点上:结算所有人的账,替身随手把账写上画面
.add_systems(FixedUpdate, (advance, snap_direct).chain())
// 鼓点之后、每帧一次:阿燕按 overstep 在两拍之间找步子
.add_systems(
RunFixedMainLoop,
glide.in_set(RunFixedMainLoopSystems::AfterFixedMainLoop),
)
.add_systems(
Update,
(
conduct,
animate,
hud.run_if(on_real_timer(Duration::from_millis(100))),
),
)
.run();
}
// ANCHOR_END: app
// ANCHOR: advance
/// 鼓点上的结算:先把“这一拍”挪进“上一拍”,再走新的一步——两本账都是给插值留的
fn advance(
time: Res<Time>,
mut walkers: Query<(&mut StagePos, &mut Walker, Option<&Interpolated>)>,
mut laps: ResMut<Laps>,
) {
for (mut pos, mut walker, interpolated) in &mut walkers {
pos.previous = pos.current;
pos.current += walker.velocity * time.delta_secs();
if pos.current.abs() > LIMIT {
pos.current = pos.current.clamp(-LIMIT, LIMIT);
walker.velocity = -walker.velocity;
if interpolated.is_some() {
laps.0 += 1;
println!("场记:第 {} 趟走完,折返。", laps.0);
}
}
}
}
/// 替身:账上是多少,画面就是多少——一秒只动四次
fn snap_direct(mut walkers: Query<(&mut Transform, &StagePos), With<Direct>>) {
for (mut transform, pos) in &mut walkers {
transform.translation.x = pos.current;
}
}
/// 阿燕:离下一拍还差几分(overstep_fraction),步子就迈到两拍之间的几分处
fn glide(
fixed: Res<Time<Fixed>>,
mut walkers: Query<(&mut Transform, &StagePos), With<Interpolated>>,
) {
let alpha = fixed.overstep_fraction();
for (mut transform, pos) in &mut walkers {
transform.translation.x = pos.previous.lerp(pos.current, alpha);
}
}
// ANCHOR_END: advance
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
commands.spawn(Camera2d);
let bold = asset_server.load("fonts/book-sans-sc-bold.otf");
let regular = asset_server.load("fonts/book-sans-sc-regular.otf");
// 前后两条台板
for (y, z) in [(FLOOR, 1.0), (BACK_LANE, 0.5)] {
commands.spawn((
Sprite {
image: asset_server.load("props/dock-plank.png"),
custom_size: Some(Vec2::new(1400.0, 56.0)),
image_mode: SpriteImageMode::Tiled {
tile_x: true,
tile_y: true,
stretch_value: 4.0,
},
..default()
},
Transform::from_xyz(0.0, y - 28.0, z),
));
}
// ANCHOR: cast
// 同一本连环画:后排替身(直写,灰青调),前排阿燕(插值,本色)
let layout = layouts.add(TextureAtlasLayout::from_grid(
UVec2::new(32, 40),
6,
2,
None,
None,
));
let sheet = asset_server.load("actors/ayan-sheet.png");
commands.spawn((
Direct,
Walker { velocity: SPEED },
StagePos {
current: -LIMIT,
previous: -LIMIT,
},
FrameClock(Timer::from_seconds(0.1, TimerMode::Repeating)),
Sprite {
color: Color::srgb(0.62, 0.72, 0.86),
..Sprite::from_atlas_image(
sheet.clone(),
TextureAtlas {
layout: layout.clone(),
index: 6,
},
)
},
Anchor::BOTTOM_CENTER,
Transform::from_xyz(-LIMIT, BACK_LANE, 2.0).with_scale(Vec3::splat(4.0)),
));
commands.spawn((
Interpolated,
Walker { velocity: SPEED },
StagePos {
current: -LIMIT,
previous: -LIMIT,
},
FrameClock(Timer::from_seconds(0.1, TimerMode::Repeating)),
Sprite::from_atlas_image(sheet, TextureAtlas { layout, index: 6 }),
Anchor::BOTTOM_CENTER,
Transform::from_xyz(-LIMIT, FLOOR, 3.0).with_scale(Vec3::splat(4.0)),
));
// ANCHOR_END: cast
// 台口名牌与 HUD
for (y, label, color) in [
(BACK_LANE + 180.0, "替身(直写)", Color::srgb(0.62, 0.72, 0.86)),
(FLOOR + 180.0, "阿燕(插值)", Color::srgb(0.91, 0.72, 0.29)),
] {
commands.spawn((
Text2d::new(label),
TextFont {
font: FontSource::Handle(bold.clone()),
font_size: FontSize::Px(26.0),
..default()
},
TextColor(color),
Anchor::CENTER_LEFT,
Transform::from_xyz(-620.0, y, 5.0),
));
}
commands.spawn((
Hud,
Text2d::new(""),
TextFont {
font: FontSource::Handle(bold),
font_size: FontSize::Px(28.0),
..default()
},
TextColor(Color::srgb(0.91, 0.88, 0.80)),
Transform::from_xyz(0.0, 320.0, 5.0),
));
commands.spawn((
Text2d::new("空格 中场/开戏 ↑↓ 换挡 鼓点 4 拍/秒"),
TextFont {
font: FontSource::Handle(regular),
font_size: FontSize::Px(22.0),
..default()
},
TextColor(Color::srgb(0.55, 0.57, 0.62)),
Transform::from_xyz(0.0, 252.0, 5.0),
));
println!("老雷:《赶月》走台——替身踩着鼓点挪,阿燕在鼓点之间找步子。");
println!("场记:空格中场/开戏,↑↓ 换挡。慢放时替身顿得更凶,阿燕照样丝滑。");
}
const SPEEDS: [f32; 5] = [0.25, 0.5, 1.0, 2.0, 4.0];
/// 指挥席(与 Listing 18-3 同款):暂停与变速拧在 Time<Virtual> 上,鼓点跟着停
fn conduct(
keyboard: Res<ButtonInput<KeyCode>>,
mut time: ResMut<Time<Virtual>>,
mut gear: Local<Option<usize>>,
) {
let gear = gear.get_or_insert(2);
if keyboard.just_pressed(KeyCode::Space) {
if time.is_paused() {
time.unpause();
println!("老雷:开戏。");
} else {
time.pause();
println!("老雷:中场——鼓也歇了,人也定了。");
}
}
let step: isize = i32::from(keyboard.just_pressed(KeyCode::ArrowUp)) as isize
- i32::from(keyboard.just_pressed(KeyCode::ArrowDown)) as isize;
if step != 0 {
let next = gear.saturating_add_signed(step).min(SPEEDS.len() - 1);
if next != *gear {
*gear = next;
time.set_relative_speed(SPEEDS[next]);
println!("鼓师:换挡——戏台钟 ×{}。", SPEEDS[next]);
}
}
}
/// 帧动画与朝向:腿脚每帧都顺
fn animate(time: Res<Time>, mut walkers: Query<(&mut Sprite, &mut FrameClock, &Walker)>) {
for (mut sprite, mut clock, walker) in &mut walkers {
if clock.0.tick(time.delta()).just_finished()
&& let Some(atlas) = sprite.texture_atlas.as_mut()
{
atlas.index = if atlas.index >= 11 { 6 } else { atlas.index + 1 };
}
sprite.flip_x = walker.velocity < 0.0;
}
}
/// 读数牌(与 Listing 18-3 同款)
fn hud(
real: Res<Time<Real>>,
stage: Res<Time<Virtual>>,
mut text: Single<&mut Text2d, With<Hud>>,
) {
let status = if stage.is_paused() { "中场" } else { "开戏" };
text.0 = format!(
"怀表 {:6.1} 秒 戏台钟 {:6.1} 秒 速度 ×{:.2} {status}",
real.elapsed_secs(),
stage.elapsed_secs(),
stage.relative_speed(),
);
}Listing 18-10(其三):完整示例——《赶月》(src/main.rs)
cargo run -p ch18-time老雷:《赶月》走台——替身踩着鼓点挪,阿燕在鼓点之间找步子。
场记:空格中场/开戏,↑↓ 换挡。慢放时替身顿得更凶,阿燕照样丝滑。
场记:第 1 趟走完,折返。
老雷:中场——鼓也歇了,人也定了。
老雷:开戏。
鼓师:换挡——戏台钟 ×0.5。
鼓师:换挡——戏台钟 ×0.25。
Figure 18-10:《赶月》——同一本账,替身按拍跳,阿燕每帧滑
同一面鼓、同一本账,观感天差地别。两个旋钮值得亲手拧:↓ 减到 ×0.25——慢放让鼓点在真实时间里变成约一秒一拍,替身蹦得愈发不堪,阿燕依旧丝滑(零头照常按比例走,插值对变速浑然不觉);空格中场——鼓停了(18.4 节的家规:固定钟跟着戏台钟),AfterFixedMainLoop 的 glide 倒是每帧照跑,但零头冻住不动,阿燕便定在原地,不漂不抖。
天下没有白吃的丝滑,这碗的价钱叫滞后:阿燕画的永远是 previous 到 current 之间的某处,也就是说画面比账本慢零到一拍(动图里细看,她始终比替身落后小半步)。4 拍/秒的教学鼓点下滞后最多 250 毫秒,肉眼可辨;默认 64 Hz 下最多 15.6 毫秒,绝大多数游戏无人察觉。在乎这十几毫秒的竞技场合有另一路解法——不插值(interpolation)而外推(extrapolation),按速度向前猜半拍,代价是猜错时会轻微回弹;官方示例的文档注释里有两派的权衡综述。还记得第 13 章镜头跟拍用的 smooth_nudge 吗?那是第三路:不求精确落点、只求指数逼近,跟拍这类“慢半拍才有手感”的场合最合适。
小结
- delta 是公平的尺子:帧的间隔天生不均匀,
Time每帧在First刷新、全帧一致;速度按“每秒多少”定义、乘delta_secs(),快慢机器走出同样的戏。elapsed_secs()做累计相位,长跑防精度磨损用_f64或_wrapped版 Time是一族:Real是没有旋钮的怀表;Virtual是可暂停(pause/unpause)、可变速(set_relative_speed)、有补时上限(max_delta,默认 250 毫秒,防瞬移与死亡螺旋)的戏台钟;Fixed按整拍跟着Virtual走。通用Res<Time>是镜子:Update里照出Virtual,FixedUpdate里照出Fixed——玩法代码只管用它。拧旋钮要指名ResMut<Time<Virtual>>,通用钟上没有pause(E0599 当场拦截)Timer不喂不走:tick(delta)喂时间,喂哪只钟的 delta 它就过谁的日子(戏台钟版暂停即冻结,怀表版戏外照走)。Once走完停表等reset,做冷却配is_finished/remaining_secs/fraction;Repeating回绕不停,做节拍配just_finished,一帧跨多轮要问times_finished_this_tick。on_timer/on_real_timer是包成run_if条件的循环表;Stopwatch正着数没终点,按住才喂就是“只记按住的时长”FixedUpdate是水缸记账:每帧把Virtual的 delta 倒进缸,够一个步长跑一拍、连跑到不够为止,零头存overstep。拍内Res<Time>的 delta 恒等步长——稳定(参数行为处处一致)与确定(同输入同结果)皆系于此。步长配置Time::<Fixed>::from_hz/from_seconds,默认 64 Hz(躲开 60 Hz 屏的拍频、且为 2 的幂);中场暂停时鼓不响- 瞬时输入过鼓点要缓存:
just_pressed只真一帧,鼓点没踩进那帧就丢、踩进几拍就重几遍。解法:每帧必跑的系统攒成意图(次数累加、状态覆写),鼓点上消费清零;收集挂RunFixedMainLoopSystems::BeforeFixedMainLoop当帧生效。pressed与Message不用缓存(消息有第 7 章的引擎兜底) - 账本归鼓点,画面归帧:固定步长移动直写
Transform会一顿一顿。记 previous/current 两本账,每帧用overstep_fraction()在两拍之间 lerp,插值系统挂AfterFixedMainLoop。代价是画面滞后至多一拍;竞技场合可换外推,跟拍手感用smooth_nudge
练习
- 赖招治理:给《连珠箭》(Listing 18-4)加中场暂停(空格之外另设一键)。先什么都不改,验证暂停期间冷却确实冻结;再造一只“赖招版”冷却——
tick喂Time<Real>的 delta——亲眼看暂停回蓝有多破坏手感。体会“喂哪只钟”是设计决定。 - 重箭蓄力:把 Listing 18-5 的
Stopwatch接进《连珠箭》:按住空格蓄力、松手出箭,蓄力时长换算成箭速(封顶 2 秒)。注意蓄力期间冷却表与秒表互不相干——两件家什各记各的账。 - 持续与瞬时:把 Listing 18-7 两个计数系统里的
just_pressed都换成pressed,慢板下按住空格两秒再松开。预测两本账各记多少,再运行对答案——体会“持续状态在 FixedUpdate 里直接读是安全的”这句话的确切含义。 - 第三种走法:给《赶月》添一位“外推替身”:不存 previous,每帧画
current + velocity * overstep(按速度向前猜)。直行时她与阿燕难分伯仲;盯住台口折返的瞬间——猜错的那一下回弹,就是外推的价钱。 - 倒计时开锣:用一只 3 秒的
Once定时器给《赶月》加开场倒计时:台口Text2d用remaining_secs().ceil()报“3、2、1”,just_finished那一帧打出“开锣”并放行两位演员(提示:演员的系统挂run_if,条件读这只表——想想它该住在哪个资源里)。