【連載】Unity時代の3D入門 – 第9回「ノーマルマッピング」

こんにちは、クライアントサイドエンジニアの矢野です。

第8回では応用編として、金属やプラスチックの材質表現について書きました。
第9回では凹凸を表現する技術であるノーマルマッピングについて説明します。

なぜ使うか?

いま、表面に細かい凹凸がある物体をレンダリングすることを考えます。

09-01-01.png
これを実現する方法として、まず頂点を細かく分割して凹凸をつける方法が考えられます。
しかし、頂点を分割するということは頂点シェーダやスキンメッシュアニメーションなどの処理負荷が大きくなり、パフォーマンスに悪影響を及ぼします。

そのため、頂点数の少ないメッシュに何かしらの工夫を施して、そこに凹凸があるように見せる必要があります。
ノーマルマッピングはこのような場合に使います。

凹凸の特徴を観察する

凹凸の表現を考えるには、まずその特徴を観察して知る必要があります。
平面と凹凸のある面を比較すると、凹凸のある面からは2つの大きな特徴を見て取れます。

  1. 高さの違いにより光が遮られて、陰やハイライトが生じる
  2. 高さの違いにより視線を遮られて、目視できない部分が生じる

09-01-00.png
これらを表現できれば、凹凸を表現することができたと言えそうです。

今回扱うノーマルマッピングでは1. の現象を表現します。
2. については今回の本筋から外れるので、補足で少しだけ触れることにします。

[原理] ノーマルマッピング

前節で説明した通り、凹凸のある面には陰やハイライトが現れます。
この陰やハイライトを作る計算は第5回第6回で行っており、それぞれ以下のように計算されました。

  1. 拡散反射光の強さはライト方向の単位ベクトルと法線ベクトルとの内積を使って計算される
  2. 鏡面反射光の強さはハーフベクトルと法線ベクトルとの内積を使って計算される

これらの計算を見比べると、どちらも法線とライトベクトルを使っていることがわかります。
この2つのうち、メッシュが持っている情報は法線であるため、法線の方向を変えれば陰やハイライトの入り方が変わりそうです。

これを試すために Unity の Plane メッシュの法線情報のみを書き換えてレンダリング結果がどう変わるか観察してみます。

下図において、緑の線は各頂点が持つ法線です。
本来はy軸正方向を向いていますが、スクリプトで書き換えてアニメーションさせています。

09-01-01.gif

法線に応じてハイライトなどのかかり方が変わっていることがわかります。

また、この法線情報をテクスチャの1ピクセル毎に書き込んでメッシュにマッピングできれば、かなり細かく法線を変更できます。
つまりかなり細かい凹凸まで表現できることになります。

このように、法線情報を書き込んだテクスチャをマッピングし、そこから法線を計算する手法をノーマルマッピングといいます。

また、このテクスチャをノーマルマップと呼びます。
ノーマルマップについては次節で説明します。

ノーマルマップ

ノーマルマップとは法線情報を色情報として書き込んだテクスチャのことです。

テクスチャはRGBのそれぞれについて0〜1の値を持っています。
一方、法線は正規化された三次元ベクトルなので、XYZそれぞれについて-1〜1です。

つまり、法線情報をテクスチャに書き込むためには-1~1の値を0~1に変換する必要があります。
したがって、法線に0.5をかけて0.5を足したものをテクスチャに法線情報として格納します。
09-02-00.png
また、ノーマルマップではRGBのBに格納されている値をy軸の正の方向(上方向)として扱うため、RGBが(XYZではなく)XZYに対応することに注意してください。

[シェーダ] ノーマルマッピング

それでは実際にシェーダを書いてノーマルマッピングを実装してみます。

