Unity の UI を ScriptableRenderPass で もちもちにする

こんにちは。 お久しぶりの投稿です。

最近、ひとりでつくっているゲームの内容が少しずつかたまってきたので、実験してみたことや没案などの紹介をたまに試みようと思います。

今回は、Unityで UI をメタボール化し やわらか形状にする方法を紹介してみようと思います。 スクリーンスペースで 任意の形状をメタボール化するテクニックをやってみました。

f:id:hadashia:20191214150538g:plain

こんなかんじです。

余談ですが、僕は以前から、UIというものはもうちょっと、丸かったりやわらかい形状をしていて良いのでは? という感想を持ってます。 同じ矩形が 几帳面に複数列に整列しているよりも、形状や大きさが全て違っているほうが素早く視認できるし、人間が紙に手描きで図を描いた場合、四角ではなく丸い形や線のほうが自然です。また、入力に対してなめらかに動くものは、有機的なかんじがして、情緒が感じられたりして楽しい。

メタボール

メタボールは、複数の形同士がお互いに溶け合ったような形をレンダリングするテクニックのひとつです。 メタボールというキーワードで検索すると、 Web上に色々と解説がみつかります。

一般的な説明だと、図形同士の距離が閾値値を下回るものに色を塗ってしまうことで、境目の曖昧な図形を描く、というようなことだと思います。

wikipedia などで、2Dでの円のメタボールの式が紹介されています。

\displaystyle{
\sum_{i=1}^{n} \frac{1}{((x0 - xi)^2 + (y0 - yi)^2)}
}

(x0, y0) と、対象の全ての物体の距離の逆数を加算した結果を、色を塗るかどうかの判定につかう、というかんじですね。

ただし、このやりかたは、距離関数によって絵を描いている場合には素直に使えますが、 Unity の UI など、CPU側で生成済みの任意の形状に似たような形状に対しては、全く同じ方法はとることはできません。

スクリーンスペースで2Dの絵をメタボール

距離関数でメターボールを描く他に、既に生成された図形をメタボール化するテクニックがあります。

今回はこれを、Unity の UI に対してやってみました。

  1. 既に描画された形状に対してブラーをかける
    • → ブラーの結果、エッジからの距離が遠ざかるごとに徐々に色が薄くなる絵を得る
  2. 閾値以下のピクセルと、閾値を上回るピクセルとで はっきり塗り分けてしまう

といったことをすると、冒頭のサンプルのような絵ができます。 仕組みは Bloom とかなり似ていますね

f:id:hadashia:20191214150538g:plain

今回は、2D の絵に対してブラーをかける方法をやてみたのですが、 3D の密集したモデルに対して、スクリーンスペースで法線やDepthをぼかし、流体のような表現をするアプローチをする技があるようです。(sugoi) http://developer.download.nvidia.com/presentations/2010/gdc/Direct3D_Effects.pdf

Unity の URP (Universal Render Pipeline) に メタボールのパスを差し込む

さて、Unity の新しい Rendering Pipeline は、描画パイプラインの任意のタイミングで 任意の描画パスを追加することができます。 僕のプロジェクトでは、 URP (旧:LWRP) を使っていますが、ここにメタボール用のパスを挟む方法を紹介してみます。

1. ScriptableRendererFeature

Unity の RP に機能を追加するには、ScriptableRendererFeature というものをつくり、これを PipelineAsset に追加します。

ScriptableRendererFeature は、描画パイプラインのプラグイン的なもので、後から追加する描画機能の最小単位です。

右クリック CreateRenderingUniversal Render PipelineRenderer Feature から C# のテンプレートを作成できます。

ScriptableRendererFeature を継承した C# のクラスをつくると、 PipelineAsset のインスペクタに登録することができるようになります。

f:id:hadashia:20191214171354p:plain

さて、ScriptableRendererFeature のクラスが担っている機能は、主に、 ScriptableRenderPass を生成することだけです。 ScriptableRendererFeature 自体は、ScriptableRendererPass のファクトリのようなものです。

ScriptableObject である、ScriptableRendererFeature は、設定した内容をシリアライズして Asset として保存することができる存在です。 そのため、実行時に、状況によって様々な状態を取り得るようなオブジェクトは、ScriptableObject とは 分離され、実行時にインスタンス化される、というつくりになっているのです。 このように、Assetとして保存され設定値を保持するだけのオブジェクトと、実行時にインスタンス化されるオブジェクトとを分けるパターンは、最近の Unity でよくみかけます。 インスペクタから設定したい項目を つくりたい場合は、 ScriptableRendererFeature のほうに追加しておきます。

    public sealed class MetaballRendererFeature : ScriptableRendererFeature
    {
        [System.Serializable]
        public sealed class MetaballSettings
        {
            public string ProfilerTag = "MetaballRenderFeature";
            public RenderPassEvent Event = RenderPassEvent.BeforeRenderingTransparents;
            public Shader MetaballShader;

            public BlendMode SrcBlend = BlendMode.SrcAlpha;
            public BlendMode DstBlend = BlendMode.OneMinusSrcAlpha;

            public Texture2D RampTexture;

            [Range(1, 16)]
            public int BlurryIterations = 1;

            [Range(0.001f, 1f)]
            public float Threshold = 0.04f;

            [Range(0f, 1f)]
            public float LineLength = 0.5f;
        }

        static readonly HashSet<IDrawable> Targets = new HashSet<IDrawable>();

        public static void AddTarget(IDrawable item) => Targets.Add(item);
        public static void RemoveTarget(IDrawable item) => Targets.Remove(item);

        [SerializeField]
        MetaballSettings settings = new MetaballSettings();
    }

