見出し画像

【Unity URP】軽くて綺麗なラディアルブラーを作ろう!

はじめまして!グラフィックスエンジニアのNithinkです。
今回は、Unity URPにおける軽くて綺麗なラディアルブラー(radial blur)の実装を紹介します。

URP(Universal Render Pipeline)とは、Unityにおけるレンダーパイプライン(一連の描画処理の構成)の一種です。記事内のソースコードはURPでの実装となっています。

記事の最後にコード全文があります。
是非、最後までご覧ください!


さまざまなブラー

ブラー(blur)とは像をぼかすことで、映像作品やゲームの様々な場面で登場します。また、その種類も様々です。
例えばガウスブラー平均ブラーは、曇りガラスや、UIのモーダルの背景をぼかす表現などで用いられます。
モーションブラーは方向性のあるブラーで、カメラやオブジェクトが素早く動いた際のブレを表現します。
被写界深度(depth of field)は、カメラのピントが合っている部分の前後をぼかすような表現です。
そして、今回扱うラディアルブラーズームブラー(zoom blur)とも呼ばれ、円形に迫ってくるようなブラーで、ドラゴンの咆哮や、ダメージを与えた瞬間などの衝撃を表現するのに用いられます。

それぞれのイメージや概要がわかるおすすめサイト

ガウスブラー:https://3dcg-school.pro/unity-post-process-gaussian-blur/
モーションブラー:https://qiita.com/rarudonet/items/fb2386a60a2468e76a00
被写界深度:https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0/manual/post-processing-depth-of-field.html
ラディアルブラー:https://vook.vc/terms/CC%20Radial%20Blur

ブラーは重たい

残念なことに、一般にブラーは重たい処理となります。
その主たる原因はテクスチャのサンプリングをたくさん行うことにあります。
画像をぼかすための一般的な方法としては、シェーダーを使って周辺のピクセルの色を取得(サンプリング)してブレンドするというものがあります。
例えばシンプルな平均ブラーを例として視覚化すると、以下のようになります。

(実際平均ブラーの実装はもっといろいろ工夫して行われます...が、本題とズレるので割愛します。)

サンプリング回数が多いほど滑らかで綺麗なブラーになりますが、その分処理負荷も上がってしまいます。
テクスチャのサンプリングは、シェーダーの中でも特に重い処理であるため、なるべく回数を抑えなければなりません。しかしそれではビジュアルが…。
今回は、そんなジレンマを解消できる方法をご紹介します!

一般的なラディアルブラーの実装

ラディアルブラーはポストエフェクトの一種です。例に漏れず、画面の像をテクスチャとして取得して、それをシェーダーで処理する流れとなります。

URPでは、Renderer Featureや、Passを作成してポストエフェクトを実装する方法が一般的ですが、Full Screen Pass Renderer Featureという機能を使って手軽に実装することもできます。ここは状況や好みに応じて、好きな手段をとるのが良いかと思います。

次にシェーダーの処理を考えます。

よくあるラディアルブラーでは、「テクスチャとして取り込んだ画面の像を、UVを少しずつ拡大しながら複数回サンプリングし、ブレンドする」という実装になっています。

例えば以下の例では、少しずつ拡大しながら3回サンプリングしてブレンドしています。(青く光る球に注目すると分かりやすいです。)

コード例は次の通りです。

#pragma vertex Vert
#pragma fragment Frag

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/PostProcessing/Common.hlsl"

//X: サンプリング回数。
//Y: サンプリング回数の逆数。
//Z: サンプリング回数-1の逆数。
half3 _SampleCountParams;

half _Intensity;
float2 _RadialCenter;

