ホーム < ゲームつくろー! < プログラマブルシェーダ編

シェーダ編
その5 0から学ぶ法線マップ


 ポリゴンの表面にはテクスチャが貼られる。これは3Dのゲームを作ろうと思う方はもちろんどなたでもご存知の事です。貼るという語意から、テクスチャには色が付いているんだろうなともイメージできます。しかし、DirectXの中では「色」というのは単なる数字です。特にシェーダの中に入ると、それは0.0〜1.0の小数点になってしまいます。

 テクスチャは何とも便利な物で、ポリゴン表面のある部分の色を示す事ができます。テクスチャの色は結局数値なのですから、これを別のもっと一般化すると「テクスチャはポリゴン表面のある部分の『値』を示す事ができる」となります。

 ポリゴン表面の性質にも色々とありますが、色味以外の代表格と言うと「法線」です。ポリゴンの向きですね。これまで、法線は頂点単位でしか定義されませんでした。ところがシェーダが公開された時、先の偉い人は「テクスチャがポリゴン表面のある部分の『値』を示せるなら、ポリゴンの内側の細かい起伏の向きをテクスチャで表現できるんじゃないかな?」と考えてくれました。それが「法線マップ(法線テクスチャ)」です。

 法線マップを用いると、一枚の板ポリゴンでさえ表面にでこぼこがあるように見せる事ができます。この技術が確立したお陰で、昨今のゲームの表現力は恐ろしいほどに向上しました。そして幸いな事に、それ程難しい事を考えなくても、法線マップはポリゴンに貼れるんです。

 この章では、そんな魅力的な法線マップについて0から見ていこうと思います。最初に法線マップ自体について解説します。次にそれをポリゴンに貼り付ける方法をじっくり検討し、そして最後に法線マップを実際に作る方法について簡単に触れます。



@ 法線マップの貼り付けイメージ

 法線マップとは1つのピクセルが「ベクトル」を表すテクスチャです。もちろん法線を表すベクトルです。法線マップをポリゴンに貼るとどうなるのか模式図で示すとこうなります:

これは4×4の法線マップを1枚の三角ポリゴンに単純に貼り付けた場合を見ています(あくまでもイメージです)。左側は貼り付ける前です。1枚のポリゴンに1つの法線が定義されています。一方右側は法線マップを貼り付けた場合です。先ほどの1枚のポリゴンの表面に、テクスチャのピクセルが覆う範囲ごとに法線が定義されています。それは1枚のポリゴンがピクセルの大きさに分割されているような状態です。テクスチャのサイズをもっと大きく(解像度を上げる)すれば、この分割がもっともっと細かくなってより詳細にポリゴン表面を演出できるのが良く分かると思います。もちろんポリゴンが実際に分割される事は無いので、頂点数は増えません。粗いポリゴンでも美しい凹凸表現が可能になるのが、法線マップ最大の魅力です。



A 法線マップのフォーマット

 法線マップの1ピクセルが1つの法線になる事がわかりました。では、実際にどういう値が格納されているのでしょうか?法線はベクトルです。つまりXYZの3つの成分が必要になります。テクスチャで3成分と言うと
「RGB」ですよね。法線マップではX成分をR、Y成分をGそしてZ成分をBで表現するのが一般的です。

 ところで、法線マップのXYZ軸とはどの空間の座標なのか?これ、凄く大切です。これは「ポリゴンのUV座標空間」を基準とするのが一般的です。ポリゴンを鯵の開き状態にすると次のようにUV空間に展開できます:

 これは円錐の展開図です。各ポリゴンの頂点には1つ1つUV座標が定義されていて、それをプロットして結ぶと上の図のように描かれるわけです。各ポリゴンが囲っているテクスチャがそのままポリゴンに貼り付けられます。テクスチャが法線マップの場合もそれは同様です。ポリゴンが鯵の開きになっているので、ポリゴン自体の法線は全部こちら側を真っ直ぐ向いています。法線マップの各ピクセルは、この座標空間で法線の向きを表現します。

 法線マップの法線がポリゴンの法線の向きと同じ場合、Z軸方向なので法線ベクトルは(0, 0, 1)になります。ちょっと左に傾いている場合は(-0.25, 0, 0.90)くらいでしょうか。法線は実質XYZのあらゆる方向に向けます。

 ただ、法線マップはあくまでもテクスチャなので、RGBの色にする必要があります。RGBの各成分は0〜1(0〜255の整数)なので、このままだとマイナスが表現できません。そこで、0.5を中心として0〜0.5までをマイナス、それ以上をプラスとして扱います。試しに-1.0から+1.0まで法線のX軸を変化させた時の色の変化を見てみましょう:

 下図は法線マップを真横から見た様子で、上はそのカラーです。こんな感じの色合いになるわけです。シェーダに入ってくる時にはRGB成分は0〜1.0に変換されています。それをXYZの値に変換すれば法線の向きになります。その辺りは後述します。

 法線マップは鯵の開きになったポリゴンに対してRGBの色成分でその法線の向きを表している。これがこの節のまとめです。