2. ScriptableRendererPass

ScriptableRendererPass を継承したクラスに、追加したい描画命令を記述します。

今回は以下のようにしてみました。

  1. Transparent のレンダリングの後に、今回の一連の描画パスを追加。
  2. メタボール化したい対象のメッシュのみを 、専用のバッファ(TemporaryRT) に描画
  3. そのバッファに対してブラーをかける
    • 今回は、ボックスフィルタをしながら 画像サイズを変えて何度もサンプリングする、という実装が簡単な方法でやってます.
  4. ブラーがかかった絵に対して、メタボール化しつつ描画するシェーダで描画して完成。
  5. できたものを スクリーンに重ねる
    • ※既存のUIをそのままレンダリングしていた場合、ここが単純なオーバードローになってしまうので、既存のパスは無効にしておくのが吉
        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            if (targets.IsEmpty())
                return;

            var cam = renderingData.cameraData.camera;
            var cmd = CommandBufferPool.Get(profilerTag);
            using (new ProfilingSample(cmd, profilerTag))
            {
                var targetDescriptor = renderingData.cameraData.cameraTargetDescriptor;
                targetDescriptor.depthBufferBits = 0;

                cmd.GetTemporaryRT(metaballSourceHandle.id, targetDescriptor, FilterMode.Bilinear);
                cmd.SetRenderTarget(metaballSourceHandle.id);
                cmd.ClearRenderTarget(true, true, Color.black, 1f);

                foreach (var x in targets)
                {
                    var pass = x.Material.FindPass("MetaballSource");

                    if (x.Transform is RectTransform rectTransform)
                    {
                        var screenPos = RectTransformUtility.WorldToScreenPoint(cam, rectTransform.position);
                        RectTransformUtility.ScreenPointToWorldPointInRectangle(rectTransform, screenPos, cam, out var worldPoint);
                        var matrix = Matrix4x4.TRS(worldPoint, Quaternion.identity, rectTransform.lossyScale);
                        cmd.DrawMesh(x.Mesh, matrix, x.Material, 0, pass, metaballSourceProps);
                    }
                    else
                    {
                        cmd.DrawMesh(x.Mesh, x.Transform.localToWorldMatrix, x.Material, 0, pass, metaballSourceProps);
                    }
                }

                // Blurring

                // Down sampling
                var currentSource = metaballSourceHandle;
                var currentDestination = temporatyTargetHandles[0];

                targetDescriptor.width /= 2;
                targetDescriptor.height /= 2;
                cmd.GetTemporaryRT(currentDestination.id, targetDescriptor, FilterMode.Bilinear);
                cmd.Blit(currentSource.id, currentDestination.id, metaballMaterial, downSamplingPass);
                cmd.ReleaseTemporaryRT(currentSource.id);

                for (var i = 1; i < BlurryIterations; i++)
                {
                    currentSource = currentDestination;
                    currentDestination = temporatyTargetHandles[i];

                    targetDescriptor.width /= 2;
                    targetDescriptor.height /= 2;
                    cmd.GetTemporaryRT(currentDestination.id, targetDescriptor, FilterMode.Bilinear);
                    cmd.Blit(currentSource.id, currentDestination.id, metaballMaterial, downSamplingPass);
                }

                // Up sampling
                for (var i = BlurryIterations - 2; i >= 0; i--)
                {
                    currentSource = currentDestination;
                    currentDestination = temporatyTargetHandles[i];

                    cmd.Blit(currentSource.id, currentDestination.id, metaballMaterial, upSamplingPass);
                    cmd.ReleaseTemporaryRT(currentSource.id);
                }

                // cmd.SetGlobalTexture("_MetaballSource", currentDestination.Identifier());
                cmd.Blit(currentDestination.id, SourceIdentifier, metaballMaterial, applyMetaballPass);
            }
            context.ExecuteCommandBuffer(cmd);
            CommandBufferPool.Release(cmd);
        }
  • ScriptableRenderPass.Execute メソッドは、毎フレーム呼ばれます。
  • 描画命令は、 CommandBuffer と呼ばれる、Unityが内部でGPUに送信するためのデータ表現に詰める形でやります。

サンプルコードをここに貼ってみました。 Metaball.shader · GitHub

参考資料