Sep 22, 2013

AndroidでOpenGLしてみた(2)

またしても運良く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軸が上になるように接しており、画面上のドラッグはその球面上をなぞっているものとして、ドラッグ開始位置と終了位置からトラックボールの回転軸と回転角を計算することによって実現していた。
SVG image
これは視点が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に変換されることから、
[up×eye up 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に転送されている気がするのである。

See more ...

Posted at 22:38 in PC一般 | WriteBacks (0)
WriteBacks

Sep 02, 2013

[OpenGL] 平面への投影変換で影を付けてみる

OpenGLでオブジェクトに影を付けようとして、最初に思い付いた方法は、地面に投影する変換行列を作って、同じオブジェクトを黒で描画することだったが、それが意外と難しかった。

そこで調べてみたが、オブジェクトへの影の付け方には色々な方法があるようで、影オブジェクトを別に作成する方法を除くと、大体次の3通りが多いようだ。
・変換行列による平面への投影
・シャドウボリューム法
・シャドウマッピング法
正確に説明できる自信が無いので、説明は省く。とりあえずわかったのは、簡単に実装できる方法は無いということだった。
平面上にできる影であれば、やはり行列による投影変換で描画するのが最も簡単なので、引き続き、納得できるまで取り組むことにした。

・結果
object drawn with shadowobject drawn with shadowobject drawn with shadow
ソースコード

光源の位置をL:(Lx,Ly,Lz,1)とすると、任意の3次元座標を、法線(a,b,c)が光源側を向く平面ax+by+cz+d=0に投影する行列は、次のような行列であることは、色々な所に書かれている。
shadow matrix
一応、これを筆者なりに求めてみる。

L=(Lx Ly Lz Lw)T : 光源
P=(x y z 1)T : 物体の位置
P'=(x' y' z' 1)T : 平面 ax+by+cz+d=0 に投影されたPの位置
N=(a b c d)T : 平面のパラメーター、但しNT L > 0(法線(a,b,c)が光源側を向くことより)

とし、P' = M P を満たす行列 M を求めることを考える。
P'はLとPを含む直線上にあるので、スカラー媒介変数 t を用いて
P' = L + t(P-L) --- (1)
と表せる。P'は平面上にあるので、
NTP' = 0
成り立つ。これより、
NT P' = NT L + t NT (P-L) = 0
なので、t = - NTL / (NT(P-L)) とわかる。NTL > 0なので、
t = NT L / (NT(L-P))
としておく。このtを(1)に代入し、両辺をNT(L-P)倍すると、
NT(L-P) P' = NT(L-P)L + NTLP - NTLL
= NTL P - NTP L
= NTL P - L NTP ※(NTPはスカラーなのでこのように入替可能)
= (NTL - LNT) P
となる。ここで、P'の4つ目の要素をscale factorだとすると、P'とそれをスカラーk倍したk P'は同じ位置を表すので、P'とNT(L-P) P'も同じ位置を表している。従って、Mは NT(L-P) P' = M P を満たすMでも良いので、Iを単位行列として
M = (NTL I - LNT)、つまり
shadow matrix
と求まる。

この行列をmodel view matrixに掛け合わせて、描画した地面に重ねて影を描画すると、影にノイズが出たり、全く影が表示されなかったりすることがある。
object drawn with shadow
少しでも影を浮かせると正常に表示されるが、全く同じ位置に重ね書きしようとすると、depth値の丸め誤差の都合で、所々に地面より向こうだと判断されてしまうフラグメントが発生するのが原因である。(Zテストで落とされるので、ブレンディング描画にしても解決しない。)
地面と全てのオブジェクトの影を先に描くことが可能なら、depth testを無効化して影を描画することも考えられるが、こういう時はpolygon offsetを設定するのがOpenGLの定跡であるので、今回もそのようにした。

glPolygonOffset(-1, -1);
glEnable(GL_POLYGON_OFFSET_FILL);
PolygonOffset()の引数の意味は難解なのだが、大抵の場合、少し向こう側ということにするなら(+1,+1)、少し手前ということにするなら(-1,-1)とすれば良いとされている。
このソースコードでは、手前に見える(地面に隠されない)はずの影を描画するための設定なので、(-1,-1)としているが、逆に地面に対してPolygonOffset(+1,+1)としても良い。

影を真っ黒で描画してしまうと、環境光で多少は照らされるはずの地面が全く見えなくなって不自然なので、影は半透明の黒で、つまり多少は地面が透けて見えるように描画したい。
その為には、αブレンディング描画をすれば良い。

glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
これだけでオーソドックスなαブレンディングが有効になる。

影をαブレンディング描画をすると、平面に投影したポリゴンが重なると、影が2重になってしまう。
object drawn with shadow
このオブジェクトはいくつかのパーツを重ねて描画しているので、パーツ毎の影が重なるとこのようになる。
物体が半透明でない限りは、これは不自然である。これを防ぐには、ステンシルバッファを使うのが最善だと思う。
OpenGLのステンシルはマスクのような役割をし、ステンシルテストをパスしたフラグメント(viewport上のピクセル)を描画しながら、描画した位置のステンシル値を更新することができる。
例えば、このようにする。

glEnable(GL_STENCIL_TEST);
glClear(GL_STENCIL_BUFFER_BIT);
glStencilFunc(GL_NOTEQUAL, 1, 1);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
描画をステンシル値が0のピクセルに限りながら、描画したピクセルのステンシル値を1に置き換えることにより、どのピクセルも2回描かれないようにするものである。

無限に広がる平面でなく、有限の地面を描画する場合、影が地面をはみ出すと不自然である。影が地面をはみ出さないようにするには、地面が四角形であればOpenGLのclip planeを無理矢理使う方法も考えられるが、今回はこれもステンシルテストで解決した。

glEnable(GL_STENCIL_TEST);
glClear(GL_STENCIL_BUFFER_BIT);
glStencilFunc(GL_ALWAYS, 1, 1);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
/* 地面の描画 */

glStencilFunc(GL_EQUAL, 1, ~0);
glStencilOp(GL_KEEP, GL_KEEP, GL_INCR);
/* 影の描画 */
glDisable(GL_STENCIL_TEST);

地面を描く時にステンシル値を1に置換し、影の描画をステンシル値が1のピクセルに限りながら、影を描画したピクセルのステンシル値を+1することにより、地面がある場所のみ描画するのと影が2重に描かれないのを実現している。

なお、影を描画する際にはdepth maskによってdepth bufferへの書き込みを停止するのが、OpenGLにおける影描画の定跡である。

glDepthMask(GL_FALSE);
これによって、depth buffer上で影の位置が地面より少し盛り上がるのを避けることができる。
そのためにも、上記のpolygon offsetの件は、地面をPolygonOffset(+1,+1)して描画するより、影をPolygonOffset(-1,-1)して描画する方が適切だと思う。

See more ...

Posted at 22:24 in PC一般 | WriteBacks (0)
WriteBacks