Android の OpenGL ES と SurfaceView / GLSurfaceView

今さらながら Android の OpenGL ES に初めて手を付けてみた。これまでは ImageView の onDraw のカスタムか、ゲーム的なものでも、2D で SurfaceView の画面を丸ごと FrameBuffer として更新するような使い方しかしてこなかった。ところが今回、ちょっと凝った 2D のマップアプリのようなものを作ることになり、先述の通り従来は ImageView の onDraw カスタムで対処してきたのだが、できれば、SurfaceView か、さらには OpenGL ES 化して、Google Map のようなヌルヌルしたものにできないかと思い、3D 用だと思って敬遠してきた OpenGL ES の領域にまで足を踏み入れてみることにした。


SurfaceView

以前、使ったことがあると言っても、ちょっとした 2D ゲームのサンプルで使った程度であり、非ゲームのアプリに利用するというような場合も含めて SurfaceView 自体を研究したことはなかったので、まずは SurfaceView をいじってみることにした。

レンダリング用スレッドを使用する

Activity 側で SurfaceHolder.Callback を implement するなりして、レンダリング用スレッドの生成や終了処理を行う。レンダリング用スレッド中では、SurfaceHolder を通じて Surface への(Canvas の描画命令を使った)描画を行う。

Canvas → SurfaceHolder → Surface という 3 重のバインド構造になっているの(参考)で初見では戸惑うかもしれないが、やっていることは Surface(framebuffer のようなもの)へのデータの書き込みである。


val canvas: Canvas = holder.lockCanvas()

// Canvas#drawBitmap 等を使った描画処理

holder.unlockCanvasAndPost(canvas)

レンダリング用スレッドを使わないで、そのままメイン UI スレッドで同様のことを行うこともできるが、圧倒的に処理が重くなる。SurfaceView を使う意味がない。

SurfaceView を使うことで、lockCanvas ~ unlockCanvasAndPost の間だけに限定して同期のために描画スレッドをロックするので、それ以外の時はレンダリング用スレッドがメイン UI スレッドをブロックしなくなる。それでスムーズな処理が可能となるわけである。

スレッドについての深掘り

ここでややスレッドについて深掘りしてみると、上掲のコードスニペットの lockCanvas ~ unlockCanvasAndPost はレンダリング用スレッドの中の無限 While ループの中で繰り返し実行される使い方が想定されている。同様のことをメイン UI スレッドの中で行うと、この無限ループにアプリプロセスの全力が使われてしまい、ダイレクトにアプリの処理を重くするのである。SurfaceView を使わない、通常の View の onDraw を使う場合も同じだ。そもそも通常、View#onDraw は無限ループで繰り返し処理するようなことを想定しておらず、イベントドリヴンで受動的に処理するような使い方が想定されている。なので、ビデオゲームのように、無限ループが自ら能動的にブンブン回っていて、60fps で継続的に描画が更新され続けるような使い方を想定するのであれば、最低でも SurfaceView による別スレッドとロック機構の使用、理想的には後述する GLSurfaceView を使うことになる。

このレンダリング用スレッドの実装の仕方だが、libGDX の作者 Mario Zechner 氏の著書“Beginning Android Games”のサンプル(AndroidFastRenderView.java)と、Google の Grafika(HardwareScalerActivity.java)とで違いがあるのに気付いた。

AndroidFastRenderView.javaHardwareScalerActivity.java
スレッドの維持方法無限 While ループLooper
スレッドとの連絡volatile なフラグ変数Handler
スレッドの開始トリガーActivity#onResumeSurfaceHolder.Callback#surfaceCreated
スレッドの終了トリガーActivity#onPauseSurfaceHolder.Callback#surfaceDestroyed
Choreographer使用せず(自律無限ループ)使用(他律反復処理)

前者は、シンプルに volatile なフラグ変数 running を使っているのみで、特別なスレッド間通信は行っておらず、Thread#start で開始し、その volatile フラグ変数が有効な限り無限ループが回り続け、Thread#join で残った処理が終わるのを待っている。つまり、通常の Java の機能のみを用いて簡潔に実装されている。また、SurfaceHolder.Callback は利用していない(Handler や Choreographer を利用しないにせよ、これは前者でも利用した方がさらに可読性が上がると思うが……)。

