読者です 読者をやめる 読者になる 読者になる

eaglesakuraの技術ブログ

twitterから派生した、技術的にちょっと込み入った話題とか。

複数スレッドでGLの処理が可能なGLSurfaceView作りました

複数スレッドでGLの処理が可能なGLSurfaceView作りました

何が出来るのか

GLSurfaceViewを継承したクラスです。動作には互換性があり、GLSurfaceViewをMultiContextGLSurfaceViewに切り替えるだけで使えます。

GLSurfaceViewとの違いは、標準で複数スレッドでのOpenGL ESコマンドの利用を可能にしている点です。GLSurfaceViewでテクスチャ等のリソースを非同期で読み込もうと思っても、最終的には GLSurfaceView#queueEvent にキューイングして、描画スレッドを止める必要があります。

GLSurfaceViewは後述のMaster & Slaveの仕組みを利用して、描画スレッドを止めることなく画像ロードを実現しています。

なぜ作ったか

今書いているOpenGL ES 2.0解説本で、付録としてGLSurfaceViewの拡張性の説明をしようと思って、その実証のため作りました。

どうやって実現しているのか

GLSurfaceViewは基本設定がアレで微妙に使いにくいです。(今は違うかもしれないけど)onPauseでGLに関するリソースをすべて解放するため、onPause/onResumeでリソースの再読み込みが必要になる場合があります。その代わり拡張性については非常に優れていて、特にConfigやContextの拡張して外部(開発者)に任せることが出来ます。

今回利用したのは、EGLContextを拡張するためのGLSurfaceView#EGLContextFactoryの仕組みです。

EGLContextFactoryとは

EGLContextFactoryはその名の通り、EGLContextの生成を行わせるクラスです。標準ではeglCreateContextを呼び出すだけですが、今回はMulti-Contextになるように仕掛けを施しています。

そもそもOpenGL ESで指定された値はどこに格納されるのか

例えばglClearColor等で指定された画面クリア用の色はどこに格納されるのでしょうか? GPU全体で1つの設定しか保持できないとしたら、複数のアプリで同時にGLを使うことが出来ませんし、Androidのシステム自体が描画できなくなってしまいます。

OpenGL ESの値が保存される領域がEGLContextです。GLのコマンドが呼び出されると、「そのThreadに紐付けられているEGLContextに対して暗黙的に」操作が行われます。この仕組のおかげで、複数箇所からGLのコマンドが呼び出されても問題なく処理が行われます。

テクスチャ等のメモリもEGLContextが包括して持っています。つまり、EGLContextはRAMとVRAMに跨る様々な情報を保持してくれています。

EGLContextは1プロセス内で(メモリが続く限り)いくつでも生成できます。また、あるEGLContextに対する操作(glClearColor等)を行ったとしても、別なEGLContextへは一切影響を及ぼしません。

EGLContextの制限

OpenGL ESの制限として、1つのThreadに対して1つのEGLContextしか指定できません。また、1つのEGLContextは1つのThreadに対してしかバインド出来ません。つまり、何処かのThreadで使われているEGLContextは別スレッドで利用することが出来ないということです。

そのため、EGLContextをアンバインドせずにThreadが終了してしまった場合、そのEGLContextは永遠に操作できないリーク状態になってしまいます。

さらにEGLContext同士のメモリは基本的に切り離されており、あるEGLContextで読み込んだテクスチャは別なEGLContextでは利用することが出来ません。

GLSurfaceViewはRendererで描画を行わせるためのEGLContextをEGLContextFactoryクラスによって生成させています。リークを避けるためかと思いますが、作成したEGLContextは外部からアクセスできず、勝手にバインド/アンバインド/解放されます。

shared_contextによる制限緩和

結論として、表題のようにOpenGL ESコマンドを複数Threadから呼ぶためにはEGLContextを複数生成し、利用したいThreadに対してバインドすればいいことになります。

但しEGLContext同士はそのままでは独立状態にあるため、仮にRendererが暗黙的に利用しているEGLContextと別なEGLContextを作っただけでは、例えばテクスチャ等の読み込んだデータを共有できません。

EGLContext同士の一部の領域(具体的にはVRAMに相当する部分)を共有するのが、shared_contextの仕組みです。

EGLContextを生成するeglCreateContextコマンドの第3引数に、shared_contextという値があります。それに適当なEGLContextを指定すると、テクスチャ等のVRAM領域を共有することが出来ます。

