e.blog

主にUnity/UE周りのことについてまとめていきます

URPで背景をぼかしてuGUIの背景にする

概要

今までのビルトインパイプラインで利用していたCommandBufferはSRP環境では少し違った方法で実装しないとならないようです。
今回はSRPでのカスタムパスの使い方と、それを利用してuGUIの背景にブラーを掛けるエフェクトについて書きたいと思います。

前回書いたこの記事のURP(Universal Render Pipeline)版です。

edom18.hateblo.jp

なお、今回の内容を実行すると以下のような感じのエフェクトが作れます。

また実装したものはGitHubにも上げてあります。

github.com

Table of Contents

URPにおけるブラーエフェクトの作成手順

今回実装するのはブラーエフェクトですが、実装する内容はいわゆるカスタムパスの実装になっています。
ということで、そもそもSRP(Scriptable Render Pipeline)でカスタムのパスをどう実装するのかを概観してみましょう。

URP(つまりSRP)では以下の手順を踏んでエフェクトを作成する必要があります。

  1. ScriptableRenderFeatureクラスを継承したクラスを実装する
  2. ScripableRenderPassクラスを継承したクラスを実装する
  3. Custom Forward Rendererアセットを作成する*1
  4. (3)のアセットに(1)で作成したFeatureを追加する

大まかに見ると上記4点が必要な内容となります。

ビルトインパイプラインとの違い

ビルトインのレンダリングパイプラインではカメラの描画命令に差し込む形でCommandBufferを生成し、Cameraオブジェクトに追加することで処理を行っていました。

しかしURPでは(上のリストで示したように)独自のパスを実装し、それを差し込む形で実現します。
もともとURPはScriptable Render Pipelineを使って実装されたもので、パイプラインをスクリプタブルなものにしたものなので当たり前と言えば当たり前ですね。

ということで、以下からその手順の詳細を説明していきます。

Custom Forward Rendererアセットを作成する

まずはCustom Forward Rendererアセットを作成します。これは以下のようにCreateメニューから作成することができます。
(上のリストでは(3)にあたる部分ですが、手順としてはここから説明したほうがイメージしやすいと思うのでそこから説明します)

f:id:edo_m18:20201027153725p:plain

(前知識として)SRPではレンダーパイプラインを定義するアセットファイルがあり、それをプロジェクトに設定することで適用できるようになっています。

そして今回作成するこのアセットはそれと異なり、前述のパイプラインアセットにカスタムパス用リストとして追加するものになっています。
なのでこれを複数作成して追加することで簡単に複数のカスタムパスを追加することができるというわけです。

以下のキャプチャはパイプラインアセットにCustom Forward Rendererアセットを設定した図です。

f:id:edo_m18:20201101223131p:plain

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ボタンを押すとリストが表示されるのでそれを選択します。

f:id:edo_m18:20201027154408p:plain

なお、該当クラスで定義したSerializeFieldは以下のように自動で生成されたScriptableObjectのパラメータとして現れ、インスペクタで編集することができます。

f:id:edo_m18:20201027154726p:plain

設定が済むと上記クラスで実装したAddRenderPassesが毎フレーム呼ばれるようになります。
ここで追加のレンダーパスを実行して処理するという流れなわけですね。

上の例のまま登録すると以下のようにログが出続けるようになります。

f:id:edo_m18:20201027155120g:plain

カスタムパス版の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);
    }
}

コンストラクタで設定されているrenderPassEventRenderPassEvent型の変数で、ベースクラスであるScriptableRenderPassで定義されている変数です。
これはカスタムパスがどのタイミングでレンダリングされるべきかを示す値となり、任意の位置にパスを差し込むことができます。

この値はenumになっていて、+2など値を加減算することで細かくタイミングを制御できるようになっています。

ブラー処理を実装する

さて、ここからが本題です。
カスタムパスの挿入方法は掴めたでしょうか。