Shader "Custom/NormalMapping" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _NormalMap ("Normal map", 2D) = "bump" {}
        _Shininess ("Shininess", Range(0.0, 1.0)) = 0.078125
    }
    SubShader {

        Tags { "Queue"="Geometry" "RenderType"="Opaque"}

        Pass {
            Tags { "LightMode"="ForwardBase" }

            CGPROGRAM
            #include "UnityCG.cginc"

            #pragma vertex vert
            #pragma fragment frag

            float4 _LightColor0;
            sampler2D _MainTex;
            sampler2D _NormalMap;
            half _Shininess;

            struct appdata {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
                // 頂点の法線と接線の情報を取得できるようにする
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
            };

            struct v2f {
                float4 pos : SV_POSITION;
                half2 uv : TEXCOORD0;
                half3 lightDir : TEXCOORD1;
                half3 viewDir : TEXCOORD2;
            };

            v2f vert(appdata v) {
                v2f o;

                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv  = v.texcoord.xy;

                // 接空間におけるライト方向のベクトルと視点方向のベクトルを求める
                TANGENT_SPACE_ROTATION;
                o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex));
                o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex));

                return o;
            }

            float4 frag(v2f i) : COLOR {
                i.lightDir = normalize(i.lightDir);
                i.viewDir = normalize(i.viewDir);
                half3 halfDir = normalize(i.lightDir + i.viewDir);

                half4 tex = tex2D(_MainTex, i.uv);

                // ノーマルマップから法線情報を取得する
                half3 normal = UnpackNormal(tex2D(_NormalMap, i.uv));

                // ノーマルマップから得た法線情報をつかってライティング計算をする
                half4 diff = saturate(dot(normal, i.lightDir)) * _LightColor0;
                half3 spec = pow(max(0, dot(normal, halfDir)), _Shininess * 128.0) * _LightColor0.rgb * tex.rgb;

                fixed4 color;
                color.rgb  = tex.rgb * diff + spec;
                return color;
            }

            ENDCG
        }
    }
}

まず、頂点の入力構造体に法線と接線の情報を追加します。
セマンティクスはそれぞれNORMALとTANGENTを使用します。

                float3 normal : NORMAL;
                float4 tangent : TANGENT;

次に頂点シェーダで、ライト方向のベクトルと視点方向のベクトルを求め、接空間に変換しています。
接空間に関しては次節で説明しますのでここでの説明は省略します。

                TANGENT_SPACE_ROTATION;
                o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex));
                o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex));

フラグメントシェーダでは UnpackNormal 関数を使ってノーマルマップから法線情報を取得しています。

half3 normal = UnpackNormal(tex2D(_NormalMap, i.uv));

UnpackNormal は Unity で用意している関数で、テクスチャに0〜1で書き込まれている法線情報を -1〜1 の値に変換します。
この変換は基本的には2をかけてから1を差し引くだけですが、Unity では都合によりプラットフォームごとに処理が異なるため、関数化してラップされています。

参考までに、この関数の中身は次のようになっています。

inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined(UNITY_NO_DXT5nm)
    return packednormal.xyz * 2 - 1;
#else
    return UnpackNormalDXT5nm(packednormal);
#endif
}

DXT5nm というフォーマットでノーマルマップを取り扱うプラットフォームがあり、その場合に処理を分岐させています。

さて、ここまでで法線情報を得られたので、あとはこの法線情報を用いてライティングの計算をするだけです。
ここではシンプルに拡散反射と鏡面反射を求めています。

                half4 diff = saturate(dot(normal, i.lightDir)) * _LightColor0;
                half3 spec = pow(max(0, dot(normal, halfDir)), _Shininess * 128.0) * _LightColor0.rgb * tex.rgb;

レンダリング結果は以下のようになります。

09-03-00.png

ノーマルマップは以下のようなものを使っています。

09-04-00.png

接空間について

ここでは前節で登場した接空間について説明します。
接空間は、ある頂点において、UV座標のU方向をX軸方向、V方向をY軸方向、そして法線方向をZ軸方向とした空間です。

これはなかなかイメージがしづらいので可視化してみます。
赤、緑がそれぞれU方向とV方向、青が法線方向です。
対象の頂点を変えながらこれを表示しています。

09-05-00.gif