half4 Frag (Varyings input) : SV_Target
{
    half4 output = 0;
    const float2 uv = input.texcoord - _RadialCenter;
    const half sampleCount = _SampleCountParams.x;
    const half rcpSampleCount = _SampleCountParams.y;
    const half rcpSampleCountMinusOne = _SampleCountParams.z;

    for (int i = 0; i < sampleCount; i++)
    {
        float t =  i * rcpSampleCountMinusOne;
                    
        output += SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, uv * lerp(1, 1 - _Intensity, t) + _RadialCenter);
    }

    output *= rcpSampleCount;
    return output;
}

・_Intensity:ブラーの強さ。0〜0.6くらいが良いです。

・_SampleCountParams:コメントにもある通り、サンプリング回数やその逆数などの値をまとめたものです。逆数を求める処理は重いので、C#側で求めたものをシェーダーに転送します。

・_RadialCenter:エフェクトの中心座標。(0.5, 0.5)で画面の真ん中になります。

・UV座標を1より小さい値で乗算することでテクスチャは拡大されます。
  一般に、拡大率をS、拡大の中心点をCとすると、(UV - C) * S + Cとなります。

・outputにサンプリングした像を足していって最後にサンプリング回数で割り算をしていますが、これはつまり平均を求めているということです。先ほど「ブレンドする」と呼んでいた処理に該当します。
実行結果は次の通りです。

サンプリング回数が少ないとかなり残像が目立ちます。また、サンプリング回数を多くしても、なかなか残像感を綺麗に消すことはできません。

サンプリング回数の少なさと、ビジュアルの綺麗さを両立させる方法はないのでしょうか?

ディザリング

ここで活用できるのがディザリング(Dithering)という技術です。
そもそも皆さんはディザリングをご存じでしょうか?
例えば、何か画像を作る際に「赤と青の二色しか使えない」ような状況があったとします。

そんな状況でグラデーションを描くことは可能でしょうか?

もちろん紫など使えない状況なので、上記画像のような完全に綺麗なグラデーションを描くことはできません。
しかし…以下のように描くのはどうでしょうか?

確かに赤と青の二色しか用いていないのに、グラデーションが表現できてはいます。

このように、「限られた数の色を特定のパターンで配置することで中間の色を表現する技術」をディザリングと呼びます。一昔前のドット絵や印刷などで用いられていた技術です。

実はこのディザリングの考え方を活用することで、先ほどのラディアルブラーの課題を解決することができるのです。

ラディアルブラーとディザリング

先ほどのラディアルブラーの課題とは、「少ないサンプリング回数で綺麗なビジュアルにしたい」というものでした。

しかし現状では、サンプリング回数が少ないと残像感が見えてしまうのでした。少ないサンプリング回数で滑らかに伸ばせるような方法…ここで、ディザリングの考え方が応用できます!

そもそもラディアルブラーは、「複数枚のテクスチャを、UVを少しずつ拡大しながらサンプリングしていってブレンドする」という実装でした。
ということは、先ほどの赤と青の例のように、n枚目のテクスチャのUVとn+1枚目のテクスチャのUVをディザリングしながら各テクスチャをブレンドしていけば、滑らかになるのではないでしょうか?

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

#pragma vertex Vert
#pragma fragment Frag
#pragma multi_compile_local_fragment _ USE_DITHER

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/PostProcessing/Common.hlsl"

//X: サンプリング回数。
//Y: サンプリング回数の逆数。
//Z: サンプリング回数-1の逆数。
half3 _SampleCountParams;

half _Intensity;
half2 _RadialCenter;

half4 Frag (Varyings input) : SV_Target
{
    half4 output = 0;
    const float2 uv = input.texcoord - _RadialCenter;
    const half sampleCount = _SampleCountParams.x;
    const half rcpSampleCount = _SampleCountParams.y;

    #if defined(USE_DITHER)//ディザリングっぽくぼかす場合。
    half dither = InterleavedGradientNoise(input.positionCS.xy, 0);
    #else
    const half rcpSampleCountMinusOne = _SampleCountParams.z;
    #endif
    
    for (int i = 0; i < sampleCount; i++)
    {
        #if defined(USE_DITHER)
        float t = (i + dither) * rcpSampleCount;//i枚目とi+1枚目をディザリング。
        #else
        float t =  i * rcpSampleCountMinusOne;
        #endif
        
        output += SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, uv * lerp(1, 1 - _Intensity, t) + _RadialCenter);
    }
    output *= rcpSampleCount;
    
    return output;
}

