UnityでSRPからデプスシャドウを描く ~複数のDirectionalLightの影を描く~

概要

前記事ではUnityでステンシルボリュームシャドウを描画する方法について書きました。

daiki-eguchi.hatenablog.com

昨今のGPUではテクセルフィルレートが劇的に向上したことで昨今主流となったのがデプスシャドウ技法のようです。 参考: 3Dグラフィックス・マニアックス(24) 影の生成(5)~デプスシャドウ技法 | マイナビニュース

テクセルフィルレートというのはテクスチャを構成する単位のテクセルの処理速度をいい、処理速度が昨今早くなったことから主流になった手法ということですね。

デプスシャドウ技法とは

光源を仮想の視点と捉えて光源から光が当たっているかどうかをシャドウマップに書き込む。 その後、シャドウマップを投影テクスチャマッピングの用法でオブジェクトに貼り付けるイメージで描画することで影が描画される仕組みです。

光源から光が届いているかどうかをバッファに書き込む

ではどのようにして「光が当たっているかどうか」を書き込むかです。

カメラから見て、レンダリングしようとしているピクセルと光源までの距離をdとして、光源からそのピクセルまで飛ばしたRayが衝突したところまでの距離をsとします。

以下のように2点考えると、s=dとなるところはレンダリングしようとしているピクセルと光源の間に遮蔽物がなかったことになるので「影ではない」ということになります。

d>sとなるところではレンダリングしようとしているピクセルと光源の間に遮蔽物があったことになるので「影」ということになります。

Unityでデプスシャドウ技法を行う

Unityでデプスシャドウ技法を行うとき、シャドウマップの描画はUnityがよしなにやってくれます。ただ今回はせっかくなのでSRPからシャドウマップの描画自体も行ってみましょう。

SRPからデプスシャドウ技法を行う

SRPからデプスシャドウ技法を行っていこうと思いますが、素晴らしい記事があるのでこちらを参照してください。 この記事を実装するとDirectionalLight1つのシャドウを描画することができます。

今回は、僭越ながらこの記事を実装した前提で複数のDirectionalLightの影を描くように実装してみます。

qiita.com

qiita.com

成果物

まずは今回の成果物です。DirectionalLightを4つおいて様々な方向を向かせていますがどのライトからのシャドウも描画されています。

シャドウマップの描画

では複数のDirectionalLightの影を落とすときはどのようにしたらよいのでしょうか。

今回、シャドウマップは以下のように描画されています。(シャドウオンのライトが4つの時)

これは、大きなシャドウマップに対して以下のようにそれぞれのライトのシャドウを分割して描画されています。

今回、影を落とせるライトの最大数を4つとしたので4つにシャドウマップを分けています。しかし、最大数が4だとしてもシーンにDirectionalLightが1つのときにこの分割数だとシャドウの品質が下がってしまいます。デプスシャドウによるシャドウの品質はシャドウマップの解像度と直結するからです。

そのため、DirectionalLightが1つの時は以下のように分けずにシャドウを描画します。

実装

コード全文は長いので折りたたみました。大事なところは抽出して説明いたします。

複数のDirectionalLightの影を描画するため、C#側は大きく二つのことを行います。ひとつは上記でお話ししたシャドウマップ描画のタイリング。そしてもう一つはライトのパラメーターを複数シェーダーに渡すために行う配列化です。

シャドウマップのタイリング

上記で説明したようにライトが1つだったときと2~4つだった時でシャドウのタイリングが変わります。Render関数内でその判定を行っています。

ライトの個数が1つの時はRenderPipelineAsset側で指定したシャドウ解像度が分割後のタイルサイズと一致するように。つまりシャドウマップ用のバッファ全体に影を描画できるようにします。

ライトの個数が2つ以上の時はRenderPipelineAsset側で指定したシャドウ解像度を2分割し、タイルのサイズとします。これによって、一枚のシャドウマップ用バッファに4つのライトシャドウ情報を入れることができます。

シャドウのサイズを2分の1にすれば4つのライト情報をシャドウマップ1枚に収めることができる

var lightIndexes = SearchLightIndexes(cullingResults, LightType.Directional);
int splitCount = lightIndexes.Count <= 1 ? 1 : 2;
float tileSize = shadowResolution / splitCount;

シャドウマップへの描画はDrawShadowで行っています。上記で説明したシャドウマップの分割を以下のSetTileViewport(cmd, index, split, tileSize, shadowResolution);で行っています。

private void DrawShadow(ScriptableRenderContext context, CommandBuffer cmd, CullingResults cullingResults,
        int lightIndex, int index, int split, float tileSize, Light light, int shadowResolution, float shadowDistance)
{
    cmd.Clear();
    cmd.SetRenderTarget(LightShadowId);

    // この関数でシャドウマップの描画のタイリングを行っている
    SetTileViewport(cmd, index, split, tileSize, shadowResolution);
   // 以下略
}

SetTileViewportの中身は大事なTile分割です。

各ライトのシャドウに対してoffsetと名付けたVector2をフィールド変数に保存しています。各ライトがシャドウマップのどこに描画しているかを表すものです。これが必要なのはShader側も、どのライトのシャドウ情報が一枚のシャドウマップのどこからどこまでにあるのかを知る必要があるためです。

実際にタイリングしているのはcmd.SetViewport(new Rect(offset.x * tileSize, offset.y * tileSize, tileSize, tileSize));の部分です。

private Vector2 SetTileViewport(CommandBuffer cmd, int index, int split, float tileSize, int shadowResolution)
{
    Vector2 offset = new Vector2(index % split, index / split);
    Vector2 tileOffset = offset * tileSize;
    Offsets[index] = tileOffset / shadowResolution;
    cmd.SetViewport(new Rect(offset.x * tileSize, offset.y * tileSize, tileSize, tileSize));

    return offset;
}

ライト情報の配列化

参考の記事でfloat定義であったLightのパラメーターを配列に変更します。複数のライトがあり、それぞれにBias等の設定があるのでそれを反映するためになります。

private readonly float[] LightShadowBiases = new float[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT];
private readonly float[] LightShadowNormalBiases = new float[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT];
private readonly float[] LightShadowDistanceSqrts = new float[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT];

