【URP14】シャドウマップを自作して、キャラクターの影をキャラクターが受けないようにする

2023/12/04に公開

この投稿は「Unity アドベントカレンダー 2023 その1」の4日目の記事です。

開発環境

この記事で使用するUnityバージョンとUniversalRPのバージョンは以下になります。

  • Unity2022.3.13f1
  • Universal RP 14.0.9

概要

キャラクターの影を地面に落としつつ、背景の影をキャラクターが受けるようにしたい、というケースを考えます。

キャラクターの影は地面に落ちる


背景の影はキャラクターが受ける

問題 : 影が汚くなる

落ち影をキャラクターが受けるようにすると、汚い影が表示されてしまいます。

今回の記事は、この汚い影を解決する方法について書いてみようと思います。

解決策

この問題を解決するには、いくつかのアプローチが考えられます

  • 手法1. キャラクターが落ち影を受けないようにする
  • 手法2. URPのShadow Bias を調整し、キャラクターに影が入らないようにする
  • 手法3. 背景だけをレンダリングしたシャドウマップを用意して、キャラクターからはこのシャドウマップを参照する

今回の記事では、手法3を採用します。

実装の概要

手法3の実装は以下のようなものとなります。

  1. 背景用シャドウマップAとキャラクター用シャドウマップBを用意する
  2. Aには背景だけの深度情報を書きこむ
  3. Bにはキャラクターだけの深度情報を書きこむ
  4. キャラクターシェーダーでは、Bを参照して落ち影を描画する (背景の影だけを受ける)
  5. 背景シェーダーでは、AとBを参照して落ち影を描画する (背景とキャラクター両方の影を受ける)
補足 : シャドウマップとは

シャドウマップとは、落ち影の描画に利用しているテクスチャです。

最初に、落ち影対象とライトの間の距離(深度値)を深度テクスチャ(シャドウマップ)に書きこみます。

オブジェクトの描画時、深度マップの深度値と比較し、影に入っているかどうかを判定します。

// シャドウマップから深度を取得
float shadowMapDepth = SAMPLE_TEXTURE2D(_BgShadowMapTexture, sampler_BgShadowMapTexture, shadowCoord.xy).r; // near=1, far=0
#if UNITY_REVERSED_Z
shadowMapDepth = 1.0 - shadowMapDepth; // near=0, far=1 となるように補正
#endif

// シャドウマップよりも深度が大きければ、影に入っていると判定する(atten=0)
half shadowAttenuation = step(depth, shadowMapDepth); 

GitHubサンプルプロジェクト

https://github.com/rngtm/URP14-ShadowSample
※サンプルプロジェクトに3Dキャラクター(ユニティちゃん)は同梱されていません。

STEP1. ライトの作成

CustomShadowSettings

まずは、影用の設定データ CustomShadowSettings を追加します。

https://github.com/rngtm/URP14-ShadowSample/blob/main/Assets/Rendeirng/Scripts/Data/CustomShadowSettings.cs

CustomLight

次に、影用のカスタムのライトを作成します。
こちらのコンポーネントはシーン上の適当なGameObjectにアタッチしておきます。

https://github.com/rngtm/URP14-ShadowSample/blob/main/Assets/Rendeirng/Scripts/Component/CustomLight.cs

STEP2. レンダーパスの作成

RenderingLayerMaskの設定

レンダーパスを作成する前に、RenderingLayerMaskの設定を行います。

Universal Render Pipeline Global Settings の Rendering Layer に Character と BGを追加します。

背景とキャラクターのレンダラーのRedneringLayerMaskをそれぞれBG, Characterに設定します、

ScriptableRenderPass

シャドウマップの描画を実行するレンダーパスCustomShadowPassを作成します。

https://github.com/rngtm/URP14-ShadowSample/blob/main/Assets/Rendeirng/Scripts/RenderPass/CustomShadowPass.cs

今回はシャドウマッピングへの理解を深めるため、ビュー行列とプロジェクション行列を自前実装してみました。
(間違っているところがあれば、コメントにてご指摘いただけますと幸いです)

  1. ビュー行列は、ワールド空間をカメラ空間へ変換する行列になります。
  2. プロジェクション行列は、カメラ空間をクリップ空間へ変換する行列になります。

ビュー行列の作成

/// <summary>
/// ビュー行列を作成
/// </summary>
private static Matrix4x4 GetViewMatrix(CustomLight light)
{
    Transform lightTransform = light.transform;
    Vector4 lightX = lightTransform.right; // ライトのX軸ベクトル
    Vector4 lightY = lightTransform.up; // ライトのY軸ベクトル
    Vector4 lightZ = lightTransform.forward; // ライトのZ軸ベクトル
    Vector4 lightW = lightTransform.position; // 平行移動成分
    lightW.w = 1f;

    // ワールド空間の座標をライト空間の座標に変換する行列 
    var worldToLight = new Matrix4x4(lightX, lightY, lightZ, lightW);

    // ライト空間の座標をワールド空間の座標に変換する行列 (ビュー行列)
    return worldToLight.inverse;
}

プロジェクション行列の作成

