Unityのシェーダーでノーマルマップを実装するには

モデリングのディテールアップには欠かせないノーマルマップですが、シェーダーを自作する上では少々難易度が高いテーマです。色味やマスクといったテクスチャと違い、ノーマルマップは法線や接線を相手にするテクスチャなので、取り出した成分をどのように変換するかがポイントとなってきます。今回は、このノーマルマップをどのようにシェーダーで実装するのかについて解説していきます。

Contents

ノーマルマップとは

シェーダーを自作する人はさすがにご存じかと思いますが、ノーマルマップとは法線情報を保存したテクスチャのことです。青っぽい独特の色味をしたテクスチャで、だれしも一度は見たことがあるでしょう。

ノーマルマップを解説する上で頻出するワードが、

  • 法線(normal)・・・接ベクトル空間において、ポリゴン面から垂直に伸びる直線
  • 接線(tangent)・・・接ベクトル空間において、法線と垂直に交わる直線
  • 従法線(binormal)・・・接ベクトル空間において、法線と接線の両方に垂直に交わる直線

となります。

しかし、この定義だけでは接ベクトル空間の方向を正しく定義することができません。なぜなら、法線と垂直に交わる直線は無数に存在するためです。接線が無数に存在するということは、従法線も無数に存在するということになります。

そのため、コンピュータグラフィックスにおける接線の方向は、接空間におけるUV座標のU方向として定義されています。そして、この接線の定義をもとに、従法線は接線と法線の外積によって求めることができるようになります。

ノーマルマップをインスペクタから設定できるようにする

それでは、実際にシェーダーでどのようにノーマルマップを実装していくのかを見ていきます。

ノーマルマップをインスペクタから設定できるようにするには、プロパティに以下のように記述します。

Properties
{
    [Normal] _NormalTex ("Normal map", 2D) = "bump" {}
}

[Normal]というアトリビュートを設定することによって、ノーマルマップとして設定されていないテクスチャがセットされた場合には、警告を出すことができるようになります。

構造体の宣言

頂点シェーダーに渡す構造体には、NORMALとTANGENTのセマンティクスをつけた変数を宣言しておきます。また、フラグメントシェーダーに渡す構造体には、法線・接線・従法線を格納するための変数を用意しておきます。

struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
    half3 normal : NORMAL;
    half4 tangent : TANGENT;
};

struct v2f
{
    float4 vertex : SV_POSITION;
    float2 uv : TEXCOORD0;
    half3 normal : TEXCOORD1; //法線
    half3 tangent : TEXCOORD2; //接線
    half3 binormal : TEXCOORD3; //従法線
    float4 worldPos : TEXCOORD4;
};

頂点シェーダー内で法線・接線・従法線を変換する

フラグメントシェーダーに渡す構造体にnormalやtangentを設定したので、その中身を計算していきます。とはいっても、モデリング座標系になっているベクトルをワールド座標系に変換するぐらいです。

唯一、従法線は法線と接線の外積から導出する必要があります。なお、下のコードではcrossにv.tangent.wがかかっています。このw成分は、UV座標が逆にマッピングされた場合に反転するようになっていますので、必要な変数となっています。

v2f vert (appdata v)
{
    v2f o;

    o.vertex = UnityObjectToClipPos(v.vertex); //頂点をMVP行列変換
    o.uv = TRANSFORM_TEX(v.uv, _MainTex); //テクスチャスケールとタイリングを加味
    o.normal = UnityObjectToWorldNormal(v.normal); //法線をワールド座標系に変換
    o.tangent = normalize(mul(unity_ObjectToWorld, v.tangent)).xyz; //接線をワールド座標系に変換
    o.binormal = cross(v.normal, v.tangent) * v.tangent.w; //変換前の法線と接線から従法線を計算
    o.binormal = normalize(mul(unity_ObjectToWorld, o.binormal)); //従法線をワールド座標系に変換
    o.worldPos = v.vertex; //変換前の頂点を渡す

    return o;
}

