ゲームループ

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

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

これにはいくつかの問題があり、最も根本的なのはゲームで「フレーム」を定義できるということです。ディスプレイはそれぞれ異なるレートで更新され、そのレートは時間とともに変化する可能性があります。ディスプレイが表示するよりも速くフレームを生成するには、いずれかのフレームをときどき削除する必要があります。生成が遅すぎると、SurfaceFlinger は定期的に新しいバッファを見つけて取得することができず、前のフレームを再表示します。いずれの場合も、グリッチが発生することがあります。

必要なのは、ディスプレイのフレームレートに合わせて、前のフレームからの経過時間に応じてゲームの状態を進めることです。これには 2 つの方法があります。1 つ目は BufferQueue をいっぱいにして、「swap buffers」のバックプレッシャーに依存します。2 つ目は、Choreographer(API 16 以降)を使用します。

Queue の詰め込み

これは実装が非常に簡単です。バッファをできるだけ速く入れ替えるだけです。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 スレッドで「手間のかかる操作」やかかる時間が確定しない操作は絶対に行わないでください。

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

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

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

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

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

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