CORETECH ENGINEER BLOG

株式会社サイバーエージェント SGEコア技術本部 技術ブログ

Adaptive Probe Volumesを自作シェーダーに対応させる【NOVA Shader】

はじめに

こんにちは。 サイバーエージェントゲームエンターテイメント事業部・SGEコア技術本部(以下、コアテク)のグラフィックスチーム所属の畳です。
コアテクでは、多機能なパーティクルシェーダーであるNova ShaderOSSとして公開しています。
前回はuGUI対応についてご紹介しました。
今回は、バージョン 2.8 にて実施した Adaptive Probe Volumes (以下、APV)対応に関する知見をもとに、自作シェーダーでの APV 対応方法を解説します。
本記事は Unity エディター バージョン 6000.0.35f1 を使用して執筆しています。

Adaptive Probe Volumesとは

APVとは、ライトプローブを用いたグローバルイルミネーション手法のひとつで、Unity 6 から URP(Universal Render Pipeline)に導入された新機能です。

詳しくは以下の公式ブログ記事をご参照ください:
unity.com

コードベースの処理解説

APV の対応自体は簡単であるものの処理の流れを読み解くのが難しいため、まずはUnity の組み込みシェーダーでの処理を解説します。
APV のライティング計算は、以下の 2 種類の方法に分けて実装することができます:

  • 頂点単位で計算(処理負荷が軽い / 精度が低い)
  • ピクセル単位で計算(処理負荷が高い / 精度が高い)

まずは頂点単位での処理から解説します。

頂点単位の計算

以下は ParticlesLit.shader *1の UniversalForward パスの頂点シェーダーから、APV 関連の処理を抜粋したコードです。

VaryingsParticle ParticlesLitVertex(AttributesParticle input)
{
    VaryingsParticle output = (VaryingsParticle)0;
    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    // (省略)
    OUTPUT_SH4(vertexInput.positionWS, output.normalWS.xyz, GetWorldSpaceNormalizeViewDir(vertexInput.positionWS), output.vertexSH, output.probeOcclusion);
    // (省略)
    return output;
}

OUTPUT_SH4 マクロは、ライトプローブを用いたライティング情報の取得に使用されます。
Lighting.hlsl*2で定義されており、内部で以下のような条件により処理が切り替わります。

#if defined(LIGHTMAP_ON)
    #define DECLARE_LIGHTMAP_OR_SH(lmName, shName, index) float2 lmName : TEXCOORD##index
    #define OUTPUT_LIGHTMAP_UV(lightmapUV, lightmapScaleOffset, OUT) OUT.xy = lightmapUV.xy * lightmapScaleOffset.xy + lightmapScaleOffset.zw;
    #define OUTPUT_SH4(absolutePositionWS, normalWS, viewDir, OUT, OUT_OCCLUSION)
    #define OUTPUT_SH(normalWS, OUT)
#else
    #define DECLARE_LIGHTMAP_OR_SH(lmName, shName, index) half3 shName : TEXCOORD##index
    #define OUTPUT_LIGHTMAP_UV(lightmapUV, lightmapScaleOffset, OUT)
    #ifdef USE_APV_PROBE_OCCLUSION
        #define OUTPUT_SH4(absolutePositionWS, normalWS, viewDir, OUT, OUT_OCCLUSION) OUT.xyz = SampleProbeSHVertex(absolutePositionWS, normalWS, viewDir, OUT_OCCLUSION)
    #else
        #define OUTPUT_SH4(absolutePositionWS, normalWS, viewDir, OUT, OUT_OCCLUSION) OUT.xyz = SampleProbeSHVertex(absolutePositionWS, normalWS, viewDir)
    #endif
    // Note: This is the legacy function, which does not support APV.
    // Kept to avoid breaking shaders still calling it (UUM-37723)
    #define OUTPUT_SH(normalWS, OUT) OUT.xyz = SampleSHVertex(normalWS)
#endif

この切替には以下のシェーダーキーワード、識別子が関連します。

LIGHTMAP_ON

マテリアルが適用されたオブジェクトが静的(Static)で、ライトマップが使用される場合に有効になります。

