Ray Marching
PC環境でのChromeまたはFirefoxで動作確認済です。スマホ環境だと動くかどうか怪しいです。
操作方法
FPS風の操作で空間内を自由に移動できます。(キーボードのWSADキーで移動、マウス左ドラッグで視点変更)
前置き
ポリゴンモデルの描画空間とレイマーチングの描画空間を統合する実験です。カメラ制御、隠蔽関係、シェーディング(ライティング)の整合性が取れているところがポイントです。
色々と知見もたまりましたので、実装のポイントなどまとめてみようと思います。
レイマーチングの原理については、ここでは端折ります。
カメラの統合
まず、オブジェクトの空間座標を一致させてやる必要があります。これは、レイマーチングのシェーダー内でレイを定義する際にカメラの情報を加味してやればできます。
最低限必要となる情報は下記
・カメラの位置
・カメラの前方向ベクトル(正規化して渡す)
・カメラの右方向ベクトル(正規化して渡す)
・カメラの上方向ベクトル(正規化して渡す)
・正規化された空間での焦点距離
求め方はいくつかあると思いますが、ここではカメラの視点、注視点、アップベクトル、画角から求めています。
アップベクトルはワールドの上方向なので、通常はカメラの姿勢によらず(0,1,0)固定になります。
var pos = camera._pos; // カメラの位置
var at = vec3.create(); // カメラの前方向ベクトル
vec3.sub(at, camera._at, camera._pos); //
vec3.normalize(at, at); //
var right = vec3.create(); // カメラの右方向ベクトル
vec3.cross(right, at, camera._up); //
vec3.normalize(right, right); //
var up = vec3.create(); // カメラの上方向ベクトル
vec3.cross(up, right, at); //
var focallength = camera._fov_y / 2.0; // 正規化された空間での焦点距離
focallength = 1.0 / Math.tan(focallength); //
求めたパラメーターをレイマーチングのシェーダーに渡して、それを元にレイを定義します。
/* レイマーチングシェーダーのレイを定義する箇所 */
vec2 position = v_uv.xy * 2.0 - 1.0;
position *= vec2(aspect, -1.0);
vec3 ray_pos = pos;
vec3 ray_dir = normalize(right * position.x + up * position.y + at * focallength);
これで、オブジェクトの空間座標が一致します。
隠蔽処理の統合
レイマーチングを描画する際に、ここではフルスクリーンの矩形モデルを使用しているので、何も考えずに深度値を描きだすと、全画面0.0になってしまいます。当然ですね。
これではオブジェクトの前後関係を判別することができません。
どうしたものかと調べてみたら、いい感じの機能が用意されていました。
フラグメントシェーダーから深度バッファへの深度値描きこみを制御できるというものです。
WebGL1.0の場合、ブラウザの拡張機能になります(スマホ用ブラウザだと対応状況がよろしくない感じです)
// これが返ってくれば使用可能
gl.getExtension("EXT_frag_depth");
// レイマーチングのフラグメントシェーダー内にもこのマクロを入れておく必要がある
#extension GL_EXT_frag_depth : enable
こんな感じで描きだす。
vec4 pos = u_vp * vec4(ray_pos, 1.0); // レイの到達地点にビュープロジェクション変換をかける
float depth = ray_pos.z / ray_pos.w; // Z座標をクリップ空間に変換
depth = (depth + 1.0) * 0.5; // -1.0~1.0を0.0~1.0に変換
gl_FragDepthEXT = depth;
余談:
GPUには通常、フラグメントシェーダーが走る前に深度テストを行い、無駄にフラグメントシェーダーを走らせないための機能がありますが、
フラグメントシェーダーが深度値を描きだす場合、おそらくこの機能は働きません。
なので、隠蔽されているはずのピクセルでも、しっかりフラグメントシェーダーが走ってしまうのではないかと予想されます。
シェーディングの統合
これまでの実装でポリゴンモデルとレイマーチングオブジェクトがかなり仲良くなってきた感じがします。折角なのでシェーディング(ライティング)処理も統合してしまおうと思います。
ここで、ディファードシェーディング(Deferred Shading)的なアプローチを取り入れてみます。
大雑把に言うと、描画を2段階に分けて、
最初に、モデル形状と後のシェーディングで必要となる情報を中間バッファ(G-Buffer)に書き込んでやります。
次に、作成したG-Bufferの情報を元にポストエフェクトのような感じでシェーディング処理を塗り重ねていくというイメージです。
■G-Bufferパス
ポリゴンモデルとレイマーチングオブジェクトのシェーダーから、シェーディングに必要な情報をG-Bufferに描きだしてやります。
ここでは「RGB..ワールド空間での法線、A..深度値」としています。
これが正解というわけではなく、必要に応じて出力する情報は取捨選択してやります。
本格的にやるにはMRT(マルチレンダーターゲット)が必須になりますが、本実装では簡略化してG-Buffer1枚にすませました。
因みに、G-Buffer用のフレームバッファにはブラウザ拡張機能の浮動小数点数テクスチャを使用しています。
// これが返ってくれば使用可能
gl.getExtension("OES_texture_float");
このフォーマットは負の数値も記録できるので、色々と楽ができます。
そもそも、WebGL1.0では選択できるテクスチャフォーマットが限られているので、浮動小数点数テクスチャを使わざるをえないのですが。
■ライティングパス
次に、作成したG-Bufferの情報を元にライティングを行います。
ここで、ワールド空間でのオブジェクト座標を求めてやる必要があります。
G-Bufferに描きこまれた深度値からワールド空間での座標を復元することができます。
/* ライティングのフラグメントシェーダー */
vec4 gbuffer = texture2D(u_texture, uv) * 2.0 - 1.0; // 法線と深度を-1.0~1.0に戻す
vec4 wpos = vec4(uv * 2.0 - 1.0, gbuffer.w, 1.0);
wpos = u_inv_vp * wpos; // ビュープロジェクション逆行列をかけてワールド空間に戻す
wpos.xyz /= wpos.w; //
復元した座標情報と別途定義したライトの位置関係から、どの方向からライトがあたっているかが分かります。
▼ポイントライト
ポイントライトの影響範囲にマッチした球状のモデルを描画します。
実際にフラグメントシェーダーが走るのはこの範囲のみになるため、塗り面積が節約できるわけです。
(更にステンシルバッファを使った最適方法があるらしいですが、ちゃんと調べてません)
この時、モデルのカリングを裏返して裏面描画にしていますが、これはカメラがモデルの内部に潜ってしまっても正しく描画させるためです。
このモデルのシェーダーで、G-Bufferから復元したオブジェクト情報とポイントライトの位置関係を計算してライティング処理をしてやります。
このプロセスを反復させて色加算してやることで、大量のポイントライトを扱えるという仕組みです。
シェーダーとライト数が分離されているので、ライト数の動的な増減も簡単です。
余談:
ポイントライトの減衰計算が超適当ですが、そこは面倒だっただけなので突っ込まないでください。
■リムライト
ついでに、全画面に対してリムライティング的な加工処理を入れています。
平行光源やポストエフェクトなども全画面処理になるので、一緒のシェーダーに含めてしまうといい感じかもしれません。