チリペヂィア

リンクフリー。サンプルコードなどは関連記事内でライセンスについて明示されない限り商用利用なども自由に行って構いませんが、自己責任でお願いします。またこれら日記内容の著作権自体は放棄していません。引用部分については引用元の権利に従ってください。

Unity、DirectX環境でのNormalMapの内部的なフォーマットお勉強

スクリプトからメモリ上のTexture2DにSetPixelsを行って法線マップ画像を生成し、NormalDiffuseシェーダーのNormalMapとして設定したい時にちょっとひっかかったので、そのお話。

基本的にUnityではGL系で一般的なNormalmapの形式(チャンネルの割り振りがRGBA=XYZW)に対応していますが、一度NormalmapとしてTextureをインポートしてしまうと、内部的には環境依存で暗黙のフォーマット変換を受けているケースがあります。

DXT5nmというフォーマットに変換されるかも

Creating runtime normal maps using renderToTexture
UnpackNormal(fixed4 packednormal) role ?
ウチのRadeon挿したWin機では変換されてました。DXT5nはGA(緑と赤、軸で言えばYとW)要素だけのフォーマット(法線マップで重要じゃない要素を切ってメモリサイズを節約し、レンダする時にシェーダ上で復元するつもりの形式)。なお、

  • NormalMapとして画像ファイルからインポートされる時、勝手に変換されている

というわけで、NormalmapとしてインポートされたテクスチャをDX環境でTexture2D.GetPixels()してPNGに保存してみると、RGBA=YYYXでロードされているのが分かり、確かにZW要素が消えている結果になります(*1)。コレ自体は「あぁ、そりゃそうした方が効率イイネ」という感じですが、問題になるのはNormalDiffuseシェーダのテクスチャサンプリング計算です。Unityからはコンパイル済みシェーダーしか読めないので、ビルトインシェーダー落としてきてチェックしますと、

NormalBumped.shader (Normal Dffuseシェーダ)

サンプリング時にマクロ処理が通されています。

o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
UnityCG.cginc

マクロ処理の実装は以下。

inline fixed3 UnpackNormalDXT5nm (fixed4 packednormal)
{
	fixed3 normal;
	normal.xy = packednormal.wy * 2 - 1;
#if defined(SHADER_API_FLASH)
	// Flash does not have efficient saturate(), and dot() seems to require an extra register.
	normal.z = sqrt(1 - normal.x*normal.x - normal.y*normal.y);
#else
	normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
#endif
	return normal;
}

inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if (defined(SHADER_API_GLES) || defined(SHADER_API_GLES3)) && defined(SHADER_API_MOBILE)
	return packednormal.xyz * 2 - 1;
#else
	return UnpackNormalDXT5nm(packednormal);
#endif
}

GLESやスマホフラグが立っていない環境ではDirectXモードということでNormalDiffuseの法線サンプリング計算はDXT5nを前提に「YW要素をXYZWのXYに変換し、Z要素をXYから復元して埋める」計算に切り替わってますね。この都合、もしスクリプト側で一般的なRGBA=XYZ(H)フォーマットで法線マップをリアルタイム生成して、マテリアルにセットすると、DX環境ではGAだけ読んで変なベクトルに変換されてしまいます(G→G,A→Rに移動して他の要素は消される)。GchannelにY、AchannelにXを割り当てるとウチでは問題なくなりますが、すると今度はGL環境で挙動が怪しくなりますね。スレにも出ている通り、とりあえずは"NormalDiffuseGL"なシェーダーを自前で用意してしまうのが一番簡単でしょうか。つまり…

NormalDiffuseのシェーダの例の場所を↓な感じに書き換えたシェーダーなら、紫色の法線マップをそのまま使用できます。

// o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
o.Normal = tex2D(_BumpMap, IN.uv_BumpMap) * 2 - 1;

このシェーダであれば、NormalMapモードとしてインポートされたテクスチャでなくても法線マップとして利用できます(*2)。

一番良いのは、Texture2DをSetPixelsする時にスクリプトからシェーダー定数を読んで要素の埋め方をスイッチする方法だと思うんですけど、スクリプトからシェーダー定数を読む方法が良く分かりません(*3)。ただ、↓を読む限り、その辺はあまりUnity的にも積極的ではないのかも。
Unityリファレンス / 定義済みシェーダ プリプロセッサマクロ

他、参考にしたリンク

*1:GetPixels()したあと、Aを1で埋めて色要素だけにした画像1、それとAをRGBに転写してAを1で埋めたA-Channelだけにした画像2をEncodeToPNG()して、それぞれ適当なファイルに書き出してみると、画像1が縦方向要素だけのグレースケールグラデーション、画像2が横方向だけのグレースケールグラデーションになり、XY要素しか残ってない状態に変換されている事が分かります。formatメンバは、DXTは定義されていないためか、ARGB32で返ってきましたw

*2:その代わり、DX環境ではDXT5n使うよりはメモリの使用効率がちょっと悪いですし、既にNormalmapとしてインポートしてDXT5n変換されているテクスチャは使用できません。

*3:判定用シェーダを書いて1pxレンダして出力された色を読むとかしか思いつきません。あるいは虹色の画像をNormalmap設定でインポートさせてテクスチャのRGB要素がグレースケールになっちゃってるか見るとか。