了解如何在游戏循环中进行渲染

实现游戏循环的热门方法如下所示:

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 调节速度,并且 SurfaceFlinger 的消费速度有多快,BufferQueue 的清空速度就有多快。

可在 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 上,则需要在专用线程中进行渲染。请勿在界面线程上进行任何“繁重”或花费时间不定的操作。而是为游戏创建两个线程:游戏线程和渲染线程。如需了解详情,请参阅提升游戏性能

Breakout 和“记录 GL 应用”使用专用渲染器线程,并且也在该线程上更新动画状态。只要可以快速更新游戏状态,这就是合理的方法。

其他游戏将游戏逻辑和呈现完全分开。如果您有一个简单的游戏,只是每 100 毫秒移动一个块,则您可以使用一个只进行以下操作的专用线程:

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

(您可能需要让休眠时间基于固定的时钟,以防止漂移现象 - sleep() 不完全一致,并且 moveBlock() 需要花费的时间不为零 - 但是您了解就行。)

当绘图代码唤醒时,它就会抓住锁,获取块的当前位置,释放锁,然后进行绘制。您无需基于帧间增量时间进行分数移动,只需要有一个移动对象的线程,以及另一个在绘制开始时随地绘制对象的线程。

对于任何复杂度的场景,都需要创建一个即将发生的事件的列表(按唤醒时间排序),并使绘制代码保持休眠状态,直到该发生下一个事件为止。