USE_APV_PROBE_OCCLUSION

コードを辿ると、これはProbeVolumeBakingSet.cs*3内のProbeVolumeBakingSetクラスのbakedProbeOcclusionメンバの値を参照していることがわかります。
bakedProbeOcclusionメンバはSerializeFieldとなっており、Baking Setファイルにシリアライズされます。
Baking Setとは対象のシーンのライティングをベイクするのに必要なファイルです*4
さらにコードを辿っていくと、ProbeGIBaking.Serialization.cs*5にて、いずれかのCellに1つでもProbe Occulusionが含まれている場合にbakedProbeOcclusionがtrueとして保存されることがわかります。

// If any cell had probe occlusion, the baking set has probe occlusion.
m_BakingSet.bakedProbeOcclusion |= bakingCell.probeOcclusion?.Length > 0;

CellとはURPが読み込む領域の最小単位です*6
また、Probe Occlusionは各プローブ毎に格納される遮光の値で、MixedLightingMode*7がIndirectOnlyでない場合にベイク時に設定されます*8
MixedLightingModeLighting SettingファイルのLighting Modeから設定できます。
つまり、この設定からUSE_APV_PROBE_OCCLUSION識別子が有効になるかが決まることになります。

SampleProbeSHVertex関数

APVが有効な場合、OUTPUT_SH4マクロはSampleProbeSHVertex関数へと展開されます(GlobalIllumination.hlsl*9内)。
下段がUSE_APV_PROBE_OCCLUSION識別子が無効の時に使われるもので、Probe Occlusionの入力が省略されているだけです。

half3 SampleProbeSHVertex(in float3 absolutePositionWS, in float3 normalWS, in float3 viewDir, out float4 probeOcclusion)
{
    probeOcclusion = 1.0;

#if (defined(PROBE_VOLUMES_L1) || defined(PROBE_VOLUMES_L2))
    return SampleProbeVolumeVertex(absolutePositionWS, normalWS, viewDir, probeOcclusion);
#else
    return SampleSHVertex(normalWS);
#endif
}

half3 SampleProbeSHVertex(in float3 absolutePositionWS, in float3 normalWS, in float3 viewDir)
{
    float4 unusedProbeOcclusion = 0;
    return SampleProbeSHVertex(absolutePositionWS, normalWS, viewDir, unusedProbeOcclusion);
}

上段の関数内で新たにPROBE_VOLUMES_L1PROBE_VOLUMES_L2の2つのシェーダーキーワードが登場しています。

PROBE_VOLUMES_L1・PROBE_VOLUMES_L2

Universal Render Pipeline AssetファイルのLighting -> SH Bands 設定に基づき適用されます。
SH Bandsの項目はLighting -> Light Probe SystemAdaptive Probe Volumesが指定されている場合にのみ表示されます。
逆に言うと従来方式であるLight Probe Groupsが指定されている場合はこれらのキーワードが有効になりません。
L1、L2は球面調和関数の展開次数を表しており、L2の方がより精度と負荷の高い方法で計算が行われます。

SampleProbeVolumeVertex関数

では次に、APVが有効な場合にSampleProbeSHVertex関数内で参照されるSampleProbeVolumeVertex関数について見ていきます。

#if (defined(PROBE_VOLUMES_L1) || defined(PROBE_VOLUMES_L2))
half3 SampleProbeVolumeVertex(in float3 absolutePositionWS, in float3 normalWS, in float3 viewDir, out float4 probeOcclusion)
{
    probeOcclusion = 1.0;

#if defined(EVALUATE_SH_VERTEX) || defined(EVALUATE_SH_MIXED)
    half3 bakedGI;
    // The screen space position is used for noise, which is irrelevant when doing vertex sampling
    float2 positionSS = float2(0, 0);
    if (_EnableProbeVolumes)
    {
        EvaluateAdaptiveProbeVolume(absolutePositionWS, normalWS, viewDir, positionSS, GetMeshRenderingLayer(), bakedGI, probeOcclusion);
    }
    else
    {
        bakedGI = EvaluateAmbientProbe(normalWS);
    }
#ifdef UNITY_COLORSPACE_GAMMA
    bakedGI = LinearToSRGB(bakedGI);
#endif
    return bakedGI;
#else
    return half3(0, 0, 0);
#endif
}

