したかみ ぶろぐ

Unity成分多め

Unity URPでのGraphics.RenderMeshIndirect 備忘録

始めに

UnityではGraphics機能も更新され Graphics.DrawMeshInstancedIndirect がObsoleteになり、代わりに Graphics.RenderMeshIndirect が新たに追加されました。

また、Built-inよりもURPの方が使われるようになり、Unity Standard Shaderが過去のものとなりました。

昔まとめた以下の Graphics.DrawMeshInstancedIndirect 記事が古くなったので、新しくURPで行う Graphics.RenderMeshIndirect についてサンプルを作ってここにまとめます。

shitakami.hateblo.jp

環境

  • Unity 2022.3.30f1
  • URP 14.0.11

リポジトリ

github.com


Graphics.RenderMeshIndirectについて

最初に、Unityの描画について簡単に紹介します。

Unityでオブジェクトを描画するとき、CPUからGPUへMaterial情報を送るSetPass Callや描画命令をするDraw Callが実行されます。

大量のオブジェクトを描画する場合、SetPass CallやDraw Callが増えて重くなる原因になります。

SetPass Call

Draw Call


そこで、Graphics.RenderMeshIndirect を使うことで同一のメッシュとマテリアルのインスタンスを一度に描画することで、少ないSetPass CallとDraw Callで大量に描画できます。

docs.unity3d.com



サンプルコード

指定されたメッシュを範囲内に個数分だけ描画するサンプルです。シェーダーコードが長いので注意してください。

サンプルコード


RenderMeshIndirect対応のシェーダー作成

RenderMeshIndirect は特殊でシェーダー側でインスタンス毎の描画するデータを設定する必要があります。

RenderMeshIndirect 用のシェーダーを作るうえで、以下の下準備が必要となります。( Graphics.DrawMeshInstancedIndirect と異なる点)

  • プリプロセッサに以下2つを追加
    • #define UNITY_INDIRECT_DRAW_ARGS IndirectDrawIndexedArgs
    • #include "UnityIndirect.cginc"
  • 頂点シェーダーの引数から SV_InstanceID を受け取る
  • InitIndirectDrawArgs(0); の呼び出し
  • GetIndirectInstanceID(svInstanceId) で instanceId を求める

プリプロセッサ#define UNITY_INDIRECT_DRAW_ARGS IndirectDrawIndexedArgs , #include "UnityIndirect.cginc" を追加します。

            #define UNITY_INDIRECT_DRAW_ARGS IndirectDrawIndexedArgs
            #include "UnityIndirect.cginc"

