またしても運良くAndroidタブレットを借りることができたので、この間のOpenGLのテストアプリをAndroidに移植してみた。
・Android用実行ファイル
AndroidTestP1.apk
・ソースコード
AndroidTestP1src.tar.gz
筆者はAndroidに疎く、見よう見まねで何とか動かしたというだけのものである。
以下、今回苦労した点を記録する。
・ADT付属のemulatorではOpenGLの動作が完全には再現されない
最も時間を取られたのは、ポリゴンを重ねて描画した時のシミュレーターの動作だった。前回のエントリーに書いた影行列を設定してオブジェクトを描画すると、上の画像でもそうなのだが、影が所々にしか塗られなかったり、汚いまだら模様になったりする。
色々試した結果、DEPTH_TESTが有効で、2つのポリゴンの面が重なるように近くにあると正しく動かないようであることがわかった。基本的にはdepth値の精度に起因する誤差による不具合だと思うが、表面と裏面を重ねてCULL_FACEを有効にしても表面が裏面に隠されて何も描画されない部分が出てくるし、色もおかしくなるなることがある((R,G,B)=(1,0.5,0)のオレンジのポリゴン表裏2枚をゆっくり重ねると、だんだん黄色になることを確認した)。
ふと、実機ではどうなるのだろう、と思って実機で動かしてみると、何の問題もなく影が表示されて、力が抜けた。
Android SDKで動作がおかしかったら、まずは実機の動作を確認することだと思い知った。
・glMaterialfv()の引数はGL_FRONT_AND_BACKでないと反映されない
GL10#glMaterialfv()の引数のfaceにGL10.GL_FRONTを指定すると、ambient colorやdiffuse colorの設定が反映されなかった(シミュレーター、実機共)。まさかそれが誤りだとは思わなかったので、そのことが原因だと特定するのに時間がかかった。何なんだろうこれは。
って、OpenGL ESの仕様なんだな。glGetError()すればGL_INVALID_ENUMが返されるのですぐにわかるのだが、glGetError()の値をこまめに見る習慣が無かったのが致命的な敗因だった。
・GLSurfaceViewのデフォルト設定ではステンシルバッファが存在しない
そのこと自体は簡単に試してみればすぐわかることであり、android.opengl.GLSurfaceViewのreferenceにも
By default GLSurfaceView chooses a EGLConfig that has an RGB_888 pixel format, with at least a 16-bit depth buffer and no stencil.とあるので、setEGLConfigChooser()すればいいこともすぐにわかる。
そこで、
setEGLConfigChooser(8, 8, 8, 8, 16, 8);とすると、実機ではステンシルテストが動くようになるが、シミュレーターではステンシルバッファがサポートされていないので、
Unfortunately, AndroidTestP1 has stopped.と出てアプリがクラッシュする。シミュレーターでは、ステンシルバッファ付きのEGLConfigが得られないことが原因である。その場合はステンシルバッファ無しのEGLConfigで継続実行するようにしようと思ったが、setEGLConfigChooserの呼び出しをtry/catchで括ってみても、ここですぐにeglChooseConfig()によるEGLConfigの検索が行われる訳ではないので、ここでは例外が発生しない。setEGLConfigChooser()はsetRenderer()より前に行わないといけないとなっているので、実際に例外が発生してから引数を変えてsetEGLConfigChooser()をやり直す訳にもいかない。
実機で動作すればそれで良い、という人も居るかも知れないが、シミュレーターで全く動作確認できなくなるのは不便である。何より、筆者は普段Androidの実機を所有しておらず、基本的にシミュレーターしか使わないのである。
すると、EGL10に依存するGLSurfaceView.EGLConfigChooserインターフェースを、ステンシルバッファを有効にするためだけに実装しないといけないことになる。
…という結論に達するまでに、結構時間が掛かった。
GLUTなら前回のエントリーに書いたようにglutInitDisplayMode()の引数にGLUT_STENCILを加えれば済む所、GLSurfaceViewだとEGLを呼び出すコードを自分で用意しないといけないとは、面倒である。自由度が高いことに、実用的なメリットはあると思うが、すっきりしない。
・GL10クラスにglGetMaterialfvメソッドが無かった
COLOR_MATERIALを有効にするとambientやdiffuseが変化してしまうので、後で戻す為に現在のambientやdiffuseの値を取得しようとしたら、glGetMaterialfvメソッドが無かった。GL11クラスにはあったが、GL10のインスタンスからGL11のインスタンスを取得する方法が見つからず、ネットで検索するまで、単に(GL11)でキャストすれば良いとはわからなかった。
Javaでは、AWTのGraphicsクラスのインスタンスを(Graphics2D)でキャストしてGraphics2Dのインスタンスを得るようなことがよくあるが、クラスの継承関係からするとGraphics2DがGraphicsのサブクラス、GL10とGL11についてはGL11 implements GL10であり、いずれもサブクラスへのダウンキャストなのだが、そうするのが正しいことは何を見ればわかるのだろうか?(一般にスーパークラスへのキャストは可能だがサブクラスへのキャストは可能とは限らない)
・例外発生時の解析手段がわからない
試行錯誤中に誤ってnull参照するコードを埋め込んでしまった時、デバッガで例外をキャッチして停止させても、どこでnullアクセスしたのかさっぱりわからず、原因コードを特定するのにすごく時間を取られた。EclipseのDebugモードでは、
(Suspended (exception NullPointerException))
や
(Suspended (exception RuntimeException))
と出るだけである。画面上で"Unfortunately, AndroidTestP1 has stopped."と出るだけより1つは情報が加わるが、それでピンと来ることは困難であり、結局、Log.dやSystem.out.printlnやtry/catchを使いまくって原因箇所を見つけるのでは、何の為にデバッガを使ってるのかわからない。
まさかAndroid SDKはそういうものなのだとは思わないが、printfデバッグでもすぐ見つかるだろうと思っていたので、Android SDKでのまともな解析手段を探す気力は起きなかった。しかし、結局数時間調べる羽目になって、後悔すると共に、二度とAndroidでOpenGLしたくないと、二度くらいは思ったと思う。
・視点を動かしたので、トラックボール風の回転動作を変更する必要があった
今回、ついカメラ位置を自動的にオブジェクト中心に回転するようにしてしまったが、そのせいで、前回Android用に作成した、タッチドラッグ(スライド?)による回転動作がそのままでは使えなくなり、それを視点の回転に対応させる方法にかなり悩んでしまった。
画面上のドラッグ操作をトラックボールの回転操作のように扱うのは、下の図の手前の矩形のように、画面が球面上の(x,y,z)=(0,0,1)の位置(手前)に、画面の上方向がY軸が上になるように接しており、画面上のドラッグはその球面上をなぞっているものとして、ドラッグ開始位置と終了位置からトラックボールの回転軸と回転角を計算することによって実現していた。
これは視点がZ軸上にある前提によって成り立っているものであり、視点が変わると、上の図の左上の矩形のように、画面がそちらに張り付いているものとして計算しないといけない。
最初は、一方の画面の法線をもう一方の画面の法線に移す回転軸と回転角度(クォータニオン)を求めて、ドラッグの開始終了位置から求めた回転軸と回転角度にそれを掛け合わせるか何かして回転を合成すれば良いのではないかと思ったが、どうしても2つの画面の縦軸同士、横軸同士を対応付ける方法がわからなかった。
ドラッグの回転軸を移すだけでは、画面右方向へのドラッグが画面右方向へのドラッグとして保たれるとは限らないので、少なくとも、画面間の法線を移す回転軸と回転角度の他に、法線を移した後に、画面の上が画面の上に対応するように画面上で回転させる角度が必要なのである。
この文章を書いている途中に、またちょっと考えればできそうな気がしてきたが、ドラッグ回転軸を求めてからの変換はクォータニオンベースでできないと意味が無いこともあるので、またドツボにはまる気がする。もしもう一度頭の中がクォータニオンまみれになることあったりしたらついでに考えてみることにする。
今回は結局、ドラッグの開始位置と終了位置をもう一方の画面に移動してから、ドラッグの回転軸を求めるようにした。上を+Y方向とする画面上の点(x,y,1)を、上をup=(upx,upyupz)、手前をeye=(eyex,eyey,eyez)とするもう一方の画面上の(x',y',z')に移動させる変換行列Mは、(1,0,0)がupとeyeの外積、(0,1,0)がup、(0,0,1)がeyeに変換されることから、
だとわかる。行列の積やsin()/cos()も無いので計算量が少ないし、シンプルでわかり易いので、実用的にはこの方法が最適なのかも知れない。
・意外と遅くなった
前回パソコンで描画したのと同じポリゴン数では、極端に遅くなった。PCでは15fpsで動かしてもCPU負荷は3%程度なのだが、Androidタブレットでは3fpsくらいしか出なかった。今回使用したAndroidタブレットのCPUやGPUは不明だが、ベンチマークテストではNexus OneよりCPU性能も3D性能も遥かに上回る機種である。Androidタブレットは、画面の見た目がパソコンのようでも、やっぱりパソコンとは処理速度が2桁くらい違うのだろうか。
影を付けるのをやめると速くなるので、ライティングによって影に着色してるのが重いのかと思ったが、ライティングを無効にして影描画してもあまり変わらなかった。
カリングを有効にしたり、depth testを有効にしたり無効にしたりしても体感的には変わらなかった。つまり、GPUによるポリゴンの着色処理に時間が掛かっているのではなく、depth testや表裏判定に至る前の処理に時間が掛かっているのだと思う。
ポリゴン数を増減すると見た目にはっきりと速くなったり遅くなったりするので、ポリゴン数に依存する処理に時間が掛かっていることはわかった。従って、例えばシーングラフを作っている為に、1回の描画で何度もDrawElementsしていることは問題ではなさそうである。
OpenGL ES 1.1ではvertex arrayが使えないので、glVertexPointerやglNormalPointerを使って頻繁にvertex bufferを切り替えているが、このやり方がまずくて不必要に遅くなっている可能性が高い。現在のコードはGL10クラスを使ったサンプルコードを流用しているが、GL10クラスにはBindBufferもBufferDataも無い(GL11やGLES11にはある)ので、vertex bufferのデータがGPUに転送されておらず、描画する度に全頂点のデータがGPUに転送されている気がするのである。
なお、setRenderer()後にステンシルバッファが使用可能か、つまりフレームバッファにstencil bitがあるかどうかを知るには、
GL10#glGetIntegerv(GL10.GL_STENCIL_BITS, ...)を呼び出すと良いようだ。
このプログラムではポリゴンをプログラムで動的に生成しているが、その威力に任せて、PCで表示していたポリゴン数は8万を超えていた。この簡単な形状のモデルに対しては多すぎるのは間違い無いので、今回のプログラムに関しては、速くしたければGL11#glBufferDataを使う云々よりも、ポリゴン数を減らすのが最も正しいアプローチなのかも知れない。
この記事を書くにあたって、一番時間が掛かったのは、上のトラックボール動作の作図であった。Diaで描こうと思ったら図形の回転ができないし、Inkscapeで描こうと思ったら楕円の半円の描き方がわからないし、SVGのタグで図形を3D回転させようと思ったらtransformation matrixの3行目を操作する手段が無い。こういう図を描くのに適したツールは無いものだろうか。
(9/25追記)
BindBufferとBufferDataを使うようにしたら、楽々15fps出るようになったので、上記リンクのAPKファイルとソースコードをそれに差し替えた。
(10/2追記)
GLSurfaceView.Renderer#onDrawFrame()以下(GLスレッド)の例外でも、LogCatにスタックトレースが出力されて、どこでnullアクセスしたかが一発でわかるし、Eclipse上でダブルクリックすると各メソッドの呼び出し行にジャンプできることを、今日知った。先に知っとけば数時間は無駄にせずに済んだ。というか、ずっとLogCatを見てたのに、なぜ気付かなかったんだろう。
それから、Thread#setDefaultUncaughtExceptionHandler()を使うと、GLスレッドの例外もcatchでき、スタックトレースも取れることがわかった。これによって、アプリがリセットする前に処理を挟むことができるし、デバッガを使っていればLogCatを探さなくてもスタックトレースを見ることができるので、少しは有用かも知れない。
例えば、Activity#onCreate()に、次のように書く。
Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler() { @Override public void uncaughtException(Thread arg0, Throwable e) { //Log.e("TestP1", e.toString(), e); //以下5行の省略形 Log.e("TestP1", e.toString()); StackTraceElement[] elements = e.getStackTrace(); for(StackTraceElement element: elements){ Log.e("TestP1", element.toString()); } finish(); //或いはここからActivity再起動 } });
コメント