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

ActionScript で立体のブーリアン演算+AGALで擬似屈折+反射シェーダ(1)


Boolean Crystal | Si+ (wonderfl.net)

FlashPlayer11 ではステンシルバッファが使えるようになったので、お約束の立体ブーリアン演算をやってみました。ただ、コレだけだと寂しいので、incubator時代に実装したAGAL擬似屈折+反射シェーダーの改良版と組み合わせてみました。あんまり本筋と関係ないところでなかなか苦労はしたのですが、全体を通してわりと素直に実装できました。FlashPlayer11の仮想GPU、良いね!
今回はFlashPlayer11で扱えるようになった3D基本技術のごった煮みたいな感じです。擬似屈折+反射シェーダには Cube Texture を使って、ブーリアン演算にはステンシルバッファを使っています。ブレンドファンクションはまだポイントスプライト用加算演算くらいにしか使ってないですが、これで一応一通り触ってみた感じにはなるのかな。


ここではまず,立体のブーリアン演算方法について解説したいと思います。一見3次元計算をしているようですが、実は2次元の画像処理で描画しています。近年のリアルタイム3DCGでは(といってもかなり昔からですが)、こういった2次元画像処理による擬似3次元計算技術が、実際の3次元計算以上に重要になって来ています。ちょっと頑張って解説を書いてみたので、3DCGにおける2次元画像処理について少しでも理解が深まれば幸いです。
なお、擬似屈折についてはまた次回詳しく解説したいと思っています。

ステンシルバッファ?

ググれば色々な解説が出てきますが、平たく言うと「マスク処理」です。このステンシルバッファを使ったマスク処理は、レンダリングした2Dイメージに対して「このピクセルは書く/このピクセルは書かない」という感じに行いますので、いわゆる画像処理における一般的なマスク処理をイメージしてもらって問題ないと思います。よく使われるのは、鏡面映りこみ、アルファ抜き、ステンシル影、そして今回のブーリアン演算あたりです。用途として一番イメージしやすいのは鏡面映りこみだと思います。まず立体を描画して、次に映り込む面以外をマスクして、最後に中の映り込みをレンダリングすることで、鏡面内のが画像を描画します。
ステンシルバッファへの書込/参照は、Contex3D.drawTriangles() を呼び出す前に、Contex3D.setStencilActions() と Contex3D.setStencilReferenceValue() を呼び出して設定することで、drawTriangles()呼び出し時に実行されます(OpenGLでステンシルバッファを扱ったことがある人には関数の使い分けや引数の順番が違うのでトラップになります、注意しましょう。個人的にはActionScript3の関数のほうが扱いやすいと感じましたが)。
実際にステンシルバッファを使う場合は、イメージやデプスバッファを描画しないでステンシルバッファだけ反映させるとか、面の表裏を変えるとかの操作を行います。実装上はsetColorMask()、setCulling()、setDepthTest()あたりと併用する形になります。

立体のブーリアン演算

立体を足したり引いたり掛けたりします。学術用語(多分)では、Constructive Solid Geometry と呼ばれているようです。wikipediaの図を見れば、どんなものかは分かると思います。
実際に2つのモデルの表面ポリゴンから計算で交線を求めて、新たな立体を造りだすことは論理的には可能ですが、とても複雑で時間のかかる処理になってしまいます。そこで今回のwonderflでは、ステンシルバッファをつかって、あたかもブーリアン演算したかのようなイメージを画像処理によって合成しています。

で、どうやるのか?

ステンシルバッファによるブーリアン演算は教科書的な手法なのでググればすぐ出てくる...と思ってたんですが、意外に日本語で引っかからなかったので、ちょっとまじめに書いてみたいと思います。

合成立体を描画する場合は、ステンシルバッファを使ってそれぞれの立体の必要な面だけを描画することになります。そのためには、立体Bの中に入っている立体Aの表面が描画されるエリアをマスクとしてステンシルバッファに設定する必要があります(図1)。

このマスクのつくり方ですが、まず立体Aをデプスバッファだけに描画します。これで立体Aの表面情報がデプスバッファに入ります(図2)。

// 立体A描画でデプスバッファを設定
setCulling(Context3DTriangleFace.BACK); // カリング。表面を書く
setColorMask(false, false, false, false); // 色バッファ。書込禁止
setDepthTest(true, Context3DCompareMode.LESS); // デプスバッファ。第一引数=書込、第二引数="less"で参照
setStencilActions(Context3DTriangleFace.NONE); // ステンシルバッファ。何もしない
drawTriangles(meshA);


