跳转到内容

连续时间游戏循环

这个示例是一个上层应用:一款即时战略游戏的主循环,支持暂停和 1x、5x、20x 倍速。你会看到如何把帧时长换算成整分钟的模拟时间,如何保留不足一分钟的余量来做平滑动画,以及如何确认 60 FPS 和 30 FPS 的玩家得到相同的模拟结果。

打开 continuous_game_loop.rs

浏览示例目录

在仓库根目录运行:

Terminal window
cargo run -p canwu-api --example continuous_game_loop

示例把同一段脚本化的游戏过程跑两遍:第一遍约 60 FPS 并打印帧日志,第二遍约 30 FPS 且不打印。输出中的关键行:

1x host policy: 60 simulation seconds per wall second
Frames are predefined; this example never sleeps or reads the real clock.
60 FPS-ish render loop
frame= 31 speed=1x wall= 0.500s visual_time=Day 0, 00:00 + 0.500m canwu_time=Day 0, 00:00 army stationary at 2
INPUT: queued commander move as IngressId(1), captured_at=Day 0, 00:00 + 0.500m, quantized_due_at=Day 0, 00:01
EVENT: at=Day 0, 00:01 type=move_ordered Army 1 was ordered from 2 to 3
frame= 3329 speed=5x wall= 54.900s visual_time=Day 0, 04:30 + 0.500m canwu_time=Day 0, 04:30 army visual progress=25.0% (2 -> 3, authoritative_location=2)
frame= 3390 speed=Paused wall= 55.900s visual_time=Day 0, 04:30 + 0.500m canwu_time=Day 0, 04:30 army visual progress=25.0% (2 -> 3, authoritative_location=2)
frame= 5847 speed=20x wall= 96.440s visual_time=Day 0, 18:01 + 0.300m canwu_time=Day 0, 18:01 army visual progress=100.0% (2 -> 3, authoritative_location=3)
EVENT: at=Day 0, 18:01 type=army_arrived Army 1 arrived in territory 3
FPS INVARIANT: 60 FPS-ish frames=5850 and 30 FPS-ish frames=2881 -> time=Day 0, 18:02, events=4, boundaries=2, state_hash=5fb68955f5b2..., checkpoint=826b0e164797...

visual_time 是渲染器绘制时使用的时间,canwu_time 是参伍的时间。最后一行说明:两遍运行渲染的帧数不同,最终的状态哈希和检查点却一样。

上层应用把三种时间分开管理,术语定义见术语表。

  • 现实时间(wall time):渲染器给出的帧时长。示例使用固定的帧时长序列:16/17 ms 和 33/34 ms。
  • 模拟时间(simulation time):参伍的时间,以整分钟计(SimTime、SimDuration)。
  • 表现时间(presentation time):模拟时间加上上层应用保留的不足一分钟的余量,只用于动画。
现实时间进入累加器,每满一分钟推进参伍,余量用于表现时间. 查看图表源码.
查看图表源码
flowchart TB
  Wall["现实时间<br/>帧时长"] -->|"× 基础速率 × 游戏倍速"| Acc["上层应用累加器<br/>整数纳秒"]
  Acc -->|"每满一分钟"| Sim["模拟时间<br/>advance_canonical(1 分钟)"]
  Sim --> Snap["WorldSnapshot"]
  Snap --> Pres["表现时间<br/>插值绘制"]
  Acc -->|"不足一分钟的余量"| Pres

GameHost::render_frame 每帧执行一次:

let converted_wall_nanos = wall_dt.as_nanos() * BASE_SIM_SECONDS_PER_WALL_SECOND;
self.accumulated_sim_nanos += converted_wall_nanos * speed.multiplier();
let mut authority_changed = false;
while self.accumulated_sim_nanos >= SIMULATION_MINUTE_NANOS {
let receipts = self.authority.advance_canonical(SIMULATION_QUANTUM)?;
self.canonical_boundaries += receipts.len();
authority_changed |= !receipts.is_empty();
self.accumulated_sim_nanos -= SIMULATION_MINUTE_NANOS;
}

示例自己规定 1x 时每秒现实时间对应一分钟模拟时间(BASE_SIM_SECONDS_PER_WALL_SECOND = 60),你的游戏可以按需要设定。倍速系数为 0、1、5 或 20。累加器是以纳秒计的整数,任何零头都会保留下来。每攒满一分钟,上层应用就调用一次 advance_canonical(SimDuration::minutes(1)),再从累加器中减去这一分钟。

按与帧率无关的时间提交玩家输入

Section titled “按与帧率无关的时间提交玩家输入”

玩家在游戏开始半分钟时点击,GameHost::submit_player_command 随即执行:

let due_at = if self.accumulated_sim_nanos == 0 {
self.authority.time()
} else {
self.authority
.time()
.checked_add(SIMULATION_QUANTUM)
.expect("the scripted command time must remain representable")
};

点击落在第 0 分钟和第 1 分钟之间,所以命令在第 1 分钟到期(输出中的 quantized_due_at=Day 0, 00:01)。随后上层应用调用 enqueue_command(due_at, 0, request) 提交命令。向上取整是这个示例的策略。只要在所有帧率下得到同一个到期时间,任何策略都可以;如果按“哪一帧轮询到输入”来定时间,60 FPS 和 30 FPS 的玩家就会玩出两局不同的游戏。

公开的 WorldSnapshot 中,军队的 TransitState 带有 from、to、departed_at 和 arrives_at。PresentationState::render 计算:

visual_progress =
(presentation_time - departed_at) / (arrives_at - departed_at)

结果是限制在 0 到 1 之间的 f64,只留在渲染器内部。从输出可以看到:5x 阶段结束时进度到达 25.0%;暂停期间帧不向累加器添加时间,进度一直停在 25.0%;Day 0, 18:01 进度达到 100.0%,参伍也在同一分钟记录了 army_arrived。

assert_fps_independent 比较 60 FPS 和 30 FPS 两遍运行。两者的帧数不同(5,850 和 2,881);参伍时间、世界状态、事件、命令日志、权威状态哈希、检查点哈希和边界数量(2)必须全部相等,否则示例会 panic。

把 30 FPS 档改成约 24 FPS:

const THIRTY_FPS_ISH: FrameProfile = FrameProfile {
name: "30 FPS-ish",
frame_millis: &[40, 41],
};

最后一行会变成 30 FPS-ish frames=2384,state_hash=5fb68955f5b2... 和 checkpoint=826b0e164797... 保持不变。

这个循环遵循的上层应用接入规则见接入并驱动模拟运行。