URP+2DRendererで画面の一部に綺麗なブラーをかける

いわゆるすりガラス効果みたいな感じで URP + 2D Renderer で画面の一部にきれいなブラーをかける方法です。

前回以下の記事で URP の 2D Renderer でも画面の一部にモザイクやブラーをかける紹介をしましたがブラーの見栄えが良くなかったので今回はガウシアンブラーを適用してきれいなブラーをかける方法の紹介をしたいと思います。

takap-tech.com

今の自分の認識では、

  • URP は GrabPass ができない
    • 代わりに Opaque Texture、Camera Sorting Layer Texture を使用する
  • マルチパスシェーダーは書ける
    • 但し GarbPass できないので最初の Pass のレンダング結果を入力に次のレンダリングに渡せない

となっているため、GrabPass の代わりに、ForwardRenderer の場合 Opaque Texture、2D Renderer の場合 Camera Sorting Layer Texture を使用しましょうという方針になっていると思います。ShaderGraph 同様かと思います。

このため画面の一部(Imageの領域だけ)にガウシアンブラーをかけるのが難しくなっています。計算量削減のために一度横にブラーをかけた結果に対して縦のブラーをかける処理が実装しずらいです。

そこで今回は、Camera Sorting Layer Texture と ShaderGraph、Graphics.Blit を使って画面の一部にガウシアンブラーを使ったガラス効果を付けてみようと思います(一部制限がありますがそれも最後に紹介した音思います)

実装結果は以下の通りです。ブラーをかけている領域自体を動かす描画が乱れますが後ろのゲームオブジェクトを動かしたときはきれいに動作していると思います。

f:id:Takachan:20211226183433g:plain

確認環境

  • Unity 2021.2.5f1
  • VisualStudio 2019
  • Windows10

最終的に Unity 上の GameView で動作するところまで確認しています。これを実機に転送してうまく動作するか、パフォーマンスはどうかまではこの記事内では調査していません。

考え方

考え方は以下の通りです。

  • ブラーをかけたい領域は UI の RawImage 領域の範囲で指定
  • 一度画面全体を RendererTexture にコピーする
  • コピーした領域から RawImage 領域の範囲を切り取る
  • 縮小バッファー1に横方向のブラーをかけて画像を複製する
  • 縮小バッファー2に縦方向のブラーをかけて画像を複製する

です。

実装コード

では早速、実装方法の紹介です。

事前準備

まず以下の通り、空の ShaderGraph とそれを設定した Material を3つ作成します。

f:id:Takachan:20211226185851p:plain

上から順に以下の通りです。

  • 「ShaderGraph_Blur_Horizontal」は「Materia_Blur_Horizontal」に設定
  • 「ShaderGraph_Blur_Vertical」は「Materia_Blur_Vertical」に設定
  • 「ShaderGraph_CaptureScreen」は「Materia_CaptureScreen」に設定

画面上には以下の通り Canvas を用意し適当な大きさの RawImage を1つ配置します。

f:id:Takachan:20211226185101p:plain

ゲーム画面は参考までに、以下形状で Light を配置し任意のオブジェクトに ShadowCaster 2D を設定しスポットライトから影が落ちている状態にしておきます。

f:id:Takachan:20211219210215p:plain

上記のライトを設定した画面に以下の通り Canvas と RawImage を「RawImage__BlurArea」という名称で配置しておきます。白くなっている領域がブラーをかける範囲になります。

f:id:Takachan:20211226185220p:plain

ShaderGraph編

各 ShaderGraph の設定は使用する順に以下の通りです。

ShaderGraph_CaptureScreen

このシェーダーグラフは「Camera Sorting Layer Texture」を受け取って何もせずに出力します。

GraphSettings の設定は以下の通り。

f:id:Takachan:20211226185508p:plain

入力は Camera Sorting Layer Texture を受け取りたいので以下のように設定します。

f:id:Takachan:20211226185618p:plain

設定の詳細は以下の通りです。

f:id:Takachan:20211226185629p:plain

ノード構成は以下の通りです。

f:id:Takachan:20211226185750p:plain

ShaderGraph_Blur_Horizontal

ShaderGraph_Blur_Horizontal は入力された MainTex に対し横方向にガウシアンブラーをかけます。

Graph Settings は「ShaderGraph_CaptureScreen」と同じため割愛します。

シェーダーへの入力は以下の通りです。

f:id:Takachan:20211226190259p:plain

「Blur」の Node Settings、数字が大きくなると計算量が倍で増えるので30くらいまでに制限しておきます。