MultiContextGLSurfaceViewではshared_contextを設定したEGLContextを生成することで、レンダリングスレッドとワーカースレッドの並列性を保ちながらGLのコマンド呼び出しを行えるようにしています。

shared_contextが共有できるリソースは下記です。ちなみにEGL仕様ではBuffer&Textureと記述されています。

  1. テクスチャオブジェクト
  2. レンダリングバッファ(フレームバッファ
  3. VertexBuffer / IndexBuffer
  4. シェーダー&プログラムオブジェクト

Master&Slave方式

MultiContextGLSurfaceViewでは、便宜上EGLContextにMaster ContextとSlave Contextという名前をつけています。

  • Master Context
    • どのThreadにもバインドされず、shared_contextに指定するだけのEGLContext
    • Slave Contextの生成数・廃棄数を数え、Slaveの生存数が0になったら廃棄される
  • Slave Context
    • レンダリングスレッドやワーカースレッドに対してバインドされるEGLContext
    • 同一のMaster Contextから派生しているため、Slave Context同士もshared状態にある

サンプルとなるMainActivityは次のような順番でContextが生成・廃棄されます。

  1. Master Contextが生成される
  2. GLSurfaceViewがEGLContextFactoryに対してEGLContextの生成をリクエストする2.
  3. レンダリング用のSlave Contextを生成する
  4. onSurfaceCreatedが呼び出される
  5. ワーカースレッドが起動し、Slave Contextを生成する
  6. onSurfaceChangedが呼び出される
  7. onDrawFrameで描画を継続する(レンダリングスレッドは停止しない)
  8. ワーカースレッドでテクスチャの読み込みを完了する
  9. ワーカースレッド用のSlave Contextが廃棄される
  10. (アプリを終了させる)
  11. レンダリングスレッド用のSlave Contextが廃棄される
  12. Slave Context数が0になったので、Master Contextが廃棄される

その他:

  • Slave ContextをActivityが保持しておけば、onPause/onResumeによるレンダリングスレッド用のSlave Context廃棄(と、それに伴うMaster Context廃棄)を避けてリソースを保持し続けることが出来ます。サンプル用のMainActivityでonPause/onResumeでテクスチャ解放されていないのが確認できます。

ダミーサーフェイスの生成

EGLContextをバインドする=eglMakeCurrentを呼び出すためには、第2・第3引数に描画対象となるEGLSurfaceが必要です。

EGL_NO_SURFACEを指定することも出来ますが、基本的に呼び出しに失敗します。eglMakeCurrentはレンダリング対象となるEGLSurfaceの指定を義務付けています。(iOSではEGLContext相当となるEAGLContextだけでバインドができるので、その点で楽ですね)

MultiContextGLSurfaceViewでは、ワーカースレッドでeglMakeCurrentを呼び出すために、1x1ピクセルの極小のPBufferSurfaceを生成しています。PBufferSurfaceはウィンドウ(View)に紐付かないレンダリングターゲットです。

MultiContextGLSurfaceView#requestAsyncGLEventの処理

以上の情報を元にrequestAsyncGLEventの実装を見ると、なんとなくこのクラスがやっていることがわかるのでは無いでしょうか。

コールバック終了後、eglMakeCurrent直前にglFinishを呼び出しているのは、一部端末でワーカースレッドがフリーズする不具合を回避するためです。

    /**
     * GL動作を非同期で行う
     * Textureの読み込み等を想定
     * @param event
     */
    public void requestAsyncGLEvent(final Runnable event) {
        Thread thread = (new Thread() {
            @Override
            public void run() {
                // initialize EGL async device.
                final EGLDisplay display = mEGLDisplay;
                final EGLContext context = newSlaveContext();
                final EGLSurface surface = newDummySurface();

                try {
                    mEGL.eglMakeCurrent(display, surface, surface, context);

                    // call event
                    event.run();
                } finally {
                    GLES10.glFinish();
                    mEGL.eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);

                    destroySlaveContext(context);
                    destroyDummySurface(surface);
                }
            }
        });

        thread.setName("GL-Background");
        thread.start();
    }

問題点

古い端末(概ね2010年前後に発売された端末。Nexus Oneとか。)ではワーカースレッドで読み込んだリソースが正常に利用できない場合があります。原因不明なので誰か原因(もしくはEGLの使い方がおかしい?)が分かる人おしえて下しあ。

以上の説明は非常に不親切なため、書籍ではちゃんと図説付きでなるべく親切にまとめます。