游戏循环

实现一个游戏的一种非常流行的方式看起来像这样:

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

这种方式有几个问题,最基本的是游戏可以定义什么是 “帧” 的想法。不同的显示器将以不同的频率刷新,且频率可能随时间而变。如果你产生帧的速度比显示器能够展示它们的快,你将不得不偶尔丢弃一个。如果你生成它们的速度太慢,SurfaceFlinger 将周期性地无法获得新缓冲区并重新展示之前的帧。这两种情况都会导致可见的毛刺。

你需要做的就是匹配显示器的帧率,并根据自上一帧开始经过了多长时间来推进游戏状态。有两种方式做到这一点:(1) 填充BufferQueue,并依赖“交换缓冲区”的背压;(2) 使用 Choreographer (API 16+)。

队列填充

这实现起来很简单:仅仅尽快交换缓冲区。在早期的 Android 版本中,这实际可能付出的代价是 SurfaceView#lockCanvas() 将使你休眠 100ms。现在,现在它被 BufferQueue 加速了,BufferQueue 清空的速度可以和 SurfaceFlinger 一样快。

Android Breakout 中可以看到一个这种方法的例子。它使用了 GLSurfaceView,其运行于一个调用应用程序的 onDrawFrame() 回调并交换缓冲区的循环中。如果 BufferQueue 满了,eglSwapBuffers() 将等待直到有缓冲区可用。缓冲区在 SurfaceFlinger 释放它们时可用,在为显示器获取一个新的之后,缓冲区就可以使用。由于这发生在 VSYNC 时,你的绘制循环时序将与刷新频率匹配。大多是。

这种方法有两个问题。首先,应用程序被绑定到了 SurfaceFlinger 活动,根据需要做多少工作以及是否与其他进程竞争 CPU 时间,将需要花费不同的时间。由于你的游戏状态根据缓冲区交换的时间推进,你的动画将不会以固定频率更新。当以 60fps 运行时,随着时间的推移,平均值不一致,尽管你可能不会注意到颠簸。

其次,第一对缓冲区交换将发生的非常快,由于 BufferQueue 还没有满。帧之间计算的时间将接近于零,因此游戏将产生一些什么也没发生的帧。在一个像 Breakout 这样的游戏中,其在每一次刷新时更新屏幕,除了游戏首次启动(或取消暂停)时队列总是满的,所以效果不明显。偶尔暂停动画,然后返回尽可能快的模式的游戏可能会看到奇怪的打嗝。

Choreographer

Choreographer 允许你设置一个在下次 VSYNC 时被调用的回调。实际的 VSYNC 时间作为一个参数传入。因此即使你的应用没有立即唤醒,对于显示器何时开始刷新你依然有一个精确的图景。使用这个值,而不是当前时间,将为你的游戏状态更新逻辑产生一个一致的时间源。

不幸的是,在每个 VSYNC 之后你得到回调的事实并不能保证你的回调将及时执行,或者你将能够迅速地执行回调。你的应用程序将需要检测它落后的情况,并手动丢弃帧。

Grafika 中的 “Record GL app” activity 提供了一个这种方法的例子。在一些设备上 (比如 Nexus 4 和 Nexus 5),如果你只是坐着观看,activity 将开始下丢帧。GL 渲染是微不足道的,但偶尔地 View 元素会被重绘,如果设备已经掉入了节电模式的话测量/布局过程可能消耗非常长的时间。(根据systrace,在Android 4.4上的时钟缓慢之后,需要28ms而不是6ms。如果在屏幕上拖动你的手指,它认为你正在与 activity 交互,因此时钟速度将保持高速,且你将从不会丢弃帧。)

简单的修复办法是在 Choreographer 回调中,如果当前时间晚于
VSYNC 之后 N 毫秒就丢弃帧。理想的 N 值根据之前观察到的 VSYNC 间隔决定。比如,如果刷新周期是 16.7ms (60fps),你可以在你运行多于 15 ms 之后丢弃帧。

如果你观看 “Record GL app 运行,你将看到丢弃的帧的计数增加,甚至能够在丢弃帧时在边缘看到红色的闪光。除非你的视力非常好,尽管,你将看不到动画波动。在 60fps 的情况下,只要动画以恒定的速度继续前进,应用程序可以丢弃偶尔的帧,而没有任何人能注意到。你能逃脱多少次取决于你在绘制什么,显示器的特性,以及使用该应用程序的人员是否在检测闪避。

线程管理

一般来说,如果你正在向 SurfaceView,GLSurfaceView,或 TextureView 渲染,你想要在一个专门的线程中执行该渲染。不要在 UI 线程中做任何 “重活” 或任何需要不确定时间的事情。

Breakout 和 “Record GL app” 使用专门的渲染线程, 且它们还在该线程中更新动画状态。只要游戏状态能够快速更新这就是合理的方法。

其它的游戏将游戏逻辑和渲染完全分开。如果你有一个简单的游戏,它什么也不做,只是每 100ms 移动一个块,你可以让专门的线程只做这些:

1
2
3
4
5
6
run() {
Thread.sleep(100);
synchronized (mLock) {
moveBlock();
}
}

(您可能希望使睡眠时间是基于一个固定的时钟的偏移计算的,以防止漂移 - sleep() 不是完美的一致的,moveBlock() 接收非零的时间值 - 但你可以根据你的想法来。)

当绘制代码唤醒时,它只是获得锁,获得时钟的当前位置,释放锁,并绘制。而不是基于帧间增量时间进行分数移动,你只需要一个线程来移动事物,而另一个线程可以在绘图开始时随时绘制事物。

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

打赏

原文

坚持原创技术分享,您的支持将鼓励我继续创作!