これを見てわかる通り、接空間における各軸の方向は頂点により変わります。
そしてこれはもちろん、ワールド空間やローカル空間と全く別の座標系です。

シェーダで計算を行う際にはこの座標系を統一しないといけないため、今回は全て接空間に変換しています。
この接空間への変換処理が、前節で説明を省略した箇所になります。

                TANGENT_SPACE_ROTATION;
                o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex));
                o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex));

TANGENT_SPACE_ROTATION マクロの結果としてrotation という名前の変換行列が得られます。
これをモデル座標系の値に掛けることで接空間の座標に変換できます。

なお、TANGENT_SPACE_ROTATION は次のような実装となっています。

#define TANGENT_SPACE_ROTATION \
    float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w; \
    float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )

これを理解するにはベクトルの外積と変換行列の知識が必要です。
今回は解説しませんが、興味がある方は調べてみてください。

ちなみに、上で接空間を可視化するのに使ったスクリプトは次の通りとなっています。
色々なモデルで確認したい方はこれをアタッチしてインスペクタのスライダーから頂点インデックスを指定してみてください。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
public class VisualizeTangentSpace : MonoBehaviour 
{
}
#if UNITY_EDITOR
[CustomEditor(typeof(VisualizeTangentSpace))]
public class VisualizeTangentSpaceEditor: Editor 
{
    private int _targetIndex = 0;
    private VisualizeTangentSpace _target;
    private Mesh _mesh;
    private Vector3 _modelPos;
    private Vector3 _normal;
    private Vector3 _tangent;
    private Vector3 _binormal;

    public override void OnInspectorGUI ()
    {
        _targetIndex = EditorGUILayout.IntSlider(_targetIndex, 0, _mesh.vertexCount - 1);

        if (GUI.changed) {
            var normal = _mesh.normals[_targetIndex];

            // 法線がカメラ方面を向いている時のみ情報を更新する
            var viewDir = SceneView.lastActiveSceneView.camera.transform.position - (_target.transform.position + _modelPos);
            if (Vector3.Dot(viewDir, normal) >= 0) {
                _modelPos = _mesh.vertices[_targetIndex];
                _normal = normal;
                var tangent = _mesh.tangents[_targetIndex];
                _tangent = tangent;
                _binormal = Vector3.Cross(_normal, _tangent) * tangent.w;
            }
        }
    }

    private void OnSceneGUI(){
        if (Event.current.type == EventType.Repaint) {
            var transform = _target.transform;
            Handles.color = Color.red;
            Handles.ArrowHandleCap(0, transform.position + _modelPos, transform.rotation * Quaternion.LookRotation(_tangent), 1.0f, EventType.Repaint);
            Handles.color = Color.green;
            Handles.ArrowHandleCap(0, transform.position + _modelPos, transform.rotation * Quaternion.LookRotation(_binormal), 1.0f, EventType.Repaint);
            Handles.color = Color.blue;
            Handles.ArrowHandleCap(0, transform.position + _modelPos, transform.rotation * Quaternion.LookRotation(_normal), 1.0f, EventType.Repaint);
        }
    }

    private void OnEnable()
    {
        _target = target as VisualizeTangentSpace;
        _mesh = _target.GetComponent<MeshFilter>().sharedMesh;
        _modelPos = _mesh.vertices[_targetIndex];
        _normal = _mesh.normals[_targetIndex];
        _tangent = _mesh.tangents[_targetIndex];
    }
}
#endif

[シェーダ] 前回のシェーダに適用する

最後に、前回(第8回)作ったシェーダにノーマルマップを適用します。
まずシェーダの全ソースコードです。