そして、ループを回してすべてのライトのShadowMapを描いた後にパラメーターをShader側に渡すように変更しています。ライトの情報をすべて配列に格納し終えてからShaderに渡すためです。

if (existValidLight)
{
    SetupLightRT(context, cmd, shadowResolution, lightIndexes.Count);
                
    for (int i = 0; i < lightIndexes.Count; i++)
    {
        var lightIndex = lightIndexes[i];
        var light = cullingResults.visibleLights[lightIndex];

        var lightVP = CalcLightViewProjection(cullingResults, lightIndex);
                    
        SetupLightVP(context, cmd, lightVP);
        DrawShadow(context, cmd, cullingResults, lightIndex, i, splitCount, tileSize, light.light, shadowResolution, shadowDistance);   
    }
                
    SetupLightParameters(context, cmd);
}

オブジェクトシェーダー側

オブジェクトにアタッチするシェーダー側の変更点もC#側の変更に合わせて大きく二つです。

一つ目はDirectionalLightが複数あるのでフラグメントシェーダーで複数のライトの処理を行えるようにループ処理させることです。以下はフラグメントシェーダー内でループ処理している部分の抽出です。

for (int t = 0; t < _DirectionalLightCount; t++)
{
    float dotNL = max(0, dot(normalize(i.normalWS), _LightDirs[t]));

    half3 diffuse = _LightColors[t] * dotNL;
    float shadowAttenuation = GetShadowAttenuation(i.positionWS, dotNL, t, _DirectionalLightCount);

    color *= diffuse * shadowAttenuation;
}

そしてもう一つはシャドウマップのサンプリング位置の調整です。以下のコードはシャドウを求めているところですが、参考の記事と比べてライトのパラメーターが配列化しているのでその変更部分。そして各ライトのシャドウがシャドウマップ内のどこにあるのかをC#側からもらっているのでサンプリング位置の調整が入っています。

float GetShadowAttenuation(float4 positionWS, float dotNL, int tileIndex, int split)
{
    // ワールド空間からLVP空間へと変換する
    float4 positionLVP = TransformWorldToLightViewProjection(positionWS, tileIndex);
    // LVP空間からシャドウマップのUV座標へと変換する
    float2 samplingUv = 0;
    float2 uv = positionLVP.xy / positionLVP.w * float2(0.5f, -0.5f) + 0.5f;

    float scale = split == 1 ? 1 : 0.5;
    samplingUv = Offsets[tileIndex] + uv * scale;

    float3 positionVS = mul(unity_MatrixV, positionWS);
    float distanceSqrt = dot(positionVS.xyz, positionVS.xyz);

    float zInLVP = CalcLightViewProjectionDepth(positionLVP);
    float zInShadow = tex2D(_LightShadow, samplingUv.xy).r;
    float bias = _ShadowNormalBiases[tileIndex] * tan(acos(dotNL)) + _ShadowBiases[tileIndex];
    float attenuation = zInLVP - bias > zInShadow ? 0 : 1;

    return samplingUv.x > 0 && samplingUv.x < 1 && samplingUv.y > 0 && samplingUv.y < 1 && distanceSqrt <= _ShadowDistanceSqrts[tileIndex] ? attenuation : 1;
}

サンプリング位置の調整は以下の部分です。uvをだしたところは投影テクスチャマッピングと同じように算出していますが、その値にscaleをかけることとオフセットをつけることでサンプリング位置を調整しています。

float2 samplingUv = 0;
float2 uv = positionLVP.xy / positionLVP.w * float2(0.5f, -0.5f) + 0.5f;

float scale = split == 1 ? 1 : 0.5;
samplingUv = Offsets[tileIndex] + uv * scale;

最後に

上記によって以下のように複数のDirectionalLightのシャドウを描くことができました。

1つのDirectionalLightのシャドウを描くときも同様ですが、うまくシャドウが描画できないときは各ライトコンポーネントのBiasやStrength等を調整してみてください。

参考

catlikecoding.com

news.mynavi.jp

qiita.com

qiita.com

鈴木直美の「PC Watch先週のキーワード」

HDRPでカスタムShader -レイヤーが一致するDirectionalLightの影響を受ける・レイヤーで指定したオブジェクトの影を落とす-

はじめに

以前HDRPでカスタムシェーダーを作成し、DirectionalLightの影を落とす・受け取る方法について書きました。

daiki-eguchi.hatenablog.com

今回はHDRPで複数のDirectionalLightがあった際にレイヤーを設定することでレイヤーが合っているものだけ影響を受ける方法についてです。

開発環境

  • Unity2022.3.4f1
  • HDRP14.08

注意点

まず、HDRPではカスケードシャドウを使用している時、複数のDirectionalLightの影を落とすことはできなさそうです。 なので一灯のDirectionalLightとは別角度の影を落としたいときはSpotLightを使用したりしています。(SpotLightでは複数方向の影を落とすことができる)

DirectionalLightを複数Shadowオンにすると出るエラー

Lightのレイヤー設定

DIrectionalLightにはLayerを設定することができ、そのライトの影響を及ぼすレイヤー分けを行うことができます。 このレイヤーを設定することでどのライトがどのオブジェクトに影響を及ぼすか。ということを設定することができます。

LightのLayerを設定する

このレイヤーはRendererのAdditionalSettings欄にあるRenderingLayerMaskと対応しています。

オブジェクト側のRenderingLayer

もし、Light側のLayer設定がない場合は以下のように「Show All Additional Properties」をクリックし、開いたPreferenceの「CoreRenderPipeline」からVisibilityをAllVisibleに設定することで確認できるかと思います。

実装

今回、以下のようにDirectionalLightを複数用意し、RenderingLayerをそれぞれ「0」と「1」に設定します。

前記事ではDirectionalLightは一つと考えていたので以下のように指定していました。DirectionalLightやスポットライトの情報はStructuredBufferとして登録されています。

DirectionalLightData directionalLightData = _DirectionalLightDatas[0];

DirectionalLightの数はDirectionalLightCountとして設定されているため、DirectionalLightDatasを順番にfor分で回し、レイヤーを判定します。