次にデプスバッファを参照しつつ書込み禁止して、描画時にステンシルバッファを+1するように設定して、立体Bのおもて面をdrawTriangles()します。この処理でも描画せず、ステンシルバッファの更新のみ実行します。こうすると、「立体Aの外にある、立体Bおもて面を描画するエリア」のステンシルバッファが+1されることになります(図3)。

また同様のデプステスト設定で、ステンシルバッファを−1するように設定して立体Bの裏面(法線が視線と同方向をむいている面)をdrawTriangles()します。こうすると、「立体Aの外にある、立体B裏面を描画するエリア」のステンシルバッファが−1されることになります(図4)。

この結果、図3/4で+1/−1したステンシルバッファ≠0の部分が立体Bの内側にある立体Aの描画エリアになります(図5)。ちなみにステンシルバッファ=0の部分は立体Bの外側にある立体Aの描画エリアです。Stage3Dでは表面/裏面のステンシルバッファ書込みはいっぺんに行えますので、上記2つの処理は1回のdrawTrianglesで行えます。

// 立体B描画でステンシルマスクを生成
setCulling(Context3DTriangleFace.NONE); // カリング。表面/裏面、両方書く
//setColorMask(false, false, false, false); // 色バッファ。書込禁止(前処理と設定が同じなので必要なし)
setDepthTest(false, Context3DCompareMode.LESS); // デプスバッファ設定。第一引数=書込禁止、第二引数="less"で参照。
// ステンシルバッファへの書込。表面はINCREMENT,裏面はDECREMENT。
setStencilActions(Context3DTriangleFace.FRONT, Context3DCompareMode.ALWAYS, Context3DStencilAction.INCREMENT_SATURATE);
setStencilActions(Context3DTriangleFace.BACK, Context3DCompareMode.ALWAYS, Context3DStencilAction.DECREMENT_SATURATE);
drawTriangles(meshB);

最後にステンシルバッファの評価方法をEQUAL か NOT_EQUALに設定して立体Aを描画することで、合成立体の表面(立体A)が描画できます。このとき注意が必要なのが、デプステストです。デプスバッファにはすでに立体Aの情報が書き込まれていますので、この状態で描画を行うと,たとえ等価評価を入れてもデプステストの成功と失敗が混在してうまく描画できませんでした。そこで今回は大雑把に一旦デプスバッファをクリアして描画しなおしました。しかしこの方法では複数の合成立体は描けなくなるので、用途によっては、Zソートや他の方法との組み合わせが必要になります。

// 生成したステンシルマスクを参照して立体Aを描画
clear(0,0,0,1,1,0,Context3DClearMask.DEPTH); // デプスバッファのクリア
setCulling(Context3DTriangleFace.BACK); // カリング。表面を書く
setColorMask(true, true, true, true); // 色バッファ。書込
setDepthTest(true, Context3DCompareMode.LESS); // デプスバッファ。第一引数=書込、第二引数="less"で参照。
setStencilReferenceValue(0); // ステンシルバッファの評価値=0
setStencilActions(Context3DTriangleFace.FRONT_AND_BACK, Context3DCompareMode.NOT_EQUAL); // バッファ≠評価値なら描画.立体Bの内側
// setStencilActions(Context3DTriangleFace.FRONT_AND_BACK, Context3DCompareMode.EQUAL); // バッファ=評価値なら描画.立体Bの外側
drawTriangles(meshA);

同様の方法を立体Bに対しても行う事で、無事、合成立体の表面が描けました。

ここで解説した描画方法は、実はたくさんの制約があります。例えばトーラスのような見る方向によって途中に空間ができるような立体は、手前の立体が奥の立体のデプス情報を上書きしてしまうため、切り取り方によって破綻します。もっと色々知りたい方は、google:Constructive Solid Geometryで検索してみてください。
この立体ブーリアン演算はインパクトがあるため、最近のデモシーンにおいてもよく見かけます。実際には、以前投稿したジオメトリブレンディングや、シャドウマップアンビエントオクルージョンなんかと組み合わせて使うことが多いようです。当然これらの技術と組み合わせるには、相応の工夫が必要になりますが面白い挑戦かも知れません。


このように、一見3次元の計算のように見える処理でも、実は2次元の処理だったりします。Flash界隈の3Dは、もともとFlashPlayer10の3次元機能が相当残念だったせいでまだ立体を描画しただけでいっぱいいっぱいの印象があります。FlashPlayer11 の Stage3D によって、ようやくPCのリアルタイム 3DCG と同じ土俵に立てるようになりました(といってもまだ5年くらいの遅れは感じますが)。もともと2次元画像処理は Flahser が得意とするところですので、これを機にモノホンのWebGLer達が参入してくる前に一気に ウェブ3D の世界を席巻してしまいたいところです。