シェーダの柔軟性があがるにつれ、個性的なシェーダの実現から、汎用ベクトルプロセッサへの応用、と世界が変化してきました。
さらに、今までは実現不可能と思われていた大域照明をリアルタイムで動かそうという努力がなされてきています。
今回は、その流れにのって、拡散光でセルフシャドウが無く、相互反射も考慮に入れない場合に関するグローバルイルミネーションを実現してみました。
つまり、物体に入射する光をレンダリング方程式で集めて拡散光とします。
上の図を見ていただくと、球の上部は天球の入射光によって赤く染まり、下部は地面の草の色を反映された緑色になっています。
これが今回の効果で、物体が移動したとしても、周りの風景に応じて瞬時にいい感じに色が付きます。
たとえば、球の位置が高くなれば、下部の緑色の成分は減じます
(周りの風景が写りこんだ環境マップはおまけです)。
なお、グローバルイルミネーションの効果は、環境マップのように法線だけに依存して、表面の位置には依存しないようにしています。
で、いつものようにプログラムです。
カーソルキーで、カメラが回って、zやxでズームが変えられます。
玉は、「W」、「S」、「Q」、「E」で、上下左右に動きます。
また、aキーで「全照明効果」、「環境マップなし」、「環境マップのみ」の切り替えができます。
一応押さえておくと、下のように拡散光と環境マップが合成されて最終的な出力になっています。
ソースには、いつものように適当にファイルが入っています。 大事なファイルは次のものです。
main.h | アプリケーションのヘッダ |
main.cpp | アプリケーションのソース |
hlsl.fx | シェーダプログラム |
paraboloid.fx | Dual paraboloid マップのためのシェーダ |
hdr.h | RADIANCEフォーマット表示用のヘッダ |
hdr.cpp | RADIANCEフォーマット表示用のプログラム |
hdr.fx | RADIANCEフォーマット表示用のシェーダ |
今回の方法は、環境マップを適当な係数を掛けて平均することにより入射光をSH展開して扱いやすくしておき、後は法線ベクトルからコサイン項(max(N.ω,0))のSH展開を求め、両者の積を足すことによって、拡散光を導出します。
数式で説明すれば、拡散光に関するレンダリング方程式
の入射光Liに関して、SH展開
を施すと(これは頑張ればリアルタイムで可能である)、レンダリング方程式は
のように変形できる。ここで、積分された部分を1つにまとめてしまえば、
と、ラディアンスを単純な積和の形で書くことができる。
これは、シェーダで計算可能な量である。
Almは、
である。この量は、lm以外には、法線ベクトルNにしか依存しないので、あらかじめ計算しておくことが可能である。今回は、法線ベクトルを反射ベクトルとみなすスフィアマップ
に、テーブルとしてその値を埋め込んだ。ここで、赤はl=1,m=-1、緑はl=1,m=0、青はl=1,m=1、の係数になっている(実際には、l=m=0の係数をアルファ成分に埋め込んでいる)。 係数は適当に補正してあり、色の強さが0.5の時が、Almが0の時である。
最初のステップは、環境マップを作ることである。
今回は、双放物マップを作成した。
スフィアマップは破綻を起こしやすいし、キューブマップはレンダリング回数が発散してしまうからの選択である。場合に応じて、別の方法を選択するものいいであろう。
環境マップは動的に作成するのが可能であり、一番上の画面の環境マップは、次のようになっている。
物体(ここでは球)のローカル座標系を基準にレンダリングした。
レンダリング方法は、適当な座標変換とテクスチャを張るだけである。
hlsl.fx 0130: VS_OUTPUT VS_Parabploid ( 0131: float4 Pos : POSITION // モデルの頂点 0132: ,float4 Tex0 : TEXCOORD0 // テクスチャ座標 0133: ){ 0134: VS_OUTPUT Out = (VS_OUTPUT)0; // 出力データ 0135: 0136: // 位置座標 0137: Out.Pos = paraboloid( Pos, mWV ); 0138: 0139: // テクスチャ座標 0140: Out.Tex0 = Tex0; 0141: 0142: return Out; 0143: }
座標変換は、昔、動的放物マップを作成した作業をHLSLで書き起こしている。
なお、裏面に回り込んだ頂点に関しては、wを負にすることによって、無限遠方に引っ張っている。
paraboloid.fx 0011: float4 paraboloid( float4 vPosition, float4x4 mWV ) 0012: { 0013: float4 Out = (float4)0; // 出力データ 0014: float3 pos; 0015: float l; 0016: 0017: float z_min = 0.1; 0018: float z_max = 100.0; 0019: 0020: // 位置座標 0021: pos = mul( vPosition, mWV ).xyz; 0022: l = length( pos ); 0023: 0024: Out.xy = pos.xy * l * (l-pos.z) / dot( pos.xy, pos.xy ); 0025: Out.z = (l-z_min)*z_max/(z_max-z_min); 0026: 0027: if( 0 <= pos.z ) 0028: { 0029: Out.z = (l-z_min)*z_max/(z_max-z_min); 0030: Out.w = l; 0031: }else{ 0032: Out.z = 0; 0033: Out.w = -0.00001; 0034: } 0035: 0036: return Out; 0037: }
ピクセルシェーダは単純にテクスチャを張っている。
hlsl.fx 0147: float4 PS_Parabploid (VS_OUTPUT In) : COLOR 0148: { 0149: float4 Out; 0150: 0151: // 色 0152: Out = tex2D( SrcSamp, In.Tex0 ); 0153: 0154: return Out; 0155: }
さて、次の手順は、レンダリングした環境マップから球面調和関数の係数を求めることである。
求める量は、
なので、レンダリングした結果に球面調和関数の基底関数を掛けて輪をとればよい。
和をとる作業は、ミップマップを作るときの要領で小さな(最終的には1テクセルの大きさまで)テクスチャへ合成を掛ければよい。
ここで、問題になるのが面積要素(Jacobian)である。球座標系をデカルト座標系で積分するときには、積分変数の変換で「sinθ」の項が余分に着いた。 座標変換をして合成する時には、この項が大事になる。
放物マップを合成するときには、この変換が必要で、環境マップにおける中心からの距離s(マップの中心から各軸の端までの距離を1とする)と、放物面へのベクトルを3次元空間のベクトルにしたときの中心からの角度θには、
s = tan(θ/2)
の関係があるので、この関係を微分した式
ds = (1/2)(1+tan2(θ/2))dθ
から、面積要素は、(1/2)(1+tan2(θ/2)) になる。
なお、計算すると、θ=0で1/2、θ=π/2で1になる、
面積要素を放物マップの座標系でテーブル化すると、各UVに対応して、その値は、次のようになる。
この係数と、入射方向に応じたSH基底関数の値を掛ると、次のテクスチャができる(それぞれ、放物マップのレンダリング方向に応じて別々のテクスチャができる)。
これらは、l=1の場合の係数で、赤がl=-1、緑がl=0、青がl=1の係数を表している。
例によって、色の強さが0.5のところがベクトルの0の値を表現している。
これらは、変化しない量なので、レンダリング前にあらかじめテクスチャを作成することができる。
これら係数を環境マップに乗算すると、次のようになる。
ここで、左上がl=m=0、右上がl=1,m=-1、左下がl=1,m=0、右下がl=1,m=1の係数になる。
後は、小さくしていけばよい。今回は、64ボックスサンプリングで、1回のレンダリングで1/8にすることを2おこなった(つまり、環境マップの大きさは64x64である)。 下のような感じで平均化されていく。
なお、実際のレンダリングでは、上の図のように、1つのテクスチャに全てのSH係数を乗せた。これは縮小するときに1度に処理できて便利だと思ってとった処置である。 また、後のサンプリングでテクスチャ座標をずらすだけでことなるSH係数をサンプリングできるというメリットもある。
以上で下準備は終了で、後はそれぞれの係数の積和計算をして、ラディアンスを算出します。
ピクセルシェーダでは、それぞれの項をベクトル値に戻してから合成し、ラディアンスを計算するとともに、環境マップの張り込みなどを行います。
hlsl.fx 0204: float4 PS_ParabploidEnvmap (VS_OUTPUT In) : COLOR 0205: { 0206: float4 Out; 0207: 0208: // GI 0209: float4 SH = 2.0 * tex2D( SHSamp, In.Sphere ) - 1.0; 0210: float4 GI = SH.a * ( 2.0 * tex2D( ReductionSamp, float2(0.25, 0.25) ) - 1.0) 0211: + SH.r * ( 2.0 * tex2D( ReductionSamp, float2(0.25, 0.75) ) - 1.0) 0212: + SH.g * ( 2.0 * tex2D( ReductionSamp, float2(0.75, 0.25) ) - 1.0) 0213: + SH.b * ( 2.0 * tex2D( ReductionSamp, float2(0.75, 0.75) ) - 1.0); 0214: 0215: // 環境マップ 0216: float4 front = tex2D( ParaboloidFrontSamp, In.Tex0 ); 0217: float4 back = tex2D( ParaboloidBackSamp, In.Tex1 ); 0218: float4 env; 0219: 0220: if( 0.5 < In.Color.w ){ 0221: env = front; 0222: }else{ 0223: env = back; 0224: } 0225: // フレネル項 0226: float F = 1 - In.Normal.z * In.Normal.z; 0227: 0228: // 色 0229: Out = 5.0 * GI // GI 0230: + 0.2f * env * F // 環境マップ 0231: + In.Color; // そのほかの色 0232: 0233: return Out; 0234: }
頂点シェーダでは、ピクセルシェーダで使う、放物マップやスフィアマップの座標値計算しておきます。
hlsl.fx 0161: VS_OUTPUT VS_ParabploidEnvmap ( 0162: float4 Pos : POSITION // モデルの頂点 0163: , float4 Normal : NORMAL // 法線ベクトル 0164: ) 0165: { 0166: float3 ray; 0167: float3 ref; 0168: VS_OUTPUT Out = (VS_OUTPUT)0; // 出力データ 0169: 0170: // 位置座標 0171: Out.Pos = mul( Pos, mWVP ); 0172: 0173: // メッシュの色 0174: Out.Color = 0.0f; 0175: 0176: // テクスチャ座標 0177: ray = normalize( Pos - vEye ).xyz; 0178: ref = reflect( ray, Normal.xyz ); // 反射ベクトル 0179: 0180: Out.Tex0.x = 0.5 * ( 1 + ref.x/(1+ref.z)); 0181: Out.Tex0.y = 0.5 * ( 1 - ref.y/(1+ref.z)); 0182: Out.Tex1.x = 0.5 * ( 1 - ref.x/(1-ref.z)); 0183: Out.Tex1.y = 0.5 * ( 1 - ref.y/(1-ref.z)); 0184: 0185: Out.Color.w = ref.z + 0.5f; // 反射ベクトルの正負の判定 0186: 0187: // 法線 0188: Out.Normal = mul( Normal.xyz, mWV ); 0189: 0190: // SH係数用のスフィア座標 0191: float3 r = Normal; 0192: r.z += 1.0f; 0193: float m = 2 * length(r); 0194: Out.Sphere.x = Normal.x / m + 0.5f; 0195: Out.Sphere.y = - Normal.y / m + 0.5f; 0196: 0197: return Out; 0198: }
あぁ、しんどかったです。
SHの1次(平行光源)までしか扱っていませんが、
高次に拡張するのはたやすいので、
挑戦してみるとより複雑なライティングが実現できるでしょう。