進一步瞭解遊戲迴圈中的轉譯功能

以下是導入遊戲迴圈極為常見的方式:

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 中填充憑證,並仰賴「swap buffers」背壓
  • 使用 Chereographer (API 16 以上版本)

Android Frame Pacing 資料庫

請參閱達到合適的影格使用速度,以瞭解如何使用這個資料庫。

將填充排入佇列

導入的方式非常容易:只要盡快切換緩衝區即可。在舊版 Android 中,這可能確實會導致一項後果,也就是 SurfaceView#lockCanvas() 會讓系統進入休眠模式達 100 毫秒。現在由 BufferQueue 進行配速,而 BufferQueue 就像 SururFlinger 一樣能快速清空。

你可以在 Android Breakout 中查看這個方法的其中一個範例。這個資料庫使用 GLSurfaceView 執行迴圈,該迴圈會呼叫應用程式的 onDrawFrame() 回呼,然後更換緩衝區。如果 BufferQueue 已滿,eglSwapBuffers() 會等到緩衝區可供使用時才會進行呼叫。在獲取用於顯示的新緩衝區後,SurfaceFlinger 會釋出緩衝區並使其可供使用。由於這項作業會在 VSYNC 中進行,因此繪畫迴圈的時間碼將會與刷新率相符。多數情況都是如此。

這種方式存在數個問題。首先,應用程式會與 SurfaceFlinger 活動建立關聯,而這項作業所需時間將取決於工作量的多寡,以及應用程式是否正在與其他程序爭取 CPU 作業時間。由於遊戲狀態會依據緩衝區交換作業之間的時間進展,因此動畫不會以一致的頻率更新。不過如果執行速度為每秒 60 個影格,而且不一致性會隨著時間達到平衡,那麼你可能不會注意到這些差異。

再者,由於 BufferQueue 尚未用盡,因此前兩個緩衝區交換作業很快就會完成。由於影格之間的運算時間將趨近於零,因此遊戲將產生一些空白內容的影格。在 Breakout 這類每次重新整理都會更新畫面的遊戲中,除了遊戲首次啟動或結束暫停時以外,佇列會時常處於已滿的狀態,因此效果不是很顯著。這類不時會暫停動畫接著又回復到盡可能快速模式的遊戲,可能會碰到一些奇怪的小問題。

Choreographer

Choreographer 可讓你設定能在下一次 VSYNC 中觸發的回呼。實際的 VSYNC 時間會以引數的形式傳遞。因此,即使應用程式不會立即喚醒,你還是可以掌握顯示器重新整理週期開始的確切時間。使用這個值而非目前時間,即可為遊戲狀態更新邏輯來產生一致的時間來源。

不過,在每次 VSYNC 後取得回呼,並不保證系統一定會及時執行回呼,或保證你一定能夠充分且迅速地採取行動。應用程式必須偵測進度落後的情況並手動捨棄影格。

Grafika 中的「Record GL app」活動就是個例子。在某些裝置上 (例如 Nexus 4 和 Nexus 5) 上,如果只是袖手旁觀,活動將開始捨棄影格。GL 算繪是微不足道的,但在某些情況下,View 元素會重新繪製,如果裝置已進入低耗電模式,測量/版面配置可能需要很長的時間才能傳遞 (根據 systrace,時鐘在 Android 4.4 上變慢需要 28 毫秒,而非 6 毫秒。如果在螢幕上拖曳手指,系統就會認為你正在和活動互動,因此時鐘會維持在極快的速度,不會有任何影格遭到捨棄)。

如果在 VSYNC 時間後的目前時間大於 N 毫秒,簡單的解決方式就是在 Choreographer 回呼中捨棄影格。在理想情況下,N 的值是取決於先前觀察到的 VSYNC 間隔。舉例來說,假設重新整理週期為 16.7 毫秒 (每秒 60 個影格),如果執行時間超過 15 毫秒,就可能會捨棄一個影格。

如果觀看「Record GL app」執行,你會看到遭捨棄影格計數器的數值在增加,甚至會在影格遭捨棄時看見畫面邊框有紅光閃爍。除非你視力絕佳,不然還是會看見動畫閃爍。設定為每秒 60 個影格時,只要動畫持續以固定速度播放,應用程式就可以在沒有人注意到的情況下偶爾捨棄影格。可捨棄影格的數量多寡,某方面取決於所繪製的東西、顯示器特性,以及應用程式使用者可偵測到的資源浪費有多少。

執行緒管理

一般來說,如要在 SurfaceView、GLSurfaceView 或 TextureView 上進行轉譯,系統都會透過專門的執行緒進行轉譯。切勿在 UI 執行緒上進行任何「複雜的運算處理」或任何需要大量不確定時間的作業。請改為替遊戲建立兩個執行緒:遊戲執行緒和轉譯執行緒。詳情請參閱改善遊戲的效能一文。

Breakout 和「Record GL app」使用專用的算繪執行緒,並會在該執行緒上更新動畫狀態。只要遊戲狀態可以快速更新,這種做法相當合理。

其他遊戲則會將遊戲邏輯和算繪完全區隔。如果你的遊戲很簡易,只有每 100 毫秒移動一個區塊,就可以使用專門用來執行此動作的專用執行緒:

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

(你可能會想以固定時鐘做為睡眠時間的依據來避免偏移 --sleep() 並非完全一致,而且 moveBlock() 所需的時間並不一定 -- 但你可以從中略知一二)。

喚醒繪圖碼後,系統只會擷取鎖定、取得區塊的目前位置、解除鎖定,然後繪圖。你不必根據影格間的差異遷移次數進行部分移動,只需讓一個執行緒跟著移動,讓另一個執行緒在繪圖開始時在任何位置進行繪圖。

針對任何複雜的場景,你可能會想依據清醒時間和休眠狀態排序來建立即將到來的活動清單,直到下一個事件到期為止,但做法是一樣的。