B 法線マップの法線をローカル座標で表すには

 さて、法線マップの点がUV座標を基準とした鯵の開きポリゴンの法線の向きを表しているのはわかりました。しかし、このままポリゴンに貼り付いたとしても、法線の向きは(0, 0, 1)のような値になっているわけです。ポリゴンに定義されている法線はローカル座標で定義されていますよね。法線マップの法線も是非そうなって欲しいわけです。じっくり攻めてみましょう。

 UV座標にある1枚の三角ポリゴン。それを抜き出します:

 Z軸はこちらを真っ直ぐ向いています。このポリゴンが実際にローカルにある様子がこちら:

 2つの座標があります。1つはローカル座標。もう1つは貼り付けられたポリゴンのUV座標です。先の真上から見たのをそのままぺタっと貼り付けたと思って下さい。法線マップの法線はこのUV座標で定義されています。

 ここで極めて重要な言葉を紹介します。上のローカル座標にあるUVZ座標(空間)。この座標空間の事を「接空間 (Tangent Space)」もしくは「サーフェース空間 (Surface Space)」と言います。接空間と出てきたら「ローカル座標にあるUVZ座標の事なんだな」と思い返して下さい。以後混乱を避けるために接空間で統一します。

 さて、ローカル座標とポリゴンの接空間。これらを結びつける鍵は「ポリゴンの法線」です。ポリゴンは(正しくは頂点は)ローカル座標で表された法線ベクトルを1つ持っています。上の図で言うとそれは接空間のZ軸とぴったり一致します。このポリゴンのローカル法線ベクトルは頂点シェーダで「NORMAL」というセマンティクスを指定すると取得できます。つまり、NORMALで得たベクトルは上のZ軸の方向というわけです。

 では接空間を規定する他の2つの軸、UとVのローカル座標での方向はどうか?実はこれも頂点シェーダで「TANGENT」及び「BINORMAL」と指定すると取得できます。こういう所をちゃんと考慮してくれているわけです。つまり、ローカル座標にある接空間の各軸の向きは既知なんです。法線マップの法線ベクトルの向きはUVZ座標で定義されていますが、3軸のローカル座標での方向がわかっているのですから、その向きも計算できてしまうわけです。

 具体的に行きます。法線マップに刻印された法線ベクトルの値をN=( t, b, n )とします(Tangent, Binormal, Normalの頭文字)。このベクトルは詳しく言えば(t, 0, 0)、(0, b, 0)、(0, 0, n)という各軸に沿っている3つのベクトルの合成です。

 一方で、頂点シェーダ内で得られる接空間のU、VそしてZ軸のローカル座標向きをそれぞれ、

 UL = (UL_x, UL_y, UL_z)
 VL = (VL_x, VL_y, VL_z)
 ZL = (ZL_x, ZL_y, ZL_z)

とします。ちなみに各軸のベクトルは標準化されているとします。

 例えば、ULの元はU軸です。この軸に沿った(t, 0, 0)というベクトルは、UL軸上だと

 t * UL = ( t * UL_x, t * UL_y, t * UL_z )

となります。単位ベクトルULをt倍しているだけですが、十分イメージできますよね。同様に他の軸に沿ったベクトルも、

 b * VL
 n * ZL

と算出できます。ローカル座標で表された3つのベクトル。このベクトルを合成すれば法線マップの法線ベクトルをローカル座標で表す事ができます。すなわち、

 N_Local = t * UL + b * VL + n * ZL

