Unityで視差遮蔽マッピング

はじめに

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

daiki-eguchi.hatenablog.com

視差マッピングでは、HeightMapから高さを取得して定数倍視線方向にサンプリング位置をずらすことで遮蔽を考慮したものでした。(以下図はその結果)

ただ、前回の記事でも述べたように凹凸が激しいところでは視差マッピングの近似はうまくできないことがあります。

このように視差マッピングでも解決できなかった凹凸の不自然さをさらに解決していこうとしたのが今回紹介する「視差遮蔽マッピング」です。

この方法は、凹凸と視線方向の交点をより正確に導こうとするため、GPU性能が非常に向上したDirectX 9世代/SM3.0対応GPUが登場してきて初めて現実味を帯びてきた技術とのこと。

視差遮蔽マッピングの考え方

ではどのように視線方向との交点をより正確に導くのかというと、その方法はかなり地道なものです。

これまでは平面に凸を貼り付けるというイメージだったが、視差遮蔽マッピングでは平面に凹を彫り込む……というようなイメージで実装されるのがスタンダードのようだ。

https://news.mynavi.jp/article/graphics-18/から引用

視差マッピングでの遮蔽された場所のサンプリング位置のずれは「定数倍かけることで視線方向にずらす」ということにあります。計算は簡単なので負荷は少ないですが、どうしても「定数倍をかけてサンプリング位置を調整する」ということがずれの原因になります。

そこで視差遮蔽マッピングは以下図のように視線方向に対してRayをポリゴン面から潜り込ませるイメージで実装します。

視線を潜り込ませた後、その直下にあるハイトマップを参照し、Rayのほうがまだ高い場合は交点はまだ訪れていないとしてステップを進めます。

ステップを進めていき、Rayがハイトマップの高さよりも下回った時、以前のステップと今回のステップとの間に凹凸と視線の交点があったということになります。

では実際の交点をどう求めるかですが、今回は前ステップと現在のステップのちょうど真ん中に交点があったと仮定して中点をサンプリングします。

もちろん中点を取るというのは近似なので誤差が生じますが、これで凹凸が激しいところでも、ある程度正しい位置をサンプリングできます。

実装

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

github.com

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

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

コード全文

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

Shader "Unlit/ParallaxlOcclusionMapping"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        [Normal] _NormalMap("NormalMap", 2D) = "bump"
        _HeightMap("HeightMap", 2D) = "white"{}
        _Shininess("Shininess", Float) = 0.07
        _HeightFactor("HeightFactor", Float) = 0.5
        
        _RayStep("RayStep", Int) = 16
        _MaxHeight("MaxHeight", Float) = 1.0
    }
    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 position : SV_POSITION;
                float3 viewDirTS : TEXCOORD1;
                float3 lightDir : TEXCOORD2;
                float3 lightColor : TEXCOORD3;
                float2 parallaxOffset : TEXCOORD4;
                float4 positionWS : TEXCOORD5;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            TEXTURE2D(_NormalMap);
            SAMPLER(sampler_NormalMap);

            TEXTURE2D(_HeightMap);
            SAMPLER(sampler_HeightMap);

            float _Shininess;
            float _HeightFactor;

            int _RayStep;
            float _MaxHeight;

            #define RAY_SAMPLE_COUNT 32;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            float4 _NormalMap_ST;
            float4 _HeightMap_ST;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                o.position = TransformObjectToHClip(v.vertex.xyz);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                /*
                 * 
                 */
                //VertexNormalInputs normalInputs = GetVertexNormalInputs(v.vertex);
                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(0);
                /*
                 * ピクセルシェーダーに受け渡される光源ベクトルや視線ベクトルを
                 * 法線マップを適用するポリゴン基準の座標系とテクスチャの座標系が合うように変換する
                 * ピクセルシェーダーで座標変換すると全ピクセルにおいて、取り出した法線ベクトルに対して座標変換するので負荷が重い
                 */
                o.lightDir = mul(rotation, light.direction);
                o.viewDirTS = mul(rotation, GetObjectSpaceNormalizeViewDir(v.vertex));
                o.lightColor = light.color;

                /*
                float2 parallaxDirection = normalize(o.viewDirTS.xy);
                float viewTSLength = length(o.viewDirTS);
                float parallaxLength = sqrt(viewTSLength * viewTSLength - o.viewDirTS.z * o.viewDirTS.z);
                o.parallaxOffset = parallaxDirection * parallaxLength;
                o.parallaxOffset *= _HeightFactor;
                o.positionWS = mul(UNITY_MATRIX_M, v.vertex);*/
                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をずらす
                 */
                float heightMax = 1;
                float heightMin = 0;
                const int stepCount = 32;
                float rayStepLength = (heightMax - heightMin) / stepCount;
                float rayHeight = heightMax;
                float height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, i.uv);
                
                float2 uv = i.uv;
                float2 uvStart = i.uv;
                float2 uvOffset = float2(0,0);
                
                for (int loopCount = 0; loopCount < stepCount; loopCount++)
                {
                    uvOffset = i.viewDirTS * loopCount * rayStepLength * _HeightFactor;
                    uv = uvStart + uvOffset;
                    height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, uv).r;

                    if(rayHeight < height)
                    {
                        break;
                    }
                    
                    rayHeight -= rayStepLength;
                }
                
                float weight = 0.5;
                float2 beforeUV = uv - uvOffset;
                uv = lerp(uv, beforeUV, weight);
                
                float4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
                float3 normal = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, 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
        }
    }
}

