Unityでノーマルマッピング (バンプマッピング)

はじめに

ゲームの3Dアセットの品質を上げる際、そのオブジェクトの凹凸も表現したいという流れが出てきました。

ただ人間の皮膚のしわや、小物の模様など細かい凹凸を表現するためにポリゴン数をあげるなるとその分負荷が上がってしまいます。

それに凹凸を表現するために何百万のポリゴンで作られたオブジェクトも画面に描画した際にはせっかく増やしたポリゴンも1ピクセル未満になってしまいます。

そのため細かい凹凸がそれらしくみえればいいというフェイクの発想が生まれ、法線マップを使用したノーマルマッピングバンプマッピング)が誕生したとのこと。

ジオメトリを変更していないので実際には平面のままだが凹凸があるかのように光り、濃淡が生まれることで凹凸があるように見えるという形になるという仕組みです。

法線マップ

法線マップは以下図の右ようなもので、各uv座標の法線方向がカラー値として書き込めれています。(左はBaseMap)

法線マップ (バンプマップ) - Unity マニュアルから引用

法線マップの考え方や作り方は以下を参照するとすごくわかりやすいです。

news.mynavi.jp

微細凹凸の高低をの嘆で表現したハイトマップから法線マップを作成します。

ハイトマップは「白を高い」「黒を低い」と定義して高低そのものを定義しているもので、ハイトマップの各テクセルにおける隣接する高低差を求め、横方向と縦方向の縦方向の傾きの双方に直交するベクトルを求め(これが法線ベクトル)テクスチャに出力することで法線マップが作成される。

この法線マップから取り出した法線方向と視線方向、光源方向と使用してオブジェクトの陰影をつけることで凹凸を表現します。

ノーマルマッピングとテクスチャ出力しただけの見た目の差異を示します。

まず、以下がテクスチャを出力しただけの見た目です。

次にノーマルマッピングで出力した壁になります。

凹凸感も出ていますし、なにより法線マップをしようすることでライティングを行った影響で見た目が全然違いますね、、

実装

実装は単純なノーマルマップ以外(発展させたもの)もふくめて以下のリポジトリに格納しています。

github.com

本記事のノーマルマッピングは以下のファイルです。

Shader/Assets/Unevenness/BumpMapping/Shader/NormalMapping.shader at main · Kinakomotimoti/Shader · GitHub

コード全文

まずはShderコード全文です。

Shader "Custom/NormalMapping"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        [Normal] _NormalMap("NormalMap", 2D) = "bump"
        
        _Shininess("Shininess", Float) = 0.07
    }
    SubShader
    {
        Tags { "Queue"="Geometry" "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline"}
        LOD 100

        Pass
        {
            Name "Normal"
            Tags { "LightMode"="UniversalForward"}
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float3 viewDirTS : TEXCOORD1;
                float3 lightDir : TEXCOORD2;
                float3 lightColor : COLOR;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            TEXTURE2D(_NormalMap);
            SAMPLER(sampler_NormalMap);

            float _Shininess;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            float4 _NormalMap_ST;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex.xyz);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                /*
                 * 
                 */
                float3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;
                float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);

                Light light = GetMainLight();
                /*
                 * ピクセルシェーダーに受け渡される光源ベクトルや視線ベクトルを
                 * 法線マップを適用するポリゴン基準の座標系とテクスチャの座標系が合うように変換する
                 * ピクセルシェーダーで座標変換すると全ピクセルにおいて、取り出した法線ベクトルに対して座標変換するので負荷が重い
                 */
                o.lightDir = mul(rotation, light.direction);
                o.viewDirTS = mul(rotation, GetObjectSpaceNormalizeViewDir(v.vertex));
                o.lightColor = light.color;
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                i.lightDir = normalize(i.lightDir);
                i.viewDirTS = normalize(i.viewDirTS);
                float3 halfVec = normalize(i.lightDir + i.viewDirTS);
                
                float4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
                float3 normal = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv));

                normal = normalize(normal);

                float4 color;
                float3 diffuse = max(0, dot(normal, i.lightDir)) * i.lightColor;
                float3 specular = pow(max(0, dot(normal, halfVec)), _Shininess * 128) * i.lightColor;
                
                color.rgb = tex * diffuse + specular;
                
                return color;
            }
            ENDHLSL
        }
    }

解説

頂点シェーダー

まず頂点シェーダーですが、コメント記載の通り光源ベクトルや視線ベクトルをテクスチャ座標系に合わせます。

ピクセルシェーダーで取り出した法線ベクトルをワールド座標系やローカル座標系にその都度変換すると負荷が重いので頂点シェーダーで光源ベクトルや視線ベクトルを法線マップを適用するポリゴン基準の座標系とテクスチャ座標系が合うように変換し、その結果をピクセルシェーダーに渡しています。

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

 Light light = GetMainLight();
 /*
  * ピクセルシェーダーに受け渡される光源ベクトルや視線ベクトルを
  * 法線マップを適用するポリゴン基準の座標系とテクスチャの座標系が合うように変換する
  * ピクセルシェーダーで座標変換すると全ピクセルにおいて、取り出した法線ベクトルに対して座標変換するので負荷が重い
  */
o.lightDir = mul(rotation, light.direction);
o.viewDirTS = mul(rotation, GetObjectSpaceNormalizeViewDir(v.vertex));
o.lightColor = light.color;

ピクセルシェーダー

float3 normal = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv));

normal = normalize(normal);

ノーマルマップのサンプリングはUnpackNormalという関数を使用します。また、正規化しているのはDXT5nmの環境は勝手に正規化されることを考え、プラットフォームで見え方が変わらないようにするためです。

float3 diffuse = max(0, dot(normal, i.lightDir)) * i.lightColor;
float3 specular = pow(max(0, dot(normal, halfVec)), _Shininess * 128) * i.lightColor;

上記に関しては、法線を使用して光の当たり方を計算しています。ここに関してはまた別に記事を書こうかと思います。

参考文献

light11.hatenadiary.com

news.mynavi.jp

news.mynavi.jp