・USE_DITHERというシェーダーキーワードがオンの場合にディザリングを行うようにしています。
 ビジュアルの都合上あえて従来の方法を使いたいケースがあるかもしれないためです。

・ディザリングはSRPパッケージ内のRandom.hlslにある、InterleavedGradientNoiseという関数で行えます。
 返り値の値域は0~1です。そのため「i + dither」は「i枚目とi+1枚目のディザリング」ということになります。
 第二引数はディザリングのパターンをずらしたいときに使いますが、今回は特にずらさないので0としておきます。
 関数の中身は以下の通りで、比較的軽い処理であることが伺えます。

float InterleavedGradientNoise(float2 pixCoord, int frameCount)
{
    const float3 magic = float3(0.06711056f, 0.00583715f, 52.9829189f);
    float2 frameMagicScale = float2(2.083f, 4.867f);
    pixCoord += frameCount * frameMagicScale;
    return frac(magic.z * frac(dot(pixCoord, magic.xy)));
}

実行結果は次の通りです。

少ないサンプリング回数でも、全く残像感が出なくなっていることが分かります。その代わりにディザリング特有のざらざら感が出ますが、これもサンプリング回数を少し増やせばかなり改善されます。

また、このざらざら感は画面の解像度が高いほど目立ちにくくなります。普通の解像度であれば、あまり目立ちません。

特筆すべきは、2回というブラーにおけるサンプリングの回数としては破格の少なさであっても、しっかりとラディアルブラーの表現として成り立っているという点です。逆に従来の方法(2回)ではもはやラディアルブラーの表現としては完全に破綻しており、何が表現されているのか分かりません。

今回の方法では、実はサンプリング回数1回でも表現が可能です。
(従来の方法では1回は不可能。)

ここまで少ないとさすがにざらざら感が強く目立ちますが、それでも有用です。なぜなら、そもそもラディアルブラーというのは、ダメージを与えた瞬間などに一瞬だけかけるようなエフェクトなので、その場合は「ラディアルブラーの表現として最低限成り立っているかどうか」ということが重要だからです。

まとめ

このように、ディザリングの考え方を応用すると、圧倒的に少ないサンプリング回数で残像感のない綺麗なブラーを表現することができます。

グラフィックスエンジニアリングでは、ビジュアルの品質と処理負荷を天秤にかけるような苦しい判断を強いられることが多いですが、今回のようにうまくいろんな技術を組み合わせるなどして工夫することで、両立できることもあります。なんでも常識に囚われて「この表現は重いからダメだ」と考えるのではなく、何か工夫できないかと知恵を絞る姿勢が大事ですね。

今回はラディアルブラーの例でしたが、ディザリングは他の種類のブラーにも活用できるものがあります。

ぜひいろいろお試しください。

コード全文

RadialBlur.shader

