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先週のキーワード」