🖌️

【Unity/URP】ポストエフェクトの上からメッシュを描画する

2024/12/28に公開

※この記事は前回の記事を前提とした内容です。

はじめに

前回、Graphics.RenderMesh()というメソッドを使って残像を描く方法について書きました。
今回は、このプロジェクトを拡張する形でポストエフェクト後にメッシュを描画する方法について紹介したいと思います。
なお、こちらで紹介する方法はURPのプロジェクトを前提としていますが、URPの導入方法については解説していないのでご了承ください。

何が変わるか

ポストエフェクトは、オブジェクトの描画を終えた後にスクリーン全体にかかるエフェクトです。しかしながら、「ここはポストエフェクトかからないでほしいのにな...」という場面が少なからずあります。
例えば、前回の残像プロジェクトをポストエフェクトでモノトーンにしたら次のようになります。

これでは残像が映えないので、「残像のところだけ色が付いたら良さげだよな」と考え、ポストエフェクト後に描画することにしました。実際にできると、下のような結果となりました。

アセットはUnity TechnologiesのRobot Kyleを使用しています。

コード全文

動作環境
Unity 2022.3.17f1
URP 14.0.9
Afterimage.cs
using UnityEngine;

namespace AfterimageSample
{
    public class AfterImage
    {
        RenderParams[] _params;
        Mesh[] _meshes;
        Matrix4x4[] _matrices;

        /// <summary>
        /// 描画された回数.
        /// </summary>
        public int FrameCount { get; private set; }

        /// <summary>
        /// コンストラクタ.
        /// </summary>
        /// <param name="meshCount">描画するメッシュの数.</param>
        public AfterImage(int meshCount)
        {
            _params = new RenderParams[meshCount];
            _meshes = new Mesh[meshCount];
            _matrices = new Matrix4x4[meshCount];
            Reset();
        }

        /// <summary>
        /// 描画前もしくは後に実行する.
        /// </summary>
        public void Reset()
        {
            FrameCount = 0;
        }

        /// <summary>
        /// メッシュごとに使用するマテリアルを用意し、現在のメッシュの形状を記憶させる.
        /// </summary>
        /// <param name="material">使用するマテリアル. </param>
        /// <param name="layer">描画するレイヤー.</param>
        /// <param name="renderers">記憶させるSkinnedMeshRendereの配列.</param>
        public void Setup(Material material, int layer, SkinnedMeshRenderer[] renderers)
        {
            for (int i = 0; i < renderers.Length; i++)
            {
                // マテリアルにnullが渡されたらオブジェクトのマテリアルをそのまま使う.
                if (material == null)
                {
                    material = renderers[i].material;
                }
                if (_params[i].material != material)
                {
                    _params[i] = new RenderParams(material);
                }
                // レイヤーを設定する.
                if (_params[i].layer != layer)
                {
                    _params[i].layer = layer;
                }
                // 現在のメッシュの状態を格納する.
                if (_meshes[i] == null)
                {
                    _meshes[i] = new Mesh();
                }
                renderers[i].BakeMesh(_meshes[i]);
                _matrices[i] = renderers[i].transform.localToWorldMatrix;
            }
        }

        /// <summary>
        /// 記憶したメッシュを全て描画する.
        /// </summary>
        public void RenderMeshes()
        {
            for (int i = 0; i < _meshes.Length; i++)
            {
                Graphics.RenderMesh(_params[i], _meshes[i], 0, _matrices[i]);
            }
            FrameCount++;
        }
    }
}
AfterimageRenderer.cs
using System.Collections.Generic;
using UnityEngine;

namespace AfterimageSample
{
    public class AfterimageRenderer : MonoBehaviour
    {
        [SerializeField] Material _material;
        [SerializeField] int _duration = 150;
        [SerializeField] int _layer = 6;

        SkinnedMeshRenderer[] _renderers;
        Stack<AfterImage> _pool = new Stack<AfterImage>();
        Queue<AfterImage> _renderQueue = new Queue<AfterImage>();

        void Awake()
        {
            _renderers = GetComponentsInChildren<SkinnedMeshRenderer>();
        }

        void Update()
        {
            Render();
        }

        /// <summary>
        /// キューに入っているAfterImageのメッシュを描画する.
        /// </summary>
        public void Render()
        {
            for (int i = 0; i < _renderQueue.Count; i++)
            {
                var afterimage = _renderQueue.Dequeue();
                afterimage.RenderMeshes();

                // 描画回数が限度を超えるまで繰り返しキューに入れる.
                // 限度を超えたらプールに返す.
                if (afterimage.FrameCount < _duration)
                {
                    _renderQueue.Enqueue(afterimage);
                }
                else
                {
                    afterimage.Reset();
                    _pool.Push(afterimage);
                }
            }
        }

