Frame Pacing 库 Android Game Development Kit 的一部分。

Android Frame Pacing 库(也称为 Swappy)是 AGDK 库的一部分。它可帮助 OpenGL 和 Vulkan 游戏在 Android 上实现流畅的渲染和帧同步。本文档定义了帧同步,介绍了需要帧同步的情况,并说明了该库如何处理这些情况。如果您希望直接跳至在游戏中实现帧同步,请参阅后续步骤

背景

帧同步是指游戏的逻辑和渲染循环与操作系统的显示子系统和底层显示硬件之间的同步。Android 显示子系统的设计可避免在显示硬件通过更新半途切换至新的帧时可能会出现的视觉伪影(称为“抖动”)。为避免出现这些伪影,显示子系统会执行以下操作:

  • 在内部缓冲之前的帧
  • 检测延迟帧的提交情况
  • 检测到延迟帧时重复显示之前的帧

游戏会通知 SurfaceFlinger(即显示子系统中的合成器)其已提交某个帧所需的所有绘制调用(通过调用 eglSwapBuffersvkQueuePresentKHR)。SurfaceFlinger 使用锁存器向显示硬件示意帧的可用性。然后,显示硬件会显示给定帧。显示硬件以恒定速率(例如 60 Hz)进行 tick 操作,在硬件需要新帧而又没有新帧的情况下,硬件会再次显示上一帧。

当游戏渲染循环的渲染速率与原生显示硬件不同时,往往会出现帧时间不一致的情况。如果以 30 FPS 运行的游戏试图在原生支持 60 FPS 的设备上渲染,游戏渲染循环不会意识到同一帧在屏幕上又重复显示了 16 毫秒。这种脱节通常会导致帧时间出现很大程度的不一致,例如:49 毫秒、16 毫秒、33 毫秒。过于复杂的场景会让该问题更复杂,因为它们会导致丢帧。

非最优解决方案

过去,游戏采用以下帧同步解决方案,这通常会导致帧时间不一致并增加输入延迟时间。

在渲染 API 允许的范围内尽快提交帧

这种方法将游戏与可变的 SurfaceFlinger Activity 相关联,会导致帧额外延迟时间。显示流水线包含一个帧队列(大小通常为 2),如果游戏尝试呈现帧的速度过快,该队列将填满。如果队列中没有更多空间,游戏循环(或至少是渲染线程)会被 OpenGL 或 Vulkan 调用阻止。然后,系统会强制游戏等待显示硬件显示帧,此背压现象会同步两个组件。这种情况称为“缓冲区填充”或“队列填充”渲染器进程不会感知到正在发生的情况,因此帧速率不一致会更加严重。如果游戏在帧之前对输入进行采样,输入延迟时间会更加严重。

单独使用 Android Choreographer

游戏还使用 Android Choreographer 进行同步。此组件从 API 16 开始在 Java 中提供,从 API 24 开始在 C++ 中提供,它以与显示子系统相同的频率提供常规 tick。在何时相对于实际硬件 VSYNC 提供此 tick 方面,仍存在细微差别,这些差别因设备而异。长帧仍然可能发生缓冲区填充。

Frame Pacing 库的优势

Frame Pacing 库使用 Android Choreographer 进行同步并为您处理 tick 交付中的变化。它使用呈现时间戳确保帧在适当的时间呈现,并同步栅栏以避免出现缓冲区填充。如果 NDK Choreographer 可用,该库会使用 NDK Choreographer,如果其不可用,就回退到 Java Choreographer

如果设备支持多个刷新频率,该库就可以处理多个刷新频率,这提高了游戏呈现帧的灵活性。例如,对于支持 60 Hz 和 90 Hz 刷新频率的设备,游戏如果无法每秒生成 60 帧,可以下降到 45 FPS,而不是 30 FPS,以保持流畅度。该库会检测预期的游戏帧速率,并相应地自动调整帧呈现时间。此外,Frame Pacing 库还可以避免不必要的显示更新,从而延长电池续航时间。例如,如果游戏以 60 FPS 渲染,但显示屏以 120 Hz 更新,则屏幕会针对每个帧更新两次。Frame Pacing 库通过将刷新率设置为设备支持的值最接近目标帧速率来避免此问题。

了解运作方式

以下部分展示了 Frame Pacing 库如何处理长游戏帧和短游戏帧,以实现正确的帧同步。

30 Hz 时的正确帧同步

在 60 Hz 设备上以 30 Hz 渲染时,Android 上的理想情况如图 1 所示。SurfaceFlinger 会锁存新的图形缓冲区(如果存在)。图中的 NB 表示“无缓冲区”,并且重复上一个缓冲区。

