下の画像をクリックすると、サテンのBRDFでライトが回った時のアニメーションが見られます。
前々から光沢反射がよくわからなかったので、いろいろと調べていたのですが、
そんな中、CEDEC2003の開発の鉄人のNVIDIAデモチームの秘密における「The Time Machine のデモ」の部分で、”Dupon Cayman のラッカー”という言葉を見つけました。
あまり聞かない単語「Cayman」と「Lacquer」でググってみたら、2つ目で参考になりそうな
Factored BRDF Repositoryを見つけました。
これらは、Waterloo 大の
Michael D. McCoolとJason AngとAnis Ahmadによる論文Homomorphic Factorization of BRDFs for High-Performance Rendering, SIGGAPH 2001, August 12-17, 2001.
のデータが公開されているもので、この論文はBRDFを2次元テクスチャで表現できる形に分解しようというものです。
Factored BRDF Repository にあるデータを使うと、下のようなライティングが32x32の2枚のテクスチャを差し替えるだけでエナメルのような光沢から金属光沢、布地の光沢まで実現することができます。
今回のプログラムは、次のものです。
「1」,「2」で、BRDF の種類を切り替えます。
「z」,「x」で、ズームを切り替えます。
カーソルキーでティーポットが動きます。
「SHIFT」+カーソルキーでライトが動きます。
シェーダは1_1で作っていますが、2_0にするとちょっと変わります。
まぁ、いつものように適当にファイルが入っています。
APP WIZARD から出力されるフレームワークのファイルは紹介を省かせていただきます。
hlsl.fx | シェーダの入ったエフェクトファイル |
main.h | アプリケーションのヘッダ |
main.cpp | アプリケーションのソース |
あと、実行ファイル、プロジェクトファイルが入っています。
そもそも、BRDFとは何かということですが、BRDFとは物質の質感をあらわすパラメータで、物質に光が入射してきたときに、どのように反射するのか記述したものです。ωiからの入射光を Li(ωi)、ωoへ反射される光をLo(ωo)とすると、BRDFは、それらをつなぐもので、
Lo(ωo) = ∫BRDF(ωo, ωi) Li(ωi) cosθi dωi
とかける量です。ランバート拡散光の場合はBRDFは定数になります。
BRDFは、「時間が進んでも戻っても物理法則は変化しない」という物理学的な信念から次の性質を持たなくてはなりません(ヘルムホルツの相反定理)。
BRDF(ωo, ωi) = BRDF(ωi, ωo)
さて、このようなBRDFですが、Michael D. McCoolらは、このBRDFが多くの場合にいくつかの変数と法線ベクトルの内積の関数として書けることに気がつきました。例えば、Phongシェーディングは
BRDF(ωo, ωi) = (H.N)n * (1/(ωi.N))
と、ハーフベクトル H=normalize(ωo+ωi)や入射ベクトルと法線ベクトルの内積で書くことができます。
特に便利なBRDFの分け方として、
BRDF(ωo, ωi) = P(ωo.N) * Q(H.N) * P(ωi.N)
が考え出されています。先ほど述べた入射ベクトルと反射ベクトルの相反性から、入射ベクトルと反射ベクトルに関する関数Pは同じものでなくてはなりません。
PやQがBRDFを特徴付ける関数です。Phong鏡面反射光の場合、PやQは、
P(x) = 1/√x Q(x) = xn
となります。
このように、いくつかの関数に分解されたBRDFを「Factored BRDF」と呼びます。
ちなみに、今回のプログラムで表現されているものは、ほぼ全てこの3つの分解ですが、一般的には、この分解方法に縛られません。
さて、因数分解されたBRDFを使う方法としては、テクスチャに格納してサンプリングする方法が良く知られています。たとえば、下のようなxnに従う右側が明るいテクスチャを用意します、
テクスチャ座標のx座標を符号化スケーリングした値にすれば、Phong 鏡面反射が実現できます。
texcoord.x = 0.5 * H.N + 0.5
ところが、この方法では、各頂点のH.N以上に大きな値にはならないので、グーローシェーディングのようになってしまい、フォンシェーディングのようにはなりません。したがって、ハイライトが出にくくなります。
今回は、2次元のテクスチャを用意し、H.Nの値が1の時にテクスチャの中心を指定し、値が小さくなるに連れて外側の値を指定するサンプリング法を採用します。
この方法なら、ポリゴンの割り方が大雑把で、各頂点での色の強さがハイライトの強さよりも大幅に小さくても、ハイライトが現れます。
さて、肝心のテクスチャ座標の指定方法ですが、接ベクトルを使います。 接ベクトルTと従法線ベクトルBを使えば、メッシュ表面でのハーフベクトルなどの法線ベクトルとのずれの角度がわかるので、その値をテクスチャ座標に使います。
texcoord.x = r * (H.B) + 0.5 texcoord.y = r * (H.T) + 0.5
0.5は、半径 r が0のときにテクスチャの中心を指すように調整する変数です。
ここで、半径の値が問題です。Michael D. McCoolらは、次の計算方法を提唱しました。
texcoord.x = pv * ar * (H.B) + 0.5 texcoord.y = pv * ar * (H.T) + 0.5 7 pv = - 8 1 ar = --------- 2(1+H.N)この変換の式に対して、H.Nを変数としたテクスチャ座標に関する中心からの距離をグラフを描くと次のようになります。
ここで、グラフが反比例のような形ではなくて、2次曲線的にひねくれているのには理由があります。 H.NとH.B及びH.Tの値は独立ではありません。元のベクトルが規格化されていれば、これらのベクトルによる内積は基底の変換に他ならないので、
1 = (H.N)2 + (H.B)2 + (H.T)2
の関係が成り立ちます。つまり、上のグラフでプロットしたテクスチャの中心からの距離というのは、
7 1 - ---------|(H.B)2 + (H.T)2|1/2 8 2(1+H.N) 7 (1-(H.N)2)1/2 = - --------- 8 2(1+H.N) 7 1 (1-H.N)1/2 = - - -------- 8 2 (1+H.N)1/2
でした。この関数は、H.Nが1のときに0で、H.Nが-1の時に無限大に発散します。
この関数は、(-1,1)の範囲で単調減少関数なので、内積の値と距離は1対1に対応します。
なお、0.5になるのは-0.13付近で、テクスチャを使ってBRDFを表現するときには、内積の値が-0.13よりも小さな範囲は、この変換では正確には表現できません(まぁ、裏側だからあまり気にするな!)。
ちなみに、今回の変換は、BRDFの値が接ベクトルや従法線ベクトルに依存するようにできるので、中心対称でないのテクスチャを使えば異方性のBRDFにすぐに移行できます。実際に Satin の BRDF は、ライトベクトル、ハーフベクトル用のテクスチャ共に中心対称ではない異方性の反射になっています。
これで、どのようにやっているかはわかりましたが、実際にどのようにテクスチャを用意すればよいかはわかりません。
SIGGRAPHとかでは、実際に測定してみてBRDFを決定するのが流行ですが、そこをはずれたNPRの世界では自分でテクスチャを描く必要があります。
テクスチャがどのように構成されているのか探ってみましょう。
「Cornell Garnet Red Duplicolor T-345 Paint」を考えて見ます。このBRDFは、下のように、定数の色と視線ベクトルV、ライトベクトルLとハーフベクトルHからテクスチャをサンプリングして合成しています。
ここで、Qは、それぞれのベクトルを放物変換で2次元のテクスチャ座標へ変換する関数で、pとqは、テクスチャを使ったサンプリングを意味します。
とりあえずHの項がわかりやすいでしょうか。この項は主にPhongの鏡面反射成分をあらわします。中心が1で、後は急激に値が小さくなるので、HとNがほぼ一致するときだけ明るくなる鏡面反射の光だということがわかります。
次は、L及びVです。この項に関してテクスチャの中心を指すということは、ポリゴンを正面から見ていることをさします。ポリゴンを横から見るにしたがって、テクスチャの外側を指すようになるので、この項は見る(ライトの照らす)傾きを表現しているのだと理解でします。
見る傾きによって変わる効果とは何でしょうか?それはフレネル効果です。正面から見たときには、それほど反射しませんが、ほぼ横から見たときには、全反射となってライトの光は強く反射されます。
フレネル効果からおきる有名な現象に「カラーシフト」があります。横からの光の反射では、全反射になってしまうので、物体に塗られた塗料の色は反映されずに、光の色がそのまま反射されます。pのテクスチャはこの現象を再現していて、テクスチャの中心をさす正面を向いているときには、物体の色が乗りますが、横から見たときには塗料の色は乗らず、真っ白な色として入射光をそのまま跳ね返します。
すぐにわかるのはこのくらいなのですが、何らかの参考になったでしょうか?
ということで、今回のプログラムを紹介してきましょう。
今回は、ピクセルシェーダ、頂点シェーダ共に1_1で可能です。
今回の方法は、頂点シェーダでライトベクトル、視線ベクトル及びハーフベクトルからテクスチャ座標を計算して、ピクセルシェーダでそれらテクスチャ座標からBRDFを計算して色として出力します。
頂点シェーダからピクセルシェーダに受け渡すデータは、3つのテクスチャ座標になります。
hlsl.fx 0053: struct VS_OUTPUT 0054: { 0055: float4 Pos : POSITION; 0056: float2 v : TEXCOORD0; 0057: float2 h : TEXCOORD1; 0058: float2 w : TEXCOORD2; 0059: };
頂点シェーダでは、いつものように座標変換すると共に、法線ベクトルN、従法線ベクトルB、接ベクトルTを使って、視線ベクトルV、ライトベクトルW、ハーフベクトルHを2次元のテクスチャ座標に変換します。
従法線ベクトルBと接ベクトルTは、データとして持つのではなく、ローカル座標のy軸方向から頂点ごとに計算してみました。この方法は、法線ベクトルがy軸を向いていると正しく動かないので、その点は注意しましょう。
hlsl.fx 0083: // ------------------------------------------------------------- 0084: // 頂点シェーダプログラム 0085: // ------------------------------------------------------------- 0086: VS_OUTPUT VS(VS_INPUT In) 0087: { 0088: VS_OUTPUT Out = (VS_OUTPUT)0; // 出力データ 0089: 0090: // 位置座標 0091: Out.Pos = mul( In.Pos, mWVP ); 0092: 0093: // 表面座標系の基底ベクトルを求める 0094: float3 N = In.Normal; // 法線ベクトル 0095: float3 B = float3(0,1,0); // 従法線ベクトル 0096: float3 T = normalize(cross(N,B)); // 接ベクトル 0097: B = cross(T,N); 0098: 0099: // 必要なベクトルを計算する 0100: float3 V = normalize(EyePos -In.Pos.xyz);// 視点ベクトル 0101: float3 W = normalize(LightPos-In.Pos.xyz);// ライトベクトル 0102: float3 H = normalize(W+V); // ハーフベクトル 0103: 0104: // 各ベクトルをテクスチャ座標へ変換する 0105: Out.v = Q(V, B,T,N); 0106: Out.w = Q(W, B,T,N); 0107: Out.h = Q(H, B,T,N); 0108: 0109: return Out; 0110: }
テクスチャ座標の変換は、放物変換の公式どおりに座標変換をします。
今回のプログラムでは、内積の値が-1の時に発散しないように、0.000000001をつけてエラーが起きないようにしています(でもこれ、固定小数点の計算だと0にまるまるね)。
hlsl.fx 0061: // ------------------------------------------------------------- 0062: // 座標変換 0063: // ------------------------------------------------------------- 0064: float2 Q(float3 In, float3 B, float3 T, float3 N) 0065: { 0066: float2 Out = (float2)0; 0067: 0068: // 表面座標系へ変換 0069: float3 v; 0070: v.x = dot(B,In); 0071: v.y = dot(T,In); 0072: v.z = dot(N,In); 0073: 0074: // 3次元パラメータを2次元パラメータに変換 0075: float pv = 7.0f/8.0f; 0076: float ar = 1.0/(2.0*(1.000000001+v.z)); 0077: 0078: Out.x = pv * ar * v.x + 0.5; 0079: Out.y = pv * ar * v.y + 0.5; 0080: 0081: return Out; 0082: }
ピクセルシェーダでは、テクスチャ座標からサンプリングして全てを掛け合わせます。
hlsl.fx 0111: // ------------------------------------------------------------- 0112: // ピクセルシェーダプログラム 0113: // ------------------------------------------------------------- 0114: float4 PS(VS_OUTPUT In) : COLOR 0115: { 0116: float4 v = tex2D( SampP, In.v ); 0117: float4 h = tex2D( SampQ, In.h ); 0118: float4 w = tex2D( SampP, In.w ); 0119: 0120: return alpha * v * h * w; 0121: }
ちなみに、テクスチャはバイリニアサンプリングとはみ出したときのクランプ設定を必ずしましょう。ポイントサンプリングを適応すると、テクセルの形が無残にもみえてしまいますし、はみだしたときにループしているとライトの裏面などにおかしな表示がされてしまいます。
hlsl.fx 0015: // ------------------------------------------------------------- 0016: // テクスチャ 0017: // ------------------------------------------------------------- 0018: texture MapP; 0019: sampler SampP = sampler_state 0020: { 0021: Texture = <MapP>; 0022: MinFilter = LINEAR; 0023: MagFilter = LINEAR; 0024: MipFilter = NONE; 0025: 0026: AddressU = Clamp; 0027: AddressV = Clamp; 0028: }; 0029: // ------------------------------------------------------------- 0030: texture MapQ; 0031: sampler SampQ = sampler_state 0032: { 0033: Texture = <MapQ>; 0034: MinFilter = LINEAR; 0035: MagFilter = LINEAR; 0036: MipFilter = NONE; 0037: 0038: AddressU = Clamp; 0039: AddressV = Clamp; 0040: };
アプリケーションプログラムはそんなに複雑なことはしていません。 適当に変数とテクスチャを設定して描画します。
main.cpp 0392: //--------------------------------------------------------- 0393: // 描画 0394: //--------------------------------------------------------- 0395: if( SUCCEEDED( m_pd3dDevice->BeginScene() ) ) 0396: { 0397: if(m_pEffect){ 0398: // シェーダの設定 0399: m_pEffect->SetTechnique( "TShader" ); 0400: m_pEffect->Begin( NULL, 0 ); 0401: m_pEffect->Pass( 0 ); 0402: 0403: // 変換行列 0404: D3DXMATRIX mWVP = m_mW * m_mV * m_mP; 0405: m_pEffect->SetMatrix("mWVP", &mWVP); 0406: 0407: // ライトの向き 0408: m = m_mW; 0409: D3DXMatrixInverse( &m, NULL, &m ); 0410: D3DXVec4Transform( &v, &LightPos, &m ); 0411: m_pEffect->SetVector("LightPos", &v); 0412: 0413: // 視点 0414: v = D3DXVECTOR4(0,0,-5,0); 0415: m = m_mW; 0416: D3DXMatrixInverse( &m, NULL, &m ); 0417: D3DXVec4Transform( &v, &v, &m ); 0418: m_pEffect->SetVector("EyePos", &v); 0419: 0420: // 追加する色 0421: m_pEffect->SetVector("alpha", (D3DXVECTOR4*)&BRDF_color[m_nBrdf][0]); 0422: 0423: // テクスチャ 0424: m_pEffect->SetTexture("MapP", m_pTex[m_nBrdf][0]); 0425: m_pEffect->SetTexture("MapQ", m_pTex[m_nBrdf][1]); 0426: 0427: // ティーポットの表示 0428: m_pD3DXMesh->DrawSubset(0); 0429: 0430: m_pEffect->End(); 0431: }
ps_1_1でできるので、Xboxのタイトルでは使われてるんですかねぇ。
放物変換は、トゥーンなどの他の方法にも応用できるので、これから頻繁に使われていくでしょう。
BBSで、新坂さんから
さらっと流し読みしかしてませんけど、こういう2次元テクスチャ使った手法って、 光源と正反対の位置に特異点(と言えばいいのかな?)ができちゃいません?
というお言葉をいただきました。
たとえば、下の絵のティーポットの右側少し上の部分に奇妙な点が発生しています。
今までのプログラムでは、テクスチャが-1から1に切り替わる部分で、途中の補間に「0」の部分を通るので、たとえばライトが裏側に行った部分で、変な点が出ていました。
このままだと汚いので、修正しましょう。
修正したプログラムは、次のものです。
変更点はエフェクトファイルだけです。
hlsl.fx | シェーダの入ったエフェクトファイル |
変更の考え方は、「裏面に光源が行ったときにおかしくなるのだから、その時の強さを0にしてしまえ」ということです。BRDF の外側が必ず黒いわけではないので危険ですが、裏に行ったときには、陰になるので問題ないでしょうという感じです。
プログラム的な変更は、頂点シェーダからの出力に「裏への隠れ度」であるIntensityを追加して、普段は1、裏になった時は0になるようにプログラムで調整します。
構造体は、具体的には次のように変更します。
hlsl.fx 0050: // ------------------------------------------------------------- 0051: // 頂点シェーダからピクセルシェーダに渡すデータ 0052: // ------------------------------------------------------------- 0053: struct VS_OUTPUT 0054: { 0055: float4 Pos : POSITION; 0056: float4 v : TEXCOORD0; 0057: float4 h : TEXCOORD1; 0058: float4 w : TEXCOORD2; 0059: float4 Intensity : COLOR0; 0060: };
ピクセルシェーダでは、その色の強さを乗算して影響させます。
hlsl.fx 0125: // ------------------------------------------------------------- 0126: // ピクセルシェーダプログラム 0127: // ------------------------------------------------------------- 0128: float4 PS(VS_OUTPUT In) : COLOR 0129: { 0130: float4 v = tex2D( SampP, In.v ); 0131: float4 h = tex2D( SampQ, In.h ); 0132: float4 w = tex2D( SampP, In.w ); 0133: 0134: return alpha * v * h * w * In.Intensity; 0135: }
実際のIntensityの計算ですが、放物変換の座標変換でアルファ成分に値を設定しておいて、Intensityに出力します。
Intensityには、全てのベクトルによる陰の影響度を入れるのではなく、ライトベクトルの影響だけを反映させました。
実際問題、H.Nが負になることはないですし、V.Nが負になるのは面が裏を向いている時で、境界でしか影響は起きないのでほとんど効果は入りません。したがって、L.Nだけを考えれば良いということになります。
hlsl.fx 0094: // ------------------------------------------------------------- 0095: // 頂点シェーダプログラム 0096: // ------------------------------------------------------------- 0097: VS_OUTPUT VS(VS_INPUT In) 0098: { 0099: VS_OUTPUT Out = (VS_OUTPUT)0; // 出力データ 0100: 0101: // 位置座標 0102: Out.Pos = mul( In.Pos, mWVP ); 0103: 0104: // 表面座標系の基底ベクトルを求める 0105: float3 N = In.Normal; // 法線ベクトル 0106: float3 B = float3(0,1,0); // 従法線ベクトル 0107: float3 T = normalize(cross(N,B)); // 接ベクトル 0108: B = cross(T,N); 0109: 0110: // 必要なベクトルを計算する 0111: float3 V = normalize(EyePos -In.Pos.xyz);// 視点ベクトル 0112: float3 W = normalize(LightPos-In.Pos.xyz);// ライトベクトル 0113: float3 H = normalize(W+V); // ハーフベクトル 0114: 0115: // 各ベクトルをテクスチャ座標へ変換する 0116: Out.v = Q(V, B,T,N); 0117: Out.w = Q(W, B,T,N); 0118: Out.h = Q(H, B,T,N); 0119: 0120: // とりあえず、ライトに関して背面の処理を入れる 0121: Out.Intensity = Out.w.a; 0122: 0123: return Out; 0124: }
実際の強さの計算ですが、0と1をいきなり切り替えると、テクスチャのサイズの小ささによるぎざぎざが目立ってしまったので、適当な場所で線形補間しています。
線形補間するのは、ちょうどテクスチャの横の端から対角の端の間までにしました。この量はいいかげんなので、好きなように調整したり、マイクロファセットを導入して、その幾何減衰率を採用したりすることも可能でしょう(ここは1次元テクスチャで問題ないはずですし)。
プログラムが汚くて申し訳ないのですが、強さの計算の部分は、次のようになります。
hlsl.fx 0062: // ------------------------------------------------------------- 0063: // 座標変換 0064: // ------------------------------------------------------------- 0065: float4 Q(float3 In, float3 B, float3 T, float3 N) 0066: { 0067: float4 Out = (float4)0; 0068: 0069: // 表面座標系へ変換 0070: float3 v; 0071: v.x = dot(B,In); 0072: v.y = dot(T,In); 0073: v.z = dot(N,In); 0074: 0075: // 3次元パラメータを2次元パラメータに変換 0076: float pv = 7.0f/8.0f; 0077: float ar = 1.0/(2.0*(1.000000001+v.z)); 0078: 0079: if(0.7*(8.0f/7.0f)<ar){ 0080: // 外に出た時は、強さを0にする 0081: Out = 0; 0082: }else if(0.5*(8.0f/7.0f)<ar){ 0083: // 途中は線形補間 0084: Out = (0.7*(8.0f/7.0f)-ar)/(0.2*(8.0f/7.0f)); 0085: }else{ 0086: Out = 1; 0087: } 0088: 0089: Out.x = pv * ar * v.x + 0.5; 0090: Out.y = pv * ar * v.y + 0.5; 0091: 0092: return Out; 0093: }
この変更で、先ほどの点は綺麗に消えます。
ただし、この方法は正確な BRDF ではなくなってしまうので、許される変更かどうかはなんともかんとも…