がその向きとなります。やりました!UV座標にあった法線ベクトルがローカル座標に移ったわけです。こうなれば、いつもの法線と全く同じように扱えます!



C 逆転の発想、ローカルを接空間にすると計算量を超稼げます

 さて、Bで法線マップの法線をローカル空間に結構あっさりと変換できる事を示しました。しかし、実はパフォーマンスの観点から、これと逆の発想がより良い事が知られています。

 BでUVZ軸のローカル座標方向と法線マップの法線ベクトルを掛け算して合成すると法線ベクトルがローカルに移ると説明しましたが、法線マップのピクセルの色は「ピクセルシェーダ内でないと取得できません」。つまり、Bの計算はピクセルシェーダ内で行うわけです。ただ考えてみて下さい。ピクセルシェーダは頂点シェーダに比べて呼び出し回数が桁違いに多いものです。頂点シェーダの呼び出し回数は高々数万回でが、ピクセルシェーダは時に数百万回を超えます。と言う事はピクセルシェーダ内であまり計算をさせたくないのが本音です。

 法線は殆どの場合、ライトの方向と法線の向きとで「表面の明るさ」を算出するのに使われます。例えば、法線に対してライトが逆向きの場合、その面は最大級に明るく照らされています。面にライトが斜めに当たるほど、その明るさは暗くなります。その大きさは法線ベクトルとライトの向きを反転させたベクトルの内積で算出されます。ただし、ここがとっても重要なのですが、この内積は「法線とライトが同じ座標で向きが表されている時だけ」有効です。そりゃそうですよね。ワールド空間にあるライトと、ローカル空間にある法線の内積を取っても何の意味もありません。両方とも空間を揃えないとだめなわけです。 

 上の赤太文字。これがポイントです。空間が同じであれば、内積で面の明るさが計算できる。Bの場合、共通空間はローカル空間です。空間はワールドでもビューでも実は構いません。となると、法線マップの法線の値が定義されているUVZ空間(接空間)にライトを持っていっても計算は出来るわけです。

 どうして接空間なのか?こうするとピクセルシェーダ内でBの計算をしなくて良いからです。ピクセルの値がそのまんま使えるんです。さらに、ライトの向きはテクスチャではありませんから、頂点シェーダでUVZ空間に移動可能です。計算した値をピクセルシェーダにそのまま持っていけば、両方とも同じ空間にいるため、法線マップの法線の値と直ぐに内積を取れるわけです。ピクセルシェーダでの1ポリゴン数百〜数千回のベクトル計算が、頂点シェーダでのライトのたった3回(三角形なので)の計算で済んでしまう。結果として全体の計算量はべらぼうに下がるわけです。

 長々と説明が入りましたが、要はローカル空間(もしくはワールド空間)にあるライトを接空間に変換できると、おいしいわけです。その方法を次の節で説明します。



D ローカル→接空間変換の計算方法

 今仮に、接空間にライトがあるとして、その向きをL=(Lt, Lb, Ln)とします。法線マップの法線の時と同様に、この向きに接空間の軸のローカル座標向きを掛けて足し算すると、ライトの向きがローカル座標に変換されます。式はこういう感じです:

 L_Local = Lt * UL + Lb * VL + Ln * ZL

法線と同じです。この右辺ですが、ちょっと行列で書き直してみます:

 左辺にライトのローカルでの方向(LLocal)、右辺にはライトの接空間での方向(L)があります。この式は「接空間にあるライトをローカルへ」です。しかし、今私たちが欲しいのは「ローカルにあるライトの向きを接空間へ」です。逆の関係ですね。と言う事は、下の行列の逆行列を右から掛けてあげると、欲しい式が出てきます。すなわち、

です。結局、行列の逆行列が求められればライトを接空間に持っていけます。

 実はここでこの行列には嬉しい性質があるんです。上の接空間の各軸のローカル座標向きを並べた行列。横方向に見ていくとUL、VL、ZLはそれぞれ標準化されて、しかもお互いに直行しています。こういう行列の事を「正規直交系」と言います。実は、正規直交系の行列の逆行列は転置行列になります。なぜかは省略します(計算するとちゃんと出てきます)。

 よって、頂点シェーダ内で上の行列が与えられたら、それをくるっと転置しちゃえば逆行列に生まれ変わります。

 この座標変換は目線(カメラの向き)に関しても言えます。目線が重要なのは「スペキュラ」です。スペキュラの計算をするには法線と、ライトと、そして目線の向きが必要になります(後の章で紹介します)。この3つの方向をやはり接空間に持っていけば、同様にスペキュラの光具合が計算できるわけです。

 法線マップを使うときには、ライトや目線を接空間に持っていこう。これが、この節のまとめです。