ノーマルマップをもとに法線を合成する

頂点シェーダー内でワールド座標系に変換した法線・接線・従法線を、ノーマルマップと合成します。合成の仕方は、以下の通りです。

half3 normalmap = UnpackNormal(tex2D(_NormalTex, i.uv)); //ノーマルマップをプラットフォームに合わせて自動解釈

//ノーマルマップをもとに法線を合成
float3 normal = (i.tangent * normalmap.x) + (i.binormal * normalmap.y) + (i.normal * normalmap.z); 

ノーマルマップのサンプリングには、UnpackNormalを使用する必要があります。どうしてこのマクロが必要かというと、通常のノーマルマップにはDXTnmという圧縮がかかっているため、そのままの状態では正しくサンプリングできないからです。DXTnmの圧縮については、こちらの記事(DXTnm形式とはなにか。あるいは、法線マップが青紫色な理由)が詳しいです。

端的に説明すると、ノーマルマップでは接線方向はRGBAのA成分に、従法線方向はG成分に格納されるように設計されています。このため、3D空間における成分計算に使用するためには、A成分とG成分をxyに直す必要があります。この成分の再配置を行っているのが、UnpackNormalというマクロになります。

またテクスチャ圧縮の歴史は長く、プラットフォームやDirectXのバージョンによっても、圧縮形式が異なる場合があります。すべてのケースを加味して、展開方法を手書きするのは非常に骨が折れることでしょう。そして、UnpackNormalはそうした差異を吸収してくれます。

ノーマルを使ったライティング

ここまで計算したノーマルを使って、メッシュの表面をライティングしていきます。とはいっても、計算したノーマルをランバート拡散反射モデルやフォン鏡面反射モデルに当てはめるだけです。この二つのモデルについては、こちらの記事をご覧ください。

そして、これらのモデルをざっくり実装するとこのような感じになります。

fixed4 frag (v2f i) : SV_Target
{
    float3 ligDirection = normalize(_WorldSpaceLightPos0.xyz); //シーンのディレクショナルライト方向を取得
    fixed3 ligColor = _LightColor0.xyz; //ディレクショナルライトのカラーを取得
    
    half3 normalmap = UnpackNormal(tex2D(_NormalTex, i.uv)); //ノーマルマップをプラットフォームに合わせて自動解釈
    float3 normal = (i.tangent * normalmap.x) + (i.binormal * normalmap.y) + (i.normal * normalmap.z); //ノーマルマップをもとに法線を合成

    //////////ランバート拡散反射
    float t = dot(normal, ligDirection); //ライト方向と法線方向で内積を計算
    t = max(0, t); //計算した内積のうち、t < 0は必要ないのでクランプ

    float3 diffuseLig = ligColor * t; //ディフューズカラーを計算。内積が0に近いほど色が黒くなる
    //////////

    //////////フォン鏡面反射
    float3 refVec = reflect(-ligDirection, normal); //ライト方向と法線方向から反射ベクトルを計算

    float3 toEye = _WorldSpaceCameraPos - i.worldPos; //カメラからの視線ベクトルを計算
    toEye = normalize(toEye); //視線ベクトルを正規化

    t = dot(refVec, toEye); //反射ベクトルと視線ベクトルで内積を計算
    t = max(0, t); //計算した内積のうち、t < 0は必要ないのでクランプ
    t = pow(t, _SpecularLevel); //反射の絞りを調整

    float3 specularLig = ligColor * t; //内積が1に近いほど照り返しが強いとみなし、ライトカラーを強く乗算
    //////////

    float4 finalColor = tex2D(_MainTex, i.uv); //カラーテクスチャからサンプリング
    finalColor.xyz *= (specularLig + diffuseLig); //ランバートとフォンの計算結果を乗算

    return finalColor;
}

シェーダー全体

ここまで説明してきた実装をすべてまとめると、以下のようなシェーダーにまとまります。

