ステンシル鏡面反射
今回のサンプルの実行結果
映り込む世界
前回は深度値のオフスクリーンレンダリングと、テクスチャへの射影マッピングを用いて、ソフトパーティクルによるフォグの実装を解説しました。
距離フォグとは趣の異なる厚みのあるフォグが演出できるソフトパーティクルは、応用すればフォグだけでなく光や炎などのエフェクトにも利用できます。是非習得していただければと思います。
さて、今回は久々にステンシルバッファを使ってみます。今回は光沢のある平面に鏡のように世界が映り込む床面鏡面反射をやってみようと思います。冒頭のサンプルの実行結果の画像を見るとわかると思いますが、板状のポリゴン上にあるトーラスや球体が見事に映り込んでいます。これを実現するためにはいろいろ手法が考えられますが、今回はステンシルバッファを用いて実現してみたいと思います。
ステンシルバッファの使いどころ
さて、さっそく実装について考えてみたいと思います。
今回はまず、例によってフレームバッファへのオフスクリーンレンダリングを使います。映り込む世界、つまり上下が反転している鏡の向こう側の世界を一度フレームバッファに描き込みます。続いて、これを合成するわけですがここで困った問題が出てきます。
たとえば半透明で鏡面世界を合成しようと考えたとき、当然と言えば当然ですが、普通に板ポリゴンにテクスチャを貼っただけではそれらしく見えるはずがありません。
それでは逆に、オフスクリーンレンダリングした鏡面世界の上に、本来レンダリングすべきモデルをどんどん上書きすればいいのかと言うと、これも NG です。これはなぜかと言えば、カメラの角度によってはあくまでも映り込んだだけのはずの鏡面世界が、板ポリゴンを飛び出してまるまるそこに存在しているように見えてしまうからですね。
鏡面世界がはみ出している状態
こんなときこそ、あえて描かない技術、ステンシルバッファが活躍してくれます。
オフスクリーンに鏡面世界をレンダリングした後、フレームバッファのバインドを解除し本来レンダリングされるモデルを三次元空間上にレンダリングしていきます。ただしこのとき、ステンシルバッファをうまく使って、板状のポリゴン(床面)部分だけステンシル値が一意になるようにしてやります。
あとは、正射影で画面全体にオフスクリーンでレンダリングした鏡面世界を半透明合成しつつ、ステンシルテストを行なって床面だけに合成が掛かるようにしてやります。これで、見事に鏡面世界は板ポリゴンがレンダリングされている領域だけにうまく合成されるというわけです。
また、今回のようなシンプルな映り込みの場合にはステンシルバッファで十分対応できますが、同じことは射影テクスチャマッピングをうまく使うことでも実現できます。まぁこのへんは用途に合わせてケースバイケースで選択すればいいと思います。
シェーダの実装
さて今回も、サンプルのシェーダ周りから見ていきます。
使うシェーダは二組。ひとつは、グーローシェーディングを行なうライティングシェーダ。ただし、今回はこのライティングシェーダで鏡面世界側もレンダリングできるようにしますので、ちょっといつもとは違う実装になります。
もう一組のシェーダは正射影で画面全体にかぶさるようにレンダリングを行なうシェーダです。このシェーダが鏡面世界を床面に合成する役割を果たします。
さてそれでは早速ですが、ライティングを行なうシェーダから見ていきます。
ライティングシェーダ
// 頂点シェーダ
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
uniform mat4 mMatrix;
uniform mat4 vpMatrix;
uniform mat4 invMatrix;
uniform vec3 lightDirection;
uniform vec3 eyePosition;
uniform vec4 ambientColor;
uniform bool mirror;
varying vec4 vColor;
void main(void){
vec3 invLight = normalize(invMatrix * vec4(lightDirection, 0.0)).xyz;
vec3 invEye = normalize(invMatrix * vec4(eyePosition, 0.0)).xyz;
vec3 halfLE = normalize(invLight + invEye);
float diffuse = clamp(dot(normal, invLight), 0.1, 1.0);
float specular = pow(clamp(dot(normal, halfLE), 0.0, 1.0), 50.0);
vColor = color * vec4(vec3(diffuse), 1.0) + vec4(vec3(specular), 1.0) + ambientColor;
vec4 pos = mMatrix * vec4(position, 1.0);
if(mirror){pos = vec4(pos.x, -pos.y, pos.zw);}
gl_Position = vpMatrix * pos;
}
// フラグメントシェーダ
precision mediump float;
varying vec4 vColor;
void main(void){
gl_FragColor = vColor;
}
ご覧のとおり、フラグメントシェーダはほとんど何もしてません。基本的には頂点シェーダが仕事します。
まず今回のサンプル最大の特徴は、従来は mvpMatrix
として受け取っていた行列関連部分の実装の違いです。よく見るとわかりますが、今回はモデル、ビュー、プロジェクションの各座標変換行列のうち、モデル座標変換行列( mMatrix
)とビュー×プロジェクション座標変換行列( vpMatrix
)を個別にシェーダ側で受け取るようになっています。
これはなぜかと言うと、鏡面世界と通常のワールド空間と、いずれも同じシェーダでレンダリングできるようにしているためです。ライトベクトルやカメラの座標などは通常行なっているライティングを行なうためのもので、各種ライティング関連の処理は従来と同じものです。注目すべきは vec4
型の変数 pos
が登場するあたりからの処理。
頂点シェーダの一部を抜粋
vec4 pos = mMatrix * vec4(position, 1.0);
if(mirror){pos = vec4(pos.x, -pos.y, pos.zw);}
gl_Position = vpMatrix * pos;
ここではまず、モデル座標変換行列に頂点座標を掛け合わせます。そして uniform 変数として入ってくる bool
型の mirror
を判断基準に、鏡面世界かどうかで処理が分岐します。
もし鏡面世界のレンダリングを行なおうとしている場合には頂点座標の Y 成分だけを反転します。こうすることで移動や回転などのモデル座標変換が正しく適用された鏡面世界がレンダリングできるわけですね。
最終的に gl_Position
に代入する頂点座標は、ビュー×プロジェクション座標変換行列に掛け合わせたものを入れます。このように、行列を個別にシェーダ内で計算するために、今回はバラバラに行列がシェーダに送られるようになっているわけです。
一方でフラグメントシェーダ側ではほとんどなにもやっていませんね。varying 変数として頂点シェーダから送られてくる色を適用しているだけです。
続いて、正射影で鏡面世界を画面全体に合成するためのシェーダも見ていきましょう。
合成シェーダ
// 頂点シェーダ
attribute vec3 position;
attribute vec2 texCoord;
uniform mat4 ortMatrix;
varying vec2 vTexCoord;
void main(void){
vTexCoord = texCoord;
gl_Position = ortMatrix * vec4(position, 1.0);
}
// フラグメントシェーダ
precision mediump float;
uniform sampler2D texture;
uniform float alpha;
varying vec2 vTexCoord;
void main(void){
vec2 tc = vec2(vTexCoord.s, 1.0 - vTexCoord.t);
gl_FragColor = vec4(texture2D(texture, tc).rgb, alpha);
}
こちらはコードの量としてはだいぶ少なくすっきりした印象ですね。
頂点シェーダには、正射影座標変換行列だけが uniform 変数として入ってきます。これらはグレイスケール変換など過去のテキストでも何度も登場しているので、大丈夫でしょう。
フラグメントシェーダ側では、オフスクリーンレンダリングした鏡面世界がテクスチャとして入ってくるのと、もう一つ uniform 変数として float
型の値 alpha
が入ってきます。
この alpha
は HTML 内に埋め込まれた input 要素から値を受け取ってシェーダに送られてくるもので、どの程度の透明度で鏡面世界を合成するのかを決めるために使われます。
メインプログラム側の処理
さて、今回のサンプルの javascript 側処理も見てみます。ポイントとなるのは、カリングの設定とステンシルバッファの設定です。
まずカリングについてですが、鏡面世界をレンダリングする際に注意が必要です。というのは、先ほどのシェーダのソースを思い出していただければわかると思いますが、鏡面世界のレンダリングでは頂点の Y 軸が反転した状態になります。こうなると、頂点を結んでポリゴンをレンダリングする際、裏表が逆転してしまいます。
カリングとは裏と表、どちらかをレンダリングしないようにすることでレンダリング効率を上げる技術ですので、カリングの設定を反転しておかないとうまくレンダリングされなくなってしまいます。
カリング面を反転する
// カリング面の反転
gl.cullFace(gl.FRONT);
カリング面を元に戻す場合には cullFace
メソッドの引数に gl.BACK
を指定してやればいいですね。これでカリング面を自在に変更することができるようになります。先ほども書いたように、鏡面世界をレンダリングする際にはカリング面を反転させる必要があります。このことに十分注意しましょう。
さて、続いてはもう一つのポイントであるステンシルバッファの設定です。
冒頭でも説明したとおり、今回は床面となる板ポリゴンにだけ合成処理が行なわれるようにしたいわけです。そこで、今回のサンプルでは次のような手順でステンシルバッファに値を描き込みます。
- ステンシルテストに全て合格するよう設定する
- ステンシル値は更新せずトーラスと球をレンダリング
- 床面をレンダリングしステンシル値をインクリメント
- ステンシル値を参照しつつ正射影で合成レンダリング
今回のサンプルでレンダリングされるモデルは、トーラス、球体、床面の三つです。このうち、トーラスと球体に関しては無条件でステンシルテストをパスしてレンダリングされるようにしておきます。
床面をレンダリングする際には、ステンシルテスト自体は無条件でパスしますが、ここでステンシルバッファをインクリメントするように設定してからレンダリングを行ないます。こうすることで、床面がレンダリングされたピクセルだけが、異なるステンシル値を持つ状態になりますね。
最終的に正射影で全画面を覆うポリゴンをレンダリングする際には、この床面レンダリングでインクリメントしたステンシル値を元に、床面部分だけに合成されるようにステンシルテストを設定します。
こうして晴れて、映り込みによる鏡面反射が実現できるわけですね。
まとめ
さて、ステンシルバッファによる鏡面反射、理解できたでしょうか。
先述したとおり、今回のような映り込み処理はステンシルバッファを使わなくても、射影テクスチャマッピングを使ったり、ある程度カメラアングルを固定してしまうことなどで対応できます。ただ、特定の領域にだけ合成処理を行ないたい場合には、ステンシルバッファを用いたほうが都合がいい場合もあります。あくまでも活用事例の一つとして、参考にしていただければと思います。
今回のサンプルも実際に動作するものを見たい場合には以下にリンクがあります。HTML の input 要素を使って鏡面世界の合成比率を自由に変更できますので、実際に動作させて確認してみてください。