1. サイトトップ
  2. ブログ
  3. Unity
  4. 【Unity】RenderMeshIndirectを使用して多量描画の負荷を抑えてみた

【Unity】RenderMeshIndirectを使用して多量描画の負荷を抑えてみた

こんにちは!情熱開発部プログラマの倉平です!

最近は暖かい日も増えてきましたね。
もう冬も明け春が近づいて来ているのが分かる今日この頃。
ポカポカ天気は外に出るのが一番ですね。
近いうちにゲームのリアルイベントに参加する為に遠出するでありがたいです。

さて、今回はオブジェクトの大量描画に役立つRenderMeshIndirectを紹介しようと思います。

※Unityバージョン 2022.3.20f1を使用しています。

オブジェクトの大量描画

背景オブジェクトの草や木などシーン上に同じオブジェクトを何個も表示することがあると思います。
その際に気になるのが負荷ではないでしょうか?
 
試しにシーン上に同じオブジェクトを10万個表示してみましょう。

平均65FPS出ています。
 
他のオブジェクトの表示や処理も無く、シンプルなオブジェクトでもこの負荷なのでもっと軽くしたいですね。
そんな時に解決策の一つとしてGPU instancing描画を試してみようと思います。

GPU instancingとは?

同じマテリアルを持つ複数のMeshを1回のDrawCallで描画することが出来ます。
大量に描画する必要があるオブジェクト(背景の草や木など)を描画する際は非常に役に立ちます。
DrawCall数を大きく減らすことが出来るので描画速度が向上します。

RenderMeshIndirect

今回はRenderMeshIndirectを使用します。
こちらはScriptで実行する描画命令です。
多量描画したいオブジェクトのMaterialとMeshを使い、描画情報を設定することでGPU instancing描画を行うことが出来ます。
 
※簡易に使用できるDrawMeshInstancedもあります。
 こちらとの違いにつきましては下部にあるおまけ1を参照ください。

使ってみた

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

/// <summary>
/// GPUInstancing描画テストクラス
/// </summary>
public class TestGPUInstancing : MonoBehaviour
{
    // RenderMeshIndirect用
    private const           int CommandCount        = 1;
    private static readonly int PropertyID_Matrices = Shader.PropertyToID("_Matrices");

    [SerializeField]    private GameObject                                  objPrefab       = null;
    [SerializeField]    private int                                         drawNum         = 0;
    [SerializeField]    private Vector3                                     viewBasePos     = Vector3.zero;
    [SerializeField]    private Vector2Int                                  viewArea        = Vector2Int.zero;

                        private GameObject                                  drawBaseObj     = null;
                        private Mesh                                        mesh            = null;
                        private MeshRenderer                                meshRenderer    = null;
                        private Matrix4x4[]                                 matrices        = null;

                        // RenderMeshIndirect用
                        private RenderParams[]                              renderParams    = null;
                        private GraphicsBuffer                              indirectBuf     = null;
                        private GraphicsBuffer                              matricesBuf     = null;
                        private GraphicsBuffer.IndirectDrawIndexedArgs[]    commandDatas    = null;

    /// <summary>
    /// 起動
    /// </summary>
    void Awake()
    {
        // 描画元オブジェクト生成
        drawBaseObj = GameObject.Instantiate(objPrefab, Vector3.zero, Quaternion.identity, transform);
        drawBaseObj.SetActive(false);

        // 表示位置設定
        var randm = new System.Random();
        matrices = new Matrix4x4[drawNum];
        var colors = new Color[drawNum];
        for (int i = 0; i < drawNum; i++)
        {
            var addPosX = (float)(randm.Next() % viewArea.x);
            var addPosY = (float)(randm.Next() % 40) / 10f;
            var addPosZ = (float)(randm.Next() % viewArea.y);
            var addPos = new Vector3(addPosX, addPosY, addPosZ);
            var setPos = viewBasePos + addPos;

            matrices[i] = Matrix4x4.TRS(
                    setPos,
                    Quaternion.identity,
                    Vector3.one
                );
            matrices[i] *= meshRenderer.transform.localToWorldMatrix;
        }

        // 描画に必要な要素を取得
        mesh            = drawBaseObj.GetComponentInChildren<MeshFilter>().mesh;
        meshRenderer    = drawBaseObj.GetComponentInChildren<MeshRenderer>();

        // RenderMeshIndirect用に描画情報を設定
        matricesBuf = new GraphicsBuffer(GraphicsBuffer.Target.Structured, matrices.Length, 4 * 4 * sizeof(float));
        matricesBuf.SetData(matrices);

        commandDatas = new GraphicsBuffer.IndirectDrawIndexedArgs[CommandCount];
        commandDatas[0].indexCountPerInstance = mesh.GetIndexCount(0);
        commandDatas[0].baseVertexIndex = mesh.GetBaseVertex(0);
        commandDatas[0].startIndex = mesh.GetIndexStart(0);
        commandDatas[0].instanceCount = (uint)matrices.Length;

        indirectBuf = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments, CommandCount, GraphicsBuffer.IndirectDrawIndexedArgs.size);
        indirectBuf.SetData(commandDatas);