        /// <summary>
        /// 描画待ちのキューにAfterimageオブジェクトを入れる.
        /// </summary>
        public void Enqueue()
        {
            AfterImage afterimage;
            if (_pool.Count > 0)
            {
                afterimage = _pool.Pop();
            }
            else
            {
                afterimage = new AfterImage(_renderers.Length);
            }
            afterimage.Setup(_material, _layer, _renderers);
            _renderQueue.Enqueue(afterimage);
        }        
    }
}
Afterimage.shader
Shader "Custom/Afterimage"
{
    Properties
    {
        _BaseColor ("Base Color", Color) = (1, 1, 1, 0)
        _EdgeColor ("Edge Color", Color) = (1, 1, 1, 1)
        _Blur ("Blur", Range(1, 5)) = 1
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

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

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
                half3 normal : NORMAL;
            };

            struct Varyings
            {
                float2 uv : TEXCOORD0;
                float4 positionCS : SV_POSITION;
                float3 positionWS : TEXCOORD1;
                half3 normal : TEXCOORD2;
            };

            TEXTURE2D(_CameraDepthTexture);
            SAMPLER(sampler_CameraDepthTexture);

            CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            half4 _EdgeColor;
            float _Blur;
            CBUFFER_END

            Varyings vert (Attributes IN)
            {
                Varyings OUT;
                VertexPositionInputs positions = GetVertexPositionInputs(IN.positionOS.xyz);
                OUT.positionCS = positions.positionCS;
                OUT.positionWS = positions.positionWS;
                OUT.uv = IN.uv;
                OUT.normal = TransformObjectToWorldNormal(IN.normal);
                return OUT;
            }

            half4 frag (Varyings IN) : SV_Target
            {
                // 深度テクスチャをサンプリング.
                float2 uv = IN.positionCS.xy / _ScaledScreenParams.xy;
                float sceneDepth = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, uv).r;

                // 深度を線形な値に変換.
                sceneDepth = LinearEyeDepth(sceneDepth, _ZBufferParams);
                float depth = LinearEyeDepth(IN.positionCS.z, _ZBufferParams);

                // sceneDepth < depth = 手前にオブジェクトが描画されていたらピクセルを破棄.
                clip(sceneDepth - depth);

                // カメラへの方向ベクトルと法線の内積を使って色を補完.
                // 輪郭に近いほどEdgeColorに、遠いほどBaseColorに近づく.
                half3 cameraDirection = normalize(_WorldSpaceCameraPos - IN.positionWS);
                half t = max(0, dot(IN.normal, cameraDirection));
                return lerp(_EdgeColor, _BaseColor, pow(t, _Blur));
            }
            ENDHLSL
        }
    }
}

githubにも公開しています。

ポストエフェクト後に描画する

早速ですが、ポストエフェクトがかかった後にメッシュを描画できるようにします。
URPには、自作の描画処理をレンダリングパイプラインに差し込むことができるRenderer Featureという機能があります。ここでは、URPに備わっているRenderObjectsというRenderer Featureを使って、メッシュを描画するタイミングを制御します。

レイヤーを追加する

ポストエフェクト後に描画するメッシュを割り当てるためのレイヤーを用意しておきます。【Edit>Project Settings>Tags and Layers】にて【Afterimage】というレイヤーを追加しています。

RenderObjectsを追加する

今度は使用しているURPのUniversal Renderer Dataアセットを選択し、インスペクターの【Add Renderer Feature】から【RenderObjects】を追加します。

RenderObjectsを設定する

追加したRenderObjectsのパラメータを設定します。今回は下記のように設定を変更しました。

◆Name
適当な名前を決めておきます。ここで設定した名前はFrameDebuggerに反映されます。

◆Event
どのタイミングで描画を行うかを決めます。今回はポストエフェクトの後が良いので【AfterRenderingPostProcessing】にしています。

◆Filters>Queue
描画するオブジェクトが不透明かどうかを設定します。今回は半透明のマテリアルを使っているので【Transparent】です。

◆Filters>Layer Mask
描画対象のレイヤーを設定します。先ほど追加した【Afterimage】レイヤーにしています。

その他のパラメータについてはこちらに解説があります。


さて、これだけでポストエフェクトの上からメッシュを描けるようになりました。
試しにシーンの中にキューブを追加してみましょう。このキューブのLayerを【Afterimage】に変更し、残像用のマテリアルを追加すると...

このようにポストエフェクトの影響を受けていないことがわかります。

RenderMeshに適用する

先程の設定をGraphics.RenderMesh()による描画に適用するためにスクリプトに手を加えます。本筋ではないので読み飛ばしても良いかもしれません。
コードの説明は前回からの変更箇所に絞っているのでご了承ください。

Afterimage.cs

Afterimage.cs
  /// <summary>
  /// メッシュごとに使用するマテリアルを用意し、現在のメッシュの形状を記憶させる.
  /// </summary>
  /// <param name="material">使用するマテリアル. </param>
+ /// <param name="layer">描画するレイヤー.</param>
  /// <param name="renderers">記憶させるSkinnedMeshRendereの配列.</param>
