LoginSignup
7
4

More than 1 year has passed since last update.

ScriptableRenderPipeline(SRP)でシャドウマップを自作する ~ディレクショナルライト編~

Last updated at Posted at 2021-11-06

はじめに

この記事はシリーズ形式でシャドウマップの実装を紹介していく予定です。
第2弾は、ディレクショナルライトのライティングとシャドウマップの作成、適用です。

今回の内容を実装すると、以下のような画面ができます。

開発環境

  • Windows 10 Home 64bit
  • Unity 2020.3.12f1

リポジトリ

コードだけ知りたい人はこちらからどうぞ。

シャドウマッピングの実装

シャドウマッピングにはデプスバッファシャドウ法を使っていきます。
デプスバッファシャドウ法の動作概念は西川善司氏の記事が非常に分かりやすいので、手法を知らない人は読んでみると良いと思います。

前回作成したレンダーパイプラインにシャドウマッピングの工程を追加すると次のようになります。
赤枠の部分が追加、変更した工程になります。

シャドウパラメータの受け取り

RenderPipelineAssetでパラメータを定義する

RenderPipelineAsset.cs
public class RenderPipelineAsset : UnityEngine.Rendering.RenderPipelineAsset {
    /// <summary>
    /// シャドウマップの解像度
    /// </summary>
    [SerializeField]
    private int shadowResolution;

    public int ShadowResolution => shadowResolution;

    /// <summary>
    /// シャドウを投影する最大距離
    /// </summary>
    [SerializeField]
    private float shadowDistance;

    public float ShadowDistance => shadowDistance;

    /// <summary>
    /// レンダーパイプラインを作る
    /// </summary>
    protected override UnityEngine.Rendering.RenderPipeline CreatePipeline() {
        return new RenderPipeline(this);
    }
}

今回はRenderPipelineAssetにシャドウの解像度と最大描画距離を指定できるようにします。
こうすることで、インスペクターでパラメータを調整できるようになります。

RenderPipelineでパラメータを受け取る