        renderParams = new RenderParams[meshRenderer.sharedMaterials.Length];
        for (int i = 0; i < meshRenderer.sharedMaterials.Length; ++i)
        {
            renderParams[i] = new RenderParams(meshRenderer.sharedMaterials[i])
            {
                worldBounds = new Bounds(Vector3.zero, 10000 * Vector3.one),
                matProps = new MaterialPropertyBlock(),
                shadowCastingMode = ShadowCastingMode.On,
                receiveShadows = true,
                layer = drawBaseObj.layer,
                camera = null,
                lightProbeUsage = LightProbeUsage.BlendProbes,
                lightProbeProxyVolume = null
            };

            renderParams[i].matProps.SetBuffer(PropertyID_Matrices, matricesBuf);
        }
    }

    /// <summary>
    /// 更新
    /// </summary>
    private void Update()
    {
        if (mesh == null)           return;
        if (meshRenderer == null)   return;
        if (matrices == null)       return;
        if (matrices.Length <= 0)   return;

        for (int i = 0; i < meshRenderer.sharedMaterials.Length; ++i)
        {
            UnityEngine.Graphics.RenderMeshIndirect(
                renderParams[i],
                mesh,
                indirectBuf,
                CommandCount
            );
        }
    }

    /// <summary>
    /// 破棄
    /// </summary>
    private void OnDestroy()
    {
        matricesBuf?.Dispose();
        matricesBuf = null;

        colorsBuf?.Dispose();
        colorsBuf = null;
    }
}

Awake()内で描画情報を設定しています。
描画するオブジェクトが同じなので描画オブジェクトを一つだけ生成(メモリにも優しい!)。
RenderMeshIndirectの実行にはGraphicsBufferとRenderParamsが必要なのでここで用意しています。
 
Update()内ではRenderMeshIndirectの実行を行っています。
描画命令を送っているのでカメラの描画タイミングで描画が実行されます。
 
ShaderもGPU instancingに対応します。
今回はシンプルな描画を行うShaderを使っています。

この中で特筆すべき項目ですがSetup()内で行っている処理です。
RenderMeshIndirectはShader内で位置設定を行わないといけない為、インスタンスごとの位置設定をここで行います。
 
では実際に起動して確認してみましょう。

平均350FPS出ています!
先ほどの描画とが平均65FPSだったので5倍以上も軽くなっています。

カリング処理も追加してみる

実はScriptで実行するGPU instancing描画はカリングされていない為、カメラ外の物も描画されています。
 
カリング処理は自前で書いてあげる必要があります。
今回はCullingGroupAPIを使用したカリング処理の実装を紹介します。
このAPIはカメラと描画位置を設定すると自動でカメラ内外の判定を行ってくれます。
 