次に頂点シェーダーの引数から SV_InstanceID を受け取り、InitIndirectDrawArgs(0) を実行し GetIndirectInstanceID(svInstanceId) で instanceId を求めます。

            Varyings ShadowPassVertexForRenderMeshIndirect(Attributes input, uint svInstanceId : SV_InstanceID)
            {
                InitIndirectDrawArgs(0);
......
......
                uint instanceID = GetIndirectInstanceID(svInstanceId);


下準備をして InstanceId を求めたら、StructuredBufferから描画に必要なデータを取得し頂点や法線などを計算します。 頂点や法線を計算するメソッドはPassの外に書くことで、すべてのPassから実行できるようにしています。 注意点として RenderMeshIndirect では設定された Bounds.center 分だけ描画がずれるので、計算した座標がワールド空間であれば修正する必要があります。

        HLSLINCLUDE

        StructuredBuffer<float4x4> _TransformMatrixArray;
        half3 _BoundsOffset;

        void transform_vertex(inout float3 position, uint instanceId)
        {
            float4x4 mat = _TransformMatrixArray[instanceId];
            position = mul(mat, float4(position, 1.0)).xyz - _BoundsOffset; // Bounds.center分だけ描画がずれるので修正
        }

        void rotate_vector(inout float3 vec, uint instanceId)
        {
            float4x4 mat = _TransformMatrixArray[instanceId];
            vec = normalize(mul((float3x3)mat, vec));
        }
        
        ENDHLSL

....
....
            Varyings LitPassVertexForRenderMeshIndirect(Attributes input, uint svInstanceID : SV_InstanceID)
            {
                InitIndirectDrawArgs(0);

                ......
                ......

                uint instanceID = GetIndirectInstanceID(svInstanceID);
                transform_vertex(input.positionOS.xyz, instanceID);
                rotate_vector(input.normalOS.xyz, instanceID);
                rotate_vector(input.tangentOS.xyz, instanceID);

                ......
                ......


今回作成したLitシェーダーでは以下の3つのPassを使用しています。

  • ForwardLit : オブジェクトの描画するPass
  • ShadowCaster : 影を描画するPass
  • DepthOnly : 深度を描画するPass

反対にUnity URPのLitシェーダーから以下のPassは不必要そうだったので除きました。もし必要であれば適宜対応が必要になります。

  • GBuffer : Deffered Renderingで使用されるPass
  • DepthNormals : _CameraNormalsTexture を描画するときに使用されるPass
  • Meta : Light Mapのベイクで使用される Pass
  • Universal2D : 2D描画用 Pass

InitIndirectDrawArgs と GetIndirectInstanceID の補足

ちょっと長いので折り畳み

この InitIndirectDrawArgs(0)GetIndirectInstanceID(svInstanceId)UnityIndirect.cginc 内で定義されており、実装をみることができます。

InitIndirectDrawArgs(0) では RenderMehsIndirect で指定された引数の設定、GetIndirectInstanceID(svInstanceId) は instanceId の計算が行われています。グラフィックスAPIによる処理の差を吸収している模様なので、これらを使用するほうが無難そうです。

void GetIndirectDrawArgs(out IndirectDrawIndexedArgs args, ByteAddressBuffer argsBuffer, uint commandId)
{
    uint offset = commandId * 20;
    args.indexCountPerInstance = argsBuffer.Load(offset + 0);
    args.instanceCount = argsBuffer.Load(offset + 4);
    args.startIndex = argsBuffer.Load(offset + 8);
    args.baseVertexIndex = argsBuffer.Load(offset + 12);
    args.startInstance = argsBuffer.Load(offset + 16);
}

void InitIndirectDrawArgs(uint svDrawID) { GetIndirectDrawArgs(globalIndirectDrawArgs, unity_IndirectDrawArgs, GetCommandID(svDrawID)); }
#if defined(SHADER_API_VULKAN)
uint GetIndirectInstanceID(IndirectDrawIndexedArgs args, uint svInstanceID) { return svInstanceID - args.startInstance; }
uint GetIndirectInstanceID_Base(IndirectDrawIndexedArgs args, uint svInstanceID) { return svInstanceID; }
#else
uint GetIndirectInstanceID(IndirectDrawIndexedArgs args, uint svInstanceID) { return svInstanceID; }
uint GetIndirectInstanceID_Base(IndirectDrawIndexedArgs args, uint svInstanceID) { return svInstanceID + args.startInstance; }
#endif

uint GetIndirectInstanceID(uint svInstanceID) { return GetIndirectInstanceID(globalIndirectDrawArgs, svInstanceID); }



ArgsBufferの初期化

RenderMeshIndirect では GraphicsBuffer.IndirectDrawIndexedArgs を使ってArgsBufferの初期化を行います。

API DrawMeshInstancedIndirect では単純な uint[] の各要素に mesh.GetIndexCount instanceCount などを設定する形でしたが、IndirectDrawIndexedArgs の登場により明示的な変数に設定するようになりました。

    private GraphicsBuffer _drawArgsBuffer;
......
......
    private void Start()
    {
        .......
        .......

        _drawArgsBuffer = CreateDrawArgsBufferForRenderMeshIndirect(_mesh, _count);

        .......
        .......
    }

    private static GraphicsBuffer CreateDrawArgsBufferForRenderMeshIndirect(Mesh mesh, int instanceCount)
    {
        var commandData = new GraphicsBuffer.IndirectDrawIndexedArgs[1];
        commandData[0] = new GraphicsBuffer.IndirectDrawIndexedArgs
        {
            indexCountPerInstance = mesh.GetIndexCount(0),
            instanceCount = (uint)instanceCount,
            startIndex = mesh.GetIndexStart(0),
            baseVertexIndex = mesh.GetBaseVertex(0),
        };

        var drawArgsBuffer = new GraphicsBuffer(
            GraphicsBuffer.Target.IndirectArguments,
            1,
            GraphicsBuffer.IndirectDrawIndexedArgs.size
        );
        drawArgsBuffer.SetData(commandData);

        return drawArgsBuffer;
    }

今回は試しておりませんが、複数の IndirectDrawIndexedArgs をArgsBufferに設定することも可能なようです。


描画データの初期化

インスタンスの描画では Matrix4x4 を使用し、ランダムな座標と回転で描画するインスタンス分だけ初期化しています。 後は GraphicsBuffer にデータを格納して、マテリアルに設定します。

    private void Start()
    {
        ......
        ......
        _dataBuffer = CreateDataBuffer<Matrix4x4>(_count);
        
        var transformMatrixArray = TransformMatrixArrayFactory.Create(_count, maxPosition, minPosition);
        _dataBuffer.SetData(transformMatrixArray);
        
        _material.SetBuffer("_TransformMatrixArray", _dataBuffer);
        _material.SetVector("_BoundsOffset", transform.position);
        
        transformMatrixArray.Dispose();
    }

    private static GraphicsBuffer CreateDataBuffer<T>(int instanceCount) where T : struct
    {
        return new GraphicsBuffer(
            GraphicsBuffer.Target.Structured, instanceCount,
            Marshal.SizeOf(typeof(T))
        );
    }

RenderMeshIndirectを呼び出して描画

RenderMeshIndirect では RenderParams に描画設定をします。あとは必要な引数を RenderMeshIndirect に渡して描画を行います。

    private void Update()
    {
        var renderParams = new RenderParams(_material)
        {
            receiveShadows = _receiveShadows,
            shadowCastingMode = _shadowCastingMode,
            worldBounds = new Bounds(transform.position, transform.localScale)
        };

        Graphics.RenderMeshIndirect(
            renderParams,
            _mesh,
            _drawArgsBuffer
        );   
    }

動作結果

動作環境

RenderMeshIndirectで10万個のCubeを描画

10万個のCubeを描画してみました。カメラを遠くに配置しているので少しだけ軽くなっています。


GameObjectで10万個のCubeを描画

比較用として、GameObjectとして10万個のCubeを描画したものが次の通りです。同じマテリアルで描画しているのでSetPass Callsは低いですが、DrawCallが20万とかなり大きくなっています。


RenderMeshIndirectで100万個のCubeを描画

最後に100万個のCubeを描画してみます。遠くから描画する場合はギリギリ60fpsになりました。


カメラを寄せると30fpsを下回りました。(内側は影で真っ黒なので、側面にカメラを設置)


ただし、Shadow Casting ModeRecieveShadow を切ることでカメラを寄せても100fpsを上回りました。影の描画は結構重たいようです。



JobSystem, ComputeShaderと組み合わせる

RenderMeshIndirectを使用して大量のインスタンスを描画できますが、何かしら動かすためには描画データを処理する必要があります。

単純に実装すると数万単位の処理が走るため、無視できないレベルの負荷になります。 そこで、JobSystemやComputeShaderを使うことで並列で高速に描画データを処理できます。(他の方法としてVertex Animation Textureなどもあります)

ここでは、そのサンプルを簡単に作って紹介します。

JobSystem

JobSystemはCPU上で並列に処理を実行する機能です。こちらを使って10万個のCubeを動かすサンプル作りました。 Burst compileも使うことでかなり高速になります。

録画処理でCPUリソースが使われているため動画上では120fps程度となりますが、実機ではもっと250fps程度でました。


www.youtube.com


実行しているコードは以下のような形で、毎フレームJobを実行し計算結果を _dataBuffer.SetData(_transformMatrixArray)GPUに渡しています。

    private void Update()
    {
        var job = new CubeAnimationJob(
            (transform.position + transform.localScale / 2).y,
            (transform.position - transform.localScale / 2).y,
            _settings.MoveVelocity,
            _settings.RotationVelocity,
            Time.deltaTime,
            _transformMatrixArray
        );

        var jobHandle = job.Schedule(_count, 64);
        jobHandle.Complete();
        
        _dataBuffer.SetData(_transformMatrixArray);

        var renderParams = new RenderParams(_material)
        {
            receiveShadows = _receiveShadows,
            shadowCastingMode = _shadowCastingMode,
            worldBounds = new Bounds(transform.position, transform.localScale)
        };

        Graphics.RenderMeshIndirect(
            renderParams,
            _mesh,
            _drawArgsBuffer
        );
    }



ComputeShader

ComputeShaderはGPU上で並列に処理を実行する機能で、JobSystemより高速になります。 半面、C#が使えず汎用的な機能が使えないため扱いづらいデメリットもあります。

ComputeShaderで実装した結果250fpsとなり、JobSystemとあまり結果は変わらずとなりました。おそらくデータの処理よりも描画の方がボトルネックになっているようです。(インスタンスの数が増えるとComputeShaderの方が若干高速)


www.youtube.com


以下が実行しているコードの一部で、JobSystemと異なりGPU上で計算をしているためCPUからGPUへデータを送信する処理が不要になります。この点をみれば RenderMeshIndirect とComputeShaderは相性が良いです。

    private void Update()
    {
        _computeShader.SetFloat(DeltaTime, Time.deltaTime);
        _computeShader.GetKernelThreadGroupSizes(_kernelIndex, out var threadGroupSizeX, out _, out _);
        var threadGroups = Mathf.CeilToInt(_count / (float)threadGroupSizeX);
        _computeShader.Dispatch(_kernelIndex, threadGroups, 1, 1);

        var renderParams = new RenderParams(_material)
        {
            receiveShadows = _receiveShadows,
            shadowCastingMode = _shadowCastingMode,
            worldBounds = new Bounds(transform.position, transform.localScale)
        };

        Graphics.RenderMeshIndirect(
            renderParams,
            _mesh,
            _drawArgsBuffer
        );
    }



まとめ

ここ最近のUnity更新に追いつけていませんでしたが、一通り触ってみますと Graphics.DrawMeshInstancedIndirect とそこまで大きく変わっていません。

良く遊びで並列処理を使ったシミュレーションを作っているので、このサンプルを元に開発を続けていく予定です。



参考

Unityの描画周りついては以下の記事をみて振り返ったりしてました。

shitakami.hateblo.jp

docs.unity3d.com

docs.unity3d.com


Graphics.RenderMeshIndirect の実装は以下の記事を参考にしました。

notargs.hateblo.jp

qiita.com

hacchi-man.hatenablog.com