连续时间游戏循环
这个示例是一个上层应用:一款即时战略游戏的主循环,支持暂停和 1x、5x、20x 倍速。你会看到如何把帧时长换算成整分钟的模拟时间,如何保留不足一分钟的余量来做平滑动画,以及如何确认 60 FPS 和 30 FPS 的玩家得到相同的模拟结果。
打开 continuous_game_loop.rs
浏览示例目录
在仓库根目录运行:
cargo run -p canwu-api --example continuous_game_loop示例把同一段脚本化的游戏过程跑两遍:第一遍约 60 FPS 并打印帧日志,第二遍约 30 FPS 且不打印。输出中的关键行:
1x host policy: 60 simulation seconds per wall secondFrames are predefined; this example never sleeps or reads the real clock.
60 FPS-ish render loopframe= 31 speed=1x wall= 0.500s visual_time=Day 0, 00:00 + 0.500m canwu_time=Day 0, 00:00 army stationary at 2INPUT: queued commander move as IngressId(1), captured_at=Day 0, 00:00 + 0.500m, quantized_due_at=Day 0, 00:01EVENT: at=Day 0, 00:01 type=move_ordered Army 1 was ordered from 2 to 3frame= 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
把帧时长换算成模拟分钟
Section titled “把帧时长换算成模拟分钟”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 的玩家就会玩出两局不同的游戏。
为显示插值计算军队位置
Section titled “为显示插值计算军队位置”公开的 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。
比较两遍运行
Section titled “比较两遍运行”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... 保持不变。
这个循环遵循的上层应用接入规则见接入并驱动模拟运行。