Shader "Hidden/PostProcessing/RadialBlur"
{
    SubShader
    {
        Cull Off
        ZWrite Off
        ZTest Always

        Pass
        {
            Name "Radial Blur"
            
            HLSLPROGRAM

            #pragma vertex Vert
            #pragma fragment Frag
            #pragma multi_compile_local_fragment _ USE_DITHER
            
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/Shaders/PostProcessing/Common.hlsl"

            //X: サンプリング回数。
            //Y: サンプリング回数の逆数。
            //Z: サンプリング回数-1の逆数。
            half3 _SampleCountParams;
            
            half _Intensity;
            half2 _RadialCenter;
            
            half4 Frag (Varyings input) : SV_Target
            {
                half4 output = 0;
                const float2 uv = input.texcoord - _RadialCenter;
                const half sampleCount = _SampleCountParams.x;
                const half rcpSampleCount = _SampleCountParams.y;

                #if defined(USE_DITHER)//ディザリングっぽくぼかす場合。
                half dither = InterleavedGradientNoise(input.positionCS.xy, 0);
                #else
                const half rcpSampleCountMinusOne = _SampleCountParams.z;
                #endif
                
                for (int i = 0; i < sampleCount; i++)
                {
                    #if defined(USE_DITHER)
                    float t = (i + dither) * rcpSampleCount;//i枚目とi+1枚目をディザリング。
                    #else
                    float t =  i * rcpSampleCountMinusOne;
                    #endif
                    
                    output += SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, uv * lerp(1, 1 - _Intensity, t) + _RadialCenter);
                }
                output *= rcpSampleCount;
                
                return output;
            }

            ENDHLSL
        }
    }
}

RadialBlurFeature.cs

using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

/// <summary>
/// ラディアルブラーのシェーダーで用いるパラメータ。
/// </summary>
[Serializable]
public class RadialBlurParams
{
    [Range(0, 1), Tooltip("ブラーの強さ")] public float intensity = 0.4f;
    [Min(1), Tooltip("サンプリング回数")] public int sampleCount = 3;
    [Tooltip("エフェクトの中心")] public Vector2 radialCenter = new Vector2(0.5f, 0.5f);
    [Tooltip("ディザリングを利用する")] public bool useDither = true;
}

public class RadialBlurFeature : ScriptableRendererFeature
{
    [SerializeField] private RadialBlurParams _parameters;
    [SerializeField] private RenderPassEvent _renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    private RadialBlurPass _pass;

    public override void Create()
    {
        _pass = new RadialBlurPass(_parameters)
        {
            renderPassEvent = _renderPassEvent,
        };
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (_pass != null) renderer.EnqueuePass(_pass);
    }

    public void OnDestroy() => _pass?.Dispose();
}

public class RadialBlurPass : ScriptableRenderPass
{
    private Material _material;
    private readonly RadialBlurParams _parameters;
    private LocalKeyword _keywordUseDither;

    private static readonly int _idIntensity = Shader.PropertyToID("_Intensity");
    private static readonly int _idSampleCountParams = Shader.PropertyToID("_SampleCountParams");
    private static readonly int _idRadialCenter = Shader.PropertyToID("_RadialCenter");
    
    public RadialBlurPass(RadialBlurParams parameters)
    {
        _parameters = parameters;
        //シェーダーの取得、マテリアルとキーワードの生成。
        //あまり好ましい取得方法ではありません。あくまでもサンプル。
        Shader shader = Shader.Find("Hidden/PostProcessing/RadialBlur");
        _material = CoreUtils.CreateEngineMaterial(shader);
        _keywordUseDither = new LocalKeyword(shader, "USE_DITHER");
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        CommandBuffer cmd = CommandBufferPool.Get("Radial Blur");
        
        //ラディアルブラーのパラメータを設定。
        _material.SetFloat(_idIntensity, _parameters.intensity);
        _material.SetVector(_idSampleCountParams,
            new Vector3(_parameters.sampleCount,
                1f / _parameters.sampleCount,
                2 <= _parameters.sampleCount ? 1f / (_parameters.sampleCount - 1) : 1));
        _material.SetVector(_idRadialCenter, _parameters.radialCenter);
        _material.SetKeyword(_keywordUseDither, _parameters.useDither);
        
        Blit(cmd, ref renderingData, _material, 0);
        
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public void Dispose()
    {
        CoreUtils.Destroy(_material);
    }
}

※Unity2022.3で作成しています。

▼G2 Studiosについてはこちら


みんなにも読んでほしいですか?

オススメした記事はフォロワーのタイムラインに表示されます!

X(Twitter)にて最新のお知らせを配信しております。ぜひフォローしてください!