👥

【Unity/URP】RenderMeshを使って残像を描画する

2024/12/21に公開

RenderMeshとは

RenderMeshは、名前のとおりメッシュを描画するためのGraphicsクラスの静的メソッドです。GameObjectを介さずにメッシュの描画を直接指示できるので、とにかくメッシュだけ描きたいというときに便利です。より詳しくは公式リファレンスで確認できます。
ちなみに、同じメッシュを大量に描くためのRenderMeshInstancedRenderMeshIndirectなどのメソッドも用意されています。

残像表現への活用

ゲーム制作で残像を表現する際、最初はGameObjectを複製する方式で実装していたのですが、どうにも無駄を感じてしまい「シンプルに描画だけできないかなー」と考え、今回RenderMeshで実装を試みた次第です。動作の様子は下のような感じです。アセットは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="renderers">記憶させるSkinnedMeshRendereの配列.</param>
        public void Setup(Material material, 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 (_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 = 30;

        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, _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;
            };

            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
            {
                // カメラへの方向ベクトルと法線の内積を使って色を補完.
                // 輪郭に近いほど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にも公開中です。

解説:Afterimage.cs

このスクリプトでは、オブジェクトのメッシュ情報を記憶し、それを使って残像を1フレーム分だけ描画するAfterimageクラスを定義しています。

コンストラクタ

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

◆_params
RenderParams構造体の配列。この構造体を通して、描画に使うマテリアルやカメラ、レイヤー、影の有無など、様々な設定を行うことができます。
◆_meshes
RenderMeshで描画するメッシュの配列。
◆_matrices
各メッシュのモデル変換行列の配列。モデル変換とは、3Dモデルのローカル座標をUnity上のワールド座標に変換することです。モデル変換行列は、この計算過程に必要な数学的な意味での行列です。[1]

いずれも後でRenderMeshメソッドに渡すためのデータで、メッシュの数だけ配列のサイズを確保しておきます。
すべてのメッシュでマテリアルを共有する場合はRenderParamsは1つでも問題ないですが、ここでは3Dモデルのデフォルトのマテリアルも使用できるようにメッシュの数だけ用意しています。

Reset()

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

FrameCountは描画した回数(フレーム数)をカウントするためのプロパティです。Afterimageのインスタンスはプールして使いまわすので、リセット用のメソッドを用意しました。

Setup()

Afterimage.cs
/// <summary>
/// メッシュごとに使用するマテリアルを用意し、現在のメッシュの形状を記憶させる.
/// </summary>
/// <param name="material">使用するマテリアル. </param>
/// <param name="renderers">記憶させるSkinnedMeshRendereの配列.</param>
public void Setup(Material material, 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 (_meshes[i] == null)
        {
            _meshes[i] = new Mesh();
        }
        renderers[i].BakeMesh(_meshes[i]);
        _matrices[i] = renderers[i].transform.localToWorldMatrix;
    }
}

このメソッドで残像の元になるオブジェクトのメッシュ情報を受け取ります。
引数materialは_paramsの初期化に使用するマテリアルです。単一のマテリアルで残像を描く場合はこの引数に値を渡し、nullを渡した場合はSkinnedMeshRendererが参照しているマテリアルを使います。
また、renderersは残像元オブジェクトのSkinnedMeshRendererの配列で、BakeMeshメソッドを使用して_meshesの各要素にメッシュの状態を保存します。
Transform.localToWorldMatrixで各メッシュに対応するモデル変換行列を取得することができます。

RenderMeshes()

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

このメソッド内でRenderMeshを呼び出し、準備したメッシュをすべて描画しています。
RenderMeshの第3引数には、描画するサブメッシュのインデックスを指定します。サブメッシュは、複数のマテリアルを適用するために分割されたメッシュです。サブメッシュを使用していなければ0に指定します。

次のAfterimageRendererクラスでAfterimageの生成と各メソッドの呼び出しを行うことで実際にメッシュの描画を実行します。

解説:AfterimageRenderer.cs

このスクリプトでは、Afterimageのインスタンスを管理し、複数フレームにわたって残像の描画を行うためのAfterimageRendererクラスを定義しています。

変数

AfterimageRenderer.cs
[SerializeField] Material _material;
[SerializeField] int _duration = 30;

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

◆_material
残像用のマテリアル。後でAfterimageに渡します。
◆_duration
1つの残像を描画し続けるフレーム数。
◆_renderers
残像元オブジェクトのSkinnedMeshRendererの配列。後でAfterimageに渡します。
◆_pool
AfterimageのインスタンスをプールするためのStack。処理軽減のため、描画を終えたAfterimageは破棄せずにこちらに戻します。
◆_renderQueue
描画待ちのAfterimageを待機させておくためのQueue。残像を追加するタイミングでこのQueueにAfterimageを加えます。

Render()

AfterimageRenderer.cs
/// <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);
        }
    }
}

変わったことはしていないので見たままの説明になってしまいますが、_renderQueueに格納されているAfterimageを順番に取り出し、メッシュを描画させています。描画回数が指定した数に満たないものは_renderQueueの最後尾に回し、指定した数に達したものは描画回数のカウントをリセットしてプールするという手続きです。

Enqueue()

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

こちらも見たままですが、Afterimageをエンキューするためのメソッドです。プールに要素があれば取り出し、プールが空なら新しいインスタンスを作って_renderQueueに入れています。
このメソッドを呼び出す度に、残像が1つ描画されることになります。

実行結果

適当なタイミングでAfterimageRenderer.Enqueue()を呼べば残像が生成されます。こちらのデモでは、単純に一定のフレーム間隔でこのメソッドを呼び出しています。

マテリアルあり

マテリアルなし

最後に

以上、RenderMeshとそれを活用した残像表現の紹介でした。
この描画処理をポストエフェクトの上から行いたいなと思ったので、次回はURPの機能やシェーダーの説明をメインにその方法について書きたいと思います。
ここまで読んでいただきありがとうございました!

リンク

https://docs.unity3d.com/ja/2023.2/ScriptReference/Graphics.RenderMesh.html
https://assetstore.unity.com/packages/3d/characters/robots/robot-kyle-urp-4696
https://github.com/kr405/UnityAfterimageSample

脚注
  1. ワールド変換、ワールド変換行列と言われることもあります。 ↩︎

Discussion