了解游戏循环中的渲染

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

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

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

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

  • 使用 Android 帧速率控制库(推荐)
  • 填满 BufferQueue 并依赖“交换缓冲区”的反压
  • 使用 Choreographer (API 16+)

Android 帧速率控制库

请参阅 实现正确的帧速率控制,以获取有关使用此库的信息。

队列填充

这非常容易实现:只需尽可能快地交换缓冲区即可。在早期版本的 Android 中,这实际上会导致一个惩罚,其中 SurfaceView#lockCanvas() 会让您休眠 100 毫秒。现在它由 BufferQueue 控制,BufferQueue 会尽快清空 SurfaceFlinger。

可以在 Android Breakout 中看到此方法的一个示例。它使用 GLSurfaceView,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 毫秒。如果您在屏幕上拖动手指,它会认为您正在与活动进行交互,因此时钟速度保持较高,并且您永远不会丢弃帧。)

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

如果您观察“录制 GL 应用程序”运行,您将看到已丢弃的帧计数增加,甚至在帧丢弃时看到边框中出现红色闪烁。但是,除非您的视力非常好,否则您将看不到动画卡顿。在 60fps 下,应用程序可以偶尔丢弃一个帧,而不会有人注意到,只要动画继续以恒定的速率推进即可。您可以逃避多少在一定程度上取决于您绘制的内容、显示器的特性以及使用该应用程序的人检测卡顿的能力。

线程管理

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

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

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

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

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

当绘制代码唤醒时,它只是获取锁、获取块的当前位置、释放锁并进行绘制。与其根据帧间增量时间进行分数移动,不如让一个线程移动事物,另一个线程在绘制开始时绘制事物所在的位置。

对于任何复杂场景,您都希望创建一个按唤醒时间排序的即将发生的事件列表,并在下一个事件到期之前休眠,但想法是一样的。