Shaderのフラグメントシェーダー部分を以下のようにします。

float4 frag (v2f i) : SV_Target
{
    float4 mainColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
                
    for (int lightCount = 0; lightCount < _DirectionalLightCount; lightCount++)
    {
        if(IsMatchingLightLayer(_DirectionalLightDatas[lightCount].lightLayers, 1 << 1))
        {
            DirectionalLightData directionalLightData = _DirectionalLightDatas[lightCount];

            float NdotL = saturate(dot(i.normal, -directionalLightData.forward));

            HDShadowContext shadowContext = InitShadowContext();
            float3 shadow = GetDirectionalShadowAttenuation(
                        shadowContext,
                        i.positionSS.xy,
                        i.positionWS,
                        i.normal,
                        directionalLightData.shadowIndex,
                       -directionalLightData.forward);

             mainColor.rgb *= directionalLightData.color * NdotL * shadow;
         }
    }
                
    return mainColor;
}

まず、for文でDirectionalLightを回します。最大数は_DirectionalLightCountになります。

for (int lightCount = 0; lightCount < _DirectionalLightCount; lightCount++)

そして、IsMatchingLightLayerメソッドでLightレイヤーとオブジェクトのRenderingLayerとあっているかを判定します。

LightLayerは_DirectionalLightDatas[lightCount].lightLayersになり、複数を設定することが可能です。以下のように書くとレイヤーが1のもののみ影響を受けるようになります。

if(IsMatchingLightLayer(_DirectionalLightDatas[lightCount].lightLayers, 1 << 1))

この関数を使用するときは以下のインクルードを行う必要があることに注意してください。

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonLighting.hlsl"

上記によってLayerが0に設定しているライトを緑、Layerを1に設定しているライトを黄色にしていますが、Layerを1の黄色の影響をうけています。

レイヤーが1の黄色の影響を受けている

しかし、影が落ちていません。

影が落ちていない

影を落とす対象オブジェクトのレイヤーを設定する

これはデフォルトではLightLayerと一致しているレイヤーの影を落とそうとするので影が落ちていないのが原因になります。

HDRPには影を落とす対象のオブジェクトのレイヤーを設定することができるのですが、これがなにもしないとLightLayerの設定値になるので(試してみた感じ)、今LightLayerを1に設定しているためにRenderingLayerが1の影を落とそうとしています。 現在、CubeのRenderingLayerは0なので、0の影を落とす必要があります。

そのため、以下のようにLightコンポーネントのShadow欄にある「CustomShadowLayer」をオンにして影を落としたいオブジェクトのレイヤーを指定します。 今回はCubeやPlaneのレイヤーは0なのでCustomShadowLayerは0にします。

この設定を行うと以下のようにしっかりとシャドウが描画されます。

無事に影が描画された

最後に

これによって複数のDirectionalLightがあったときにレイヤー指定したライトの影響を受けるように。そしてレイヤー分けで狙った影を描画することができました。

次はHDRPのカスタムシェーダーで記載するDepthPrepassについて記事を書こうかと思っています。

HDRPでカスタムShader -影を落とす・影を受け取る-

はじめに

以前HDRPでカスタムシェーダーを作成する方法をUnlit形式で紹介しました。

daiki-eguchi.hatenablog.com

今回はHDRPで影を出す、影を受け取る方法についてです。

最小限ということで影響を受ける光はまず「1つのみ」とします。

開発環境

  • Unity2022.3.4f1
  • HDRP14.08

成果物

最終的に以下のような見た目になります。PlaneとCube二つには同じShaderを使っており、落ち影やほかの落ち影の影響をうけることが確認できます。

Shader

まずはShader全文です。見慣れないincludeファイルがありますが一つ一つ解説していきます。

Shader "Custom/SampleLit"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags 
        {
            "RenderType"="Opaque"
            "RenderPipeline"="HDRenderPipeline"
        }
        LOD 100

        Pass
        {
            Tags {"LightMode" = "Forward"}
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/Lighting/LightLoop/HDShadow.hlsl"

            #pragma multi_compile SHADOW_LOW SHADOW_MEDIUM SHADOW_HIGH SHADOW_VERY_HIGH
            #pragma multi_compile_fragment AREA_SHADOW_MEDIUM AREA_SHADOW_HIGH
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/Material/NormalBuffer.hlsl"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float3 normal : NORMAL;
                float4 positionSS : TEXCOORD1;
                float3 positionWS : TEXCOORD2;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.normal = TransformObjectToWorldNormal(v.normal);
                
                float4 ss = o.vertex * 0.5f;
                ss.xy = float2(ss.x, ss.y * _ProjectionParams.x) + ss.w;
                ss.zw = o.vertex.zw;
                o.positionSS = ss;
                
                o.positionWS = TransformObjectToWorld(v.vertex);
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                DirectionalLightData directionalLightData = _DirectionalLightDatas[0];

                HDShadowContext shadowContext = InitShadowContext();
                float3 shadow = GetDirectionalShadowAttenuation(
                    shadowContext,
                    i.positionSS.xy,
                    i.positionWS,
                    i.normal,
                    directionalLightData.shadowIndex,
                    -directionalLightData.forward);

                float4 mainColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
                mainColor.rgb *= shadow.r;
                return mainColor;
            }
            ENDHLSL
        }
        Pass
        {
            Name "ShadowCaster"
            Tags {"LightMode" = "ShadowCaster"}
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_shadowcaster
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"

            struct appdata
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
            };

            v2f vert(appdata i)
            {
                v2f o;
                o.pos = TransformObjectToHClip(i.vertex);
                return o;
            }

            float4 frag(v2f i) : SV_Target
            {
                return 0;
            }
            
            ENDHLSL
        }
    }
}

影を落とす

影を落とすのはURPなどと同様に影を落とすようのパスを追加します。

LightModeをShadowCasterにしないと判別されないので注意です。この辺はURPと同様ですね。

Pass
{
    Name "ShadowCaster"
    Tags {"LightMode" = "ShadowCaster"}
            
     HLSLPROGRAM
     #pragma vertex vert
     #pragma fragment frag
     #pragma multi_compile_shadowcaster
     #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
     #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"

     struct appdata
     {
         float4 vertex : POSITION;
         float3 normal : NORMAL;
         float2 texcoord : TEXCOORD0;
      };

      struct v2f
      {
          float4 pos : SV_POSITION;
      };

      v2f vert(appdata i)
      {
          v2f o;
          o.pos = TransformObjectToHClip(i.vertex);
          return o;
      }

      float4 frag(v2f i) : SV_Target
      {
          return 0;
      }
            
      ENDHLSL
}

普段よく使うような関数は以下のインクルードファイルの中に入っていました。

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"

影を受け取る

影を受け取るのが少し大変だったのですが一つ一つ見てみます。

URPであったようなGetMainLight()GetAdditionalLight()というような便利関数がHDRPにはありません。

まずはDirectionalLightの取得ですがHDRPでは_DirectionalLightDatasというStructuredBufferにシーン内のDirectionalLightが入ってきます。 実装されているのは以下で示すようなShaderVariablesLightLoop.hlslです。

#include "Packages/com.unity.render-pipelines.high-definition/Runtime/Lighting/LightDefinition.cs.hlsl"
#include "Packages/com.unity.render-pipelines.high-definition/Runtime/Core/Utilities/GeometryUtils.cs.hlsl"

// don't support Buffer yet in unity
StructuredBuffer<uint>  g_vBigTileLightList;

StructuredBuffer<uint>  g_vLightListTile;
StructuredBuffer<uint>  g_vLightListCluster;

StructuredBuffer<uint>  g_vLayeredOffsetsBuffer;
StructuredBuffer<float> g_logBaseBuffer;

#ifdef USE_INDIRECT
    StructuredBuffer<uint> g_TileFeatureFlags;
#endif

StructuredBuffer<DirectionalLightData> _DirectionalLightDatas;
StructuredBuffer<LightData>            _LightDatas;
StructuredBuffer<EnvLightData>         _EnvLightDatas;

// Used by directional and spot lights
TEXTURE2D(_CookieAtlas);

// Used by cube and planar reflection probes
TEXTURE2D_ARRAY(_ReflectionAtlas);

// Contact shadows
TEXTURE2D_X_UINT(_ContactShadowTexture);

// Screen space shadows
TEXTURE2D_ARRAY(_ScreenSpaceShadowsTexture);

// Indirect Diffuse Texture
TEXTURE2D_X(_IndirectDiffuseTexture);

今回はDirectionalLightが一つということで進めていくので配列の0番目を取得します。

DiectionalLightが複数の時の実装方法はまた別途書こうと思います。

float4 frag (v2f i) : SV_Target
{
    DirectionalLightData directionalLightData = _DirectionalLightDatas[0];

    HDShadowContext shadowContext = InitShadowContext();
    float3 shadow = GetDirectionalShadowAttenuation(
                    shadowContext,
                    i.positionSS.xy,
                    i.positionWS,
                    i.normal,
                    directionalLightData.shadowIndex,
                    -directionalLightData.forward);
}

DirectionalLightを取得しましたがそこからまた呪文が続いています。

HDShadowContext shadowContext = InitShadowContext();
float3 shadow = GetDirectionalShadowAttenuation(
                    shadowContext,
                    i.positionSS.xy,
                    i.positionWS,
                    i.normal,
                    directionalLightData.shadowIndex,
                    -directionalLightData.forward);

ここがShadowMapから影の情報を取得し、shadowAttenuationを取得する関数です。実はHDRPのLit.shaderの中身での影の受け取りはほかにもいろいろやっているのですが、最小限ではこのぐらいでよさそうです。

InitShadowContext()はHDShadowContext.hlslで定義されている関数でシャドウデータを取得する初期化のような関数です。

以下はHDShadowContext.hlslの切り抜きです。

///////// 中略

StructuredBuffer<HDShadowData>              _HDShadowDatas;
// Only the first element is used since we only support one directional light
StructuredBuffer<HDDirectionalShadowData>   _HDDirectionalShadowData;

HDShadowContext InitShadowContext()
{
    HDShadowContext         sc;

    sc.shadowDatas = _HDShadowDatas;
    sc.directionalShadowData = _HDDirectionalShadowData[0];

    return sc;
}

////// 中略

これらを使用するために必要なファイルをインクルードしています。

#include "Packages/com.unity.render-pipelines.high-definition/Runtime/Lighting/LightLoop/HDShadow.hlsl"

また、以下のマクロを使用しています。

#pragma multi_compile SHADOW_LOW SHADOW_MEDIUM SHADOW_HIGH SHADOW_VERY_HIGH
#pragma multi_compile_fragment AREA_SHADOW_MEDIUM AREA_SHADOW_HIGH

GetDirectionalShadowAttenuationという関数の引数に必要な変数がpositionSS(スクリーンスペース)、positionWS(ワールドスペース)、normal(法線情報)が必要なので頂点シェーダーのほうで格納しています。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    o.normal = TransformObjectToWorldNormal(v.normal);
                
    float4 ss = o.vertex * 0.5f;
    ss.xy = float2(ss.x, ss.y * _ProjectionParams.x) + ss.w;
    ss.zw = o.vertex.zw;
    o.positionSS = ss;
                
    o.positionWS = TransformObjectToWorld(v.vertex);
    return o;
}

最後にShadowの色味をテクスチャの色味に乗算することで今回はシャドウを表現しました。

float4 mainColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
mainColor.rgb *= shadow.r;
return mainColor;

設定

最後にUnityの設定を行います。 上記のシェーダーをCubeとPlaneにアタッチすると以下のようにシャドウが汚く見えます。

これを改善するには、、、!と設定周りを探してみると方法が二つありました。

一つ目はDirectionalLight側の設定を調整する方法です。実はこのシャドウの汚さはShadow Filtering Qualityに由来するものでした。

このShadow Filtering Qualityは以下にドキュメントがありました。

docs.unity3d.com

Highに設定するとパーセンテージ クローザー ソフト シャドウ (PCSS)が働くのでそこが悪さしているのか、、、という感じです。ソフトシャドウの質はAngular DiameterとSampleCountによると記載があるのでそちらを調整します。