Shader "CAGraphicsAcademy/Normal"
{
    Properties
    {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        [Normal] _NormalTex ("Normal map", 2D) = "bump" {}
        _SpecularLevel("Specular Level", Range(1, 100)) = 0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                half3 normal : NORMAL;
                half4 tangent : TANGENT;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float2 uv : TEXCOORD0;
                half3 normal : TEXCOORD1; //法線
                half3 tangent : TEXCOORD2; //接線
                half3 binormal : TEXCOORD3; //従法線
                float4 worldPos : TEXCOORD4;
            };

            sampler2D _MainTex;
            sampler2D _NormalTex;
            float4 _MainTex_ST;
            float4 _NormalMap_ST;
            float _SpecularLevel;

            v2f vert (appdata v)
            {
                v2f o;

                o.vertex = UnityObjectToClipPos(v.vertex); //頂点をMVP行列変換
                o.uv = TRANSFORM_TEX(v.uv, _MainTex); //テクスチャスケールとタイリングを加味
                o.normal = UnityObjectToWorldNormal(v.normal); //法線をワールド座標系に変換
                o.tangent = normalize(mul(unity_ObjectToWorld, v.tangent)).xyz; //接線をワールド座標系に変換
                o.binormal = cross(v.normal, v.tangent) * v.tangent.w; //変換前の法線と接線から従法線を計算
                o.binormal = normalize(mul(unity_ObjectToWorld, o.binormal)); //従法線をワールド座標系に変換
                o.worldPos = v.vertex; //変換前の頂点を渡す

                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                float3 ligDirection = normalize(_WorldSpaceLightPos0.xyz); //シーンのディレクショナルライト方向を取得
                fixed3 ligColor = _LightColor0.xyz; //ディレクショナルライトのカラーを取得
                
                half3 normalmap = UnpackNormal(tex2D(_NormalTex, i.uv)); //ノーマルマップをプラットフォームに合わせて自動解釈
                float3 normal = (i.tangent * normalmap.x) + (i.binormal * normalmap.y) + (i.normal * normalmap.z); //ノーマルマップをもとに法線を合成

                //////////ランバート拡散反射
                float t = dot(normal, ligDirection); //ライト方向と法線方向で内積を計算
                t = max(0, t); //計算した内積のうち、t < 0は必要ないのでクランプ

                float3 diffuseLig = ligColor * t; //ディフューズカラーを計算。内積が0に近いほど色が黒くなる
                //////////

                //////////フォン鏡面反射
                float3 refVec = reflect(-ligDirection, normal); //ライト方向と法線方向から反射ベクトルを計算

                float3 toEye = _WorldSpaceCameraPos - i.worldPos; //カメラからの視線ベクトルを計算
                toEye = normalize(toEye); //視線ベクトルを正規化

                t = dot(refVec, toEye); //反射ベクトルと視線ベクトルで内積を計算
                t = max(0, t); //計算した内積のうち、t < 0は必要ないのでクランプ
                t = pow(t, _SpecularLevel); //反射の絞りを調整

                float3 specularLig = ligColor * t; //内積が1に近いほど照り返しが強いとみなし、ライトカラーを強く乗算
                //////////

                float4 finalColor = tex2D(_MainTex, i.uv); //カラーテクスチャからサンプリング
                finalColor.xyz *= (specularLig + diffuseLig); //ランバートとフォンの計算結果を乗算

                return finalColor;
            }
            ENDCG
        }
    }
}

まとめ

ノーマルマップの実装方法について解説してきましたが、Standardシェーダーでは簡単に設定できる分、意外に理解に苦しむかたもいるかもしれません。しかし、テクスチャ圧縮といった裏側の実装までのぞいてみると、ノーマルマップが普通のテクスチャと違って扱いが特殊になっていることも、自然と納得がいきます。

今回の記事では紹介しませんでしたが、キューブマップの映り込みはノーマルマップと非常に相性がいいです。興味のある方は、ぜひこちらの記事を参考に実装してみてはいかがでしょうか。

返信を残す

メールアドレスが公開されることはありません。