E 実装:メッシュにTangentとBinormalを追加する

 理屈の話はここまでで十分です。ここからは実装の話に移ります。

 DirectX9の場合、一番ポピュラーなメッシュフォーマットは「Xファイル」です。Xファイルを出力できる3Dモデリングツールは多いのですが、物によっては法線マップを貼るのに必要な各頂点ごとのTangent(U軸 or X軸)とBinormal(V軸 or Y軸)を出力しないものがあります。この場合、DirectX側でメッシュを作り直す必要があります。

 既存のメッシュ情報から新しい頂点情報を追加するにはID3DXMesh::CloneMeshメソッドを用います:

ID3DXMesh::CloneMeshメソッド
HRESULT CloneMesh(
   DWORD                     Options,
   CONST LPD3DVERTEXELEMENT9 pDeclaration,
   LPDIRECT3DDEVICE9         pDevice,
   LPD3DXMESH*               ppCloneMesh
);

Optionsにはメッシュ作成時のオプションを指定します。色々あるのですが、今回は元のメッシュの情報を流用します(後述)。
pDeclarationには作成するメッシュに含める頂点情報をD3DVERTEXELEMENT9構造体の配列で指定します。
pDeviceは描画デバイスです。
ppCloneMeshには作成した新しいメッシュが返ります。

 このメソッドを用いてTangentとBinomalの情報を追加する関数は次のようです:

TangentとBinormalの情報を付記する関数
HRESULT CloneMeshWithTangentAndBinormal(
   IDirect3DDevice9* pDev,         // 描画デバイス
  ID3DXMesh*        pSrcMesh,     // 元のメッシュ
   ID3DXMesh**       pOutCloneMesh // クローンメッシュ
)
{
   // 頂点宣言
   const D3DVERTEXELEMENT9 vertexDecl[] =
   {
      { 0,  0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0 },
      { 0, 12, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 0 },
      { 0, 20, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_NORMAL, 0 },
      { 0, 32, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TANGENT, 0 },
      { 0, 44, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_BINORMAL, 0 },
      D3DDECL_END()
   };

   LPD3DXMESH pTempMesh = NULL;

   // クローンメッシュ生成
   if ( FAILED( pSrcMesh->CloneMesh( pSrcMesh->GetOptions(), vertexDecl, pDev ppOutCloneMesh ) ))
   {
      return E_FAIL;
   }

   return D3D_OK;
);

引数に必要な情報を入れれば、内部でクローンメッシュを作ってくれます。
(尚、このソースはDirectX SDKのParallaxOcclusionMappingサンプルを参考にしています)

 このメッシュを描画すれば、頂点シェーダにNormal、Tangent、Binormalの3軸情報が入るはずです。



F 実装:頂点シェーダ

 では頂点シェーダの仕事に移りましょう。頂点シェーダですることを列挙します:

・ ローカル座標にある頂点を射影空間へ (いつもの頂点変換)
・ Tangent(X)、Binormal(Y)、Normal(Z)を行列TangentMatに格納
・ TangentMatを転置して逆行列を作成
・ ローカルにあるライトを接空間へ
・ 接空間内のライトの方向をTEXCOORDに格納

色々なものが入り組んでいるので、分かりやすく関数にわけてみます。まずはいつもの頂点変換です:

頂点変換
float4 TransVertex(
   float4 vertex,
   float4x4 worldMat,
   float4x4 viewMat,
   float4x4 projMat )
{
   vertex = mul( vertex, worldMat );
   vertex = mul( vertex, viewMat );
   vertex = mul( vertex, projMat );
   return vertex;
}

特に問題ありません。続いて、TangentMat(接空間行列)の逆行列を求める関数を作ります:

