318. 主循环帧节奏控制

将手动前端循环限制在 NES NTSC 速度。

318 / 356 · tests/chapter_10_performance/test_318_main_frame_pacing.py

需要更新的文件

main.py

为什么需要这一步

在采用了更快的 pygame 帧缓冲上传方式以及可选的 PyPy 启动脚本之后,手动运行的模拟器可能会比真实 NES 运行得更快。在教程开发过程中,未加限制的路径大约达到了 90-120 FPS。

运行得更快是有用的性能证据,但这会让游戏玩法、动画和输入时序都变得过快。当一帧提前完成时,前端应当等待。

关键术语:帧节奏 帧节奏是指当模拟出的一帧比目标的实际时钟时长更快完成时,延迟其呈现的做法。

目标 NTSC 时序

NES_NTSC_FPS = 60.0988
TARGET_FRAME_SECONDS = 1.0 / NES_NTSC_FPS

一个目标帧大约是

16.64 milliseconds

示例实现

# --- NEW BLOCK: NES FRAME-TIME TARGET ---
NES_NTSC_FPS = 60.0988
TARGET_FRAME_SECONDS = 1.0 / NES_NTSC_FPS
# --- END NEW BLOCK ---

...

while running:
    # --- NEW LINE: START THIS FRAME'S TIMER ---
    frame_start_time = time.perf_counter()
    # --- END NEW LINE ---

    for event in pygame.event.get():
        ...

    executed = console.step_until_next_frame()
    framebuffer = console.render_framebuffer()
    draw_framebuffer(window, framebuffer, SCALE)
    pygame.display.flip()

    # --- NEW BLOCK: WAIT FOR THE UNUSED FRAME BUDGET ---
    # Sleep only for the unused part of this frame's time budget.
    frame_end_time = time.perf_counter()
    frame_elapsed_time = frame_end_time - frame_start_time
    wait_time = TARGET_FRAME_SECONDS - frame_elapsed_time

    if wait_time > 0:
        time.sleep(wait_time)
    # --- END NEW BLOCK ---

    # --- UPDATED BLOCK: MEASURE FPS AFTER PACING ---
    # Measure FPS after sleeping so the report shows paced gameplay speed.
    frames_since_last_report += 1
    now = time.perf_counter()
    elapsed = now - last_fps_report_time

    if elapsed >= FPS_REPORT_INTERVAL_SECONDS:
        fps = frames_since_last_report / elapsed
        print(f"fps={fps:.1f}")
        frames_since_last_report = 0
        last_fps_report_time = now
    # --- END UPDATED BLOCK ---

重要的不变量

Fast frame:
    frame_elapsed_time < TARGET_FRAME_SECONDS
    wait_time > 0
    frontend sleeps for the remaining budget

Slow frame:
    frame_elapsed_time >= TARGET_FRAME_SECONDS
    wait_time <= 0
    frontend does not sleep

为什么不在 Console/CPU/PPU 中休眠?模拟器核心负责确定性的模拟状态转换。基于实际时钟的节奏控制属于前端策略,因此只有 main.py 应当休眠。

常见的误解

帧节奏并不能修正 CPU 周期精度,也不会让缓慢的模拟变得更快。它只是防止运行速度足够快的模拟超过目标硬件的速度。

当前手动参考

在这一性能步骤中,继续使用本地由用户自行提供的 MarioBros.nes。对《超级马里奥兄弟》的验证要延后到实现精灵 0 命中之后再进行。

为什么要做源码结构测试?自动化测试不得调用 main()、进行真实的休眠、打开 pygame,也不得要求使用商业 ROM。这些测试只验证节奏控制的结构,而不实际运行手动循环。

未来的兼容性

步骤 356 会用基于绝对截止时间的节奏控制,替换本课中基于相对休眠的源码结构。当检测到这种完全替换时,下面这六条过时的源码结构断言会被跳过。而那些持久有效的前端/核心边界以及“每次循环只模拟一帧”的断言仍然保持生效,由测试 356 来验证新的约定。不要在 main.py 中添加已废弃的 wait_time 或 time.sleep 兼容代码。

本步骤范围之外

  • CPU 分支/跨页周期精度
  • 精灵 0 命中
  • 《超级马里奥兄弟》验证
  • pygame 渲染优化(已在上一步完成)
  • 从 pytest 中调用 main()

运行本课

uv run pytest tests/chapter_10_performance/test_318_main_frame_pacing.py -v