概要
今までのビルトインパイプラインで利用していたCommandBuffer
はSRP環境では少し違った方法で実装しないとならないようです。
今回はSRPでのカスタムパスの使い方と、それを利用してuGUIの背景にブラーを掛けるエフェクトについて書きたいと思います。
前回書いたこの記事のURP(Universal Render Pipeline)版です。
なお、今回の内容を実行すると以下のような感じのエフェクトが作れます。
URPで背景にブラーかけてUIの背景にするってやつできた。が、色々ハマりどころがあったな・・。 #Unity #URP pic.twitter.com/nmEvFoH13w
— edom18@XR / MESON CTO (@edo_m18) 2020年10月28日
また実装したものはGitHubにも上げてあります。
Table of Contents
- 概要
- URPにおけるブラーエフェクトの作成手順
- Custom Forward Rendererアセットを作成する
- ScriptableRendererFeatureクラスを実装する
- ScriptableRenderPassクラスがパスを表す単位
- ブラー処理を実装する
- ブラー結果をuGUIの背景に設定する
- まとめ
- ハマった点
- その他参考にした記事
URPにおけるブラーエフェクトの作成手順
今回実装するのはブラーエフェクトですが、実装する内容はいわゆるカスタムパスの実装になっています。
ということで、そもそもSRP(Scriptable Render Pipeline)でカスタムのパスをどう実装するのかを概観してみましょう。
URP(つまりSRP)では以下の手順を踏んでエフェクトを作成する必要があります。
ScriptableRenderFeature
クラスを継承したクラスを実装するScripableRenderPass
クラスを継承したクラスを実装する- Custom Forward Rendererアセットを作成する*1
- (3)のアセットに(1)で作成したFeatureを追加する
大まかに見ると上記4点が必要な内容となります。
ビルトインパイプラインとの違い
ビルトインのレンダリングパイプラインではカメラの描画命令に差し込む形でCommandBuffer
を生成し、Camera
オブジェクトに追加することで処理を行っていました。
しかしURPでは(上のリストで示したように)独自のパスを実装し、それを差し込む形で実現します。
もともとURPはScriptable Render Pipelineを使って実装されたもので、パイプラインをスクリプタブルなものにしたものなので当たり前と言えば当たり前ですね。
ということで、以下からその手順の詳細を説明していきます。
Custom Forward Rendererアセットを作成する
まずはCustom Forward Renderer
アセットを作成します。これは以下のようにCreate
メニューから作成することができます。
(上のリストでは(3)にあたる部分ですが、手順としてはここから説明したほうがイメージしやすいと思うのでそこから説明します)
(前知識として)SRPではレンダーパイプラインを定義するアセットファイルがあり、それをプロジェクトに設定することで適用できるようになっています。
そして今回作成するこのアセットはそれと異なり、前述のパイプラインアセットにカスタムパス用リストとして追加するものになっています。
なのでこれを複数作成して追加することで簡単に複数のカスタムパスを追加することができるというわけです。
以下のキャプチャはパイプラインアセットにCustom Forward Rendererアセットを設定した図です。
ScriptableRendererFeatureクラスを実装する
次に説明するのは前述のリストの(1)の部分です。つまりScriptableRendererFeature
クラスを継承したクラスを作成します。
ScriptableRendererFeature
はまさにレンダリングの特徴です。
このクラスの役割はカスタムのパスをQueueに入れ、それをパイプラインで実行するよう指示することです。
そしてそれをForward Renderer
アセットに登録します。(登録については後述)
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering.Universal; public class BlurRendererFeature : ScriptableRendererFeature { [SerializeField] private float _anyParam = 0; public override void Create() { Debug.Log("Create Blur Renderer Feature."); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { Debug.Log("Add Render Passes."); } }
上記コードは内容が分かりやすいようにダミーコードでの実装です。
ScriptableRendererFeature
クラスを継承したクラスを生成すると自動的にForward Renderer
アセットで認識されます。
Custom Forward RendererアセットのAdd Renderer Feature
ボタンを押すとリストが表示されるのでそれを選択します。
なお、該当クラスで定義したSerializeField
は以下のように自動で生成されたScriptableObjectのパラメータとして現れ、インスペクタで編集することができます。
設定が済むと上記クラスで実装したAddRenderPasses
が毎フレーム呼ばれるようになります。
ここで追加のレンダーパスを実行して処理するという流れなわけですね。
上の例のまま登録すると以下のようにログが出続けるようになります。
カスタムパス版のUpdate
メソッド、とイメージすると分かりやすいと思います。
ScriptableRenderPassクラスがパスを表す単位
RendererFeature
はパスを束ねる役割でした。一方、ScriptableRenderPass
クラスはその名の通りパスを表す単位で、この中で実際に行いたい処理を書いていくことになります。
以下は簡単のため、たんにレンダリング結果の色味を反転するだけのパスです。
いわゆるポストエフェクトですね。
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class ReverseColorRendererPass : ScriptableRenderPass { private const string NAME = nameof(ReverseColorRendererPass); private Material _material = null; private RenderTargetIdentifier _currentTarget = default; public ReverseColorRendererPass(Material material) { renderPassEvent = RenderPassEvent.AfterRenderingOpaques; _material = material; } public void SetRenderTarget(RenderTargetIdentifier target) { _currentTarget = target; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer buf = CommandBufferPool.Get(NAME); CameraData camData = renderingData.cameraData; int texId = Shader.PropertyToID("_TempTexture"); int w = camData.camera.scaledPixelWidth; int h = camData.camera.scaledPixelHeight; int shaderPass = 0; buf.GetTemporaryRT(texId, w, h, 0, FilterMode.Point, RenderTextureFormat.Default); buf.Blit(_currentTarget, texId); buf.Blit(texId, _currentTarget, _material, shaderPass); context.ExecuteCommandBuffer(buf); CommandBufferPool.Release(buf); } }
コンストラクタで設定されているrenderPassEvent
はRenderPassEvent
型の変数で、ベースクラスであるScriptableRenderPass
で定義されている変数です。
これはカスタムパスがどのタイミングでレンダリングされるべきかを示す値となり、任意の位置にパスを差し込むことができます。
この値はenum
になっていて、+2
など値を加減算することで細かくタイミングを制御できるようになっています。
ブラー処理を実装する
さて、ここからが本題です。
カスタムパスの挿入方法は掴めたでしょうか。
ブラー処理は大まかに以下のように処理をしていきます。
- 不透明オブジェクトがレンダリングされた結果をコピーする(処理負荷軽減のためダウンスケールする)
- ダウンスケールしたコピーに対してぼかし処理を適用する
- 他のシェーダで利用できるように、結果をテクスチャとして設定する
- (3)で設定されたテクスチャを背景にする
という流れです。
以下から詳細を見ていきましょう。
ブラー用のScriptableRendererFeatureを実装する
まずはScriptableRendererFeature
を継承したクラスを作成します。
インスペクタから値が設定できるようにいくつかのパラメータを定義し、このあと説明するパスの呼び出しまでを行います。
public class BlurRendererFeature : ScriptableRendererFeature { [SerializeField] private Shader _shader = null; [SerializeField, Range(1f, 100f)] private float _offset = 1f; [SerializeField, Range(10f, 1000f)] private float _blur = 100f; [SerializeField] private RenderPassEvent _renderPassEvent = RenderPassEvent.AfterRenderingOpaques; private GrabBluredTextureRendererPass _grabBluredTexturePass = null; public override void Create() { Debug.Log("Create Blur Renderer Feature."); if (_grabBluredTexturePass == null) { _grabBluredTexturePass = new GrabBluredTextureRendererPass(_shader, _renderPassEvent); _grabBluredTexturePass.SetParams(_offset, _blur); _grabBluredTexturePass.UpdateWeights(); } } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { _grabBluredTexturePass.SetRenderTarget(renderer.cameraColorTarget); _grabBluredTexturePass.SetParams(_offset, _blur); renderer.EnqueuePass(_grabBluredTexturePass); } }
Create
のタイミングでGrabBluredTextureRendererPass
を生成し、適切な処理を行っています。
Create
はMonoBehaviour
で言うところのStart
メソッドに当たる処理です。
初期化処理などはここで行うのが適切でしょう。
そしてすでに説明したように、AddRenderPasses
メソッドが毎フレーム呼ばれパスの実行が促されます。
ここではGrabBluredTextureRendererPass
をキューに入れているのが分かりますね。
GrabBluredTextureRendererPassクラスを実装する
ここがブラーを掛けるメイン処理となります。まずはコードを見てみましょう。
public class GrabBluredTextureRendererPass : ScriptableRenderPass { private const string NAME = nameof(GrabBluredTextureRendererPass); private Material _material = null; private RenderTargetIdentifier _currentTarget = default; private float _offset = 0; private float _blur = 0; private float[] _weights = new float[10]; private int _blurredTempID1 = 0; private int _blurredTempID2 = 0; private int _screenCopyID = 0; private int _weightsID = 0; private int _offsetsID = 0; private int _grabBlurTextureID = 0; public GrabBluredTextureRendererPass(Shader shader, RenderPassEvent passEvent) { renderPassEvent = passEvent; _material = new Material(shader); _blurredTempID1 = Shader.PropertyToID("_BlurTemp1"); _blurredTempID2 = Shader.PropertyToID("_BlurTemp2"); _screenCopyID = Shader.PropertyToID("_ScreenCopyTexture"); _weightsID = Shader.PropertyToID("_Weights"); _offsetsID = Shader.PropertyToID("_Offsets"); _grabBlurTextureID = Shader.PropertyToID("_GrabBlurTexture"); } public void UpdateWeights() { float total = 0; float d = _blur * _blur * 0.001f; for (int i = 0; i < _weights.Length; i++) { float r = 1.0f + 2.0f * i; float w = Mathf.Exp(-0.5f * (r * r) / d); _weights[i] = w; if (i > 0) { w *= 2.0f; } total += w; } for (int i = 0; i < _weights.Length; i++) { _weights[i] /= total; } } public void SetParams(float offset, float blur) { _offset = offset; _blur = blur; } public void SetRenderTarget(RenderTargetIdentifier target) { _currentTarget = target; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer buf = CommandBufferPool.Get(NAME); ref CameraData camData = ref renderingData.cameraData; if (camData.isSceneViewCamera) { return; } RenderTextureDescriptor descriptor = camData.cameraTargetDescriptor; buf.GetTemporaryRT(_screenCopyID, descriptor, FilterMode.Bilinear); descriptor.width /= 2; descriptor.height /= 2; buf.GetTemporaryRT(_blurredTempID1, descriptor, FilterMode.Bilinear); buf.GetTemporaryRT(_blurredTempID2, descriptor, FilterMode.Bilinear); int width = camData.camera.scaledPixelWidth; int height = camData.camera.scaledPixelHeight; float x = _offset / width; float y = _offset / height; buf.SetGlobalFloatArray(_weightsID, _weights); buf.Blit(_currentTarget, _screenCopyID); Blit(buf, _screenCopyID, _blurredTempID1); buf.ReleaseTemporaryRT(_screenCopyID); for (int i = 0; i < 2; i++) { buf.SetGlobalVector(_offsetsID, new Vector4(x, 0, 0, 0)); Blit(buf, _blurredTempID1, _blurredTempID2, _material); buf.SetGlobalVector(_offsetsID, new Vector4(0, y, 0, 0)); Blit(buf, _blurredTempID2, _blurredTempID1, _material); } buf.ReleaseTemporaryRT(_blurredTempID2); buf.SetGlobalTexture(_grabBlurTextureID, _blurredTempID1); context.ExecuteCommandBuffer(buf); CommandBufferPool.Release(buf); } }
ScriptableRendererFeature
と同様にScriptableRenderPass
にもoverride
しておくべきメソッドがあります。
一番重要なメソッドがExecute
です。その名の通り、パスの処理が実行されるべきタイミングで呼び出されるメソッドです。
今回はこのメソッド内で画面のキャプチャとブラー処理をしていきます。
いくつかの変数についてはCommandBuffer
やシェーダの扱いのためのものになるのでここでは説明を割愛します。
ブラー自体の詳細については前回の記事を参照ください。
ここではブラー処理を実行しているExecute
に絞って説明します。
Execute内でキャプチャとブラー処理を行う
Execute
メソッドの実装自体はそれほど長くはありません。
なにをしているのかをコメントを付与する形でコード内で説明しましょう。
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { // CommandBufferをプールから取得する CommandBuffer buf = CommandBufferPool.Get(NAME); // カメラの設定などにまつわる情報を取得する ref CameraData camData = ref renderingData.cameraData; // 詳細は「ハマった点」で解説しますが、シーンビューだとおかしくなっていたので分岐を入れています。 if (camData.isSceneViewCamera) { return; } // 今現在、このパスを実行しているカメラのれんだーターゲットに関する情報を取得します。 RenderTextureDescriptor descriptor = camData.cameraTargetDescriptor; // シーン結果コピー用のテンポラリなRenderTextureを取得します。 // 取得の際に、前段で取得したRenderTextureDescriptorを利用することで、カメラの描画情報と同じ設定のものを取得することができます。 buf.GetTemporaryRT(_screenCopyID, descriptor, FilterMode.Bilinear); // 次に、ダウンスケールするために解像度を半分にします。 descriptor.width /= 2; descriptor.height /= 2; // 解像度を半分にしたRenderTextureDescriptorを使ってブラー処理用のふたつのテンポラリなRTを取得します。 buf.GetTemporaryRT(_blurredTempID1, descriptor, FilterMode.Bilinear); buf.GetTemporaryRT(_blurredTempID2, descriptor, FilterMode.Bilinear); // ここはブラー用のパラメータ調整です。 int width = camData.camera.scaledPixelWidth; int height = camData.camera.scaledPixelHeight; float x = _offset / width; float y = _offset / height; buf.SetGlobalFloatArray(_weightsID, _weights); // 現在レンダリング中のレンダーターゲットをコピーします。 // _currentTargetの詳細については大事な点なので後述します。 buf.Blit(_currentTarget, _screenCopyID); // コピーした結果をダウンスケールしてブラー用RTにコピーします。 // なお、ここではbuf.BlitではなくScriptableRenderPassのBlitを呼び出している点に注意してください。 // 詳細は後述します。 Blit(buf, _screenCopyID, _blurredTempID1); buf.ReleaseTemporaryRT(_screenCopyID); // ブラー処理 for (int i = 0; i < 2; i++) { buf.SetGlobalVector(_offsetsID, new Vector4(x, 0, 0, 0)); Blit(buf, _blurredTempID1, _blurredTempID2, _material); buf.SetGlobalVector(_offsetsID, new Vector4(0, y, 0, 0)); Blit(buf, _blurredTempID2, _blurredTempID1, _material); } buf.ReleaseTemporaryRT(_blurredTempID2); // ブラー処理したテクスチャをグローバルテクスチャとして設定します。 buf.SetGlobalTexture(_grabBlurTextureID, _blurredTempID1); // 最後に、これら一連の流れを記述したCommandBufferを実行します。 context.ExecuteCommandBuffer(buf); CommandBufferPool.Release(buf); }
細かくコメントを付けてみたので詳細はそちらをご覧ください。
以下で2点、ハマりポイントも含めつつ解説します。
現在のレンダーターゲットをScriptableRenderFeatureからもらう
BlurRendererFeature
の実装で現在のレンダーターゲット(RenderTargetIdentifier
型)を渡している箇所があります。
以下の部分ですね。
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { _grabBluredTexturePass.SetRenderTarget(renderer.cameraColorTarget); // 後略 }
ScriptableRenderer
のcameraColorTarget
をパスに渡しています。
実は最初に実装した際、シーンのコピーをする際にBuiltinRenderTextureType.CameraTarget
を利用していました。
が、後述する「ハマった点」でも取り上げるように、このレンダーターゲットはどうも1フレーム前の情報を格納しているようでした。
そのせいでブラーをかける対象がちらついたりして正常に動作していませんでした。
Frame Debuggerで見るとコピーすべきテクスチャ名が_CameraColorTexture
になっていたのでもしや、と思ってcameraColorTarget
を使うようにしたところ正常に動作するようになりました。
ScriptableRenderPassのBlitを使ってブラー処理を行う
これはちょっと理由が分からないのですが、CommandBuffer
のBlit
を利用してブラー処理を実行したところ、なぜかそれ以後のパスのレンダーターゲットがGrabBluredTextureRendererPass
内で取得したテンポラリなRTになる現象がありました。
もう少し具体的に言うと、パス処理の中で最後に実行したbuf.Blit
の第一引数に指定したレンダーターゲットが後半のパスのレンダーターゲットになってしまっている、という感じです。
しかしこれをScriptableRenderPass
クラスのBlit
を経由して実行することで回避することができました。
該当の処理を見てみると、確かにレンダーターゲットの変更っぽい処理がされているのでそれが原因かもしれません。
(ただ、最初のシーン結果のコピーにこちらのBlit
を使うと正常に動作せず、しかもそのレンダーターゲットは以後のパスに影響しないという謎挙動なので確かなことは分かっていません・・・)
ブラー結果をuGUIの背景に設定する
さぁ、最後は処理した結果のテクスチャをuGUIの背景に指定するだけです。
実はこの処理は前回書いた記事とまったく同じになるので、詳細については以前の記事を参考にしてください。
まとめ
以上でURPでぼかし背景を作る方法の解説はおしまいです。
色々ハマりどころはありましたが、ブラー処理以外にも、そもそもSRPでカスタムのパスをどう差し込むのか、それらがどう動作するのかの概観を得ることができました。
今回のエフェクト以外にも、例えば特定のオブジェクトだけ影を別にレンダリングする、なんてこともできそうだなと思っています。
今回の実装はそうした意味でも色々と実りのあるものになりました。
最後に少しだけハマった点など備忘録としてメモを残しておくので興味がある方は見てみてください。
ハマった点
今回の実装にあたり、いくつかのハマりポイントがありました。
Unity Editor自体のUIがバグる
これは普通にUnityのバグな気がしないでもないんですが、自分が今回実装したパスを適用するとなぜかUnity Editorのシーンビューがおかしくなるというものです。
具体的にはタブ部分(他のビューをドッキングしたりできるあれ)が黒くなったり、あるいはシーンの一部が描画されてしまったり、という感じです。
(こんな感じ↓)
さすがにバグ感ありますよね・・。ってことでバグレポートもしてみました。
さて、とはいえこのままになってしまうと開発に多少なりとも支障が出てしまうので回避したいところです。
結論から言うと、シーンビューをレンダリングしている場合には処理をしない、ということで回避しました。(そもそもシーンビューに適用する意味のない処理だったので)
コードとしてはこんな感じです。
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer buf = CommandBufferPool.Get(NAME); ref CameraData camData = ref renderingData.cameraData; if (camData.isSceneViewCamera) { return; } // ... 以下略 }
処置内容は、実際にパスが実行される際に呼ばれるExecute
メソッド内で、CameraData.isSceneViewCamera
がtrue
だったら処理せずすぐにreturn
するだけです。
シーンビューのUIが表示されない
実は問題の原因自体は上のものと同一です。
ただ現象としてあったので書いておきます。
具体的には、シーンビュー内の、World Space
に設定されたuGUIが描画されないという問題です。
原因は一緒っぽいので、上の回避方法を導入することでこちらも回避できました。
VRで描画がおかしくなる
結論から言うと、コピー元をBuiltinRenderTextureType.CameraTarget
にしていたのが間違いでした。
buf.Blit(BuiltinRenderTextureType.CameraTarget, _screenCopyID);
最初原因がまったく分からず、色々試していくうちにふと思い立って、コピー結果をBefore Rendering Opaques
時に無加工で表示するとなぜかすでに描画された状態になっていました。
これは推測ですが、ダブルバッファなどで「1フレーム前の状態」を保持しているのではないかなと思います。
VRだとレンダリングが遅れた際にタイムワープなどを使ってレンダリングの遅延を気にさせない機能があるので、それに利用しているんじゃないかなとか思ったり。(完全に推測です)
なので、ScriptableRendererFeature.AddRenderPasses
時にパイプラインから渡されるScriptableRenderer.cameraColorTarget
を利用してキャプチャを行うようにしたところ正常に表示されました。
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { _grabBluredTexturePass.SetRenderTarget(renderer.cameraColorTarget); // 以下略 }