Shader "Custom/BasicMaterialNormalMap"
{
    Properties
    {
        _MainTex ("Main Texture (RGB)", 2D) = "white" {}
        _Color ("Diffuse Tint (RGB)", Color) = (1.0, 1.0, 1.0, 1.0)
        _Metalness ("Metalness", Range(0.0, 1.0)) = 0.0
        _IndirectDiffRefl ("Indirect Diffuse Reflection", Range(0.0, 1.0)) = 1.0
        _Roughness ("Roughness", Range(0.0, 1.0)) = 0.0
        [Normal] _NormalMap ("Normal map", 2D) = "bump" {}
        _Shininess ("Shininess", Range(0.0, 1.0)) = 0.078125
    }
    SubShader
    {
        Pass
        {
            Tags { "LightMode"="ForwardBase" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float4 pos : SV_POSITION;
                half2 uv : TEXCOORD0;
                half3 lightDir : TEXCOORD1;
                half3 viewDir : TEXCOORD2;
                half3 normal : TEXCOORD3;
                half3 tangent : TEXCOORD4;
                half3 binormal : TEXCOORD5;
            };

            #define F0                            0.04f
            #define INDIRECT_DIFF_TEX_MIP        8.5f
            #define MAX_SHININESS                50.0f
            #define SHININESS_POW                0.2f
            #define DIRECT_SPEC_ATTEN_MIN        0.2f
            #define MAX_MIP                        8.0f

            sampler2D _MainTex;
            half4 _MainTex_ST;
              half4 _Color;
            half4 _LightColor0;
              half _Metalness;
              half _IndirectDiffRefl;
              half _Roughness;
            sampler2D _NormalMap;
            half _Shininess;

            v2f vert (appdata v)
            {
                v2f o = (v2f)0;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);

                // ワールド空間のライト方向と視点方向を求める
                o.lightDir = normalize(mul(unity_ObjectToWorld, ObjSpaceLightDir(v.vertex)));
                o.viewDir = normalize(mul(unity_ObjectToWorld, ObjSpaceViewDir(v.vertex)));

                // ワールド <-> 接空間変換行列を作成するため、ワールド空間のnormal, tangent, binormalを求めておく
                o.binormal = normalize(cross(v.normal, v.tangent) * v.tangent.w);
                o.normal = UnityObjectToWorldNormal(v.normal);
                o.tangent = mul(unity_ObjectToWorld, v.tangent.xyz);
                o.binormal = mul(unity_ObjectToWorld, o.binormal);

                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // 接空間 -> ワールド空間変換行列
                half3x3 tangentToWorld = transpose(half3x3(i.tangent.xyz, i.binormal, i.normal));

                half4 base = tex2D(_MainTex, i.uv) * _Color;
                half3 specColor = lerp(1.0, base.rgb, _Metalness);

                // ノーマルマップから法線情報を取得する
                half3 normal = UnpackNormal(tex2D(_NormalMap, i.uv));
                normal = mul(tangentToWorld, normal);

                half3 directDiff = base * (max(0.0, dot(normal, i.lightDir)) * _LightColor0.rgb);

                half3 halfDir = normalize(i.viewDir + i.lightDir);
                half shininess = lerp(MAX_SHININESS, 1.0, pow(_Roughness, SHININESS_POW));
                half directSpecAtten = lerp(DIRECT_SPEC_ATTEN_MIN, 1.0, shininess / MAX_SHININESS);
                half3 directSpec = pow(max(0.0, dot(normal, halfDir)), shininess) * _LightColor0.rgb * specColor * directSpecAtten;

                half fresnel = F0 + (1 - F0) * pow(1 - dot(i.viewDir, normal), 5);
                half indirectRefl = lerp(fresnel, 1, _Metalness);
                half3 indirectDiff = base * UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, normal, INDIRECT_DIFF_TEX_MIP) * _IndirectDiffRefl;

                half3 reflDir = reflect(-i.viewDir, normal);
                half3 indirectSpec = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflDir, _Roughness * MAX_MIP) * specColor;

                fixed4 col;
                col.rgb = lerp(directDiff, directSpec, _Metalness) + lerp(indirectDiff, indirectSpec, indirectRefl);
                return col;
            }
            ENDCG
        }
    }
}

このシェーダでは、フラグメントシェーダでキューブマップをサンプリングしています。
この処理はワールド空間で行うため、今回は全ての計算をワールド空間に変換してから行っています。