また新たにEVALUATE_SH_MIXEDキーワードとEVALUATE_SH_VERTEXキーワードが登場しました。
これらのキーワードが有効の場合はGIの値が計算され、そうでなければ何もしない処理になっていることがわかります。

EVALUATE_SH_MIXED・EVALUATE_SH_VERTEX

これらはUniversal Render Pipeline AssetファイルのLighting -> SH Evaluation Modeに対応しており、Per VertexがEVALUATE_SH_VERTEX, MixedがEVALUATE_SH_MIXED, Per Pixelが定義なしにそれぞれ対応しています。

SHとはSpherical Harmonics(球面調和関数)のことで、つまりSH Evaluation Modeはライトプローブの計算を頂点とピクセル、どちらの単位で行うかの指定を意味しています。

以上が頂点単位での計算に必要な処理となります。

ピクセル単位の計算

次のコードはピクセルシェーダーについて、APVに関連する箇所だけ抜粋したものです(ファイルは頂点側と同様です)。

void InitializeInputData(VaryingsParticle input, half3 normalTS, out InputData inputData)
{
    inputData = (InputData)0;

    // (省略)法線などの設定

#if !defined(LIGHTMAP_ON) && (defined(PROBE_VOLUMES_L1) || defined(PROBE_VOLUMES_L2))
    inputData.bakedGI = SAMPLE_GI(input.vertexSH,
        GetAbsolutePositionWS(inputData.positionWS),
        inputData.normalWS,
        inputData.viewDirectionWS,
        input.clipPos.xy,
        input.probeOcclusion,
        inputData.shadowMask);
#else
    inputData.bakedGI = SampleSHPixel(input.vertexSH, inputData.normalWS);
#endif
}

half4 ParticlesLitFragment(VaryingsParticle input) : SV_Target
{
    // (省略)

    InputData inputData;
    InitializeInputData(input, surfaceData.normalTS, inputData);

    half4 color = UniversalFragmentPBR(inputData, surfaceData);
    // (省略)

    return color;
}

InitializeInputDataメソッドでライティングデータを設定していますが、今回着目するのはinputData.bakedGIに値を代入している箇所です。
ここがプローブによって得られたライティング情報を取得している箇所になります。

#if !defined(LIGHTMAP_ON) && (defined(PROBE_VOLUMES_L1) || defined(PROBE_VOLUMES_L2))
    inputData.bakedGI = SAMPLE_GI(input.vertexSH,
        GetAbsolutePositionWS(inputData.positionWS),
        inputData.normalWS,
        inputData.viewDirectionWS,
        input.clipPos.xy,
        input.probeOcclusion,
        inputData.shadowMask);
#else
    inputData.bakedGI = SampleSHPixel(input.vertexSH, inputData.normalWS);
#endif
}

コードを見てみると、条件によって二つの処理に分岐していることがわかります。
上段がAPV用、下段が従来からある処理です。

頂点側でのキーワードの解説から、

#if !defined(LIGHTMAP_ON) && (defined(PROBE_VOLUMES_L1) || defined(PROBE_VOLUMES_L2))

は「ライトマップを利用しておらず、かつAPVが有効な設定になっている」という条件を意味していることがわかります。
この条件を満たしている場合、GlobalIllumination.hlsl*10に定義されているSAMPLE_GIマクロを利用しています。

SAMPLE_GIの宣言について、APVに関する箇所を抜き出したのが次になります。

// (省略)
#elif defined(PROBE_VOLUMES_L1) || defined(PROBE_VOLUMES_L2)
#ifdef USE_APV_PROBE_OCCLUSION
    #define SAMPLE_GI(shName, absolutePositionWS, normalWS, viewDir, positionSS, vertexProbeOcclusion, probeOcclusion) SampleProbeVolumePixel(shName, absolutePositionWS, normalWS, viewDir, positionSS, vertexProbeOcclusion, probeOcclusion)