/// <summary>
/// プロジェクション行列の作成 (Orthographic)
/// </summary>
/// <param name="orthographicSize">タテヨコの大きさ</param>
/// <param name="near">ニアクリップ面</param>
/// <param name="far">ファークリップ面</param>
private static Matrix4x4 GetProjectionMatrix(float orthographicSize, float near, float far)
{
    Matrix4x4 projMatrix = Matrix4x4.identity;

    // カメラのビューボリューム サイズ
    float viewSizeX = orthographicSize / 2f; // 横のサイズ
    float viewSizeY = orthographicSize / 2f; // 縦のサイズ

    // プロジェクション行列 作成
    projMatrix.m00 = 1f / viewSizeX; // x: [xmin, xmax] -> [-1, 1]
    projMatrix.m11 = 1f / viewSizeY; // y: [ymin, ymax] -> [-1, 1]

    // テクスチャに対して描画するので、UVが反転しているかどうかを考慮
    if (SystemInfo.graphicsUVStartsAtTop) // UV.yが反転 : DirectX, Vulkan, Metal
    {
        projMatrix.m11 *= -1f;
    }

    // プラットフォームによって、深度バッファの向きが異なっている
    if (SystemInfo.usesReversedZBuffer) // 逆向きの深度 : DirectX, Metal, Vulkan
    {
        // z: [n,f] -> [1, 0] 
        projMatrix.m22 = -1f / (far - near);
        projMatrix.m23 = far / (far - near);
    }
    else // 従来の向き : OpenGLESなど
    {
        // z:[n,f] -> [0, 1]
        projMatrix.m22 = 1f / (far - near);
        projMatrix.m23 = -near / (far - near);
    }

    return projMatrix;
}

ScriptableRendererFeature

シャドウマップの描画を実行するRendererFeatureを作成します。
このRendererFeatureは、2つのパスを実行します。

  • 背景用のシャドウマップ描画パス
  • キャラクター用のシャドウマップ描画パス

https://github.com/rngtm/URP14-ShadowSample/blob/main/Assets/Rendeirng/Scripts/RenderPass/CustomShadowFeature.cs

STEP3. シェーダーの実装

今回のカスタムの影を利用するためには、カスタムの影に対応した頂点変換をシェーダー内に実装する必要があります。

  • 影描画用の頂点変換 (シャドウマップの描画)
  • シャドウマップのサンプリング処理

カスタムのShaderCasterパス

ライト用の行列 _LightVP を利用して座標変換を行います。

CustomShadowCasterPass.hlsl
float4x4 _LightVP; // ライト用のViewProjection行列

v2f vert (appdata v)
{
    const float3 positionWS = TransformObjectToWorld(v.vertex);
    const float3 lightDir = _LightVP[2].xyz; // ライトの向き
    
    v2f o;
    o.positionCS = mul(_LightVP, float4(positionWS, 1));
    return o;
}

half4 frag (v2f i) : SV_Target
{
    return 0;
}
#endif

https://github.com/rngtm/URP14-ShadowSample/blob/main/Assets/Rendeirng/Shaders/ShaderLibrary/CustomShadowCasterPass.hlsl

_LightVP によって変換された頂点のz座標(深度値)がシャドウマップに書きこまれます。

シャドウマップから影情報を取り出す

シャドウマップから影の情報を取り出す処理は以下のようになります。
プラットフォームによって、テクスチャUVや深度バッファが反転しているため、
それを考慮した処理を入れております。

参考 : https://docs.unity3d.com/ja/2019.4/Manual/SL-PlatformDifferences.html

CustomShadow.hlsl
// 背景用シャドウマップテクスチャ
TEXTURE2D(_BgShadowMapTexture);
SAMPLER(sampler_BgShadowMapTexture);

float4x4 _LightVP; // ライト用のViewProjection行列
float3 _LightPos; // ライト位置

half SampleCustomShadow(float3 positionWS)
{
    // ワールド空間の座標をクリップ空間に変換
    float3 shadowCoord = mul(_LightVP, positionWS - _LightPos);

    // 範囲[-1,1]を範囲[0,1]に変換 
    shadowCoord.xy = saturate(shadowCoord.xy * 0.5 + 0.5);

    // プラットフォームによっては、テクスチャのUVのyが反転しているので、その補正を入れる
    #if UNITY_UV_STARTS_AT_TOP 
    shadowCoord.y = 1 - shadowCoord.y;
    #endif

    // 頂点座標から深度値を取り出す
    float depth = shadowCoord.z; 

    // シャドウマップから深度値を取り出す
    float shadowMapDepth = SAMPLE_TEXTURE2D(_BgShadowMapTexture, sampler_BgShadowMapTexture, shadowCoord.xy).r;

    // プラットフォームによって、深度値の向きが異なっているため、その補正を入れる
    #if UNITY_REVERSED_Z
    depth = -depth; // near=0, far=1 となるように補正
    shadowMapDepth = 1.0 - shadowMapDepth; // near=0, far=1 となるように補正
    #endif

    // シャドウマップよりも深度が大きければ、影に入っていると判定する(atten=0)。 影に入っていなければatten=1
    half shadowAttenuation = step(depth, shadowMapDepth); 
    return shadowAttenuation;
}

https://github.com/rngtm/URP14-ShadowSample/blob/main/Assets/Rendeirng/Shaders/ShaderLibrary/CustomShadow.hlsl

結果

キャラクターの影はキャラクターが受けず、地面だけに落ちています。 (画像左)
背景の影はキャラクターが受けています。(画像右)

ライセンス表記

この記事で使用している3Dキャラクター(ユニティちゃん)はユニティちゃんライセンス条項の元に提供されています。
© Unity Technologies Japan/UCL

Discussion