o.lightDir = normalize(mul(unity_ObjectToWorld, ObjSpaceLightDir(v.vertex)));
o.viewDir = normalize(mul(unity_ObjectToWorld, ObjSpaceViewDir(v.vertex)));
o.binormal = normalize(cross(v.normal, v.tangent) * v.tangent.w);
o.normal = UnityObjectToWorldNormal(v.normal);
o.tangent = mul(unity_ObjectToWorld, v.tangent.xyz);
o.binormal = mul(unity_ObjectToWorld, o.binormal);

したがって、ノーマルマップからサンプリングした法線情報もワールド空間に変換する必要があります。
ノーマルマップは接空間のものなので、接空間からワールド空間に変換する変換行列が必要です。
この変換行列に関しては、頂点シェーダで求めたワールド空間のnormal, tangent, binormalを用いて作成しています。

half3x3 tangentToWorld = transpose(half3x3(i.tangent.xyz, i.binormal, i.normal));

このようにして法線をノーマルマップから取得している部分以外は、前回と同じ処理になっています。

レンダリング結果は次のようになります。
まずノーマルマップ適用前です。

09-06-00.png

これにノーマルマップを適用すると以下のようになります。

09-06-00.png

細かい凹凸が表現できていることがわかります。
もちろんノーマルマップを替えればいろんな凹凸表現が可能です。

09-08-00.png

[補足] グレイスケール画像からノーマルマップを生成する

さて、ここまでノーマルマッピングを説明しましたが、実は肝心のノーマルマップの作り方については触れていませんでした。

ノーマルマップはペイントツールで直接色を描き込んで作るようなものではなく、専用のツールがリリースされています。
また、Photoshop や gimp などのペイントツールでもフィルタやプラグインの機能を使って作ることができます。

しかし Unity ではより手軽に、グレイスケールの画像からノーマルマップに変換することができます。
まず、最も隆起している部分を白、最も窪んでいる部分を黒として表現したグレイスケールの画像を用意します。

前節でノーマルマップに使用したテクスチャ

これを Unity にインポートし、Texture Type を Normal Map にした上で Create From Grayscale にチェックを入れます。

09-10-00.png

そして Apply すると、この高さ情報を元にノーマルマップが作成されます。

09-11-00.png

ちなみに、インポート設定の Bumpiness で凹凸の高さも設定できます。
これは直接ノーマルマップを作った場合には調整できない項目です。

グレイスケール画像からノーマルマップを作る場合、偏微分や積分の考え方を用いて計算しますが、この過程で高さの調整ができるためこのような設定項目が存在します。

[補足] 視差マッピング

「凹凸の特徴」の節で、下のような記述をしました。

2.高さの違いにより視線を遮られて、目視できない部分が生じる

この現象はノーマルマッピングでは表現できないため、ノーマルマッピングのレンダリング結果は斜めから見ると若干高さが自然に感じられないものとなります。

09-12-00 (1).png

これを改善するための手法として視差マッピングがあります。
視差マッピングではまず、高さを定義するテクスチャを用意します。

09-13-00 (1).png

一番高い部分は白、一番低い部分は黒です。
そして高い部分ほど、テクスチャをサンプリングする位置をずらします。

ずらす方向は視点から見て奥向きです。
イメージはこんな感じです。

09-14-00.PNG

するとレンダリング結果はこうなります。

09-15-00.PNG

凸部分がより立体的に感じられるようになりました。
ここでは概念のみの説明に留めますが、難しい処理ではないので興味がある方は調べてみてください。

まとめ

今回は以下のことを説明しました。

  • ノーマルマッピングの意義
  • 凹凸の特徴について
  • ノーマルマップとは
  • ノーマルマッピングの実装
  • 接空間についての説明
  • ノーマルマップの作り方
  • 視差マッピング

次回はレンダリング結果に対して掛けるエフェクト、ポストエフェクトについて解説します。

バックナンバー