Unityで視差マッピング

はじめに

前回の記事ではノーマルマッピングの概要とUnityでの実装方法を紹介しました。今回も凹凸に関する記事で、前回の続きになります。

daiki-eguchi.hatenablog.com

ノーマルマッピングではノーマルマップからオブジェクトの法線ベクトルを取得し、ライティングを適用することで凹凸を表現しました。(以下図はその結果)

オブジェクトのジオメトリ情報を変更したわけはないのでカメラとオブジェクトが近くなると凹凸に違和感を感じ出してしまう場面があります。

その理由は

実際に凹凸があれば、その凹凸による前後関係の遮蔽が感じ取れるはずなのに、それがなく、平面に陰影が付いたように見えてしまう

ということのようです。 3Dグラフィックス・マニアックス(17) バンプマッピングの先にあるもの(1)~視差マッピング | マイナビニュースより引用

これを改良し、普及したのが「視差マッピング」とのこと。本記事ではこの視差マッピングを実装します。

HeightMap

視差マッピングには「HeightMap」というテクスチャを使用します。

以下図の右のように例えば「白」=高い、「黒」=低いという情報を表しているものです。

ハイトマップ - Unity マニュアルより引用

これはオブジェクトの高さの情報が入っているものだが、もともと法線マップを作成するために作成されているので手間が増えないとのこと。

ノーマルマッピングの不自然さの原因とその解決法

通常のノーマルマッピングでは視線方向と凹凸の前後による遮蔽を考慮に入れていないため、以下図のようなサンプリング位置になります。

しかし、もし遮蔽を考慮して該当箇所も実際に遮蔽されていたとしたらそのサンプリング位置よりも、もうすこし視線方向にずらさなければいけないことになります。

そのため、視差マッピングでは以下の工程を踏んでサンプリング位置を調整します。

  1. ノーマルマッピングでサンプリングしようとしていたuv座標でハイトマップから高さ情報を取得
  2. それを定数倍かけて視線方向にずらす
  3. その位置を視差マッピングのサンプリング位置とする

定数倍というのはあらかじめ決めておき、オブジェクトを画面で見て調整しています。

定数倍で本当に近似できるのかというと、たしかに上記の図のように誤差はありますが

表現しようとしているその凹凸が「ものすごくなだらかである」という仮定を立てられるとき、描画しようとしているピクセル位置の高さと、その視線がその凹凸と交差する場所の高さはほぼ同じということが出来る。

https://news.mynavi.jp/article/graphics-17/より引用

というところから視差マッピングは「凹凸がすごくなだらかである」という仮説の上ではしっかりと遮蔽を考慮できているということになります。 (視差マッピングよりも正確な近似ができるのは次の記事)

以下の図のように凹凸が急な場合、定数倍かけて視線方向にずらすと意図しているサンプリング位置との誤差が大きくなってしまうことがあります。

そのため、視差マッピングは「凹凸がなだらか」な場合に良い近似がとれるということのようです。

実装

実装は視差マッピング以外(発展させたもの)もふくめて以下のリポジトリに格納しています。

github.com

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

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

コード全文

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

Shader "Unlit/ParallaxlMapping"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        [Normal] _NormalMap("NormalMap", 2D) = "bump"
        _HeightMap("HeightMap", 2D) = "white"{}
        _Shininess("Shininess", Float) = 0.07
        _HeightFactor("HeightFactor", Float) = 0.5
    }
    SubShader
    {
        Tags { "Queue"="Geometry" "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline"}
        LOD 100

        Pass
        {
            Name "Normal"
            Tags { "LightMode"="UniversalForward"}
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #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);

            TEXTURE2D(_HeightMap);
            SAMPLER(sampler_HeightMap);

            float _Shininess;
            float _HeightFactor;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            float4 _NormalMap_ST;
            float4 _HeightMap_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);

                /*
                 * ハイトマップをサンプリングしてuvをずらす
                 */
                float4 height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, i.uv);
                i.uv += i.viewDirTS.xy * height.r * _HeightFactor;
                
                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
        }
    }
}

解説

視差マッピングとノーマルマッピングの違いはピクセルシェーダーのみです。 概要で説明した通り、

/*
* ハイトマップをサンプリングしてuvをずらす
*/
float4 height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, i.uv);
i.uv += i.viewDirTS.xy * height.r * _HeightFactor;

でサンプリング位置をずらしています。

Shader内の_HeightFactorというのが概要で話した「定数倍」の部分です。

視差マッピングを適用した見た目は以下のようになりました。

通常のノーマルマッピングとの比較は以下のようになりました。 左が通常のノーマルマッピング、右が視差マッピングになります。

gifで見ると違いがわかりやすいです。

参考文献

light11.hatenadiary.com

news.mynavi.jp