f:id:Takachan:20211226190327p:plain

「MainTex」の Node Settins

f:id:Takachan:20211226190403p:plain

ノード構成は以下の通りです。

f:id:Takachan:20211226190448p:plain

ノードの「LoopX」はカスタムノードで設定は以下の通りです。

f:id:Takachan:20211226190529p:plain

LoopX の Body に張り付けるコードは以下の通りです。

col = (0, 0, 0, 0);
float weight_total = 0;
float uvPitchX = _MainTex_TexelSize.x * 1.5;

for (float x = -blur; x <= blur; x += 1)
{
    float distance_normalized = abs(x / blur);
    float weight = exp(-0.5 * pow(distance_normalized, 2) * 5.0);
    weight_total += weight;
    
    float4 pos = grabPos;
    pos.x = grabPos.x + (x * uvPitchX);
    col += SAMPLE_TEXTURE2D(grabTexture.tex, grabTexture.samplerstate, grabTexture.GetTransformedUV(pos.xy)) * weight;
}
col /= weight_total;
ShaderGraph_Blur_Vertical

ShaderGraph_Blur_Vertical は入力された MainTex に対し縦方向にガウシアンブラーをかけます。

Graph Settings は「ShaderGraph_CaptureScreen」と同じため割愛します。

シェーダーへの入力は以下の通りです。内容的には「ShaderGraph_Blur_Horizontal」と完全に同じです。

f:id:Takachan:20211226190259p:plain

「Blur」の Node Settings。こちらも計算量の関係で30くらいまでに制限しておきます。

f:id:Takachan:20211226190327p:plain

「MainTex」の Node Settins

f:id:Takachan:20211226190403p:plain

ノード構成は以下の通りです。

f:id:Takachan:20211226191729p:plain

ノードの「LoopY」はカスタムノードで設定は以下の通りです。

f:id:Takachan:20211226191916p:plain

LoopY の Body に張り付けるコードは以下の通りです。

col = (0, 0, 0, 0);
float weight_total = 0;
float uvPitchY = _MainTex_TexelSize.y / 1.5;

for (float y = -blur; y <= blur; y += 1)
{
    float distance_normalized = abs(y / blur);
    float weight = exp(-0.5 * pow(distance_normalized, 2) * 5.0);
    weight_total += weight;
    
    float4 pos = grabPos;
    pos.y = grabPos.y + (y * uvPitchY);
    col += SAMPLE_TEXTURE2D(grabTexture.tex, grabTexture.samplerstate, grabTexture.GetTransformedUV(pos.xy)) * weight;
}
col /= weight_total;

スクリプト編

上記の定義を使用するためのスクリプトは以下の通りです。適当なゲームオブジェクトに張り付けてインスペクターを以下の通り設定します。

f:id:Takachan:20211226192222p:plain

GaussianBlurクラス

コードは長いですが全文以下の通りです。

/// <summary>
/// ガウシアンブラーをかけるためのクラスです。
/// </summary>
public class GaussianBlur : MonoBehaviour
{
    // 縮小倍率
    public enum DimScale
    {
        /// <summary>等倍</summary>
        x1 = 1,
        /// <summary>2分の1</summary>
        x2 = 2,
        /// <summary>4分の1</summary>
        x4 = 4,
        /// <summary>8分の1</summary>
        x8 = 8,
        /// <summary>16分の1</summary>
        x16 = 16,
    }

    private const int BLUR_MIN = 1;
    private const int BLUR_MAX = 30;

    [SerializeField] private RawImage _rawImage;
    [SerializeField] private Material _material1;
    [SerializeField] private Material _material2;
    [SerializeField] private Material _material3;

    // RenderTextureの縮小倍率、大きい数字の方が低負荷
    [SerializeField] DimScale _horizaontalScale = DimScale.x4;
    [SerializeField] DimScale _verticalScale = DimScale.x4;
    // 描画をスキップするフレーム数
    // e.g. 2の場合、2フレームに1回だけ描画する
    [SerializeField, Range(1, 10)] uint _drawSkipFrame = 2;
    // ブラーの強さ
    [SerializeField, Range(BLUR_MIN, BLUR_MAX)] uint _blurStrength = 1;

    // 中間バッファー
    private RenderTexture _rt1;
    private RenderTexture _rt2;
    private RenderTexture _rt3;
    private RenderTexture _rt4;
    // 前回のRawImageの大きさ
    Rect _previousRect;
    // フレームスキップ数
    int _skipFrameCount = 9999;