+ public void Setup(Material material, int layer, SkinnedMeshRenderer[] renderers)
  {
      for (int i = 0; i < renderers.Length; i++)
      {
          // マテリアルにnullが渡されたらオブジェクトのマテリアルをそのまま使う.
          if (material == null)
          {
              material = renderers[i].material;
          }
          if (_params[i].material != material)
          {
              _params[i] = new RenderParams(material);
          }
+         // レイヤーを設定する.
+         if (_params[i].layer != layer)
+         {
+             _params[i].layer = layer;
+         }
          // 現在のメッシュの状態を格納する.
          if (_meshes[i] == null)
          {
              _meshes[i] = new Mesh();
          }
          renderers[i].BakeMesh(_meshes[i]);
          _matrices[i] = renderers[i].transform.localToWorldMatrix;
      }
  }

layerという引数にレイヤーの番号を渡すと、メッシュの描画方法を決めるRenderParams構造体にそのレイヤーがセットされます。RenerMesh()を呼び出すときにここで設定したRenderParamsを使うので、この変更だけで描画のタイミングが変わるようになります。

AfterimageRenderer.cs

AfterimageRenderer.cs
  [SerializeField] Material _material;
  [SerializeField] int _duration = 150;
+ [SerializeField] int _layer = 6;
  /// <summary>
  /// 描画待ちのキューにAfterimageオブジェクトを入れる.
  /// </summary>
  public void Enqueue()
  {
      AfterImage afterimage;
      if (_pool.Count > 0)
      {
          afterimage = _pool.Pop();
      }
      else
      {
          afterimage = new AfterImage(_renderers.Length);
      }
+     afterimage.Setup(_material, _layer, _renderers);
      _renderQueue.Enqueue(afterimage);
  }        

こちらは特に説明することはないですね。レイヤーを設定するための変数を用意し、それをAfterimage.Setup()に渡しているだけです。


ここで実行してみると、下のようにしっかりとポストエフェクトの後で残像が描かれています。

しかし、このままでは単に上からメッシュを描いているだけなので問題があります。シーンの中に遮蔽物を置いてみると...

このように位置関係を無視して手前に描かれてしまうんですね。そこで、シェーダーを変更してこの問題を解決していきます。

前後関係を維持する

シェーダーの中で深度テクスチャを利用することで、オブジェクト同士の前後関係が反映されるようにします。

Depth Textureをオンにする

使用しているUniversal Render Pipeline Assetを選択し、インスペクターの【Depth Texture】にチェックを入れておきます。これにより、常に _CameraDepthTextureが生成され、このテクスチャにオブジェクトの深度が格納されるようになります。

この後、シェーダーの中で_CameraDepthTextureをサンプリングし、その値を使って描画するか否かを判断します。

深度テクスチャを宣言

Afterimage.shader
+ TEXTURE2D(_CameraDepthTexture);
+ SAMPLER(sampler_CameraDepthTexture);

_CameraDepthTextureをサンプリングするためにテクスチャとサンプラーを宣言しています。

深度をサンプリング

Afterimage.shader
+ // 深度テクスチャをサンプリング.
+ float2 uv = IN.positionCS.xy / _ScaledScreenParams.xy;
+ float sceneDepth = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, uv).r;

深度テクスチャはスクリーンと同じサイズで確保されているため、uv座標を得るためにスクリーン座標を利用します。

この時点でIN.positionCS.xyにはスクリーン上に投射された頂点の座標が入っており、_ScaledScreenParams.xyという組み込み変数には現在のスクリーンのサイズが入っているため、除算することで座標値が[0,1]の範囲にマッピングされてuv座標として使えるようになります。

深度を線形化

Afterimage.shader
+ // 深度を線形な値に変換.
+ sceneDepth = LinearEyeDepth(sceneDepth, _ZBufferParams);
+ float depth = LinearEyeDepth(IN.positionCS.z, _ZBufferParams);

深度はいわばカメラからオブジェクトまでの距離ですが、描画までの過程でこの値が変換され、線形(直線的)な距離の値ではなくなっています。LinearEyeDepth() はこの変換と逆のことを行い、線形な値に戻す関数です。

描画の要否を決定

Afterimage.shader
+ // sceneDepth < depth = 手前にオブジェクトが描画されていたらピクセルを破棄.
+ clip(sceneDepth - depth);

clip() は渡された値が0未満なら描画をスキップします。自分よりも深度が低い(手前に)オブジェクトがある場所は描画しないように、上のような計算結果を渡しています。


ここまでできると、下のような結果が得られます。前後関係が破綻せずに描画できました。

最後に

以上、ポストエフェクトの上からメッシュを描く方法でした。
なお、お気付きの方もいると思いますが、基本的に_CameraDepthTextureには不透明オブジェクトの深度しか書き込まれないため、シーンに半透明なオブジェクトがある場合は他の対策が必要になりそうです。また、上から描くメッシュが不透明な場合も、そのメッシュの深度は書き込まれていないため描画結果が不安定になります...これらの対策について、また別の記事で扱いたいですね。
ここまで読んでいただきありがとうございました!

リンク

https://assetstore.unity.com/packages/3d/characters/robots/robot-kyle-urp-4696
https://docs.unity3d.com/ja/Packages/com.unity.render-pipelines.universal@14.0/manual/urp-renderer-feature.html
https://github.com/kr405/UnityAfterimageSample

Discussion