Unityで視差遮蔽マッピングにセルフシャドウをつける

はじめに

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

daiki-eguchi.hatenablog.com

視差遮蔽マッピングでは、ポリゴン面から視線方向にRayをどんどん潜り込ませていき、凹凸との交点を求めました。(以下図はその結果)

この手法にもう一工夫すると凹凸にセルフシャドウ(近隣の凹凸の影)を生成することができます。また、その考え方は視差遮蔽マッピングで凹凸との交点を求めた考え方とかなり似ています。

セルフシャドウのつけかた

凹凸との交点を求めた後、光源方向にRayを飛ばします。

Rayのステップを進めて、HeightMapを参照。HeightMapの高さとRayの高さを比べて、Rayの高さのほうが高ければ衝突が起きていないとします。

さらにRayのステップを進めて、HeightMapを参照。Rayの高さと比べてHeightMapの高さのほうが高ければ凹凸と衝突があったとします。

すると、光源とRay開始時の凹凸の交点との間に別の凹凸があったことになり、セルフシャドウを書く。と判断できます。

もし、ポリゴン面を抜けるまでRayを進めて衝突がなければRayの開始時の凹凸の交点と光源の間に別の凹凸がなかったことになり、セルフシャドウは書きません。

実装

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

github.com

本記事の「視差遮蔽マッピングーセルフシャドウ付き」は以下のファイルです。

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

コード全文

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

Shader "Unlit/ParallaxlOcclusionMappingWithSelfShadow"
{
    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
        
        _HardShadowColor("HardShadowColor", Color) = (0,0,0,1)
        _ShadowStepFactor("ShadowStepFactor", 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 position : SV_POSITION;
                float3 viewDirTS : TEXCOORD1;
                float3 lightDir : TEXCOORD2;
                float3 lightColor : TEXCOORD3;
                float2 parallaxOffset : TEXCOORD4;
                float4 positionWS : TEXCOORD5;
                float3 lightPosition : TEXCOORD6;
                float3 worldPos : TEXCOORD7;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            TEXTURE2D(_NormalMap);
            SAMPLER(sampler_NormalMap);

            TEXTURE2D(_HeightMap);
            SAMPLER(sampler_HeightMap);

            float _Shininess;
            float _HeightFactor;

            int _RayStep;
            float _MaxHeight;

            float4 _HardShadowColor;

            float _ShadowStepFactor;

            float4 _VirtualLightPosition;

            #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;
                //o.lightPosition = float3(_MainLightPosition.xyz);
                
                float3 virtualLightPositionDir = _VirtualLightPosition.xyz - mul(unity_ObjectToWorld, v.vertex).xyz;
                o.lightPosition = mul(rotation, virtualLightPositionDir);

                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                //return float4(i.lightDir, 0);
                i.lightPosition = normalize(i.lightPosition);
                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 = uv;
                
                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 = 0;
                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;

                const int selfShadowStepCount = 16;
                // rayが衝突した位置からオブジェクトを抜けるまでステップを進める
                float selfShadowLength = (heightMax - rayHeight) / selfShadowStepCount;
                float selfShadowHeight = rayHeight + rayStepLength;

                float2 selfShadowUv = uv;
                float2 selfShadowUvStart = uv;
                float2 selfShadowUvOffset = beforeUV;

                float selfShadow = 1;
                
                for (int shadowLoopCount = 1; shadowLoopCount < selfShadowStepCount; shadowLoopCount++)
                {
                    selfShadowUvOffset = i.lightDir * shadowLoopCount * selfShadowLength * _ShadowStepFactor;
                    selfShadowUv = selfShadowUvStart + selfShadowUvOffset;
                    height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, selfShadowUv).r;

                    if(selfShadowHeight < height)
                    {
                        selfShadow = 0.5;
                        break;
                    }
                    
                    selfShadowHeight += selfShadowLength;
                }
                
                return color * selfShadow;
            }
            ENDHLSL
        }
    }
}

解説

視差遮蔽マッピングに対して、フラグメントシェーダーを追加していきます。

float2 selfShadowUv = uv;
float2 selfShadowUvStart = uv;
float2 selfShadowUvOffset = beforeUV;

float selfShadow = 1;

diffuseとspecularの反映を行った後からセルフシャドウの処理を書いています。まずはセルフシャドウのための変数定義です。

float2 selfShadowUvOffset = beforeUV;としてセルフシャドウのoffsetの初期化をbeforeUVという凹凸と視線の交点を求めた前ステップでの凹凸からRayを発射させています。