    public uint BlurStrength
    {
        get => _blurStrength;
        set
        {
            if (value < BLUR_MIN)
            {
                value = BLUR_MIN;
            }
            else if (value > BLUR_MAX)
            {
                value = BLUR_MAX;
            }

            if (_material2 != null)
            {
                _material2.SetFloat("_Blur", value);
            }
            if (_material3 != null)
            {
                _material3.SetFloat("_Blur", value);
            }
        }
    }

#if UNITY_EDITOR
    private void OnValidate()
    {
        BlurStrength = _blurStrength;
    }
#endif

    public void OnDestroy()
    {
        if (_rt1) RenderTexture.ReleaseTemporary(_rt1);
        if (_rt2) RenderTexture.ReleaseTemporary(_rt2);
        if (_rt3) RenderTexture.ReleaseTemporary(_rt3);
        if (_rt4) RenderTexture.ReleaseTemporary(_rt4);
    }

    private void Awake()
    {
        _rawImage.color = Color.white;
        BlurStrength = _blurStrength;
    }

    public void Update()
    {
        Draw();
    }

    // ブラーをかける領域の大きさが変更されたとき or 初回に作業領域を作成する
    private void SetupRenderTexture(ref Rect currentRect)
    {
        currentRect.width = Mathf.Round(currentRect.width);
        currentRect.height = Mathf.Round(currentRect.height);
        if (currentRect.width != _previousRect.width || 
            currentRect.height != _previousRect.height)
        {
            if (_rt1) RenderTexture.ReleaseTemporary(_rt1);
            if (_rt2) RenderTexture.ReleaseTemporary(_rt2);
            if (_rt3) RenderTexture.ReleaseTemporary(_rt3);
            if (_rt4) RenderTexture.ReleaseTemporary(_rt4);

            float w = Screen.width;
            float h = Screen.height;
            _rt1 = RenderTexture.GetTemporary((int)w, (int)h);
            _rt2 = RenderTexture.GetTemporary(
                (int)currentRect.width, (int)currentRect.height);
            _rt3 = RenderTexture.GetTemporary(
                (int)currentRect.width / (int)_horizaontalScale, 
                (int)currentRect.height / (int)_horizaontalScale);

            _rt4 = RenderTexture.GetTemporary(
                (int)currentRect.width / (int)_verticalScale, 
                (int)currentRect.height / (int)_verticalScale);

            _previousRect = currentRect;

            Debug.Log("Create RenderTexture.");
        }
    }

    // 領域にブラーをかける
    public void Draw()
    {
        if (_skipFrameCount <= _drawSkipFrame) // フレームスキップ
        {
            _skipFrameCount++;
            return;
        }
        _skipFrameCount = 0;

        Rect r = _rawImage.GetScreenRect();
        SetupRenderTexture(ref r);

        // 画面を写し取る
        Graphics.Blit(null, _rt1, _material1);

        // RawImage と同じ位置とサイズを切り抜く
        Graphics.CopyTexture(
            _rt1, 0, 0, (int)r.x, (int)r.y, (int)r.width, (int)r.height, _rt2, 0, 0, 0, 0);
        
        Graphics.Blit(_rt3, _rt4, _material3); // 縦方向
        Graphics.Blit(_rt2, _rt3, _material2); // 横方向

        // 切り取ったものをUIに張り付け
        _rawImage.texture = _rt4;
    }
}

public static class GraphicExtension
{
    public static Rect GetScreenRect(this Graphic self)
    {
        var _corners = new Vector3[4];
        self.rectTransform.GetWorldCorners(_corners);

        if (self.canvas.renderMode != RenderMode.ScreenSpaceOverlay)
        {
            var cam = self.canvas.worldCamera;
            _corners[0] = RectTransformUtility.WorldToScreenPoint(cam, _corners[0]);
            _corners[2] = RectTransformUtility.WorldToScreenPoint(cam, _corners[2]);
        }

        return new Rect(_corners[0].x, 
                        _corners[0].y, 
                        _corners[2].x - _corners[0].x,
                        _corners[2].y - _corners[0].y);
    }
}

実行すると以下のようになります。

f:id:Takachan:20211226193435p:plain

ゲーム画面

f:id:Takachan:20211226193416p:plain

最後に

最後になりますが、Camera Sorting Layer に入ってくる画像が、SceneView だと SceneViewカメラの画像でScene画面で実行すると以下のように表示がおかしくなります。これさえなければ割といい感じですが現状バグってる十全に機能するとは言い難い状況みたいです。

f:id:Takachan:20211226193618p:plain

非常に長くなりましたが以上です。