WebGL の描画結果を HTML に正しく合成する方法
先日、最速チュパカブラ研究会さんのこちらの記事で、 WebGL で描画した半透明オブジェクトを HTML に重ねるとおかしな結果になることが報告されていました(同時にクールなデモも公開されているので、必見です)。この問題、実は私も以前にハマったことがあって、一応解決策も見つけました。 Render To Texture による 2 パスレンダリングでも必要になる、なにげに重要な知識なので、本日はこの問題の原因と解決策をご紹介します。
※ この記事ではいくつか WebGL のサンプルを提示していますが、動作確認は Google Chrome 11 の dev 版でしか行っていません。他の環境で動かなかったときはご容赦ください。また、 Google Chrome であってもたまに(というか頻繁に)背景画像が表示されなくなります。そのときはブラウザを再起動してください。原因はわかりませんが、 WebGL はまだ不安定なようですね…。
まずは普通にアルファ合成してみる
まずは論より証拠ということで、本当に半透明合成がおかしくなるか、以前書いた WebGL 入門記事のサンプルを改造して試してみました。地球の透明度を操作して、フェードイン・アウトを繰り返すようにしてあります。以下の画像をクリックすると、実際に動作するデモが表示されます(要 Google Chrome 9+)。
かなり微妙なのですが、地球と背景のブレンド比率が崩れていて、不自然なフェードになっています。とくに消える直前に地球の画像が少し白っぽくなり、たしかに加算合成に近い効果になっています。
なぜこうなるのか
このような不思議な結果になる理由を探るため、まずは半透明合成(アルファブレンディング)の計算方法を考えてみましょう。一般的に半透明合成は以下の計算式で表されます。
C' = C*A + Cb*(1-A) C : 描画色 A : アルファ値 Cb : 背景色(通常、初期値は [0, 0, 0] なので、半透明ポリゴンが重ならなければ第二項は常に 0) C' : 半透明合成後の色
アルファ値が 0.6 であれば、描画色の 60% と背景色の 40% を足したものが最終結果になるというわけです。実際、 WebGL 内での半透明合成は厳密にこの計算式どおりに行われています(というか、そうするように指定しています)。 WebGL の描画結果を HTML に合成する際もほぼ同じなのですが、上記の計算で既にカラー値にアルファ値が乗算されている (premultiplied-alpha) ことを考慮して、 WebGL 側のカラー値は単純に加算します。
C'' = C' + Ch*(1-A') Ch : WebGL 合成前の HTML のピクセル色 A' : WebGL のフレームバッファに格納されているアルファ値 C'' : 最終的に画面に表示される色
一見すると正しそうですが、なぜ最終画像がおかしくなるのでしょうか。その鍵は WebGL のフレームバッファに保存されているアルファ値 A' にあります。前述のサンプルでは、 WebGL 内での半透明合成を以下のように設定しています(各メソッドの機能詳細は WebGL の仕様を参照してください)。
gl.enable(gl.BLEND); gl.blendEquation(gl.FUNC_ADD); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
この場合、カラー値についてはもちろん、アルファ値に対しても前述の半透明合成が行われます。したがって、フレームバッファのアルファ値 A' は以下のように計算されます。
A' = A*A + Ab*(1-A) Ab : フレームバッファの元のアルファ値(通常、初期値 0)
A' が本来の描画オブジェクト(上のサンプルでは地球)の透明度 A と異なるのは一目瞭然ですね。けっきょく、 C'' の計算は以下のようになり(Cb, Ab の項は 0 と仮定して削除)、 A + (1-A*A) が 1 にならないので合成比率が崩れ、加算合成っぽく見えたりするわけです。
C'' = C*A + Ch*(1-A*A)
それならば、そんな変な計算をしないで描画オブジェクトのアルファ値をそのままフレームバッファに書きこめばいいじゃないかと思いますね。そのために blendFuncSeparate というメソッドが用意されており、カラー値とアルファ値の合成モードを別々に指定できます。
gl.blendFuncSeparate( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ZERO);
こうすれば A と A' が一致するので、 HTML の上に直接 WebGL のオブジェクト(地球)を描画したのと同じ結果になります。
C'' = C*A + Ch*(1-A)
実際に上記の変更を施してみました。
最初のデモと見比べるとフェードが自然になっているのがわかると思います。
半透明オブジェクトのオーバーラップにも対応する
しかし、これで一件落着とはいきません。試しに 2 個の地球を重ねたデモをご覧ください。
地球が重なっている部分の透明度がおかしくなっています。まあ、アルファ値を単純に上書きしているだけなので当然ですが。
このように 2 個のオブジェクトを半透明合成する際の計算式を以下に示します。最初に示した基本的な半透明合成の式を入れ子にしただけです。
C' = C2*A2 + (C1*A1 + Cb*(1-A1))(1-A2) C1, A1 : 最初に描画するオブジェクト(奥の地球)のカラー・アルファ値 C2, A2 : 二番目に描画するオブジェクト(手前の地球)のカラー・アルファ値
これを少し整理するとこうなります。
C' = C2*A2 + C1*A1*(1-A2) + Cb*(1-A1)(1-A2)
このうち、カラー値の計算(第一項、第二項)は現状で正しく計算できています。問題は第三項、背景のアルファ値の計算です。上式から、本来ならフレームバッファに格納されるアルファ値は 1-(1-A1)(1-A2) になるべきですが、現状は単なる上書きなので、 1-A2 になっています。
C' = C2*A2 + C1*A1*(1-A2) + Cb*(1-A2)
これでは正しい結果が得られるわけはありませんね。
それでは、正しいアルファ値を計算するにはどうすれば良いでしょうか。他にも方法があるかもしれませんが、私は以下のようにしています。
- まず、フレームバッファのアルファ値を 1 でクリアしておく。
- プリミティブを描画する際は、フレームバッファのアルファ値に (1-A) を乗算していく。
- 最後にフレームバッファのすべてのアルファ値を反転(1 からアルファ値を引く)する。
(1) は問題ないでしょう。 (2) を実現するには、半透明合成を以下のように設定します。
gl.blendFuncSeparate( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE_MINUS_SRC_ALPHA);
こうすることで、アルファ値が (1-A1)(1-A2)… というように積算されていきます。そして、すべての描画が終わった後に (3) を行うわけですが、これには以下の設定で全画面を覆うポリゴンを、アルファ値 1 で描画します。
gl.blendFuncSeparate( gl.ZERO, gl.ONE, gl.ONE_MINUS_DST_ALPHA, gl.ZERO);
この変更を施したデモがこれです。
重なっている部分の透明度も自然になっているのがお分かりいただけるかと思います。証明は省きますが、 3 個以上の物体が重なった場合でも、アルファ値を (1-A1)(1-A2)(1-A3)… と乗算していくだけで正しく合成されるはずです。
それでも、半透明はトラップいっぱい
このように、 WebGL でも半透明オブジェクトを HTML に正しく合成することは可能です。こんなの自動でやってくれよと思うかもしれませんが、その代わり合成方法をカスタマイズできるので、意図的に加算合成にすることも可能です。いろいろ試してみれば、思わぬ効果が実現できるかもしれません。
ただ、 HTML との合成の問題を解決したとしても、 3DCG では半透明オブジェクトはかなり鬼門だったりします。まず描画順序を奥からきちんとソートしないといけませんし、半透明(アルファ合成)と加算合成を同時に使うとさらにややこしくなります。また、深度バッファを利用したエフェクト(被写界深度とか)と半透明が重なると惨いことになるとか、もう本当にトラップいっぱいです。私はそういう世界からはここ数年離れていますが、たぶんまだ完全な解決方法って見つかってないんじゃないのかな。
ということで、 WebGL ではたかが半透明されど半透明。なかなか深い世界が広がっています。興味のある方は、いろいろ調べてみると楽しいと思います。
詳しくはこちらの記事をどうぞ!
この記事にコメントする