RenderPipeline.cs
/// <summary>
/// コンストラクタ
/// </summary>
public RenderPipeline(RenderPipelineAsset asset) {
    Asset = asset;

/// <summary>
/// このレンダーパイプラインを使って描画する
/// </summary>
protected override void Render(ScriptableRenderContext context, Camera[] cameras) {
    var shadowResolution = Asset.ShadowResolution;
    var shadowDistance = Asset.ShadowDistance;

パラメータをレンダーパイプラインで使えるようにするため、RenderPipelineのコンストラクタでRenderPipelineAssetを受け取ります。
RenderメソッドでRenderPipelineAssetのパラメータを展開します。

シャドウの最大距離の指定

RenderPipeline.cs
cullingParameters.shadowDistance = Mathf.Clamp(shadowDistance, camera.nearClipPlane, camera.farClipPlane);

cullingParameters.shadowDistanceでシャドウの最大距離を指定することができます。
シャドウの最大距離はライトのプロジェクション行列を計算する時に必要ですが、指定するのはこのタイミングになります。

ディレクショナルライトの取得

RenderPipeline.cs
/// <summary>
/// 指定したタイプのライトのインデックスリストを取得する
/// </summary>
/// <param name="lightType">取得したいライトの種類</param>
private List<int> SearchLightIndexes(CullingResults cullingResults, LightType lightType) {
    var lights = new List<int>();

    // カメラから見える範囲にあるライトの中から指定したタイプのライトを探す
    for (var i = 0; i < cullingResults.visibleLights.Length; i++) {
        var visibleLight = cullingResults.visibleLights[i];

        // 指定したタイプと異なればスキップ
        if (visibleLight.lightType != lightType) {
            continue;
        }

        // ライトに照らされる範囲にシャドウキャスターが存在しないならばスキップ
        if (!cullingResults.GetShadowCasterBounds(i, out var bounds)) {
            continue;
        }

        lights.Add(i);
    }

    return lights;
}

RenderPipelineにSearchLightIndexesメソッドを追加し、視界内で有効な特定タイプのライトを取得するようにします。

RenderPipeline.cs
// 視界内で有効なディレクショナルライトのインデックスを取得
var lightIndexes = SearchLightIndexes(cullingResults, LightType.Directional);

// 有効なライトが存在するかどうか
var existValidLight = lightIndexes != null && lightIndexes.Count > 0;

// 有効なライトが存在する時
if (existValidLight) {
    // 1つ目のライトを取得
    var lightIndex = lightIndexes[0];
    var light = cullingResults.visibleLights[lightIndex].light;

Renderメソッドで、ディレクショナルライトのインデックスをリストとして受け取ります。
インデックスリストがnullでなく、かつ要素が1つ以上あれば、有効なディレクショナルライトが存在しているということになります。
有効なディレクショナルライトが存在している場合はシャドウマップの処理を実行します。

今回は複数のライトを考慮した実装はしないので、とりあえず最初の1つだけを取得します。

ライトビュープロジェクション(LVP)行列の計算

RenderPipeline.cs
/// <summary>
/// ライトビュープロジェクション行列の計算
/// </summary>
/// <param name="lightIndex">ライトのインデックス</param>
private Matrix4x4 CalcLightViewProjection(CullingResults cullingResults, int lightIndex) {
    var light = cullingResults.visibleLights[lightIndex].light;

    // ライトのビュー行列とプロジェクション行列を取得する
    cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
        lightIndex,
        0,
        1,
        Vector3.zero,
        0,
        light.shadowNearPlane,
        out var viewMatrix,
        out var projMatrix,
        out var shadowSplitData);

    // プロジェクション行列を描画ライブラリに適合した状態にする
    projMatrix = GL.GetGPUProjectionMatrix(projMatrix, true);

    // ビュー行列とプロジェクション行列を乗算して返す
    return projMatrix * viewMatrix;
}

ライトのビュープロジェクション(LVP)行列を計算します。
LVP行列は3D空間の物体をライトから見た画面として描画するのに必要です。
LVP行列は自力で計算することも可能ですが、今回はcullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitivesメソッドを使用します。

このメソッドはカスケードシャドウで真髄を発揮してくれますが、今回はカスケードに対応しない方法で使用します。
第2~5引数は固定で、0, 1, Vector3.zero, 0を指定します。
第1引数に計算対象のライトのインデックスを指定します。
第6引数にライトから見た時の視錐台の近平面の距離を指定します。
第7引数にライトのビュー行列を格納するための変数を指定します。
第8引数にライトのプロジェクション行列を格納するための変数を指定します。
第9引数にカスケードの分割データを格納するための変数を指定しますが、今回は使用しません。

さらに、プロジェクション行列を描画ライブラリに適した形式にするため、GL.GetGPUProjectionMatrixを使用します。
最後に、プロジェクション行列を左、ビュー行列を右にして乗算することで、LVP行列ができます。

ライトプロパティのセット

RenderPipeline.cs
/// <summary>
/// ライトプロパティの設定(ViewProjection行列、ライトパラメータの設定など)
/// </summary>
/// <param name="light">プロパティを設定するライト</param>
/// <param name="lightVP">ライトビュープロジェクション行列</param>
/// <param name="shadowDistance">シャドウを投影する最大距離</param>
private void SetupLightProperties(ScriptableRenderContext context, CommandBuffer cmd, Light light, Matrix4x4 lightVP, float shadowDistance) {
    cmd.Clear();
    // LVP行列をシェーダーに送信
    cmd.SetGlobalMatrix(LightVP, lightVP);
    // ライトの向きをシェーダーに送信
    cmd.SetGlobalVector(LightDir, -light.transform.forward);
    // ライトの色をシェーダーに送信
    cmd.SetGlobalColor(LightColor, light.color * light.intensity);
    // シャドウバイアスをシェーダーに送信
    cmd.SetGlobalFloat(ShadowBias, light.shadowBias);
    // シャドウ法線バイアスをシェーダーに送信
    cmd.SetGlobalFloat(ShadowNormalBias, light.shadowNormalBias);
    // シャドウを投影する最大距離をシェーダーに送信
    cmd.SetGlobalFloat(ShadowDistanceSqrt, shadowDistance * shadowDistance);
    context.ExecuteCommandBuffer(cmd);
}

ライトに関するパラメータや行列をシェーダーで使えるようにします。
シャドウバイアスとシャドウ法線バイアスというパラメータは、ライトのインスペクターから指定できるBiasとNormalBiasを使っています。
これらについては後述します。

シャドウマップ(レンダーテクスチャ)の初期化

RenderPipeline.cs
/// <summary>
/// シャドウマップ用レンダーテクスチャのセットアップ
/// </summary>
/// <param name="shadowResolution">シャドウマップの解像度</param>
private void SetupLightRT(ScriptableRenderContext context, CommandBuffer cmd, int shadowResolution) {
    cmd.Clear();
    // 色を1チャネルの32bit、深度を32bitでシャドウマップを取得
    cmd.GetTemporaryRT(LightShadow, shadowResolution, shadowResolution, 32, FilterMode.Bilinear, RenderTextureFormat.RFloat);
    cmd.SetRenderTarget(LightShadowId);
    cmd.ClearRenderTarget(true, true, Color.white, 1);
    context.ExecuteCommandBuffer(cmd);
}

シャドウマップもレンダーテクスチャなので初期化が必要です。
GetTemporaryRTでは、ライトから見た時の深度値という1種類の値を精度よく格納するために、今回はRenderTextureFormat.RFloatを指定しています。
また、自分で書き込む以外の普通の深度も必要なので深度も32bitで指定しています。

ClearRenderTargetでは、色をColor.white、深度を1で初期化しています。
深度は近いと0、遠いと1なので、初期化時に何でも描画できるようにしておくためには、このように指定する必要があります。

シャドウマップ描画

レンダーパイプライン側の処理

RenderPipeline.cs
/// <summary>
/// シャドウの描画
/// </summary>
/// <param name="lightIndex">ライトインデックス</param>
private void DrawShadow(ScriptableRenderContext context, CommandBuffer cmd, CullingResults cullingResults, int lightIndex) {
    // シャドウマップにレンダーターゲットを切り替える
    cmd.Clear();
    cmd.SetRenderTarget(LightShadowId);
    context.ExecuteCommandBuffer(cmd);

    // シャドウ描画データの設定
    var shadowDrawingSettings = new ShadowDrawingSettings(cullingResults, lightIndex);

    // シャドウの描画
    context.DrawShadows(ref shadowDrawingSettings);

    // シャドウマップをシェーダーに送信
    cmd.Clear();
    cmd.SetGlobalTexture(LightShadow, LightShadowId);
    context.ExecuteCommandBuffer(cmd);
}

シャドウマップにライトから見た時の物体の深度値を描画します。
ShadowDrawingSettingsは、シャドウ描画用のデータです。
今回はカスケードに対応しないので、cullingResultsと対象のライトのインデックスを指定するだけでOKです。

DrawShadowsでシャドウの描画を実行します。

シャドウマップへの描画が完了したら、SetGlobaTextureでシャドウマップをシェーダーで使えるようにします。

シェーダー側の処理

シェーダー側の処理は全文を載せておきます。

ShadowCaster.hlsl全文
ShadowCaster.hlsl
// シェーダー名
Shader "Hidden/Gamu2059/Shadowing/ShadowCaster"
{
    SubShader
    {
        // シャドウキャスター
        Pass
        {
            // パス名
            Name "SHADOW_CASTER"
            Tags
            {
                // 【重要】レンダーパイプライン側でDrawShadowsを使ってシャドウ描画する時は、必ずShadowCasterにする
                "LightMode" = "ShadowCaster"
            }

            HLSLPROGRAM
            #include "Light.cginc"
            #pragma vertex vert
            #pragma fragment frag

            struct Attributes
            {
                float3 positionOS : POSITION;
            };

            struct Varyings
            {
                float4 positionLVP : SV_POSITION;
            };

            // シャドウキャスターの頂点シェーダー
            Varyings vert(Attributes i)
            {
                Varyings o;
                // オブジェクト空間からLVP空間へと変換する
                o.positionLVP = TransformObjectToLightViewProjection(i.positionOS);
                return o;
            }

            // シャドウキャスターのピクセルシェーダー
            float frag(Varyings i) : SV_Target
            {
                // ピクセルがある位置のLVP空間上の深度をシャドウマップに書き込む
                return CalcLightViewProjectionDepth(i.positionLVP);
            }
            ENDHLSL
        }
    }
}

シャドウマップに描画するためのシェーダーは、シャドウキャスターと呼ばれたりします。
シャドウキャスターの要点を説明していきます。

シェーダー名とパス名

ShadowCaster.hlsl
// シェーダー名
Shader "Hidden/Gamu2059/Shadowing/ShadowCaster"
ShadowCaster.hlsl
// パス名
Name "SHADOW_CASTER"

シャドウキャスターは汎用的なパスなので、関数のように再利用したいです。
今回はUnityのUsePassという機能を利用するために、シェーダー名とパス名を明示しています。
このシェーダー名とパス名は後に再登場するので覚えておいてください。

LightModeの指定

ShadowCaster.hlsl
// 【重要】レンダーパイプライン側でDrawShadowsを使ってシャドウ描画する時は、必ずShadowCasterにする
"LightMode" = "ShadowCaster"

コメントに書いてあることが全てです。
DrawShadowsを使用している時は、これ以外の名前にすると正しく描画できません。

LVP空間への変換

ShadowCaster.hlsl
// オブジェクト空間からLVP空間へと変換する
o.positionLVP = TransformObjectToLightViewProjection(i.positionOS);
Light.cginc
/// <summary>
/// オブジェクト空間からLVP空間へと変換する
/// </summary>
float4 TransformObjectToLightViewProjection(float3 positionOS)
{
    return mul(_LightVP, mul(unity_ObjectToWorld, float4(positionOS, 1)));
}

頂点シェーダーで使用している関数はLight.cgincという別ファイル内で定義されています。
Light.cgincの中身を使うには、#include "Light.cginc"という1文が必要です。

やっていることはシンプルで、頂点をオブジェクト空間からワールド空間に変換した後に、レンダーパイプラインから渡されたLVP行列を使ってLVP空間に変換しているだけです。

ライトから見た時の深度値を書き込む

ShadowCaster.hlsl
// ピクセルがある位置のLVP空間上の深度をシャドウマップに書き込む
return CalcLightViewProjectionDepth(i.positionLVP);
Light.cginc
/// <summary>
/// LVP空間の深度値を計算する
/// </summary>
float CalcLightViewProjectionDepth(float4 positionLVP)
{
    float depth = positionLVP.z / positionLVP.w;
    #if UNITY_REVERSED_Z
    return 1 - depth;
    #else
    return depth;
    #endif
}

ピクセルシェーダーで使用している関数もLight.cgincに定義されています。

基本的にはLVP空間でのZ値を深度として書き込めば良いのですが、実は描画ライブラリによって手前が0か1か異なります。
UNITY_REVERSED_Z定数を使うと手前が1の時を判定することができるので、手前が0になるように調整します。

シャドウマップを考慮して不透明描画

レンダーパイプライン側は前回と全く変わりません。
今回はライトを考慮するLitシェーダーを記述します。
シェーダー側の処理は全文を載せておきます。

Forward-Lit.hlsl全文
Forward-Lit.hlsl
Shader "Gamu2059/Shadowing/Forward-Lit"
{
    Properties
    {
        _MainTex("Diffuse", 2D) = "white" {}
        _Color("Color", Color) = (1,1,1,1)
    }

    SubShader
    {
        Tags
        {
            "Queue" = "Geometry"
            "RenderType" = "Opaque"
        }

        // シャドウキャスターパスを使う
        UsePass "Hidden/Gamu2059/Shadowing/ShadowCaster/SHADOW_CASTER"

        Pass
        {
            Tags
            {
                "LightMode" = "Forward"
            }

            HLSLPROGRAM
            #include "UnityCG.cginc"
            #include "Light.cginc"

            #pragma vertex vert
            #pragma fragment frag

            struct Attributes
            {
                // 頂点の座標(オブジェクト空間)
                float4 positionOS : POSITION;
                // 頂点のUV座標
                float2 uv : TEXCOORD0;
                // 頂点の法線(オブジェクト空間)
                float3 normalOS : NORMAL;
            };

            struct Varyings
            {
                // クリップ空間の座標
                float4 positionCS : SV_POSITION;
                // ワールド空間の座標
                float4 positionWS : TEXCOORD0;
                // UV座標
                float2 uv : TEXCOORD1;
                // ワールド空間の法線
                float3 normalWS : NORMAL;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            half4 _Color;

            Varyings vert(Attributes i)
            {
                Varyings o;
                o.positionCS = UnityObjectToClipPos(i.positionOS);
                // 頂点座標をオブジェクト空間からワールド空間に変換
                o.positionWS = mul(unity_ObjectToWorld, i.positionOS);
                // 法線をオブジェクト空間からワールド空間に変換
                o.normalWS = UnityObjectToWorldNormal(i.normalOS);
                o.uv = TRANSFORM_TEX(i.uv, _MainTex);
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                // ディレクショナルライトの取得
                DirectionalLight light = GetDirectionalLight();
                // 法線の向きとライトの向きの内積を求める
                float dotNL = saturate(dot(normalize(i.normalWS), light.lightDir));

                half3 color = tex2D(_MainTex, i.uv).xyz;
                color *= _Color.xyz;

                // Lambert拡散反射光を求める
                half3 diffuse = light.lightColor * dotNL;
                // ライトの減衰度を求める
                float shadowAttenuation = GetShadowAttenuation(i.positionWS, dotNL);

                // 影による減衰を考慮した拡散反射を色に反映させる
                color *= diffuse * shadowAttenuation;
                return half4(color, 1);
            }
            ENDHLSL
        }
    }
}

要点を説明していきます。

シャドウキャスターパスの使用

Forward-Lit.hlsl
// シャドウキャスターパスを使う
UsePass "Hidden/Gamu2059/Shadowing/ShadowCaster/SHADOW_CASTER"

Litシェーダーを適用する物体も影を落として欲しいので、シャドウキャスターが必要です。
UsePassを使って別ファイルのシャドウキャスターを再利用しています。

シャドウマップを使ってライトの減衰度を求める

Forward-Lit.hlsl
// ライトの減衰度を求める
float shadowAttenuation = GetShadowAttenuation(i.positionWS, dotNL);
Light.cginc
/// <summary>
/// シャドウによるライトの減衰度を取得する
/// </summary>
float GetShadowAttenuation(float4 positionWS, float dotNL)
{
    // ワールド空間からLVP空間へと変換する
    float4 positionLVP = TransformWorldToLightViewProjection(positionWS);
    // LVP空間からシャドウマップのUV座標へと変換する
    float2 uv = positionLVP.xy / positionLVP.w * float2(0.5f, -0.5f) + 0.5f;

    // ワールド空間からカメラのビュー空間へと変換する
    float3 positionVS = mul(unity_MatrixV, positionWS);
    // カメラのビュー空間の座標を使って、カメラからの距離の2乗を求める
    float distanceSqrt = dot(positionVS, positionVS);

    // ピクセルがある位置のLVP空間上の深度を取得する
    float zInLVP = CalcLightViewProjectionDepth(positionLVP);
    // シャドウマップに書き込まれている位置のLVP空間上の深度を取得する
    float zInShadow = tex2D(_LightShadow, uv).x;
    // シャドウバイアスを求める
    float bias = _ShadowNormalBias * tan(acos(dotNL)) + _ShadowBias;
    // ピクセルがある位置がシャドウマップに書き込まれている位置よりも奥なら影になる
    float attenuation = zInLVP - bias > zInShadow ? 0 : 1;

    // UV座標がシャドウマップ範囲外、またはシャドウ投影範囲外なら、強制的に影にさせない
    return uv.x > 0 && uv.x < 1 && uv.y > 0 && uv.y < 1 && distanceSqrt <= _ShadowDistanceSqrt ? attenuation : 1;
}

シャドウマップを使ってライトの減衰度を求めます。
処理が多いのですが、とても重要なのは以下の部分になります。

Light.cginc
// ピクセルがある位置のLVP空間上の深度を取得する
float zInLVP = CalcLightViewProjectionDepth(positionLVP);
// シャドウマップに書き込まれている位置のLVP空間上の深度を取得する
float zInShadow = tex2D(_LightShadow, uv).x;
// シャドウバイアスを求める
float bias = _ShadowNormalBias * tan(acos(dotNL)) + _ShadowBias;
// ピクセルがある位置がシャドウマップに書き込まれている位置よりも奥なら影になる
float attenuation = zInLVP - bias > zInShadow ? 0 : 1;

シャドウキャスターでライトから見た物体の深度値を求めたように、ライトから見たピクセル位置の深度値を求めます。
次に、ライトから見た時にピクセルと同じ場所にあった物体の深度値をシャドウマップから取得します。
ピクセルの深度値がシャドウマップ上の深度値よりも大きかったら、そのピクセルの位置には影ができるということになります。

基本的にはこれでデプスバッファシャドウ法を使った影の計算は満たせるのですが、このままだと以下の画像のようになってしまいます。

このマッハバンドが発生する現象は、シャドウアクネと呼ばれます。
シャドウアクネが発生する理由と対処の詳細は以下の記事が参考になりました。

レンダーパイプラインから渡されたシャドウバイアスとシャドウ法線バイアスを使って、ピクセルの深度値にバイアスを付けることでシャドウアクネを解消することができます。

シャドウバイアスとシャドウ法線バイアスの値は、ライトのインスペクターでいい感じになるところを見つけてください。

シャドウマップの解放

RenderPipeline.cs
/// <summary>
/// シャドウマップ用レンダーテクスチャのクリーンアップ
/// </summary>
private void CleanupLightRT(ScriptableRenderContext context, CommandBuffer cmd) {
    cmd.Clear();
    cmd.ReleaseTemporaryRT(RenderTarget);
    context.ExecuteCommandBuffer(cmd);
}

最後にシャドウマップを解放します。
これはカメラ用のレンダーテクスチャの時と特に変わりありません。

さいごに

本記事はシャドウマップを自作するための連載記事の第2弾ということで、ディレクショナルライトのシャドウマップの作成と適用を紹介しました。
次回はシンプルなスポットライトのシャドウマップを作成する記事にする予定ですが、今回の内容が多めだったので、次回は少なくなると思います。

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4