ゲームループ内のレンダリングについて

非常に一般的なゲームループの実装方法は次のようになります。

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」のバックプレッシャーを利用する
  • Choreographer(API 16 以降)を使用する

Android Frame Pacing ライブラリ

このライブラリの使用については、フレーム ペーシングを適切に行うをご覧ください。

キューへの詰め込み

これは実装が非常に簡単です。バッファをできるだけ速く入れ替えるだけです。Android の初期バージョンでは、実際にこれを行うと SurfaceView#lockCanvas() で 100 ミリ秒の間スリープ状態になる可能性がありました。現在は、BufferQueue に基づいてペース調整され、BufferQueue は SurfaceFlinger が可能な速さで空になります。

このアプローチの一例は、Android Breakout で確認できます。GLSurfaceView を使用して、アプリケーションの onDrawFrame() コールバックを呼び出すループで実行し、バッファを交換します。BufferQueue がいっぱいの場合、eglSwapBuffers() の呼び出しは、バッファが使用可能になるまで待機します。バッファは、SurfaceFlinger によって(表示用の新しいバッファが取得された後に)解放されると使用可能になります。これは VSYNC で発生するため、描画ループのタイミングはほとんどの場合でリフレッシュ レートと一致します。

このアプローチにはいくつかの問題があります。まず、アプリは SurfaceFlinger のアクティビティに関連付けられており、その時間は処理量や他のプロセスとの CPU 時間の競合状況に応じて異なります。ゲームの状態は、バッファ交換の時間に応じて変化するため、アニメーションは一定のレートで更新されません。60 fps で実行した場合、時間の経過に伴って不一致が平均化されますが、バンプには気づかない可能性があります。

第 2 に、バッファ交換の最初の 2 つは、BufferQueue がまだいっぱいになっていないため、すぐに実行されます。フレーム間の計算時間はゼロに近くなり、ゲームは何も起こらないフレームをいくつか生成します。更新のたびに画面が更新される Breakout のようなゲームでは、ゲームが最初に開始されたとき(または一時停止を解除したとき)以外、キューは常にいっぱいであるため、その効果は顕著ではありません。アニメーションをときどき一時停止して「できるだけ速く」モードに戻すゲームでは、たまに問題が発生する可能性があります。

Choreographer

Choreographer では、次の VSYNC で配信するコールバックを設定できます。実際の VSYNC 時間は引数として渡されます。アプリがすぐに起動しなくても、ディスプレイの更新間隔が始まった時点を正確に把握できます。現在の時刻ではなく、この値を使用することで、ゲーム ステータスの更新ロジックの一貫したタイムソースが生成されます。

ただし、VSYNC の後に毎回コールバックを取得しても、タイムリーにコールバックが実行されるとも、十分迅速に対応できるようになるとも限りません。遅れが生じている状況をアプリで検出し、手動でフレームをドロップする必要があります。

Grafika の「Record GL app」アクティビティでこの例を示します。一部のデバイス(Nexus 4 や Nexus 5 など)では、ただ見るだけでフレーム落ちが発生します。GL レンダリングは簡単ですが、View 要素が再描画されることがあり、デバイスを省電力モードに設定しても測定パスやレイアウトパスが非常に長くなることがあります(systrace によると、Android 4.4 では時計が遅くなってから 6 ミリ秒ではなく 28 ミリ秒かかります。画面上でドラッグすると、アクティビティが操作されていると認識されて、時計の速度は速いままとなり、フレーム落ちしなくなります)。

従来の簡単な修正は、現在の時刻が VSYNC 時間より N ミリ秒以上後の場合に、Choreographer のコールバックにフレームをドロップすることでした。N の値は、以前に観測された VSYNC の間隔に基づいて決定されるのが理想的です。たとえば、更新の間隔が 16.7 ミリ秒(60 fps)の場合、15 ミリ秒以上遅れて実行しているときにフレームをドロップすることもできます。

「Record GL app」が実行されると、ドロップしたフレームのカウンタが増え、フレームがドロップするときに枠線が赤く点滅します。ただし、視力がよほど良くない限り、アニメーションの途切れは見えません。60 fps では、アニメーションが一定のレートで進み続ける限り、アプリは誰にも気づかれることなく不定期にフレームをドロップできます。どの程度で済むかは、描画する内容、ディスプレイの特性、アプリを使用したユーザーがどの程度ジャンクを検出できるかによって多少異なります。

スレッドの管理

一般的に、SurfaceView、GLSurfaceView、TextureView にレンダリングする場合は、レンダリングを専用のスレッドで行います。UI スレッドで「手間のかかる操作」やかかる時間が確定しない操作は絶対に行わないでください。代わりに、ゲームスレッドとレンダリング スレッドの 2 つのスレッドを作成します。詳細については、ゲームのパフォーマンスを改善するをご覧ください。

Breakout と「Record GL app」は専用のレンダラ スレッドを使用し、そのスレッドのアニメーションの状態も更新します。ゲームの状態をすばやく更新できる場合、これは妥当な方法です。

他のゲームでは、ゲームロジックとレンダリングが完全に分離されます。100 ミリ秒ごとにブロックを移動するだけのシンプルなゲームを作成した場合は、それを行うだけの専用スレッドを作成できます。

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

(ブレを防ぐために固定クロックに基づいてスリープタイムを設定することをおすすめします。sleep() は完全に一貫性があるわけではなく、moveBlock() はゼロ以外の時間を取得します。)

描画コードが起動すると、ロックを取得してブロックの現在の位置を取得し、そのロックを解除して描画します。フレーム間の差分処理時間に基づいて部分移動を行うのではなく、沿って移動する 1 つのスレッドと、描画が開始するとその場所にかかわらず描画するもう 1 つのスレッドを作成します。

複雑なシーンの場合は、起動時間で並び替えられた今後のイベントのリストを作成し、次のイベントまでスリープさせますが、考え方は同じです。