在 60 Hz 设备上以 30 Hz 实现理想的帧同步

图 1. 在 60 Hz 设备上以 30 Hz 实现理想的帧同步

短游戏帧会导致卡顿

在大多数现代设备上,游戏引擎都依靠平台 Choreographer 交付 tick 以推动帧的提交。但是,由于存在短帧,仍可能造成帧同步不佳,如图 2 所示。如果短帧后跟长帧,玩家会感觉出现卡顿。

短游戏帧

图 2. 短游戏帧 C 导致帧 B 仅显示一个帧,后接多个 C 帧

Frame Pacing 库通过使用呈现时间戳解决此问题。该库使用呈现时间戳扩展 EGL_ANDROID_presentation_timeVK_GOOGLE_display_timing,以免帧提前呈现,如图 3 所示。

呈现时间戳

图 3. 游戏帧 B 呈现两次,画面更流畅

长帧导致卡顿和延迟时间

如果“显示工作负载”花费的时间比“应用工作负载”长,额外的帧将被添加至队列。这同样会导致出现卡顿现象,还可能会因缓冲区填充而导致帧额外延迟时间(参阅图 4)。该库会消除卡顿和帧的额外延迟时间。

长游戏帧

图 4. 长帧 B 导致两种帧(A 和 B)同步错误

该库使用同步栅栏(EGL_KHR_fence_syncVkFence)将等待注入应用,让显示管道能够跟上进度,而不是积累背压,从而解决此问题。帧 A 仍然呈现一个额外的帧,但帧 B 现在可以正确呈现,如图 5 所示。

等待被添加至应用层

图 5.帧 C 和 D 等待呈现

支持的操作模式

您可以将 Frame Pacing 库配置为采用以下三种模式之一:

  • 自动模式关闭 + 流水线
  • 自动模式开启 + 流水线
  • 自动模式开启 + 自动流水线模式(流水线/非流水线)

您可以尝试使用“自动模式 + 流水线”模式,但首先需将其关闭,并在初始化 Swappy 后添加以下内容:

  swappyAutoSwapInterval(false);
  swappyAutoPipelineMode(false);
  swappyEnableStats(false);
  swappySwapIntervalNS(1000000000L/yourPreferredFrameRateInHz);

流水线模式

为了协调引擎工作负载,该库通常使用流水线模型,跨 VSYNC 边界分隔 CPU 和 GPU 工作负载。

流水线模式

图 6. 流水线模式

非流水线模式

一般而言,此方法会降低输入屏幕延迟时间并增加其可预测性。如果游戏的帧时间非常短,CPU 和 GPU 工作负载可能都会归入单个交换间隔。在这种情况下,实际上非流水线方法实际上会降低输入屏幕延迟时间。

非流水线模式

图 7. 非流水线模式

自动模式

大多数游戏不知道如何选择交换间隔,也就是每一帧显示的时长(例如,在 30 Hz 下为 33.3 ms)。在某些设备上,游戏可以 60 FPS 渲染,而在另一些设备上这一数字需要下降到较低的值。自动模式测量 CPU 和 GPU 时间以执行以下操作:

  • 自动选择交换间隔:如果游戏在某些场景中提供 30 Hz 而在另一些场景中提供 60 Hz,游戏可以允许该库动态调整此间隔。
  • 为超快的帧停用流水线:在所有情况下都可提供最优的输入屏幕延迟时间。

多种刷新频率

支持多种刷新频率的设备在选择交换间隔使运行流畅方面具有更高的灵活性:

  • 在 60 Hz 设备上:60 FPS / 30 FPS / 20FPS
  • 在 60 Hz + 90 Hz 设备上:90 FPS / 60 FPS / 45 FPS / 30 FPS
  • 在 60 Hz + 90 Hz + 120 Hz 设备上:120 FPS / 90 FPS / 60 FPS / 45 FPS /40 FPS / 30 FPS

该库会选择与游戏帧的实际渲染时长最匹配的刷新频率,从而提供更好的视觉体验。

如需详细了解多种刷新频率的帧同步,请参阅博文 Android 上的高刷新频率渲染

帧统计信息

Frame Pacing 库提供以下统计信息用于调试和性能剖析:

  • 渲染完成后,帧在合成器队列中等待的屏幕刷新次数直方图。
  • 所请求的呈现时间与实际呈现时间之间传递的屏幕刷新次数直方图。
  • 两个连续帧之间传递的屏幕刷新次数的直方图。
  • 该帧的 CPU 开始工作与该帧与实际呈现时间之间传递的屏幕刷新次数直方图。

后续步骤

请参阅以下任一指南,将 Android Frame Pacing 库集成到您的游戏中: