URPで丸影を真下に落とす方法の検討

はじめに

Shader好きなエンジニアのおうどん(@oudon)です。

キャラクターの影を軽量化するため丸影にしつつ真下に落とす、かつ数も多く表示したい
という要件でShadowMap拡張とDecalの2パターンほど試してみました。

ShadowMapをそのまま利用できないか

軽量にということで、標準のShadowMapを利用してなんとかできないかというところからスタートしました。 特定のオブジェクトだけ常に真上から光を受け取れる状態がつくれば真下に落ちそうです。

丸影

シンプルにキャラクターのCast Shadowsをoffにして、 子オブジェクトに球体を配置しCast ShadowsをShadows Onlyとする方法にしました。
ついでに常に上から光を受けた2Dの円表現になる想定なので一旦Yは0にしてつぶしておきます。

ShadowCaster

基本的にはShadowCasterで影を落とすオブジェクトの位置情報を決定してあげると RenderingPipelineの方でその情報を元に実際にShadowMapに書き込んでくれてるようです。
ということでCaster側でオブジェクトの影が常に真下に落ちるように位置を偽装してやればいけそうな気がします。

URPの標準パッケージ内のコードを参考に改造していきます。

  • Universal RP/Shaders/Lit.shader
  • Universal RP/Shaders/ShadowCasterPass.hlsl

ワールド座標からクリップ座標を計算している該当の処理がGetShadowPositionHClip関数にありました。

float4 GetShadowPositionHClip(Attributes input)
{
    float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
    float3 normalWS = TransformObjectToWorldNormal(input.normalOS);

#if _CASTING_PUNCTUAL_LIGHT_SHADOW
    float3 lightDirectionWS = normalize(_LightPosition - positionWS);
#else
    float3 lightDirectionWS = _LightDirection;
#endif

    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));

#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif

    return positionCS;
}

ここのpositionWS(ワールド座標)を弄ってxzのライトによる影響を無効化、yの高さでの変動を無効化してやりました。

    positionWS.xz = positionWS.xz +  lightDirectionWS.xz;
    positionWS.y = lightDirectionWS.y;
    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));

一旦これでメインライトの向きを無視してオブジェクトの真下に影を落とせました。

が地面の高さが0以外だと影が動いてしまいます。

ShadowReceiver

ということで今度は地面側を工夫して高さの影響を受けない(常にワールド座標のYが0地点の影情報を参照する)ようにしてみます。 地面はShaderGraphでカスタマイズします。 ただしShadowMapの情報を直接取得するノードがないのでカスタム関数を作成して受け取る必要があります。

カスタムノード作成

参考: blog.unity.com こちらの「void MainLight_half」ですね。

また影を自前で受け取るためGraph Settingsの「Receive Shadows」を無効にします。

しかし無効にすると影が表示されなくなってしまいました。
コードを追ってみると

  • GetMainLight (Universal RP/ShaderLibrary/RealtimeLights.hlsl内)
Light GetMainLight(float4 shadowCoord)
{
    Light light = GetMainLight();
    light.shadowAttenuation = MainLightRealtimeShadow(shadowCoord);
    return light;
}

内の

  • MainLightRealtimeShadow (Universal RP/ShaderLibrary/Shadows.hlsl内)
half MainLightRealtimeShadow(float4 shadowCoord)
{
#if !defined(MAIN_LIGHT_CALCULATE_SHADOWS)
    return half(1.0);
#elif defined(_MAIN_LIGHT_SHADOWS_SCREEN) && !defined(_SURFACE_TYPE_TRANSPARENT)
    return SampleScreenSpaceShadowmap(shadowCoord);
#else
    ShadowSamplingData shadowSamplingData = GetMainLightShadowSamplingData();
    half4 shadowParams = GetMainLightShadowParams();
    return SampleShadowmap(TEXTURE2D_ARGS(_MainLightShadowmapTexture, sampler_MainLightShadowmapTexture), shadowCoord, shadowSamplingData, shadowParams, false);
#endif
}

どうやらここで戻り値が常に1(影を受けない)でshadowAttenuationにセットされてしまうようです。
MAIN_LIGHT_CALCULATE_SHADOWSの挙動を確認すると 「Receive Shadows」を無効にした場合、未定義になるようです。
とりあえず該当の行を削除して強制的に計算してくれるよう変更します。 ざっとまとめるとこんな感じです。

void MainLight_half(float3 WorldPos, out half3 Direction, out half3 Color, out half DistanceAtten, out half ShadowAtten)
{
#if SHADERGRAPH_PREVIEW
    Direction = half3(0.5, 0.5, 0);
    Color = 1;
    DistanceAtten = 1;
    ShadowAtten = 1;
#else
#if SHADOWS_SCREEN
    half4 clipPos = TransformWorldToHClip(WorldPos);
    half4 shadowCoord = ComputeScreenPos(clipPos);
#else
    half4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
#endif
    Light mainLight = GetMainLight();
    ShadowSamplingData shadowSamplingData = GetMainLightShadowSamplingData();
    half4 shadowParams = GetMainLightShadowParams();
    mainLight.shadowAttenuation = SampleShadowmap(TEXTURE2D_ARGS(_MainLightShadowmapTexture, sampler_MainLightShadowmapTexture), shadowCoord, shadowSamplingData, shadowParams, false);
    Direction = mainLight.direction;
    Color = mainLight.color;
    DistanceAtten = mainLight.distanceAttenuation;
    ShadowAtten = mainLight.shadowAttenuation;
#endif
} 

後は参考の通りShaderGraphを組みますが カスタム関数に渡すワールド座標のYに0固定にします。

高さに応じて影のサイズを変更

合わせてShadow Caster側でサイズ調整もやってみました。 先ほどのpositionCSの箇所を

    float3 positionCenter = TransformObjectToWorld(float3(0, 0, 0));
    
    float max = 3;
    float size = saturate(Remap(clamp(positionCenter.y, 0, max), float2(0, max), float2(1, 0)));
    if(size < 0.00001) return 0;
    float3 resizePosOS = input.positionOS.xyz;
    resizePosOS.xz *= size;
    float3 resizePosWS = TransformObjectToWorld( resizePosOS);

    lightDirectionWS *= 0.1;
    resizePosWS.xz = resizePosWS.xz + lightDirectionWS.xz;
    resizePosWS.y = lightDirectionWS.y;

    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(resizePosWS, normalWS, lightDirectionWS));

のような感じで、0位置からの相対でサイズを計算してます。Remap便利

結果

と、ここまでやってそれなりに軽くてそれっぽく表現はできたのですが

  • 完全にこの丸影専用の床です。他の通常の影も拾えますが、Yが0地点の影しか拾えなくなってます。場合によっては使えるかもしれませんが、やはりちゃんと自前で丸影専用のマップ作って合成した方が良さそうです。
  • サイズ調整も地面の高さが0ベースなのでこれもなんとかしないとダメそうです。
  • また床が2重の場合両方に同じ影が反映されます。
補足
  • ここまでとりあえずDirectional Lightのみ想定の検証でした。
  • 他にもDirectional Lightを2つ用意して片方を丸影用に~と考えたんですが、ShadowMapは複数Directional Lightサポートしないので実現できず。
  • また機会があれば、Pipeline側での拡張やCustomFeature等も模索してみたいと思います。

Decal

URPのサンプルにそのままあったので試してみました。

こちらのBlob shadowsです。
Package Manager → Universal RP → Samples → importからダウンロード可能です。 docs.unity3d.com

使用手順

  • Renderer Featureの設定

「Universal Render Pipeline Asset_renderer(Universal Renderer Data)」のAddRenderer FeatureでDecalを追加します。

  • Decal Projectorをセット

影を落とすオブジェクトのCast Shadowsをoffにして 子オブジェクトに「URP Decal Projector」コンポーネントを追加します。 Decalに対応したShaderのMaterialをセットします。URPサンプルの「Decal/BlobShadow/BlobShadow_Mat」参考

以上のようにとても簡単です。

以下のパラメータで投影する範囲を決定するバウンディングボックスを調整できます。

  • Width/Height:投影するサイズ
  • Projection Depth:深さ。投影する範囲の長さ
  • Pivotでバウンディングボックス:positionをoffsetで調整します。

ShaderGraphでDecal対応のShaderを作ることで簡単に差し替えることができます。
なお補足ですが、下に投影する場合transformでX軸のRotationを90にしないと正しく投影されませんでした。

結果

  • 扱いが容易
  • ただしProjectorのバウンディングボックスのサイズとシーンオブジェクトの配置をうまく調整しないとShadowMapの時と同様、床が2重の場合両方に同じ影が反映されたりします。
  • 別物なので標準のShadowMapの影と馴染まない。
  • また複数Decalを使用した際のDraw Distance(描画距離)が個別に判定されない不安定な印象でした。

負荷比較

手元のPCで100オブジェクト分をStatisticsで単純に比較したのですが

結果、影無しの状態を基準に

  • 標準のshadow caster ≠ カスタマイズしたshadow caster が -15fps程度
  • Decalが -20fps程度

と思ったよりそこまで大きな差はなさそうで、Decalが汎用的に活躍できそうな印象でした。
(もちろん飲めるメリット・デメリット次第ではありますが)
またどちらのパターンもチューニングしていない状態なので改善の余地はありそうです。

最後に

ShadowMapの利用に関しては結果というよりか、弄る際の参考になれば幸いです。
Decalに関してはURPの新しめの機能ということもあり、今後も色々試してみたいです。
表題に関しても他のアプローチがあればまた試してみたいと思います。