Unity
Shader

[Unity] バンプマッピング(法線マッピング)をやってみる

More than 1 year has passed since last update.

[2017.06.30 更新]
若干、説明などがあやふやだったので改めて整理して書き直しました。


バンプマッピング(法線マッピング)を自前実装してみたメモです。実装はUnityで行いました。

ちなみに実装にあたり、以下の記事を参考にさせていただきました。

サンプルコード

まずはシェーダコードから。

Shader "Custom/BumpMap" {
    Properties {
        _Color ("Color", Color) = (1,1,1,1)
        _BumpMap ("Bump map", 2D) = "bump" {}
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200

        Pass
        {
            CGPROGRAM

            #include "UnityCG.cginc"
            #pragma vertex vert
            #pragma fragment frag

            sampler2D _BumpMap;

            fixed4 _Color;

            // 接空間へ変換する行列を生成する
            // ※ 接空間からローカル空間への変換の逆行列で、ローカルのライトを接空間に変換する
            float4x4 InvTangentMatrix(float3 tan, float3 bin, float3 nor)
            {
                float4x4 mat = float4x4(
                    float4(tan, 0),
                    float4(bin, 0),
                    float4(nor, 0),
                    float4(0, 0, 0, 1)
                );

                // 正規直交系行列なので、逆行列は転置行列で求まる
                return transpose(mat);
            }

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : TEXCOORD1;
                float3 lightDir : TEXCOORD2;
            };

            v2f vert(appdata_full v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.normal = v.normal;
                o.uv = v.texcoord;

                // ローカル空間上での接空間ベクトルの方向を求める
                float3 n = normalize(v.normal);
                float3 t = v.tangent;
                float3 b = cross(n, t);

                // ワールド位置にあるライトをローカル空間へ変換する
                float3 localLight = mul(unity_WorldToObject, _WorldSpaceLightPos0);

                // ローカルライトを接空間へ変換する(行列の掛ける順番に注意)
                o.lightDir = mul(localLight, InvTangentMatrix(t, b, n));

                return o;
            }

            float4 frag(v2f i) : SV_Target
            {
                float3 normal = float4(UnpackNormal(tex2D(_BumpMap, i.uv)), 1);
                float3 light = normalize(i.lightDir);
                float diff = max(0, dot(normal, light));
                return diff * _Color;
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

法線マッピングの要、「接空間」

法線マッピングは、ざっくり言えば凹凸の情報をテクスチャに焼き付けて、それをライティング時に利用することで凹凸が「あるように」見せるテクニックです。
頂点情報的には凹凸があるわけではないので、モデルがカメラの近くに来るとだましているのがバレてしまいます。
しかし、ある程度距離を置いて見た場合は問題にならないので、使い方によってはとても有意義なテクニックです。

そして、この法線マッピングをするにあたって重要になるのが「接空間」の概念です。

Wikipediaから画像を引用させてもらうと、以下のようなイメージです。

ざっくり言うと、取り出したテクセルの位置に接する平面を基準とした座標空間のことです。(なので「接」空間)

そして大事な知識として、「上記平面の法線は、該当テクセルの位置の法線と一致する」ということです。

実際に適用したイメージを、wgld.orgさんから引用させてもらうと、以下のようなものです。
bump-sample.jpg

接空間へのベクトル変換処理

さて、テクスチャに情報を焼き付けると書きましたが、当然、そのままでは使えません。

なぜか。

法線マップ用のテクスチャを見てもらうと、青みがかったものをよく目にすると思いますが、これは、前述の接空間上でどう見えるか、という観点から法線を観測し、それを書き込んだものだからです。
つまり、ポリゴンの法線と同じ場合は(0.5, 0.5, 1.0)(Z軸に向かっている)となります。

法線マップの値

ちなみに、なぜZ軸に向かっている法線なのに(0.5, 0.5, 1.0)なのでしょうか。
普通に考えれば(0, 0, 1)になりそうです。

実は、テクスチャは(当然ですが)RGBを表すために用いられます。
つまり、RGBの値は0 ~ 1の値しか取れない(マイナスの値が取れない)のです。
しかし、法線は当然マイナスの値も存在し得ます。

なので、-1 ~ 1の値を0 ~ 1の値に適応させるために、-1011として扱うので0の値が真ん中の0.5になっている、というわけなんですね。

接空間への変換行列

さて、なぜ変換が必要かが分かったところで、どうやって変換するのかを見ていきます。

なんらかの変換を経て、ライティングに使えるようになります。

テクスチャ(法線マップ)に焼き付けられている情報は、接空間という空間上のベクトルでした。

大雑把に言えば、座標空間が違うわけですね。
なので、モデル座標変換などと同様、計算する際には同じ座標空間にベクトルを置いてやる必要があります。
それが上記の「変換」と言った部分です。

接空間への座標変換は頂点シェーダで行います。
以下が今回書いた頂点シェーダです。

// 接空間へ変換する行列を生成する
// ※ 接空間からローカル空間への変換の逆行列で、ローカルのライトを接空間に変換する
float4x4 InvTangentMatrix(float3 tan, float3 bin, float3 nor)
{
    float4x4 mat = float4x4(
        float4(tan, 0),
        float4(bin, 0),
        float4(nor, 0),
        float4(0, 0, 0, 1)
    );

    // 正規直交系行列なので、逆行列は転置行列で求まる
    return transpose(mat);
}

v2f vert(appdata_full v)
{
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);
    o.normal = v.normal;
    o.uv = v.texcoord;

    // ローカル空間上での接空間ベクトルの方向を求める
    float3 n = normalize(v.normal);
    float3 t = v.tangent;
    float3 b = cross(n, t);

    // ワールド位置にあるライトをローカル空間へ変換する
    float3 localLight = mul(unity_WorldToObject, _WorldSpaceLightPos0);

    // ローカルライトを接空間へ変換する(行列の掛ける順番に注意)
    o.lightDir = mul(localLight, InvTangentMatrix(t, b, n));

    return o;
}

接空間への変換用ベクトルを作る

まず最初にに行っているのが、接空間への変換をするためのベクトル作成です。
どうしてこれらのベクトルが必要なのか、どうしてこういう計算なのかの詳細は参考にしたこちらの記事を参照ください。

// ローカル空間上での接空間ベクトルの方向を求める
float3 n = normalize(v.normal);
float3 t = v.tangent;
float3 b = cross(n, t);

ちなみにnは「normal」、tは「tangent」、bは「binormal」の頭文字です。

nはそのままモデルの頂点法線を利用します。
続いてtは、接空間の情報がTANGENTセマンティクスを付けた変数に渡ってくるため、それを使います。
最後にbは、nと、tとの外積を取りY軸を求めています。

ライト位置を接空間に変換

最後に行っているのが、ライトの位置を接空間へ変換している箇所です。

// ワールド位置にあるライトをローカル空間へ変換する
float3 localLight = mul(unity_WorldToObject, _WorldSpaceLightPos0);

// ローカルライトを接空間へ変換する(行列の掛ける順番に注意)
o.lightDir = mul(localLight, InvTangentMatrix(t, b, n));

InvTangentMatrixは、接空間からローカルにベクトルを変換するための行列の転置行列です。
正規直交系の行列は転置を取ることで「逆行列になる」ため、つまりは「逆行列を求めている」わけですね。

そして、ローカル位置に変換したライトに上記の逆行列を掛けることで無事、接空間にライトがやってきた、というわけです。

ちなみに、行列の掛ける順番と意味についてはこちらの記事(その60 変換行列A×BとB×Aの違いを知ろう)がとても分かりやすいので、気になる人は読んでみてください。

以上で無事、ライトが接空間に変換されました。
あとは、フラグメントシェーダで、法線マップから取り出した法線と、接空間に変換されたライトとの比較を行うだけでライトの影響を計算することが可能となります。

バンプマッピング、思いついた人はすごいですね。