#else
    #define SAMPLE_GI(shName, absolutePositionWS, normalWS, viewDir, positionSS, vertexProbeOcclusion, probeOcclusion) SampleProbeVolumePixel(shName, absolutePositionWS, normalWS, viewDir, positionSS)
#endif
#else
#define SAMPLE_GI(staticLmName, shName, normalWSName) SampleSHPixel(shName, normalWSName)
#endif

SampleProbeVolumePixel関数

APVが有効な場合にSAMPLE_GIが置換されるSampleProbeVolumePixel関数について見ていきます。
定義されているファイルは頂点側で解説したSampleProbeVolumeVertex関数と同一です。

half3 SampleProbeVolumePixel(in half3 vertexValue, in float3 absolutePositionWS, in float3 normalWS, in float3 viewDir, in float2 positionSS, in float4 vertexProbeOcclusion, out float4 probeOcclusion)
{
    probeOcclusion = 1.0;

#if defined(EVALUATE_SH_VERTEX) || defined(EVALUATE_SH_MIXED)
    probeOcclusion = vertexProbeOcclusion;
    return vertexValue;
#elif defined(PROBE_VOLUMES_L1) || defined(PROBE_VOLUMES_L2)
    half3 bakedGI;
    if (_EnableProbeVolumes)
    {
        EvaluateAdaptiveProbeVolume(absolutePositionWS, normalWS, viewDir, positionSS, GetMeshRenderingLayer(), bakedGI, probeOcclusion);
    }
    else
    {
        bakedGI = EvaluateAmbientProbe(normalWS);
    }
#ifdef UNITY_COLORSPACE_GAMMA
        bakedGI = LinearToSRGB(bakedGI);
#endif
    return bakedGI;
#else
    return half3(0, 0, 0);
#endif
}

half3 SampleProbeVolumePixel(in half3 vertexValue, in float3 absolutePositionWS, in float3 normalWS, in float3 viewDir, in float2 positionSS)
{
    float4 unusedProbeOcclusion = 0;
    return SampleProbeVolumePixel(vertexValue, absolutePositionWS, normalWS, viewDir, positionSS, unusedProbeOcclusion, unusedProbeOcclusion);
}

SampleProbeVolumeVertex関数と見比べると、ほぼ同じ処理でAPVの計算を行っていることがわかります。
異なっているのは、EVALUATE_SH_VERTEXEVALUATE_SH_MIXEDキーワードが有効な場合は頂点側で計算したProbe Occlusionの値を返しているという点です。
コードが複雑に見えますが、以上から「APVが有効になっているか」、「Probe Occlusionを利用するか」、「頂点とピクセルでどちらでライティング処理を行うか」の分岐をシェーダーキーワードで実現しているということがわかります。

自作シェーダーのAPV対応

ここまででAPVの処理の流れについて解説しました。
次は自作シェーダーをAPV対応させる際のポイントを解説します。
つぎのコードは解説用にParticlesLit.shaderを抜粋・改変したものです。