スクリーンスペースによるものなのでサンプリング回数を上げれば軽減します。サンプリング回数はライトのほうの設定のShadow欄にある「Filter Sample Count」なのでこちらを上げれば軽減します。

ただ今回はMaxのサンプリング数64にしても軽減しなかったのでAngular Diameter Scale For Softnessを下げました。

もちろんサンプリング回数を上げるということは負荷も上がるということだと思うのでこの辺は二つの変数をいいかんじに調整する必要がありそうですね。

2つ目はProjectSettingsでQuality>HDRP>Shadowsの「Shadow Filtering Quality」をHighからMidiumに変更することです。

HighからMediumにするとそもそもPCSSではなくなるので汚く見えなくなった。というだけになります。

これらの方法のどちらかをとると今回のような見た目に見えます。

おわりに

HDRPで影を落とし、影を受ける方法について書いてみました。

次はライトの影響を受けてシェーディングできる方法について書こうと思います。

Unityでステンシルシャドウボリューム

はじめに

前回、投影テクスチャマッピングシャドウを取り上げました。しかし、投影テクスチャマッピングシャドウは影生成の単位がキャラ単位なので自身の部位から受ける影「セルフシャドウ」を描画することができません。

daiki-eguchi.hatenablog.com

そこでセルフシャドウ表現まで行える技法で比較的互換性が高く、汎用性が高い技法がステンシルシャドウボリュームのようです。

ステンシルシャドウボリュームの理論

まず、視点方向からの深度情報(Zバッファ)を保持しておきます。

その後、以下のようにオブジェクトの輪郭を光源方向(光源からオブジェクトの方向)に伸ばします。

そのあと、以下のように視点からみてシャドウボリュームの表面(前面)をステンシルバッファに+1を書き込みます。 光源が複数あったり、オブジェクトが重なっているときはステンシルバッファでの+1演算が複数になります。

ステンシルバッファは算術処理用のフレームバッファです。

ステンシルバッファはあらかじめ0に初期化しておきます。

そして、視点から見てシャドウボリュームの裏面でステンシルバッファを-1します。

すると影を書く以外が0に戻り、影が生成される場所のステンシルは1以上になります。そのため、ステンシルが1以上の場所に影色つけていきます。

これによってセルフシャドウ表現もでき、モデル同士が影を落としあうこともできます。

成果物

以下のように落ち影が描画できています。

Unityでの実装

今回はURPで描画してみるのでRendererFeatureを使用してみます。 ProjectSettingsで現在使用しているRenderPipelineAssetを探します。

Stencil値の初期化

メニューの下からAdd RendererFeatureをクリックし、RenderObjectsを選択します。

名前をStencilInitializeにしてOverrides内のStencilをオン、ValueをONにしました。 これによって、画面全体のStencilが0になります。

オブジェクトの前面を伸ばし、ステンシル値をあげる

同様にRenderObjectsのRendererFeatureをもう一つ追加します。 命名をStencilIncreaseにして、新しく作成したStencilIncreaseというMaterialをOverrideの欄に入れます。

このMaterialにアサインするShaderは以下のように作成しました。

Shader "Custom/StencilIncrease"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadowColor ("Shadow Color", COlor) = (0, 0, 0, 0.5)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            NAME "StencilFront"
            
            Tags { "LightMode" = "UniversalForward" }
            //カラーチャンネルに書き込むレンダーターゲットを設定する
            //0の場合、全てのカラーチャンネルが無効化され何も書き込まれない
            ColorMask 0
            ZWrite Off
            Cull Back
            ZTest LEqual

            Stencil
            {
                Ref 1
                Comp Always
                Pass IncrSat
            }
            
            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;
                float4 normal : NORMAL;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            float _ShadowLength;
            half4 _ShadowColor;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                // ライトの取得
                Light light = GetMainLight();

                // 法線をワールド空間に変換
                float3 worldNormal = TransformObjectToWorldNormal(v.normal);
                float nDotL = dot(worldNormal, light.direction);

                // 頂点を押し出す
                float3 vertex2 = v.vertex + TransformWorldToObjectDir(-light.direction) * _ShadowLength;

                o.vertex = TransformObjectToHClip(lerp(v.vertex, vertex2, 1 - step(-0.3, nDotL)));
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return 0;
            }
            ENDHLSL
        }
    }
}

ステンシルと頂点シェーダーがポイントになります。

まずはCullをBack指定し表面を伸ばすようにします。

Cull Back

Stencilを記述するだけなのでカラーチャンネルには何も書き込まれないように設定します。

ColorMask 0

ステンシルは以下のように記述し、必ずステンシルの値を1あげるようにしています。

Stencil
{
    Ref 1
    Comp Always
    Pass IncrSat
}

頂点シェーダー内では自分の頂点からライトの方向に対して伸ばしています。

まずは伸びた先の頂点位置を計算しています。

/ / 頂点を押し出す
float3 vertex2 = v.vertex + TransformWorldToObjectDir(-light.direction) * _ShadowLength;

その後、ライト方向と法線方向の内積から判断して頂点を伸ばしました。

o.vertex = TransformObjectToHClip(lerp(v.vertex, vertex2, 1 - step(-0.3, nDotL)));

オブジェクトの背面を伸ばし、ステンシル値を下げる

RendererFeatureを一つ追加します。RenderObjectsのRendererFeatureを追加し、名前をStencilDecreaseとしました。

Materialを新規で一つ追加し、アサインしたStencilを下げるShaderは以下のように記述しています。