正確にはしっかりと凹凸の交点から始めたかったのですが、Rayのスタートが凹凸の下側にあることがあり、ループの1回目で必ずセルフシャドウを書いてしまうようになってしまったので、あえてセルフシャドウの判定を行うuvOffsetの初期化は凹凸と視線の交点がまだ訪れていないところからスタートしています。 (もっといい方法があるかも知れません)

                
for (int shadowLoopCount = 1; shadowLoopCount < selfShadowStepCount; shadowLoopCount++)
{
     selfShadowUvOffset = i.lightDir * shadowLoopCount * selfShadowLength * _ShadowStepFactor;
     selfShadowUv = selfShadowUvStart + selfShadowUvOffset;
     height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, selfShadowUv).r;

     if(selfShadowHeight < height)
     {
           selfShadow = 0.5;
           break;
     }
                    
     selfShadowHeight += selfShadowLength;
}

その後、セルフシャドウの判定としてRayをfor文で光源方向に飛ばしています。

今回、光源と凹凸の交点の間に遮蔽があったと判定した時は素直にselfShadow(float変数)を0.5にしています。

if(selfShadowHeight < height)
{
    selfShadow = 0.5;
    break;
}

その後、color値にselfShadowの変数をかけて色を出力しています。

return color * selfShadow;
遮蔽がある・・・selfShadow = 0.5なのでcolor * 0.5で暗くなる
遮蔽がない・・・selfShadow = 1なのでcolor * 1でそのまま

これによる結果が以下のような形です。シャドウが描かれていることがわかると思います。

並べてみてもシャドウが描かれている違いがわかると思います。

シャドウの改良

先ほどのシャドウの付け方では遮蔽があったところの色を一律に暗くして影をつけたため影の輪郭がくっきり出てしまっています。

そのため、次にRayのステップを進めるたびに遮蔽度合いを調べ、影の濃さに反映させます。

これによって遮蔽度合いが高いところは、より暗くなり、遮蔽度合いが小さい場所は少しだけ暗くなる。ということが実現できます。

結果としては以下のようになります。左がセルフシャドウなし、右がセルフシャドウありになります。写真で比べてもわかりづらいですが、gifで見ると違いがわかると思います。

以前と異なり、セルフシャドウの輪郭がくっきり出ずに表現できていると思います。

別のアセットで検証してみます。木のBoxにセルフシャドウ付き視差遮蔽マッピングを適用させてみました。

わかりやすいように影がついているところを黒く、影がついていないところを白く出してみると以下のようになります。木のBoxの四方にあるネジのようなところからセルフシャドウが落ちていることがわかります。

セルフシャドウありとなしを比較してみます。以下写真の左がセルフシャドウなし、右がセルフシャドウアリになります。

実装

遮蔽度合いを考慮したセルフシャドウつき視差遮蔽マッピングは以下のGitにおいています。

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

解説

先ほどのセルフシャドウ判定を行ったコードを以下のように変更します。

float selfShadow = 1;

for (int shadowLoopCount = 1; shadowLoopCount < selfShadowStepCount; shadowLoopCount++)
 {
     selfShadowUvOffset = i.lightDir * shadowLoopCount * selfShadowLength * _ShadowStepFactor;
     selfShadowUv = selfShadowUvStart + selfShadowUvOffset;
     height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, selfShadowUv).r;

     if(selfShadowHeight < height)
     {
        selfShadow -= (height - selfShadowHeight) * _ShadowIntensity;
        selfShadow = max(0, selfShadow);
        break;
     }
                    
     selfShadowHeight += selfShadowLength;
  }
                
return color * selfShadow;

以前と変わったのは以下の部分で、Rayのステップを進めた後、HeightMapの高さよりもRayが低い(遮蔽があった)と判定された時、selfShadow(最終的に色に対してどのぐらいの乗算で影を乗せるかのパラメーター)を遮蔽度合いを考慮して計算させています。

なお、0よりも低くなってしまうと色がおかしくなるので0より下回らないようにしています。

if(selfShadowHeight < height)
{
   selfShadow -= (height - selfShadowHeight) * _ShadowIntensity;
   selfShadow = max(0, selfShadow);
   break;
}

以前影がくっきり出ていたのは以下のような処理だったので違いがわかるかと思います。

if(selfShadowHeight < height)
{
   selfShadow = 0.5;
   break;
}

参考文献

news.mynavi.jp