ブラー処理は大まかに以下のように処理をしていきます。

  1. 不透明オブジェクトがレンダリングされた結果をコピーする(処理負荷軽減のためダウンスケールする)
  2. ダウンスケールしたコピーに対してぼかし処理を適用する
  3. 他のシェーダで利用できるように、結果をテクスチャとして設定する
  4. (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を生成し、適切な処理を行っています。
CreateMonoBehaviourで言うところの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);

    // 後略
}

ScriptableRenderercameraColorTargetをパスに渡しています。
実は最初に実装した際、シーンのコピーをする際にBuiltinRenderTextureType.CameraTargetを利用していました。

が、後述する「ハマった点」でも取り上げるように、このレンダーターゲットはどうも1フレーム前の情報を格納しているようでした。
そのせいでブラーをかける対象がちらついたりして正常に動作していませんでした。

Frame Debuggerで見るとコピーすべきテクスチャ名が_CameraColorTextureになっていたのでもしや、と思ってcameraColorTargetを使うようにしたところ正常に動作するようになりました。

ScriptableRenderPassのBlitを使ってブラー処理を行う

これはちょっと理由が分からないのですが、CommandBufferBlitを利用してブラー処理を実行したところ、なぜかそれ以後のパスのレンダーターゲットがGrabBluredTextureRendererPass内で取得したテンポラリなRTになる現象がありました。

もう少し具体的に言うと、パス処理の中で最後に実行したbuf.Blitの第一引数に指定したレンダーターゲットが後半のパスのレンダーターゲットになってしまっている、という感じです。
しかしこれをScriptableRenderPassクラスのBlitを経由して実行することで回避することができました。

該当の処理を見てみると、確かにレンダーターゲットの変更っぽい処理がされているのでそれが原因かもしれません。
(ただ、最初のシーン結果のコピーにこちらのBlitを使うと正常に動作せず、しかもそのレンダーターゲットは以後のパスに影響しないという謎挙動なので確かなことは分かっていません・・・)

ブラー結果をuGUIの背景に設定する

さぁ、最後は処理した結果のテクスチャをuGUIの背景に指定するだけです。
実はこの処理は前回書いた記事とまったく同じになるので、詳細については以前の記事を参考にしてください。

edom18.hateblo.jp

まとめ

以上でURPでぼかし背景を作る方法の解説はおしまいです。
色々ハマりどころはありましたが、ブラー処理以外にも、そもそもSRPでカスタムのパスをどう差し込むのか、それらがどう動作するのかの概観を得ることができました。

今回のエフェクト以外にも、例えば特定のオブジェクトだけ影を別にレンダリングする、なんてこともできそうだなと思っています。
今回の実装はそうした意味でも色々と実りのあるものになりました。

最後に少しだけハマった点など備忘録としてメモを残しておくので興味がある方は見てみてください。

ハマった点

今回の実装にあたり、いくつかのハマりポイントがありました。

Unity Editor自体のUIがバグる

これは普通にUnityのバグな気がしないでもないんですが、自分が今回実装したパスを適用するとなぜかUnity Editorのシーンビューがおかしくなるというものです。

具体的にはタブ部分(他のビューをドッキングしたりできるあれ)が黒くなったり、あるいはシーンの一部が描画されてしまったり、という感じです。
(こんな感じ↓)

f:id:edo_m18:20201029092652p:plain

さすがにバグ感ありますよね・・。ってことでバグレポートもしてみました。

さて、とはいえこのままになってしまうと開発に多少なりとも支障が出てしまうので回避したいところです。
結論から言うと、シーンビューをレンダリングしている場合には処理をしない、ということで回避しました。(そもそもシーンビューに適用する意味のない処理だったので)

コードとしてはこんな感じです。

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.isSceneViewCameratrueだったら処理せずすぐに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);
    // 以下略
}

その他参考にした記事

github.com

github.com

*1:Forward Rendererなのは今回VR対応もしたためです。場合によっては別のRendererアセットを使うこともできます。