[Unity][SRP]Screen Space Lens Flareを改造する

に公開

概要

UnityのSRPによる自作レンダーパイプラインにおいて、ScreenSpaceLensFlareを移植・改造し、以下の二点を目指します。

  • 処理負荷を軽減する。
  • Streaksを複数表示できるようにする。

    Unityバージョンは6000.0.23です。

SRPでのレンダーパイプライン自作に関して詳しくは以下などをご覧ください。
https://zenn.dev/nithink/articles/3810a563f2e2d6
なお、対応にあたっては先にBloomを実装していなければならないのでご注意ください。

内容

ScreenSpaceLensFlareとは

ScreenSpaceLensFlareとは、画面の明るい部分にポストエフェクトとしてlens flareを表示する機能です。
概要や使い方については以下をご覧ください。
https://docs.unity3d.com/6000.0/Documentation/Manual/urp/shared/lens-flare/post-processing-screen-space-lens-flare.html
https://youtu.be/-6zbFTBMDmU?si=Z-UHroIPlB-vqRxm
(上記動画のサムネではStreaksが2本出ていますが、自分の認識では今回の記事のような方法で改造するなどしない限り普通は1本しか出せないはずです...。)

上記動画にもあるように、そもそもlens flareには以下の2種類があります。

  • GameObjectにComponentとして付けるもの(data driven
  • ポストエフェクトとして画面全体にかけるもの(screen space

https://docs.unity3d.com/6000.0/Documentation/Manual/urp/shared/lens-flare/choose-a-lens-flare-type.html
data drivenという呼称は私がSRPのソースコードから取ってきたものですが、ドキュメント等では使われていないようです。

Core RP Libraryに用意されている

ScreenSpaceLensFlareはCore RP Libraryにおいて以下に実装されており、URPもHDRPもこれらを利用しています。

  • Packages/com.unity.render-pipelines.core/Runtime/PostProcessing/LensFlareCommonSRP.cs
  • Packages/com.unity.render-pipelines.core/Runtime/PostProcessing/Shaders/LensFlareScreenSpaceCommon.hlsl

このLensFlareCommonSRPクラスには2種類(data drivenとscreen space)のlens flareの処理がどちらも含まれています。

ScreenSpaceLensFlareのHLSLファイルはあってもShaderLabファイルはないので、以下のURPのコードを参考にするなどしてレンダーパイプライン側で作成する必要があります。
Packages/com.unity.render-pipelines.universal/Shaders/PostProcessing/LensFlareScreenSpace.shader

VolumeComponentも、URPかHDRPを参考に作成します。
Packages/com.unity.render-pipelines.universal/Runtime/Overrides/ScreenSpaceLensFlare.cs

レンダーパイプライン側では以下のメソッドを適切に呼び出すことでScreenSpaceLensFlareの描画が行われます。
LensFlareCommonSRP.DoLensFlareScreenSpaceCommon(...)
ScreenSpaceLensFlareではBloomの処理途中で得られるテクスチャ(mip)などを流用するので、引数として渡す必要があります。このためScreenSpaceLensFlareはBloomとセットで用います。(ちなみにURPではBloomのテクスチャはpublicでないため、もし今回の記事の対応をURPで行う場合はURPパッケージの改造が避けられません。)
具体的にどのようなテクスチャを渡せば良いのかについては、例えばURPでFrameDebuggerやコードを見れば分かると思います。

なお、このクラスには初期化/破棄メソッドがあるため一応レンダーパイプラインの寿命に紐づけてそれぞれ呼んでください。ただ現状この初期化/破棄メソッドはdata drivenのlens flareに関する処理のみであり、ScreenSpaceLensFlareとは関係がありません。

LensFlareCommonSRPクラスでは全体的にRenderGraph的でない書き方になっているのが気になりますね。もしかすると将来的には大きくリファクタリングされるかもしれません。(恐らくUnsafePassになるのでそれほど大きくは変わらないとは思いますが。)

URP/HDRPと密結合

LensFlareScreenSpaceCommon.hlslですが、以下のような記述がいくつかあります。
#if defined (URP_LENS_FLARE_SCREEN_SPACE)
#if defined (HDRP_LENS_FLARE_SCREEN_SPACE)
つまり、Core RP Libraryの中にURPやHDRP専用の処理が書かれてしまっているということになります。
Core RP Libraryはあくまでも「SRPでレンダーパイプラインを制作するためのライブラリ」であり、公式とはいえプリセットの一種に過ぎないはずのURPやHDRP専用の処理がここに記述されているのは設計上問題があるのではないかと思うのですが、一旦受け入れることにします。(速度重視/品質重視みたいなキーワード名であればまだ納得感があったのですが...。)
URPかHDRPどちらかの処理そのままで良いという場合は、HLSLファイルはそのままで、レンダーパイプライン側で作成したShaderLabに以下のどちらかのキーワードを定義しておきます。
#define URP_LENS_FLARE_SCREEN_SPACE
#define HDRP_LENS_FLARE_SCREEN_SPACE

Streakの改造

移殖の動機

ScreenSpaceLensFlareを特徴づける機能として、上記ドキュメントにもある通りFlaresStreaksがありますが、今回はStreaksに注目します。
FrameDebuggerで確認すると、Streaksの描画にあたってかなり多くのBlitが発生していることが分かります。また、シェーダーコードを確認すると、サンプル回数も多めであることが分かります。(途中でバッファが縮小されるのでややマシではありますが。)

ただ、FrameDebuggerを見る限り、このうち何回かのBlitではそれほどStreaksの見た目に変化がなく、もう少し節約できそうな感じがします。

また、Streakは1本だけでなく2本以上が交差するような表現をしたいというケースもありますが、現状の機能では実現できません。

そうなると、Core RP LibraryのScreenSpaceLensFlareをそのまま使うわけにはいかず、移植・改造する必要が出てきます。

移殖

再掲になりますが、以下二つを独自のレンダーパイプライン側に複製します。
Packages/com.unity.render-pipelines.core/Runtime/PostProcessing/LensFlareCommonSRP.cs
Packages/com.unity.render-pipelines.core/Runtime/PostProcessing/Shaders/LensFlareScreenSpaceCommon.hlsl

LensFlareCommonSRPの複製後、エラーがたくさん出ます。
まずクラス名を変えておきます。(例えばLensFlareScreenSpaceCustom.csなど)
また、DoLensFlareScreenSpaceCommonと依存関係のないメソッドは削除しておきます。これはdata drivenの(つまり今回関係のない)lens flareの処理も含まれているためです。
具体的にはDoLensFlareScreenSpaceCommonメソッド以外のメソッドを全て削除すれば良いです。

次にBlitter.DrawQuadが存在しないというエラーを解消しましょう。これはBlitter.DrawQuadがinternalなメソッドであり、Core RP Library内からでしかアクセスできないためです。そのためここでは以下のようなメソッドを用意し、置き換えて用います。

private static void DrawQuad(CommandBuffer cmd, Material material, int pass)
{
	cmd.DrawProcedural(Matrix4x4.identity, material, pass, MeshTopology.Quads, 4, 1);
}

他にも、cmd.m_WrappedCommandBufferのエラーが出ます。これもm_WrappedCommandBufferがinternalであることによるエラーなので、以下のように書き換えましょう。

CommandBufferHelper.GetNativeCommandBuffer(cmd)

このメソッドではNativeCommandBufferからCommandBufferを取得することができます。

これで移植は完了です。LensFlareCommonSRP.DoLensFlareScreenSpaceCommonメソッドの適切な呼び出しができていれば、URPやHDRPと同様にScreenSpaceLensFlareが機能していることが確認できるはずです。

Streaksの負荷を下げる

まずは、Streakの処理負荷を下げます。この後本数を増やすことを考えても、負荷は下げておくべきです。

Streaksの処理において多くのBlitとサンプリングが行われていることを先に述べました。
各Blitとサンプル回数を以下に示します。

パス名 備考 Blit回数 サンプリング回数(シェーダー)
LensFlareScreenSpac Prefilter Streaks用の処理。thresholdの適用など。誤字っている...。 1回 2回
LensFlareScreenSpace Downsample Streaks用の処理。典型的な一方向ブラーの処理。 10回程度(後述) 6回
LensFlareScreenSpace Upsample Streaks用の処理。典型的な一方向ブラーの処理。 2回 3回
LensFlareScreenSpace Composition StreaksやFlaresを合成する処理。 1回 各機能を使っているかどうかによって変わる。Streaks:1回、FirstFlare:1回、SecondaryFlare:1回、WarpedFlare:1回。色収差使用時はそれぞれx3回。
LensFlareScreenSpace Write to BloomTexture 1回 1回

このうちの多くはdown sampleの処理で、シェーダーのコードを見てみるとサンプリング回数は6回です。
Blitの回数は以下の通り画面解像度によって増減するようになっています。

int maxLevel = Mathf.FloorToInt(Mathf.Log(Mathf.Max(actualHeight, actualWidth), 2.0f));
int maxLevelDownsample = Mathf.Max(1, maxLevel);

エッセンスとしては\log_{2} (解像度)であり、だいたい10~11になります。

  • HDの場合:log_{2} 1280≒10
  • FHDの場合:log_{2} 1920≒10
  • QHDの場合:log_{2} 2560≒11
  • 4Kの場合:log_{2} 3840≒11

つまり、例えばFHDだとBlit10回で、しかもBlit1回につきサンプリング6回ということになり、なかなか負荷が大きいことが分かります。
繰り返しになりますが、FrameDebuggerで確認するとdown sampleの最後の数回のBlitではほとんどStreaksの見た目に変化がなく、省略できるようにしても良さそうです。

方法としては、例えばBlit回数に乗算するパラメータをVolumeComponentに新設します。

public ClampedFloatParameter streaksSampleCountMultiplier = new(1, 0.1f, 1);
int maxLevel = Mathf.FloorToInt(Mathf.Log(Mathf.Max(actualHeight, actualWidth), 2.0f) * lensFlare.streaksSampleCountMultiplier);

Blit回数を少なくしていくと、Streaksが短くなってしまいます。しかしこれは代わりにstreaksLengthというstreaksの長さを制御するパラメータを大きめに設定することで解消できます。
URPやHDRPのコードでは上限が1になっているので、MinFloatParameterなどに変えて大きな値を設定できるようにします。

//public ClampedFloatParameter streaksLength = new ClampedFloatParameter(0.5f, 0f, 1f);
public MinFloatParameter streaksLength = new MinFloatParameter(0.5f, 0f);

ただしBlit回数を減らしてサイズを長くすると、このようなアーティファクトが出やすくなります。

これはブラーの処理においてサンプリングやBlitの回数が足りていない場合に見られる典型的な現象と同様ですので、解説は省略します。
つまりBlit回数とstreaksLengthの値は、ビジュアルが破綻しないよう、しかし負荷が必要最低限になるようバランスをとって設定する必要があります。

Streaksの本数を増やす

次にStreaksの本数を増やせるようにする実装を行います。
Streaksは典型的なガウスブラーの実装とは異なり、例えば一度横にブラーをかけたバッファに対してさらに縦にブラーをかける...というわけにはいきません。その例でいうと横のブラーと縦のブラーは互いに独立に実行し、別途用意したバッファに順次加算合成していくような処理が必要になります。
例えば3本の場合は以下のようになります。

コードの改変例は以下の通りです。

//streaksが複数ある場合は一個ずつ角度をずらさなければならない。
//元々parameter4.zがstreaksの角度に相当するが、Vector4なので角度を変えるたびに他の定数ごと再度送信することになってしまうのが微妙。
//ここではパラメータを分けることにする。
private static readonly int IdStreaksOrientation = Shader.PropertyToID("_StreaksOrientation");
//角度をずらした複数のStreaksを合成するためのバッファ。
private static readonly int IdStreaksAccumulationTex = Shader.PropertyToID("_LensFlareScreenSpaceStreaksAccumulationTex");
//Streaksを合成するパス。
int accumulatePass = lensFlareShader.FindPass("LensFlareScreenSpace Streaks Accumulate");if (parameters4.x > 0)
{
	int maxLevel = Mathf.FloorToInt(Mathf.Log(Mathf.Max(actualHeight, actualWidth), 2.0f) * streaksSampleCountMultiplier);
	int maxLevelDownsample = Mathf.Max(1, maxLevel);
	int maxLevelUpsample = 2;
	float orientation = parameters4.z;
	for (int j = 0; j < streaksCount; j++)
	{
		//シェーダー側としては、orientationは0~4が0~360°に対応する想定。
		//360°を等分するような角度にする。
		cmd.SetGlobalFloat(IdStreaksOrientation, orientation + (j / (float)streaksCount) * 2f);

		// Prefilter
		Rendering.CoreUtils.SetRenderTarget(cmd, streakTextureTmp);
		DrawQuad(cmd, lensFlareShader, prefilterPass);
		int startIndex = 0;
		bool even = false;

		// Downsample
		for (int i = 0; i < maxLevelDownsample; i++)
		{
			even = (i % 2 == 0);
			cmd.SetGlobalInt(_LensFlareScreenSpaceMipLevel, i);
			cmd.SetGlobalTexture(_LensFlareScreenSpaceStreakTex, even ? streakTextureTmp : streakTextureTmp2);
			Rendering.CoreUtils.SetRenderTarget(cmd, even ? streakTextureTmp2 : streakTextureTmp);
			DrawQuad(cmd, lensFlareShader, downSamplePass);
		}

		if (even) startIndex = 1;

		//Upsample
		for (int i = startIndex; i < (startIndex + maxLevelUpsample); i++)
		{
			even = (i % 2 == 0);
			cmd.SetGlobalInt(_LensFlareScreenSpaceMipLevel, (i - startIndex));
			cmd.SetGlobalTexture(_LensFlareScreenSpaceStreakTex, even ? streakTextureTmp : streakTextureTmp2);
			Rendering.CoreUtils.SetRenderTarget(cmd, even ? streakTextureTmp2 : streakTextureTmp);
			DrawQuad(cmd, lensFlareShader, upSamplePass);
		}

		cmd.SetGlobalTexture(_LensFlareScreenSpaceStreakTex, even ? streakTextureTmp2 : streakTextureTmp);
		
		CoreUtils.SetRenderTarget(cmd, streaksAccumulation);
		DrawQuad(cmd, lensFlareShader, accumulatePass);
	}
	//Accumulate
	cmd.SetGlobalTexture(IdStreaksAccumulationTex, streaksAccumulation);
}
half4 FragmentStreaksAccumulate(VaryingsSSLF input) : SV_Target
{
    float2 uv = input.texcoord;
    return SAMPLE_TEXTURE2D(_LensFlareScreenSpaceStreakTex, sampler_LinearClamp, uv);
}

GetLensFlareTexture関数の_LensFlareScreenSpaceStreakTexは全て_LensFlareScreenSpaceStreaksAccumulationTexに置き換えます。

r = SAMPLE_TEXTURE2D_X(_LensFlareScreenSpaceStreaksAccumulationTex, sampler_LinearClamp, uv).x;
g = SAMPLE_TEXTURE2D_X(_LensFlareScreenSpaceStreaksAccumulationTex, sampler_LinearClamp, diff + uv).y;
b = SAMPLE_TEXTURE2D_X(_LensFlareScreenSpaceStreaksAccumulationTex, sampler_LinearClamp, diff * 2.0 + uv).z;
…
result = SAMPLE_TEXTURE2D_X(_LensFlareScreenSpaceStreaksAccumulationTex, sampler_LinearClamp, uv).xyz;

Streaks合成用のパスをShaderLab側に追加します。

Pass
{
    Name "LensFlareScreenSpace Streaks Accumulate"
    Tags { "Queue" = "Transparent" "RenderType" = "Transparent" }
    LOD 100

    ZWrite Off
    Cull Off
    ZTest Always
    Blend SrcAlpha One

    HLSLPROGRAM
            #pragma target 3.0
            #pragma vertex vert
            #pragma fragment FragmentStreaksAccumulate
    ENDHLSL
}

Streaks合成用のバッファ(_LensFlareScreenSpaceStreaksAccumulationTex)がある分負荷が増えるので、本当は1本の場合には場合分けして従来の描画フローになるようにするのが良いです。

最後に

一旦これで、Streaksを任意本数描画でき、なおかつ処理負荷が調整可能なScreenSpaceLensFlareができました。

綺麗ですね~。

今回はなるべく元のコードを変えすぎない方針で簡易的に移殖・改造しましたが、もっとRenderGraphに準拠するのであればUnsafeCommandBufferで実装したいところです。

Discussion