了解游戏循环中的渲染

一种非常流行的游戏循环实现方式如下所示

while (playing) {
    advance state by one frame
    render the new frame
    sleep until it’s time to do the next frame
}

这种做法存在一些问题,最根本的是游戏可以定义“帧”的概念。不同的显示器会以不同的速率刷新,并且该速率可能会随时间变化。如果您生成帧的速度快于显示器显示的速度,则偶尔需要丢弃一帧。如果您生成帧的速度过慢,SurfaceFlinger 将会周期性地找不到要获取的新缓冲区,并会重新显示上一帧。这两种情况都可能导致可见的卡顿。

您需要做的是匹配显示器的帧速率,并根据上一帧以来经过的时间来推进游戏状态。有几种方法可以实现这一点

  • 使用 Android Frame Pacing 库(推荐)
  • 将 BufferQueue 填满,并依赖“交换缓冲区”反压
  • 使用 Choreographer (API 16+)

Android Frame Pacing 库

请参阅实现适当的帧同步,了解如何使用此库。

队列填充

这实现起来非常简单:只需尽可能快地交换缓冲区即可。在早期版本的 Android 中,这实际上可能会导致性能下降,SurfaceView#lockCanvas() 会让您休眠 100 毫秒。现在它由 BufferQueue 进行步调控制,BufferQueue 会以 SurfaceFlinger 所能达到的最快速度清空。

此方法的一个示例可见于Android Breakout。它使用 GLSurfaceView,该视图在一个循环中运行,调用应用的 onDrawFrame() 回调,然后交换缓冲区。如果 BufferQueue 已满,eglSwapBuffers() 调用将等待直到缓冲区可用。当 SurfaceFlinger 释放缓冲区时(在获取用于显示的新缓冲区后),缓冲区将变为可用状态。由于此操作发生在 VSYNC 上,因此您的绘制循环时序将与刷新率匹配。大部分情况是这样。

这种方法存在一些问题。首先,应用与 SurfaceFlinger 活动绑定,这会根据工作量以及是否与其他进程争夺 CPU 时间而花费不同的时间。由于您的游戏状态是根据缓冲区交换之间的时间推进的,因此您的动画不会以一致的速率更新。但是,当以 60fps 运行且不一致性随时间推移而平均化时,您可能不会注意到这些卡顿。

其次,前几次缓冲区交换会发生得非常快,因为 BufferQueue 尚未填满。计算出的帧之间的时间将接近于零,因此游戏将生成几帧,其中什么也没有发生。在像 Breakout 这样每次刷新都更新屏幕的游戏中,队列总是满的,除非游戏刚开始(或取消暂停),因此这种效果不明显。一个偶尔暂停动画然后返回到尽可能快模式的游戏可能会出现奇怪的卡顿。

Choreographer

Choreographer 允许您设置一个在下一个 VSYNC 时触发的回调。实际的 VSYNC 时间作为参数传入。因此,即使您的应用没有立即唤醒,您仍然可以准确了解显示器刷新周期何时开始。使用此值而非当前时间,可为您的游戏状态更新逻辑提供一致的时间源。

不幸的是,每次 VSYNC 之后都会收到回调这一事实并不能保证您的回调能够及时执行,或者您能够足够迅速地对其采取行动。您的应用需要检测何时落后并手动丢帧。

Grafika 中的“录制 GL 应用”活动提供了这方面的一个示例。在某些设备(例如 Nexus 4 和 Nexus 5)上,如果您只是静观,该活动将开始丢帧。GL 渲染是微不足道的,但偶尔 View 元素会重新绘制,如果设备进入了低功耗模式,测量/布局过程可能需要很长时间。(根据 systrace,Android 4.4 上时钟变慢后,它需要 28 毫秒而不是 6 毫秒。如果您在屏幕上拖动手指,它会认为您正在与活动交互,因此时钟速度保持高位,您将永远不会丢帧。)

简单的修复方法是,如果当前时间比 VSYNC 时间晚 N 毫秒以上,则在 Choreographer 回调中丢弃一帧。理想情况下,N 的值是根据之前观察到的 VSYNC 间隔确定的。例如,如果刷新周期为 16.7 毫秒 (60fps),如果您运行延迟超过 15 毫秒,则可能会丢弃一帧。

如果您观看“录制 GL 应用”运行,您会看到丢帧计数器增加,甚至在丢帧时边框会闪烁红色。但是,除非您的眼睛非常敏锐,否则您不会看到动画卡顿。在 60fps 下,只要动画继续以恒定速率前进,应用就可以偶尔丢弃一帧而不会被任何人注意到。您能容忍多少卡顿,在某种程度上取决于您正在绘制的内容、显示器的特性以及应用用户检测卡顿的能力。

线程管理

一般来说,如果您要渲染到 SurfaceView、GLSurfaceView 或 TextureView 上,您希望在专用线程中进行渲染。切勿在 UI 线程上执行任何“繁重工作”或耗时不确定的操作。相反,为游戏创建两个线程:一个游戏线程和一个渲染线程。请参阅提高游戏性能了解更多信息。

Breakout 和“录制 GL 应用”使用专用渲染器线程,并且它们也在该线程上更新动画状态。只要游戏状态能够快速更新,这是一种合理的方法。

其他游戏则完全分离游戏逻辑和渲染。如果您有一个简单的游戏,每 100 毫秒只移动一个方块,那么您可以有一个专门的线程来执行此操作

run() {
    Thread.sleep(100);
    synchronized (mLock) {
        moveBlock();
    }
}

(您可能希望根据固定时钟来设置休眠时间以防止漂移 — sleep() 并非完全一致,并且 moveBlock() 需要非零的时间 — 但您应该明白了。)

当绘制代码唤醒时,它只是获取锁,获取方块的当前位置,释放锁,然后绘制。与其基于帧间增量时间进行分数移动,不如只使用一个线程来移动对象,另一个线程在绘制开始时绘制对象所在的位置。

对于任何复杂度的场景,您可能都希望创建一个按唤醒时间排序的即将发生的事件列表,并休眠直到下一个事件到期,但其思想是相同的。