CullingGroupの設定処理を追加

    // CullingGroup用
    private CullingGroup                                cullingGroup    = null;
    private BoundingSphere[]                            bounds          = null;
    private Dictionary<int, Matrix4x4>                  cullingMatrices = new Dictionary<int, Matrix4x4>();     // 描画位置
    private Matrix4x4[]                                 drawMatrices    = null;
    private bool                                        calcDrawFlag    = false;

    /// <summary>
    /// 起動
    /// </summary>
    void Awake()
    {
        // 変わらない部分は省略…

        //------------------------------------------------------------
        // カリング設定
        //------------------------------------------------------------
        // カリングを行うカメラを設定
        cullingGroup = new CullingGroup();
        cullingGroup.targetCamera = Camera.main;
        cullingGroup.SetDistanceReferencePoint(Camera.main.transform);

        // 視界判定情報を設定
        bounds = new BoundingSphere[matrices.Length];
        for (int i = 0; i < matrices.Length; i++)
        {
            bounds[i].position = matrices[i].GetPosition();
            bounds[i].radius   = matrices[i].lossyScale.magnitude;
        }
        cullingGroup.SetBoundingSpheres(bounds);
        cullingGroup.SetBoundingSphereCount(bounds.Length);

        // 視認状態が変化した際のコールバックを登録
        cullingGroup.onStateChanged = OnChangeRange;
        //------------------------------------------------------------
    }

    /// <summary>
    /// カリング範囲外or内へ移動の検知
    /// </summary>
    /// <param name="ev">CullingGroupEvent情報</param>
    private void OnChangeRange( CullingGroupEvent ev )
    {
        if (ev.isVisible)
        {
            cullingMatrices[ev.index]   = matrices[ev.index];
        }
        else
        {
            cullingMatrices.Remove(ev.index);
        }
        calcDrawFlag = true;
    }

RenderMeshIndirect実行前にカリングの結果を取得して描画に反映

    /// <summary>
    /// 更新
    /// </summary>
    private void Update()
    {
        if (mesh == null)           return;
        if (meshRenderer == null)   return;

        // カリング結果を反映
        if (calcDrawFlag)
        {
            drawMatrices    = cullingMatrices.Values.ToArray();
            if ( drawMatrices.Length <= 0 ) return;

            // 表示位置設定
            matricesBuf.SetData(drawMatrices);
            for (int h = 0; h < meshRenderer.sharedMaterials.Length; ++h)
            {
                renderParams[h].matProps.SetBuffer(PropertyID_Matrices, matricesBuf);
            }

            // 描画数更新
            commandDatas[0].instanceCount = (uint)drawMatrices.Length;
            indirectBuf.SetData(commandDatas);
            
            calcDrawFlag    = false;
        }
        if (drawMatrices == null)       return;
        if (drawMatrices.Length <= 0)   return;

        for (int i = 0; i < meshRenderer.sharedMaterials.Length; ++i)
        {
            UnityEngine.Graphics.RenderMeshIndirect(
                renderParams[i],
                mesh,
                indirectBuf,
                CommandCount
            );
        }
    }

では起動して確認します。

カメラ外の描画が無くなっていることが確認出来ます。
 
カメラを動かしてみましょう。

カリング出来ていますが少々重いですね。
調べてみたらカリング結果を反映する際のToArray()が原因の様です。
CullingGroup自体の負荷は軽いので結果の反映処理を工夫してあげる必要がありますね。

            drawMatrices    = cullingMatrices.Values.ToArray();
            if ( drawMatrices.Length <= 0 ) return;

おまけ1 DrawMeshInstancedと比べてみた

DrawMeshInstancedはRenderMeshIndirectと比べると
・Shader内で位置情報の設定が必要ない
・最低限、MeshとMaterialと位置情報だけあれば描画出来る
など簡易に使用出来ます。
 
RenderMeshIndirectの方が良い面もあります。
・インスタンスごとに異なる描画情報を設定出来る
 ※GraphicsBufferでShaderに渡します。
・負荷が軽い
 DrawMeshInstancedは描画最大数が1024個と言う制限があります。
 制限を超える数を描画する際は複数回にわたってDrawCallが呼ばれます。
 RenderMeshIndirectはその制限が無いので負荷が軽いです。
  
実際に負荷を比べてみましょう
DrawMeshInstanced : 平均160FPS

RenderMeshIndirect : 平均350FPS

一目瞭然ですね。

おまけ2 インスタンスごとに異なる色を設定

ShaderのSetup()にソース側から指定された色を取得して加算。

インスタンスごとに変わっていることが確認出来ますね。

まとめ

今回は負荷軽減の一つとしてRenderMeshIndirectを紹介しました。
まだまだ色々な方法があるのでより良い負荷軽減を見つけ、快適な描画を構築していきましょう!


【免責事項】

本サイトでの情報を利用することによる損害等に対し、
株式会社ロジカルビートは一切の責任を負いません。