後者がある意味、曲者で、Android のビルトインなスレッド間通信機構である Handler を使った、Android プログラムとしては高度な完成度の高い実装となっている。それ故、サンプルプログラムとしては却って不親切極まりない、学習者が題材として学習し辛い代物となっている。

グラフィックスの話から外れて、純粋な Android と Java のスレッド制御の話になってしまうが、先程解説した AndroidFastRenderView.java の無限 While ループに相当するものを Android のビルトインな機構として用意したのが言ってみれば Looper である。通常の無限ループでは、ひたすら全力で自律的に処理を繰り返すので、無駄が多いし、かといってループさせなかったとすると、一周分の処理でスレッドが終了してしまう(改めて何らかの形で他律的にスレッドの開始をトリガーしなればならない)。Looper は無限ループの場合のようにスレッドを常駐させてくれながら、処理はキュー機構によって他律的な要求があったものを順次処理してくれる。(参考

その Looper を備えたスレッドに(メイン UI 等の他のスレッドから)メッセージや Runnable オブジェクトを送ってくれるのが、Handler である。レンダリング用スレッドと(メイン UI 等の)他のスレッド間で、高度なメッセージのやり取りをする必要がないのであれば、Looper や Handler を定義する必要はなく、AndroidFastRenderView.java のような volatile なフラグ変数を通じた while ループの停止程度で十分だろう。レンダリング用スレッドにメイン UI 側から適宜何らかのデータを送りたいとか、ある程度以上、高度なやり取りをしたいのであれば、HardwareScalerActivity.java のように、がっつりと カスタム Handler を定義する必要も生じるかもしれない。

カスタム Handler を用意する

Google のサンプル(Grafika)では、レンダリング用スレッドにカスタムメッセージを渡せるようにするために、カスタム Handler を用意して、カスタムメッセージを定義している。

Choreographer.FrameCallback を使用する

さらに、SurfaceView をより最適化して使う方法として、Choreographer.FrameCallback を Activity に implement して使うテクニックがある。自律的に爆走する無限 While ループを使うのではなく、v-sync のタイミングでコールバックが発動されるので都度、メイン UI スレッド側から、Handler でメッセージをレンダリング用スレッド(の Looper)に送って他律的にレンダリング処理を走らせる。また、このコールバックは、次回の v-sync のタイミングをナノ秒単位で通知するので、それを利用して、モデル更新に時間がかかり過ぎている場合は、今回のレンダリングをスキップしてフレームを落とすようにも工夫できる。

Grafika

以上のノウハウの Google 公式サンプルが Grafika の HardwareScaleActivity.java。ただし、このサンプルでは、SurfaceView を使っているものの、レンダリングは OpenGL ES を使っていて、Canvas は使っていない。自分で、OpenGL ES 関連の描画コードを全て除去して、上の Canvas による FrameBuffer 描画で動くようにする必要があった。


GLSurfaceView

本来、OpenGL ES 自体の話が主で、GLSurfaceView はその一環となるものだが、OpenGL ES の話題はボリュームが大きくなるので、SurfaceView との比較から結論を先出しして要点を述べておく。

GLSurfaceView は SurfaceView の派生クラスである

GLSurfaceView はあくまでも SurfaceView の一種に過ぎないというのがポイントで、リファレンスを見ても明らかであり、また具体的にはソースコードを見ればさらに詳細な拡張内容を把握することができる。GLSurfaceView を解析するにあたって、先に SurfaceView の使い方を理解しておく必要があるのは、このためである。


public class GLSurfaceView extends SurfaceView implements SurfaceHolder.Callback2 {}

GLSurfaceView ≒ SurfaceView (+ Handler/Choreographer) + EGL

SurfaceView.CallbackGLSurfaceView.Renderer
スレッド開始#surfaceCreated#onSurfaceCreated
サイズ変更#surfaceChanged#onSurfaceChanged
スレッド終了#surfaceDestroyedなし
ループ処理部分レンダースレッド内で無限 While ループを回す
または Handler 経由で
Choreographer.FrameCallback#doFrame から
レンダースレッドにキューを継続的に送る
#onDrawFrame
v-syncChoreographer.FrameCallbackEGL

Grafika では、SurfaceView をベースにしてカスタム Handler を実装したりして、OpenGL ES でレンダー用スレッドからレンダリングを行なっていた。GLSurfaceView ではそのようなスレッド関連の処理を組み込んであり、また OpenGL ES を利用する際に必要となる、EGL 関連の手続も組み込まれている。自分は、これらは実際に SurfaceView で組まれている Grafika のサンプルコード(HardwareScaleActivity.java)を GLSurfaceView 化して確認したが、公式ドキュメントにも以下のように説明されている。

The GLSurfaceView class provides helper classes for managing EGL contexts, interthread communication, and interaction with the activity lifecycle. You don't need to use a GLSurfaceView to use GLES.

For example, GLSurfaceView creates a thread for rendering and configures an EGL context there. The state is cleaned up automatically when the activity pauses. Most apps don't need to know anything about EGL to use GLES with GLSurfaceView.

SurfaceView and GLSurfaceView: GLSurfaceView

また、Choreographer.FrameCallback は、GLSurfaceView のソースコードでは確認できなかったが、実際上の挙動から、一定以上の負荷で結果的にフレームが落とされていることを確認した。つまり、


glSurfaceView.renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY

とすることで、Choreographer.FrameCallback からの都度呼び出し(mGLSurfaceView.requestRender())を行って試してみたのだが、かなり負荷をかけても 60fps で requestRender される。しかし、requestRender は結局レンダリング用スレッドへ requestQueue を投げるだけであり、requestQueue からタスクが取り出されて onDrawFrame() される段階で、フレームが間引かれて落ちているようなのである。描画の方は実際にコマ落ちしており、Choreographer.FrameCallback から呼び出される doFrame() 側ではなく、GLSurfaceView のレンダリング用スレッドの onDrawFrame() 内で fps を計測すると、60fps を下回っているのが確認できる。

つまり、GLSurfaceView では Choreographer.FrameCallback を内部的に実装しているわけではないものの、結局、Choreographer.FrameCallback を使った機能の実装を我々が心配する必要もないことになる。

☞ 追記:やはり、EGL レベルで fps が v-sync されているようだ。(参考:eglSwapInterval

GLSurfaceView のまとめ

つまり、OpenGL ES を使うにあたって、素の SurfaceView を使う場合は、スレッドの扱いや v-sync、EGL についても我々が自前で対処する必要があったが、GLSurfaceView を使えばそれらを組み込み済なので、レンダリング用スレッドにおける onSurfaceCreated() / onSurfaceChanged() / onDrawFrame() を通じた、OpenGL ES の描画命令を記述することに限定して取り組むことができる。


import android.opengl.GLSurfaceView
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10

class MainActivity : AppCompatActivity(), GLSurfaceView.Renderer {

    private lateinit var glSurfaceView: GLSurfaceView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        glSurfaceView = GLSurfaceView(this)
        glSurfaceView.setEGLContextClientVersion(2)
        glSurfaceView.setRenderer(this)

        setContentView(glSurfaceView)
    }

    override fun onResume() {
        super.onResume()
        glSurfaceView.onResume()
    }

    override fun onPause() {
        glSurfaceView.onPause()
        super.onPause()
    }
    
    override fun onSurfaceCreated(gl10: GL10, eglConfig: EGLConfig) {
        TODO("Not yet implemented")
    }

    override fun onSurfaceChanged(gl10: GL10, width: Int, height: Int) {
        TODO("Not yet implemented")
    }

    override fun onDrawFrame(gl: GL10?) {
        TODO("Not yet implemented")
    }
}

OpenGL ES

さて、比較のためにまず SurfaceView の話題から入ったが、本題は OpenGL ES、ここからである。

まず、Android プログラミングの OpenGL ES に関する情報が意外に乏しい。Web 上や書籍などを探っても、2014 年とか 2015 年頃の情報が多く、そこで停滞しているようなのだ。つまり、Android で OpenGL ES が採用されたばかりの時期には一時的に脚光を集めてはいたものの、そのまま立ち消えになっているかのようである。実際には OpenGL ES の方は以後も 1.0 → 2.0 → 3.x と Android での対応が進み、さらには Vulkan 対応も導入されている。にもかかわらず、エンドプログラマー用の情報が、ほとんど立ち消えてしまっている感がある。これは結局、OpenGL ES にしても Vulkan にしても、ゲームフレームワークやゲームエンジンの側での対応の対象となる API であって、エンドプログラマーが直接扱う対象ではないという立ち位置なのかもしれない。

またそういった時代の趨勢に加えて、元々の OpenGL の立ち位置的な問題もある。本来はドライバーで隠蔽されている低レベルのグラフィックスハードウェアへのアクセスをエンドプログラマーに解放するものだから、C/C++ で利用する前提で設計されている。決して Android の Java プログラマー用に作ったオブジェクト志向な API ではない。であるから、Android API に OpenGL ES 用の API があるといっても、基本的には OpenGL ES へのラッパー的な命令が揃えられているに過ぎない感じである。Google の態度も、とりあえずそういったラッパー的命令を一通り用意するだけで、リファレンスも特に説明がほとんどない。チュートリアル的なガイドもない。OpenGL のことは、Android とは独立して、OpenGL 自体の情報として勝手に勉強してくれという、投げ出した状態である。

既に PC で OpenGL 自体を扱った経験のあるプログラマーならば、OpenGL 自体の学習は終えているので、問題ないのだろうが、Android で新たに OpenGL を学ぼうとする人間にとっては、中々これは厳しい。Google の唯一の情報源である、サンプルコードの Grafika を参考にするより他はないが、Google のサンプル(Grafika に限らず)の常として、ただ模範解答だけが示してあって、その解答に到るための道筋についての何の解説もない。このサンプルを解読するにあたって、どこまでが Android 固有のもので、どこからが OpenGL ES 固有のものかを、解析するのはかなりの労苦である。

OpenGL の基本コンセプトについて学ぶ

そこで参考にしたのが『独学で 1 ヶ月間 OpenGL を学んで得た基礎知識のまとめ ~ 2D 編 ~』という 2014 年 12 月に書かれた記事。いきなり Android でサンプルコードをいじりながら学習しようとする限界を認識し、最低限必要な OpenGL が背景にしているコンセプトをコンパクトに 5 回分にまとめて紹介し、最後の 6 回目でやっとサンプルコードを掲載している。

自分は当初、ともかくサンプルを動かしながら進めていたが、シェーダーや、何が OpenGL ES として純粋に必要なのか等、疑問点が次々と湧いて頭が混乱してきたので、こちらの記事で一通り OpenGL のコンセプトについて頭に入れることで、やっと Grafika などのサンプルコードが「OpenGL として何をしているか」を整理して解析できるようになった。サンプルコード的には、Grafika の方がより模範的な気もするが、最後の 6 回目に掲載されたサンプルコードも、細かくパーツごとに解説が付されているので、Grafika の解析時に大いに参考になった。

テクスチャーマッピング

残念ながら、『独学で 1 ヶ月間 OpenGL を学んで得た基礎知識のまとめ ~ 2D 編 ~』では、テクスチャーマッピングが割愛されてしまっている。自分の目的的にテクスチャーマッピングは必須だったので、それについては、Grafika のサンプルコードおよび、『Learn OpenGL ES』の 「Android Lesson Four: Introducing Basic Texturing」などを参考にした。

OpenGL ES 所感

以上の結果、まあ、どこまでが OpenGL ES 固有の処理なのかという切り分けができてしまえば、自分の学習範囲が 2D 用途限定ではあるものの、学習前に抱いていたほどの難しいものではなかった気がする。自分でデバイス画面上のピクセルに対応した FrameBuffer を直接操作しようとする SurfaceView のユースケースとは違い、OpenGL ES ではポリゴンおよびそこに貼り付けるテクスチャーはグラフィックスハードウェア上の VRAM に置く。それゆえ、インデックスを通じた遠隔操作でそれらのオブジェクトを操作するような手続となる。基本的にポリゴンの移動は各頂点の移動を GPU に指示することで行う。VRAM 上の実際のデータ(FrameBuffer)を操作するのは、GPU であって、エンドプログラマーではない。それゆえの API 設計となっている。その辺りの遠隔操作的な性質が呑み込めた感じがする。

コメント

このブログの人気の投稿

Mac → Mac のメールアカウントの移行

LiveData と MutableLiveData の使い分け

WireGuard の OpenWrt での運用