Android图形系统架构(游戏的循环)

译自:https://source.android.com/devices/graphics/arch-gameloops

游戏的循环

有一种非常受欢迎的方法来实现游戏的循环,看起来像这样:

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

有几个问题,这个想法最基本的理念是,这游戏可以定义什么是一个”帧”。不同的显示器将会用不同的刷新频率,并且频率可能会随着时间的推移而发生变化。如果帧生成的速度比显示器可以显示他们的速度更快,你将时不时地会丢帧。如果生成速度太慢,SurfaceFlinger 会周期性地无法acquire一个新的缓冲区,只好重新显示前一帧。两种情况下都可能会导致显示的不稳定。

你需要做的是匹配显示器的帧率,并根据前一个帧到现在经过了多少时间来推进游戏状态。有两种方法能达到这个目的:(1)喂饱 BufferQueue,并依靠”swap buffer”回压;(2)使用 Choreographer (API 16+)。

喂饱队列

喂饱队列这个很容易实现:尽可能快地swap buffers。在 Android 的早期版本,这实际上可能导致的不良后果是 SurfaceView#lockCanvas()会让你睡 100ms。现在它的进度是由 BufferQueue 控制的,BufferQueue 将被 SurfaceFlinger 尽快地清空。

此方法的一个示例是 Android Breakout。它使用 GLSurfaceView,运行在循环中,该循环调用应用程序的 onDrawFrame() 回调,然后swap buffers。如果 BufferQueue 队列是满的,eglSwapBuffers()调用将等待,直到有缓冲区可用。当 SurfaceFlinger 释放他们,缓冲区变得可用。释放是在 SurfaceFlinger acquire 了一个新的用于显示的缓冲区之后。因为这发生在 VSYNC,大部分的情况下,你的绘画循环时序将匹配刷新率。

这种方法有几个问题。第一,该应用程序被绑在 SurfaceFlinger 活动,该活动需要多少时间取决于有多少工作要做,和它是否需要与其他进程抢 CPU 时间。因为你的游戏状态进展是基于swap buffer之间的时间,你的动画不会以恒定的速度更新。不过当运行在60fps时,即使有与超过平均时间不一致的地方,你也大概不会注意到有颠簸。

第二,第一对的缓冲区互换会发生得很快,因为 BufferQueue 还没有队列满。帧之间的计算时间将接近于零,所以这个游戏将会产生里面什么都没有的几帧。像 Android Breakout 的游戏,每刷新一次都更新屏幕,所以队列一直是全满的,但在游戏刚开始 (或从暂停变开始)时不是这样的,不过效果并不能被注意到。游戏如果偶尔会暂停动画,然后返回到尽可能快的模式,可能会看到奇怪的打嗝现象。

Choreographer

Choregrapher 允许您设置下一个 VSYNC 去触发回调。真正的 VSYNC 时间作为参数传入回调。所以即使你的应用程序不会马上醒来,你仍然有准确的显示刷新周期开始的时间。使用此值,而不是当前时间,为你的游戏状态更新逻辑提供一致的时间源。

不幸的是,实际上,VSYNC 触发的回调,并不能保证及时地执行,否则就足够流畅了。您的应用程序需要检测在游戏落后的情况下,人为地丢掉一些帧。

Grafika 中的 “Record GL app” activity 提供了一个这样的例子。在一些设备上(如 Nexus 4 和 Nexus 5),如果你只是坐着等看,该 activity 将开始丢帧。GL 的渲染是微不足道的,但偶尔的 View 元素需要重新绘制,那么如果设备处于省电模式,度量/布局的过程会需要很长的时间。(据 systrace,在 Android 4.4上如果使用慢的时钟频率,该过程花了 28ms 而不是 6 毫秒。如果你的手指在屏幕上拖动,手机 会认为你在与 activity 交互,所以时钟频率维持在高水平,这种情况你永远不会丢帧)。

简单的解决办法是如果当前时间已经比 VSYNC 时间落后 N 毫秒了,在 Choreographer 回调中丢掉一帧。理想情况下 N 值的确定基于先前所观察到的 VSYNC 间隔。例如,如果刷新周期是 16.7ms (60 fps),而你的运行已经在15ms之后了,那么就丢掉一帧。

如果你看看”Record GL app”的运行,您将会看到丢帧计数器的增加,丢帧时甚至能看到红色边框的闪动。不过,除非你的眼睛很好,否则丢帧的时候。你不会看到结结巴巴的动画。60 fps的速率,偶尔的丢帧没人会注意到,只要动画继续以恒定的速率推进。你能侥幸的程度取决于你正在绘制的内容、显示器的特性,和使用该应用程序的人检测到丢帧的水平。

线程管理

一般来说,如果你渲染到 SurfaceView、 GLSurfaceView 或 TextureView,你会想到在一个专用线程中渲染。在 UI 线程上永远不要做任何”繁重”的或耗时不定的东西。

”Breakout“ 和 “Record GL app” 使用专用的渲染线程,同时也在该线程上更新动画状态。这是一个合理的方法,只要可以快速更新游戏的状态。

其他游戏把游戏逻辑和渲染完全地分开。比如你有一个简单的游戏,每 100 毫秒移动一下块,那么可以有一个专用的线程,仅需这样做:

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

(也许你要把睡眠时间基于固定的时钟来防止时间漂移 —— sleep () 并不完全一致,并且 moveBlock() 也要花费一些时间 —— 但你已经了解了这个想法。)

绘制代码当醒来时,它只是抓住锁、获取当前块的位置、释放锁、和绘制。不是基于帧间时间差做部分移动,你只需要有一个线程移动东西,另一个线程绘制东西,无论这些东西在绘制启动时身处何地。

对于任意复杂的场景,要创建按基于即将到来事件的唤醒时间排序的列表,睡眠直到下一个事件到点,想法是同样的。

avatar

咕嘟代码

细细品味,代码本来有滋有味。