解説

前回の視差マッピングに加えてフラグメントシェーダーに処理を追加していきます。

今回は最大ステップ数を32として、HeightMapの最大の高さを1としています。Rayの1ステップの長さは

(ハイトマップの最大高さーハイトマップの最低高さ) / Rayの最大ステップ

で求められます。

/*
* ハイトマップをサンプリングしてuvをずらす
*/
float heightMax = 1;
float heightMin = 0;
const int stepCount = 32;
float rayStepLength = (heightMax - heightMin) / stepCount;
float rayHeight = heightMax;
float height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, i.uv);

視線を潜り込ませる処理をfor文で回します。

概念の章で説明したようにハイトマップの高さを参照してRayの高さとの比較。もし、Rayの高さのほうが低い場合は交点があったということでfor文を抜けます。

for (int loopCount = 0; loopCount < stepCount; loopCount++)
{
     uvOffset = i.viewDirTS * loopCount * rayStepLength * _HeightFactor;
     uv = uvStart + uvOffset;
     height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, uv).r;

     if(rayHeight < height)
     {
         break;
     }
                    
     rayHeight -= rayStepLength;
}

その後、前ステップと今回のステップの中点をサンプリング位置と仮定してサンプリングするuv座標を決定しています。

float weight = 0.5;
float2 beforeUV = uv - uvOffset;
uv = lerp(uv, beforeUV, weight);

視差マッピングと比較すると以下のような形になります。左が視差マッピング、右が視差遮蔽マッピングになります。

赤枠で示したような凹凸の差が激しい箇所が改良されたことがわかります。

gifで見るとこんな形です。

さらなる改良

交点を求める際、前ステップと今回のステップの中点をサンプリング位置としていましたが、ハイトマップの高さよりもRayの高さが下回ったときに、逆向きにRayを長さを短くしてステップを進めることで交点をより誤差なく求めることができます。

ただ、負荷はもちろん上がるのでその部分はトレードオフになります。

逆向きにRayを進めていき、ハイトマップよりもRayの高さが高くなった時、前のステップと今回のステップの間に凹凸との交点があったということになります。

そして、同じように前のステップとの中点を求めてサンプリング位置とします。

たしかに、また中点を求めることで誤差は出ますがRayを逆向きに進めなかった時よりも誤差が少なくなるときがあり、品質が上がることがあります。(冗長な場合もアセットによってはあります。)

実装

コードは上記で挙げたリポジトリ内にあります。

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

前の視差遮蔽マッピングのコードに対してfor文をもう一つ用意し、逆向きにRayを進めていきます。

for (int loopCount = 0; loopCount < stepCount; loopCount++)
{
    uvOffset = i.viewDirTS * loopCount * rayStepLength * _HeightFactor;
    uv = uvStart + uvOffset;
    height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, uv).r;

    if(rayHeight < height)
    {
       break;
    }
                    
    rayHeight -= rayStepLength;
}

/////////////////ここからRayを逆向きに飛ばします////////////////////////////

const int reliefStepCount = 32;
float2 uvPOMEnd = uv;
float2 uvOffset2 = float2(0,0);
for (int reliefStep = 0; reliefStep < reliefStepCount; reliefStep++)
{
    // rayの長さを半分にする
    rayStepLength /= 2;
    // 今度は逆に進める
    uvOffset2 = - i.viewDirTS * reliefStep * rayStepLength * _HeightFactor;
    uv = uvPOMEnd + uvOffset2;
    height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, uv).r;

    // 今回はrayを逆に進めているのでheightmapより超えたらbreak
    if(rayHeight > height)
    {
       break;
    }

    rayHeight += rayStepLength;
}

float weight = 0.5;
float2 beforeUV = uv + uvOffset2;
uv = lerp(uv, beforeUV, weight);
                

もちろんRayを逆向きに伸ばさなくてもしっかりと遮蔽を考慮できているアセットもありますので見た目と負荷を比較して採用するかを判断するとよさそうです。

参考文献

news.mynavi.jp