Shader "Unlit/StencilDecrease"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadowColor ("Shadow Color", COlor) = (0, 0, 0, 0.5)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            NAME "StencilBack"
            
            Tags { "LightMode" = "UniversalForward" }
            //カラーチャンネルに書き込むレンダーターゲットを設定する
            //0の場合、全てのカラーチャンネルが無効化され何も書き込まれない
            ColorMask 0
            ZWrite Off
            Cull Front
            ZTest LEqual
            
            Stencil
            {
                Ref 1
                Comp Always
                Pass DecrSat
            }
            
            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;
                float4 normal : NORMAL;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

            float3 _LightPosition;

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            float _ShadowLength;
            half4 _ShadowColor;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                // ライトの取得
                Light light = GetMainLight();

                // 法線をワールド空間に変換
                float3 worldNormal = TransformObjectToWorldNormal(v.normal);
                float nDotL = dot(worldNormal, light.direction);

                // 頂点を押し出す
                float3 vertex2 = v.vertex + TransformWorldToObjectDir(-light.direction) * _ShadowLength;

                o.vertex = TransformObjectToHClip(lerp(v.vertex, vertex2, 1 - step(-0.3, nDotL)));
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return 0;
            }
            ENDHLSL
        }
    }
}

Stencilを上げるShaderと比較すると変更点は少ないです。

CullをFront指定することでオブジェクトの背面を伸ばします。

Cull Front

Stencilは以下のように記述してStencilを下げています。変更点はこれぐらいになります。

Stencil
{
    Ref 1
    Comp Always
    Pass DecrSat
}

Stencilをもとに影を描画する

Stencilは上記のRendererFeatureで操作できているので、あとはそれをもとに影を書いていきます。

RendererFeatureをまた一つ増やします。RenderObjectsを追加し、命名をDrawStencilShadowにしました。

Materialを新規で一つ作成して、それにアサインするShaderは以下のように記述しています。

Shader "Unlit/StencilShadowReceiver"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadowColor ("Shadow Color", COlor) = (0, 0, 0, 0.5)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            NAME "StencilShadowReceiver"
            
            Tags { "LightMode" = "ShadowStencil" }
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha
            
            Stencil
            {
                Ref 1
                Comp Equal
                Pass Keep
            }
            
            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;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            float _ShadowLength;
            half4 _ShadowColor;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex.xyz);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return _ShadowColor;
            }
            ENDHLSL
        }
    }
}

Stencilは以下のように記述しており今回は簡単なシーン設定のため、Stencilが1のところに影を書かせています。

Stencil
{
    Ref 1
    Comp Equal
    Pass Keep
 }

フラグメントシェーダーはMaterialから設定した色を描画するようにしています。

float4 frag (v2f i) : SV_Target
{
    return _ShadowColor;
}

最後に

上記によって以下のような影の描画を実現できました。

今回はステンシルシャドウボリュームを書いてみました。

ステンシルシャドウボリュームの弱点は影の生成単位がポリゴン単位なので例えば平面にTextureのアルファ抜きで模様や葉っぱを書いていた時、模様や葉っぱの影はできず平面の影ができてしまいます。

それを解決するために影生成用の引き延ばし用頂点を事前に仕込んでいたそうです。

ただ、これによって頂点シェーダーの負荷が上がるため、それを見越してポリゴン数を下げて品質が下がってしまうということもあったそうです。

参考

news.mynavi.jp

news.mynavi.jp

https://gameprogrammingunit.web.fc2.com/shadow/volume_simple.htm

zenn.dev

qiita.com

Unityで投影テクスチャマッピングシャドウ

はじめに

3Dゲームの影生成は90年代、キャラクターの足元に黒い丸を置いた「丸影」が主流だったようです。

しかし丸影は負荷は軽いですが「影」としてのリアリティはありませんでした。それを進化させたのが「投影テクスチャマッピング」です。

投影テクスチャマッピングの理論

投影テクスチャマッピングによるシャドウは以下のステップで実現できます。

まず、光源から見た(カメラに見立てて)キャラクターのシルエットをRenderTextureに書き出します。

そして、その作成したRenderTextureを影が生成される場所に貼る(マッピング)することで影が落ちているように見えます。

投影テクスチャマッピングの強みと弱み

この技法の強みはほぼすべてのGPUにサポートされることやキャラの影が別のキャラに投影されるようなことも実現する点になることです。

しかし、投影テクスチャマッピングの影の生成単位は「キャラ」であるため自分の部位の影が自分の体に映る「セルフシャドウ」は行えないことが弱点の一つになります。

また、光源からみた影のRenderTextureをどこにマッピングするのか(貼るのか)の判定が甘いと、ありえないところに影が発生してしまうことも弱点の一つのようです。

Unityでの実装例

まずは今回の成果物になります。

まず、光源と同じ位置、同じ回転値にしたカメラを用意します。

そしてこのカメラを以下のように

  • ProjectionをOrthographicに

  • CullingMaskを影を落としたいオブジェクトに設定しているLayerに

  • BackgroundColorをSolidColorにして背景色のアルファ値を0に

という風に設定しました。

スクリプト

そしてカメラにアタッチするC#スクリプトを以下のように作成します。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteAlways]
[RequireComponent(typeof(Camera))]
public class ShadowProjector : MonoBehaviour
{
    [SerializeField] private int renderTextureSize = 512;

    [SerializeField] private RenderTexture renderTexture;

    [SerializeField] private Light directionalLight;

    private Camera camera;

    private int textureSize;

    private void OnEnable()
    {
        camera = GetComponent<Camera>();
        camera.SetReplacementShader(Shader.Find("Custom/ShadowReciever"), null);
        camera.depth = -100000;
        renderTexture = new RenderTexture(renderTextureSize, renderTextureSize, 0, RenderTextureFormat.ARGB32, 0);
        camera.targetTexture = renderTexture;
        camera.clearFlags = CameraClearFlags.Color;
        camera.backgroundColor = Color.black;
    }

    private void OnDisable()
    {
        if (renderTexture == null)
        {
            return;
        }

        camera.targetTexture = null;
        DestroyImmediate(renderTexture);
    }

    private void LateUpdate()
    {
        textureSize = renderTexture.width;
        #if UNITY_EDITOR
        if (textureSize != renderTextureSize)
        {
            renderTexture.Release();
            renderTexture = new RenderTexture(renderTextureSize, renderTextureSize, 0, RenderTextureFormat.ARGB32, 0);
            camera.targetTexture = renderTexture;
        }
        #endif
        camera.transform.position = directionalLight.transform.position;
        camera.transform.rotation = directionalLight.transform.rotation;

        var viewMatrix = camera.worldToCameraMatrix;
        // プロジェクション行列のプラットフォームの差を吸収
        var projectionMatrix = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true);
        Shader.SetGlobalMatrix("_ShadowProjectorMatrixVP", projectionMatrix * viewMatrix);
        Shader.SetGlobalTexture("_ShadowProjectorTexture", renderTexture);