接空間行列の逆行列を算出
float4x4 InvTangentMatrix(
   float3 tangent,
   float3 binormal,
   float3 normal )
{
   float4x4 mat = { float4(tangent , 0.0f),
                    float4(binormal, 0,0f),
                    float4(normal  , 0.0f),
                   {0,0,0,1}
                  };
   return transpose( mat );   // 転置
}

 正規直交系なので転置するだけでOKです。この関数が返す逆行列をローカル空間にあるライトが向く方向ベクトルに対して掛け算すると、ライトは接空間に移ります。通常ライトはワールド空間に置かれるもんですが、このシェーダに入力する前にローカルに変換しておきます(オブジェクトのワールド変換行列の逆行列を掛ければ良いだけです)。

ライトを接空間に移動
Out.lightTangentDirect = mul( -lightLocalDirect, InvTangentMatrix( tangent, binormal, normal ) );

 これで頂点シェーダでの作業は終了です。



G 実装:ピクセルシェーダ

 ピクセルシェーダの作業は次の通りです:

・ 法線マップから取得した色を法線ベクトルに変換(標準化)
・ 同じ空間にあるライトの向きと法線ベクトルの内積から点の明度を計算
・ ディフューズの色を明度で調節して点を打つ

 法線マップの色は0〜1.0です。これを-1.0〜1.0のベクトル範囲にするのがポイントですね。一気に参ります:

ピクセルシェーダ
float4 NormalMap_PS( float4 inUV : TEXCOORD0, float3 inLightTangentDirect : TEXCOORD3 ) : COLOR0
{
   // 法線の色を取得
   float3 normalColor = tex2D( normalSampler, inUV );
   float3 normalVec = 2 * normalColor - 1.0f;  // ベクトルへ変換
   normalVec = normalize( normalVec );         // 標準化

   // ライトの向きと法線マップの法線とで明度算出
   float3 bright = dot( inLightTangentDirect, normalVec );
   bright = max( 0.0f, bright );   // マイナスは0に補正

   // ディフューズ色を取得して明度で明るさを補正
   float4 diffuseColor = tex2D( diffuseSampler, inUV );
   return float4( bright * diffuseColor.xyz, 1.0f );
}

 これで法線マップをモデルに貼り付けられます。ライトが当たっていないと判断される部分は黒くなり、その逆はより白色に近くなるので、凹凸間が出てきます。

 シェーダについての完全な実装はサンプルプログラムの中のNormal.fxにあります。ほぼ同じ事を書いていますが、並行してご覧頂くと参考になると思います。



H 法線マップの作り方

 さて、ここまでは法線マップありきのお話でした。でも、実際に法線マップを作成するにはどうしたら良いのでしょうか?

 プロの現場では色々なツールで法線マップを作っています。Maya等の3Dモデリングツールに付属している高機能の法線マップ作成機能がメジャーのようです。私が教えてもらったのはMayaにある法線マップをベイクする機能で、「こりゃすごい」といたく感動したもんです。もっと強烈に凄いのがZBrush(Pixologic, Inc.)という法線マップを作るため(≒高解像度メッシュを作るため)の専門ツールです。ポリゴンの表面を直に削ったり盛り上げたり、スタンプを押すような感覚で表面をでこぼこにしたりと、粘土細工でも扱っているかのごとく高解像度ポリゴンを作成できます。ZBrush 3で9万円くらいなので、気合を入れれば買えます。他にもSOFTIMAGE|XSI(ver5以降)のUltimapperでも作成できるようです。

 これらのツールはいずれも「超高解像度のポリゴンメッシュで凹凸の詳細を作り、それを低解像度のメッシュに投影すれば法線マップができる」という方式を取っています。考え方はとてもシンプルですが、実用できるソフトがあるのはすばらしい事です。

 高さマップ(バンプマップ、ハイトマップ)から高品質の法線マップを作ってくれるソフトとしてはCrazyBumpがあるようです。法線マップだけでなくオクルージョンマップなども出力できるようです。お値段も300ドルくらいで手ごろなのも良いですね。また、法線マップのみですが同様の機能をPhotoshopのプラグインとして無償提供しているのがNVIDIAが提供している「NVIDIA Tools」の「NormalMapFilter」プラグインです。ちょっとした凹凸であればこれでも十分な品質が出ます。



 法線マップについてこれ以上無いくらいじっくりと説明してきました。理屈は面倒だったのですが、わかってしまうと実装はそれ程難しくはありません。サンプルプログラムも用意しましたのでご参照下さい。