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

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

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() 需要花费的时间不为零 - 但是您了解就行。)

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

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