        // プロジェクターのポジションを渡す
        var projectPos = Vector4.zero;
        projectPos = camera.orthographic ? transform.forward : transform.position;
        projectPos.w = camera.orthographic ? 0 : 1;
        Shader.SetGlobalVector("_ShadowProjectorPos", projectPos);
    }
}

このC#ではステップ1である光源方向から見た影のRenderTextureを生成するところまでを行い、作成したRenderTextureをShader側に渡しています。

Enable処理では以下コードのようにCameraの設定、RenderTextureの生成、そしてCameraのoutputにあるTargetTextureにRenderTextureを設定するところまでを行っています。

影を描画するカメラは一番初めに描画したいためdepthをかなり小さい値にしています。

private void OnEnable()
{
    camera = GetComponent<Camera>();
    camera.SetReplacementShader(Shader.Find("Custom/ShadowReciever"), null);
    camera.depth = -100000;
    renderTexture = new RenderTexture(renderTextureSize, renderTextureSize, 0, RenderTextureFormat.ARGB32, 0);
    camera.targetTexture = renderTexture;
    camera.clearFlags = CameraClearFlags.Color;
    camera.backgroundColor = Color.black;
}

LateUpdate内ではShaderに必要な計算を行っています。Shader側でView行列とProjection行列が必要なのでShader側に渡しています。

カメラのView行列はcamera.worldToCameraMatrixで取得し、カメラのProjection行列はcamera.projectionMatrixでとれます。ただ、カメラ空間からクリッピング空間に座標変換(射影変換)はGPUに渡す際、OpenGLDirectXで差異があるためGL.GetGPUProjectionMatrixでプラットフォームの差異を吸収します。

これは、XYZ座標をW座標で割って正規化する際、Z値を収める範囲がOpenGLは-1~1、DirectXは0~1なのでこのような処理が必要になります。

var viewMatrix = camera.worldToCameraMatrix;
// プロジェクション行列のプラットフォームの差を吸収
var projectionMatrix = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true);
Shader.SetGlobalMatrix("_ShadowProjectorMatrixVP", projectionMatrix * viewMatrix);
Shader.SetGlobalTexture("_ShadowProjectorTexture", renderTexture);

// プロジェクターのポジションを渡す
var projectPos = Vector4.zero;
projectPos = camera.orthographic ? transform.forward : transform.position;
projectPos.w = camera.orthographic ? 0 : 1;
Shader.SetGlobalVector("_ShadowProjectorPos", projectPos);

この状態でカメラのOutputにあるtargetTextureをクリックしRenderTextureの情報を見ると以下のようになっています。

RenderTextureのアルファ値のみを確認すると以下のようになっています。

この白い部分が投影された時の影になる場所になります。

Shader

まずは全文になります。このShaderをアタッチしたMaterialを影を受け取る側のオブジェクトにアタッチすれば影が描画されます。

Shader "Custom/ShadowReciever"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadowColor("ShadowColor", Color) = (0,0,0,0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100
        Pass
        {
            NAME "ShadowReceiver"
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            struct appdata
            {
                float4 vertex : POSITION;
                float4 normal : NORMAL;
            };
            struct v2f
            {
                float4 vertex : SV_POSITION;
                float4 projectorPos : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
                float3 worldNormal : TEXCOORD2;
            };
            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);
            TEXTURE2D(_ShadowProjectorTexture);
            SAMPLER(sampler_ShadowProjectorTexture);
            float4x4 _ShadowProjectorMatrixVP;
            float4 _ShadowProjectorPos;
            float4 _ShadowColor;
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex);
                o.projectorPos = mul(mul(_ShadowProjectorMatrixVP, unity_ObjectToWorld), v.vertex);
                o.projectorPos = ComputeScreenPos(o.projectorPos);
                o.worldNormal = TransformObjectToWorldNormal(v.normal);
                o.worldPos = TransformObjectToWorld(v.vertex);
                return o;
            }
            float4 frag (v2f i) : SV_Target
            {
                i.projectorPos.xyz /= i.projectorPos.w;
                float2 uv = i.projectorPos.xy;
                float4 projectorTex = SAMPLE_TEXTURE2D(_ShadowProjectorTexture, sampler_ShadowProjectorTexture, uv);
                float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
                // カメラの範囲外は適用しない
                float3 isOut = step((i.projectorPos - 0.5) * sign(i.projectorPos), 0.5);
                float alpha = isOut.x * isOut.y * isOut.z;

                // プロジェクターから見て裏面の面には適用しない
                alpha *= step(-dot(lerp(-_ShadowProjectorPos.xyz, _ShadowProjectorPos.xyz - i.worldPos, _ShadowProjectorPos.w), i.worldNormal), 0);;
                return lerp(col, 0 , alpha * projectorTex.a);
            }
            ENDHLSL
        }
    }
}

大事になるのは頂点シェーダーです。テクスチャマッピングを行うので流れとしては

  • VP行列を頂点座標にかけて座標変換
  • _ProjectionParams.xをかけることでプラットフォームの違いを吸収する
  • wで除算することで-1~1の範囲に
  • それを0-1に変換する

の流れになります。

(影描画用の)カメラから見たときのCubeやPlaneをTextureに描画させるのでモデル行列に対して影描画用のカメラのビュー行列とプロジェクション行列をかけます。

今回はC#側から渡ってきたVP行列を使って座標変換した後、ComputeScreenPos()を行うことで上の

  • _ProjectionParams.xをかけることでプラットフォームの違いを吸収する
  • それを0-1に変換する(0-wに変換し、後でwでわると0-1の範囲になるようにしている)

を解決しています。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex);
    o.projectorPos = mul(mul(_ShadowProjectorMatrixVP, unity_ObjectToWorld), v.vertex);
    o.projectorPos = ComputeScreenPos(o.projectorPos);
    o.worldNormal = TransformObjectToWorldNormal(v.normal);
    o.worldPos = TransformObjectToWorld(v.vertex);
    return o;
}