Pass
{
    Name "ForwardLit"
    Tags
    {
        "LightMode" = "UniversalForward"
    }
    // (省略)

    HLSLPROGRAM
    #pragma vertex ParticlesLitVertex
    #pragma fragment ParticlesLitFragment
    // (省略)

    // ① EVALUATE_SH_MIXED と EVALUATE_SH_VERTEX のキーワードを有効にする
    #pragma multi_compile _ EVALUATE_SH_MIXED EVALUATE_SH_VERTEX
    
    // ② PROBE_VOLUMES_L1 と PROBE_VOLUMES_L2 のキーワードを有効にする
    #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/ProbeVolumeVariants.hlsl"

    struct VaryingsParticle
    {
        // (省略)

        // ③ Probe Occlusionを利用
        #if !defined(PARTICLES_EDITOR_META_PASS)
            #ifdef USE_APV_PROBE_OCCLUSION
                float4 probeOcclusion  : TEXCOORD9;
            #endif
        #endif
    };


 void InitializeInputData(VaryingsParticle input, half3 normalTS, out InputData inputData)
    {
        inputData = (InputData)0;
        // (省略)

        // ④ APVが有効な場合の処理
    #if !defined(LIGHTMAP_ON) && (defined(PROBE_VOLUMES_L1) || defined(PROBE_VOLUMES_L2))
        inputData.bakedGI = SAMPLE_GI(input.vertexSH,
            GetAbsolutePositionWS(inputData.positionWS),
            inputData.normalWS,
            inputData.viewDirectionWS,
            input.clipPos.xy,
            input.probeOcclusion,
            inputData.shadowMask);
    #else
        inputData.bakedGI = SampleSHPixel(input.vertexSH, inputData.normalWS);
    #endif
    }


    VaryingsParticle ParticlesLitVertex(AttributesParticle input)
    {
        VaryingsParticle output = (VaryingsParticle)0;
        VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
        // (省略)

        // ⑤ Unity バージョンの互換性を考慮
        #if UNITY_VERSION >= 60000000
        OUTPUT_SH4(vertexInput.positionWS, output.normalWS.xyz, GetWorldSpaceNormalizeViewDir(vertexInput.positionWS), output.vertexSH, output.probeOcclusion);;
        #else
        OUTPUT_SH(output.normalWS.xyz, output.vertexSH);
        #endif

        // (省略)
        return output;
    }

    half4 ParticlesLitFragment(VaryingsParticle input) : SV_Target
    {
        SurfaceData surfaceData;
        // (省略)
        
        InputData inputData;
        InitializeInputData(input, surfaceData.normalTS, inputData);
        half4 color = UniversalFragmentPBR(inputData, surfaceData);
        return color;
    }
    ENDHLSL
}

コメントで記載している①~⑤がAPV対応に必要な記述です。 以下補足です。

③ Probe Occlusionを利用

 #if !defined(PARTICLES_EDITOR_META_PASS)
    #ifdef USE_APV_PROBE_OCCLUSION
        float4 probeOcclusion  : TEXCOORD9;
    #endif
#endif

Probe Occlusionの反映のため、入力用の構造体にメンバを追加する必要があります PARTICLES_EDITOR_META_PASSキーワードはシーンビュー用のパスなどで設定されるものです。

⑤ Unity バージョンの互換性を考慮

#if UNITY_VERSION >= 60000000
OUTPUT_SH4(vertexInput.positionWS, output.normalWS.xyz, GetWorldSpaceNormalizeViewDir(vertexInput.positionWS), output.vertexSH, output.probeOcclusion);;
#else
OUTPUT_SH(output.normalWS.xyz, output.vertexSH);
 #endif

OUTPUT_SH4マクロは Unity 6 から第5引数まで受け取る新しい形式をサポートしており、Unity6未満の Unity バージョンとの互換性を考慮する場合は、上記のようにプリプロセッサで分岐させるとよいでしょう。

処理負荷の検証

自作シェーダーでのAPV対応の解説は以上となりますが、頂点単位とピクセル単位での処理負荷について検証しましたので、その結果を共有いたします。

検証環境

以下の環境にて、APVの有効/無効の状態でシェーダーのGPU の処理時間を Xcodeの Metal System Trace により計測しました。
Metal System Traceについてはコアテクブログの別記事*11で解説しています。

  • 使用デバイスiPhone 14(画面解像度 2532 x 1170)
  • 計測描画対象:121 頂点のレンガ+2318 頂点のティーポット
    • 塗りつぶし面積としては画面全体の8割程度
  • 使用シェーダー:NOVA Shader の UberLit(不透明)

左: Per Vertex 右: Per Pixel

検証結果

  • 非APV(Light Probe Groups)
    • 1.04 ms
  • APV(Per Pixel)
    • 2.89 ms
  • APV(Per Vertex)
    • 1.06 ms

結果から、APV の有効化により処理負荷は上昇しますが、頂点単位であればモバイルでも十分実用可能な範囲であることがわかります。

おわりに

以上、自作シェーダーにおける Adaptive Probe Volumes 対応についてご紹介しました。
本記事の内容が、皆さまのご参考となりましたら幸いです。