实现游戏循环的一种非常流行的方式如下所示
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 这样的游戏中,它在每次刷新时都会更新屏幕,队列始终是满的,除非游戏刚开始(或未暂停),因此效果不明显。偶尔暂停动画然后返回到尽可能快的模式的游戏可能会看到奇怪的 hiccups。
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() 也需要花费一定的时间——但您明白我的意思了。)
当绘制代码唤醒时,它只需获取锁,获取块的当前位置,释放锁,然后进行绘制。您无需根据帧间增量时间进行分数运动,只需一个线程移动物体,另一个线程在绘制开始时绘制物体所在的位置即可。
对于任何复杂的场景,您都需要创建一个按唤醒时间排序的即将发生的事件列表,并在下一个事件到期之前休眠,但原理是一样的。