ComputeScreenPos()は以下のようにxyが0~wに変換されてyの上下問題を解決してくれるもので、Unityが用意してくれている関数になっています。

inline float4 ComputeNonStereoScreenPos(float4 pos) {
    // この時点でxyは-wからwの値を取る(MVP変換後だから)
    // xyzwに0.5をかけるのでxyは-0.5w~0.5wになる
    float4 o = pos * 0.5f;

    // この処理でxyが0~wになる
    // yがプラットフォームにより上下反転する問題も吸収
    o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;
    o.zw = pos.zw;
    return o;
}

inline float4 ComputeScreenPos(float4 pos) {
    // 通常はこの処理だけ
    float4 o = ComputeNonStereoScreenPos(pos);

// こっちはVR用の処理
#if defined(UNITY_SINGLE_PASS_STEREO)
    o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
#endif
    return o;
}

【Unity】【シェーダ】スクリーンに対してテクスチャをマッピングする方法を完全解説する - LIGHT11から引用

yがプラットフォームによって違うのはプラットフォームによって射影行列が反転しているためです。

フラグメントシェーダーでwによる除算を行なってからテクスチャサンプリングを行います。

float4 frag (v2f i) : SV_Target
{
     i.projectorPos.xyz /= i.projectorPos.w;
     float2 uv = i.projectorPos.xy;
     float4 projectorTex = SAMPLE_TEXTURE2D(_ShadowProjectorTexture, sampler_ShadowProjectorTexture, uv);
     float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
     // カメラの範囲外は適用しない
     float3 isOut = step((i.projectorPos - 0.5) * sign(i.projectorPos), 0.5);
     float alpha = isOut.x * isOut.y * isOut.z;

     // プロジェクターから見て裏面の面には適用しない
     alpha *= step(-dot(lerp(-_ShadowProjectorPos.xyz, _ShadowProjectorPos.xyz - i.worldPos, _ShadowProjectorPos.w), i.worldNormal), 0);
     return lerp(col, 0 , alpha * projectorTex.a);
}

カメラの範囲外かどうかの判定

カメラの範囲外かどうかの判定式は以下で行っています。

float3 isOut = step((i.projectorPos - 0.5) * sign(i.projectorPos), 0.5);
float alpha = isOut.x * isOut.y * isOut.z;

i.projectorPosはクリップスペース空間になるので、0-1の範囲外の場合はカメラの範囲外という判定になります。

sign(x)は以下の値を返す関数になります。

1.0 (if x>0)、0 (if x=0)、-1.0 (if x<0)を返す

裏面かどうかの判定

裏面かどうかは以下の処理で見ていますが、視線方向と放線方向のベクトルの内積をとり、負である場合は裏面ということになります。

alpha *= step(-dot(lerp(-_ShadowProjectorPos.xyz, _ShadowProjectorPos.xyz - i.worldPos, _ShadowProjectorPos.w), i.worldNormal), 0);

影の描画

最後に影を描画します。今回は影を0で書いているので真っ黒ですがPropertiesで定義してあげて影色を指定してあげるといいかと思います。

return lerp(col, 0 , alpha * projectorTex.a);

最後に

上記のShaderをアタッチしたMaterialを影を描画する側のオブジェクトにアタッチし、最終的には以下のような絵が作れます。

参考

news.mynavi.jp

light11.hatenadiary.com

tech.drecom.co.jp

soramamenatan.hatenablog.com

light11.hatenadiary.com

HDRPでカスタムShader - Unlit編

概要

HDRPはUnityが提供しているレンダーパイプラインの1つです。PBRや写実性が高いものを描画するのに長けている印象で、URPと同じくSRPの上に構築されたレンダーパイプラインの一つです。

しかし、URPと比較するとHDRPに関する記事は多いわけではなく内部のスクリプトも複雑になっているため敷居がたかいイメージがあります。

HDRPはShaderGraphを使用することや、HDRPに入っている標準のポストエフェクト等を使用することを目指して作られているような所感を受けましたが、元々作っているものをHDRPに移植したい時やHLSLを書いて表現を構築したい場合はあります。

本記事やこのブログで、HDRPにおいてカスタムシェーダーを作りたい方の少しでも助けになればと思います。

HDRPでカスタムUnlitシェーダーを書く

本記事では最低要素のUnlitシェーダーを書いてみます。

筆者の環境は

  • Unity2022.3.4f1
  • HDRP 14.08

になります。

Unity設定

まず、前提として今回はHDRPのRenderingのLitShaderModeを「Forward Only」に変更します。

以下のようにProjectSettingsからRenderingタブから変更してください。

Deferred Onlyの場合やBothの設定の場合はまた別の記事で書いてみようと思います。

Shader

ではUnlitShader全文です。

Shader "Custom/SampleUnLit"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags 
        {
            "RenderType"="Opaque"
            "RenderPipeline"="HDRenderPipeline"
        }
        LOD 100

        Pass
        {
            Tags {"LightMode" = "Forward"}
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
            }
            ENDHLSL
        }
    }
}

解説

基本はURPで行うUnlitと同じ処理になります。ただ、HDRPでのincludeやTag指定は気を付けないと描画されません。

include

includeは基本的にこの2つかと思います。影の描画を行う場合は別のincludeが必要ですが、この2つがカスタムシェーダーを書く際のUnityが用意している関数やGlobal変数などを保持しています。

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"

Tags

Tag指定は以下のように行っています。もちろんRenderTypeはその時に応じて変更しますが、RenderPipelineはHDRenderPipelineを指定します。

Tags 
{
    "RenderType"="Opaque"
    "RenderPipeline"="HDRenderPipeline"
}

描画結果

上記のShaderをもとにMaterialを作成し、Cubeにアタッチすると以下のように描画されます。

最後に

これで最低要素のUnlitShaderを記述できました。これをもとにお好みのカスタマイズを行えます。

次はLitShader、特に影を落とす・他の影を受ける。というところを詳細に書こうと思います。

参考

docs.unity3d.com

tips.hecomi.com

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