🌿

Unity: 2D で大量の草を生やす

に公開

2D で草を表現するのに、大量のメッシュを Transparent キューで描画する方法を調べました。

前提条件

  • URP 17 カスタム
    • 2D Renderer と Lights 2D を使用
    • すでにいろいろ改造しており、いざとなれば追加の改造も可能である前提
  • 描画対象
    • Per Instance なデータのある描画
      • 個別の草が風でなびいたり、Rigidbody とのインタラクションをもたせるため、インスタンスごとに状態を持たせる必要がある
    • 既存の 2D ライティングと統合するため Shader Graph の Sprite Lit シェーダーを使いたい
    • Transparent キューだが半透明描画は必要なし
    • 特定の Sorting Layer / Order in Layer で描画したい

この記事では最終的に URP の改造まで行っており、手法としての再現性は高くありません。問題解決までのプロセスを記録したものとして読んでいただければ幸いです。

Unity における大量描画 API を復習

SRP Batcher

SRP で利用できる SRP Batcher を有効化すると、通常の Renderer コンポーネント等を使った描画において、使われているシェーダー(バリアント)が同じ Draw Call を結合し負荷を軽減できます。これは異なるマテリアルであっても自動的に適用される強力な機能です。ただし MaterialPropertyBlock に対応しておらず、Per Instance なデータを持たせるには Material を別々に作成する必要があるという、大量描画においてはやや本末転倒感のある制限があります。

https://discussions.unity.com/t/per-renderer-properties-with-srp-batcher/844740

特定のユースケースに特化した機能というよりは、シーン全体にロバストに適用する最適化という性格が強そうです。今回はもともと SRP Batcher は有効化していましたが、大量描画に対しては別の方法を探すことにしました。

Graphics.RenderMesh()

Renderer コンポーネントに頼らずにメッシュを描画できる API です。GameObject が不要というメリットはありますが、それ以外は通常の Renderer と変わらず、SRP Batcher 以上の最適化は行われないと思います。

Graphics.RenderMeshInstanced()

Built-in RP の時代からある標準の GPU Instancing を利用して描画を行います。ただし、試したところ Shader Graph の Sprite Lit シェーダーでは何も描画されず、修正方法が見つからなかったため採用を見送りました。

Graphics.RenderMeshIndirect()

Compute Shader を利用して GPU 上で描画コマンドを発行できる API で、RenderMeshInstanced()よりも CPU 負荷を下げることができます。ただし RenderMeshInstanced() の時点でシェーダーとの互換性に不安があったため今回は試していません。

VFX Graph

VFX Graph は Graphics.RenderMeshIndirect() と同様の Compute Shader ドリブンなパーティクルのシミュレーションや描画をノードベースで記述できるパッケージです。Shader Graph と連携できる機能もあるのですが、残念ながら Sprite Lit ではノーマルの描画が行われずうまく動かせませんでした。

BatchRendererGroup (DOTS Instancing)

SRP Batcher と互換性のある GPU インスタンシング API として BatchRendererGroup が提供されています。これは DOTS Instancing という名前でも呼ばれています。こちらも Shader Graph の Sprite Lit シェーダーはそのままでは動作しませんでしたが、少しだけシェーダーを改変すれば対応できたため、最終的に BatchRendererGroup を採用しました。

BatchRendererGroup の使い方

Unity Blog の記事 で API としての特徴が一通り説明されています。

BatchRendererGroup (DOTS Instancing) では、Per Instance なデータも Per Material なデータも共通の GraphicsBuffer に格納してシェーダーから参照させることができます。この GraphicsBuffer をユーザーが手動で構築して永続化し、自由にデータをレイアウトできるようになっていることで Graphics.RenderMeshIndirect() よりも最適化の幅が広がっているようです。

この記事でもざっくりと BatchRendererGroup の使い方を紹介します。より詳しくは マニュアル を見るか、サンプルプロジェクト を見るのがおすすめです。

1. プロジェクトの設定

いくつか事前に必要な設定があります。

  • SRP Batcher の有効化
  • Project Settings > Graphics > BatchRendererGroup variantsKeep all にする

詳しくはこちらを確認してください。

https://docs.unity3d.com/ja/2023.2/Manual/batch-renderer-group-getting-started.html

2. BatchRendererGroup の作成

BatchRendererGroup brg = new BatchRendererGroup((_, ctx, cullOutput, _) =>
{
    // のちのちここで描画コマンドの発行を行う
    return new JobHandle();
});

BatchMeshID meshId = _brg.RegisterMesh(_mesh);
BatchMaterialID matId = _brg.RegisterMaterial(_material);

3. GraphicsBufferMetadataValue の更新

各プロパティのデータを GraphicsBuffer に詰め、各プロパティのオフセットをシェーダーに伝えるための MetadataValue を作成します。
Unity のビルトインのプロパティ unity_ObjectToWorld 等についても手動で GraphicsBuffer に詰める必要があります。ここでは unity_ObjectToWorld とカスタムプロパティ _Color を使用しています。

var numInstances = /* ... */;

// 今回は問題ないがアラインメントも考慮が必要
var numElements = ((2 * sizeof(BrgMatrix)) + numInstances * (sizeof(BrgMatrix) + sizeof(float4)) / sizeof(int);
_instanceData = new GraphicsBuffer(GraphicsBuffer.Target.Raw, numElements, sizeof(int));

// GraphicsBuffer のアドレス 0 は未定義のプロパティ参照でアクセスされる可能性があり、安全のためゼロを入れておく
_instanceData.SetData(_zero ??= new[] { Matrix4x4.zero }, 0, 0, 1);

// Per Instance なデータの格納
using var objectToWorldMatrices = new NativeArray<BrgMatrix>(numInstance, Allocator.Persistent);
using var colors = new NativeArray<float4>(numInstance, Allocator.Persistent);

/* objectToWorldMatrices, colors に値を詰める */

// GraphicsBuffer にアップロード
// インデックスは要素サイズ基準なので注意
_instanceData.SetData(objectToWorldMatrices, 0, 2, numInstances);
_instanceData.SetData(colors, 0, (2 + numInstances) * sizeof(BrgMatrix) / sizeof(float4), numInstances);

// 各プロパティの格納されているオフセットをシェーダーに伝えるための Metadata を構築する
// オフセットの最上位ビットを立てると Per Instance な連続したデータとして認識される
// 最上位ビットをクリアすると uniform なデータになり全てのインスタンスでそのオフセットが参照される
var metadata = new NativeArray<MetadataValue>(2, Allocator.Temp);
metadata[0] = new(){ NameID = Shader.PropertyToID("unity_ObjectToWorld"), Value = 0x80000000 | (2 * sizeof(BrgMatrix)) };
metadata[1] = new(){ NameID = Shader.PropertyToID("_Color"), Value = 0x80000000 | ((2 + numInstances) * sizeof(BrgMatrix)) };

// バッチの追加
BatchID batchId = brg.AddBatch(metadata, _instanceData.bufferHandle);

// _instanceData のサイズや Metadata に変化がある場合はバッチを消して再度追加する
// brg.RemoveBatch(batchId);

// BRG では 4x4 ではなく 4x3 の行列を使って帯域を節約する
public unsafe struct BrgMatrix
{
    private fixed float _value[12];

    public BrgMatrix(Matrix4x4 m)
    {
        _value[0] = m.m00;
        _value[1] = m.m10;
        _value[2] = m.m20;
        _value[3] = m.m01;
        _value[4] = m.m11;
        _value[5] = m.m21;
        _value[6] = m.m02;
        _value[7] = m.m12;
        _value[8] = m.m22;
        _value[9] = m.m03;
        _value[10] = m.m13;
        _value[11] = m.m23;
    }
}

4. 描画コマンドの発行

BatchRendererGroup のコンストラクタに戻って、引数の cullingCallback を実装していきます。

このコールバックは戻り値が JobHandle になっており Job System に投げたジョブを非同期で完了させることが可能ですが、今回は普通にメインスレッドで処理してしまいます。また、必要に応じてここでカリングを行いますが、今回は省略します。Frustum Culling すら自動的には行われないので最終的にはきちんと実装しましょう。

brg = new BatchRendererGroup((_, _, cullOutput, _) =>
{
    if (numInstances <= 0) return new JobHandle();

    unsafe
    {
        ref var commands =
            ref UnsafeUtility.AsRef<BatchCullingOutputDrawCommands>(cullOutput.drawCommands.GetUnsafePtr());

        var alignment = UnsafeUtility.AlignOf<long>();

        // TempJob で確保すれば勝手に解放される
        commands.drawCommands = (BatchDrawCommand*)UnsafeUtility.Malloc(
            UnsafeUtility.SizeOf<BatchDrawCommand>(), alignment,
            Allocator.TempJob);
        commands.drawRanges = (BatchDrawRange*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<BatchDrawRange>(),
            alignment, Allocator.TempJob);
        commands.visibleInstances =
            (int*)UnsafeUtility.Malloc(numInstances * sizeof(int), alignment, Allocator.TempJob);
        commands.drawCommandPickingInstanceIDs = (int*)UnsafeUtility.Malloc(1 * sizeof(int), alignment, Allocator.TempJob);

        commands.drawCommandCount = 1;
        commands.drawRangeCount = 1;
        commands.visibleInstanceCount = numInstances;

        commands.instanceSortingPositions = null;
        commands.instanceSortingPositionFloatCount = 0;

        // UnsafeUtility.Malloc() はゼロ初期化の保証がないので、うっかり Uninitialized なデータを渡すとクラッシュしうる(1敗)。BatchDrawCommandのコンストラクタを使えば大丈夫
        commands.drawCommands[0] = new BatchDrawCommand
        {
            flags = BatchDrawCommandFlags.None,
            batchID = batchId,
            materialID = matId,
            splitVisibilityMask = 0xFF,
            lightmapIndex = 0,
            sortingPosition = 0,
            visibleOffset = 0,
            visibleCount = (uint)numInstances,
            meshID = meshId,
            submeshIndex = 0
        };

        commands.drawRanges[0] = new BatchDrawRange
        {
            drawCommandsType = BatchDrawCommandType.Direct,
            drawCommandsBegin = 0,
            drawCommandsCount = 1,
            filterSettings = new BatchFilterSettings()
            {
                renderingLayerMask = uint.MaxValue,
                layer = (byte)gameObject.layer,
                motionMode = MotionVectorGenerationMode.ForceNoMotion,
                receiveShadows = false,
                shadowCastingMode = ShadowCastingMode.Off,
                allDepthSorted = false,
                staticShadowCaster = false
            }
        };

        // どのインスタンスを描画するか決定できる。GraphicsBufferのサイズを変えなくても各インスタンスの可視性を制御できて効率がいい
        for (var i = 0; i < numInstances; i++)
        {
            commands.visibleInstances[i] = i;
        }

        commands.drawCommandPickingInstanceIDs[0] = GetInstanceID();
    }

    return new JobHandle();
}, (nint)0);

Sprite Lit シェーダーを DOTS Instancing に対応させる

ここまでは普通の BatchRendererGroup の使い方ですが、今回は 2D かつライティング付きなので、Shader Graph の Sprite Lit シェーダーを使う必要がある点で特殊です。

試しにこのコードのまま Sprite Lit シェーダーを使おうとするといくつかの問題が発生します。順番に見ていきます。

DOTS_INSTANCING_ON キーワードがない

A BatchDrawCommand is using the pass "Sprite Lit" from the shader "Shader Graphs/Grass0" which does not define a DOTS_INSTANCING_ON variant.
This is not supported when rendering with a BatchRendererGroup (or Entities Graphics). MaterialID: 1 ("Grass0"), MeshID: 1 (""), BatchID: 1.

DOTS_INSTANCING_ON という variant がないと怒られます。普通の URP Lit シェーダーだとこの問題は起きないのですが Sprite だと標準では対応されていないようです。

これを解決するには、Packages/com.unity.render-pipelines.universal/ShaderLibrary/DOTS.hlsl#include_with_pragmas します。

#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DOTS.hlsl"

unity_SpriteProps がない

上記のincludeを追加すると別のエラーが発生します。

Shader error in 'Shader Graphs/Grass0': undeclared identifier 'unity_SpriteProps' at Packages/com.unity.render-pipelines.universal/Editor/2D/ShaderGraph/Includes/SpriteUnlitPass.hlsl(13) (on d3d11)

unity_SpriteProps は SpriteRenderer で設定できる Flip にあたるプロパティで、通常は UnityPerDraw という cbuffer に定義されていますが、DOTS_INSTANCING_ON が有効だと定義されません。

https://github.com/Unity-Technologies/Graphics/blob/a09c468ac8130c638e2144b86c8f0af4a84baadf/Packages/com.unity.render-pipelines.universal/ShaderLibrary/UnityInput.hlsl#L174

今回は Flip を使用しないので適当な値で強引に上書きしてしまいます。

#if defined(DOTS_INSTANCING_ON)
#define unity_SpriteProps float4(1.0, 1.0, 0.0, 0.0)
#endif

unity_SpriteColor がない

Shader error in 'Shader Graphs/Grass0': undeclared identifier 'unity_SpriteColor' at Packages/com.unity.render-pipelines.universal/Editor/2D/ShaderGraph/Includes/SpriteUnlitPass.hlsl(15) (on d3d11)

unity_SpriteColor は SpriteRenderer で設定される color にあたるプロパティです。こちらも同様に通常は UnityPerDraw で定義されますが DOTS_INSTANCING_ON が有効だと定義されません。

今回はインスタンスごとに色をセットできるようにしてみます。BatchRendererGroup で GraphicsBuffer に詰めたプロパティにシェーダーでアクセスする方法がここで説明されています。

https://github.com/Unity-Technologies/Graphics/blob/a09c468ac8130c638e2144b86c8f0af4a84baadf/Packages/com.unity.render-pipelines.high-definition/Documentation~/dots-instancing-shaders-declare.md

UNITY_DOTS_INSTANCING_START UNITY_DOTS_INSTANCING_END というマクロで囲ったブロックにプロパティを定義する必要があり、さらに使用できるブロック名が3つあると書かれています。また、同じ名前のブロックは重複して使えません。

  • BuiltinPropertyMetadata
  • MaterialPropertyMetadata
  • UserPropertyMetadata

Shader Graph で作成したプロパティは MaterialPropertyMetadata で定義されるので、それとの重複を避けるため BuiltinPropertyMetadata を使用したいところです。しかし Sprite Lit で生成されるシェーダーでは既に BuiltinPropertyMetadata が宣言されています。次の部分です:

https://github.com/Unity-Technologies/Graphics/blob/a09c468ac8130c638e2144b86c8f0af4a84baadf/Packages/com.unity.render-pipelines.universal/ShaderLibrary/UniversalDOTSInstancing.hlsl#L11-L23

実は、この中で実際に Sprite Lit で使われているのは unity_ObjectToWorld だけです。では使われていないいずれかの float4, 例えば unity_SpecCube0_HDRunity_SpriteColor として使ってしまうのはどうでしょうか?

#if defined(DOTS_INSTANCING_ON)
#define unity_SpriteColor unity_SpecCube0_HDR
#endif

ということで、このように記述しつつ BatchRendererGroup 側で unity_SpecCube0_HDR にインスタンスの色をセットしてみたところ、エラーはなくなり描画が行われるようになりました。

Shader Graph の生成シェーダーを改造する

ここまでの改変をまとめると、次のような HLSL ファイルを Shader Graph で include すればよさそうです。

#ifndef EGO_SPRITEBATCH_INCLUDED
#define EGO_SPRITEBATCH_INCLUDED

#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DOTS.hlsl"

#if defined(DOTS_INSTANCING_ON)
#define unity_SpriteColor unity_SpecCube0_HDR
#define unity_SpriteProps float4(1.0, 1.0, 0.0, 0.0)
#endif

#endif

Custom Function ノードなどを使えばバニラな URP でもどうにか実現できるかもと思いましたが、今回は URP を改造できる前提なので手早く改造してしまうことにしました。だいたい このあたり に少しコードを足すだけで include を増やすことができます。

Shader Graph で DOTS Instancing に対応したプロパティを定義する

Shader Graph の GUI 上でカスタムプロパティを定義する際、Node Settings で ScopeHybrid Per Instance に設定する必要があります。

描画タイミングを制御する

これでいったん描画が行われるようになりましたが、まだ問題があります。

一つ目の問題は描画順が制御できていないことです。通常スプライトは Sorting LayerOrder in Layer を使って描画タイミングを制御しますが、BatchRendererGroup にはそれらを設定する API が(おそらく)ありません。

二つ目は、描画が複数回重複して行われていることです。2D Renderer ではライティングの状況に応じて Sorting Layer ごとに描画コマンド(DrawRenderers)が分割されるのですが、Frame Debugger を確認すると分割された DrawRenderers の呼び出しごとに同じ BatchRendererGroup の描画が実行されてしまっています。

どうやら BatchRendererGroup は Sorting Layer を設定できないばかりか、Sorting Layer による Renderer のフィルタリング自体が機能しないようです。おそらく Transparent キューでの利用をあまり想定されていないのでしょう。

いろいろ試したところ Sorting Layer によるフィルタは無理でしたが、Layer によるフィルタは可能でした。BatchRendererGroup のコンストラクタに渡した cullingCallback の中で BatchCullingOutput.drawCommands[i].drawRanges[i].filterSettings として BatchFilterSettings を渡しますが、そこに layer フィールドがあります。ここに専用の Layer を新設して指定してしまいます。

commands.drawRanges[0] = new BatchDrawRange
{
    drawCommandsType = BatchDrawCommandType.Direct,
    drawCommandsBegin = 0,
    drawCommandsCount = 1,
    filterSettings = new BatchFilterSettings()
    {
        renderingLayerMask = uint.MaxValue,
        layer = (byte)gameObject.layer,
        motionMode = MotionVectorGenerationMode.ForceNoMotion,
        receiveShadows = false,
        shadowCastingMode = ShadowCastingMode.Off,
        allDepthSorted = false,
        staticShadowCaster = false,
    }
};

これを利用して特定の Sorting Layer の直前あるいは直後に BatchRendererGroup の描画が実行されるように 2D Renderer を改造して対応しました。

これですべての問題が解決し、正しく描画が行われるようになりました。

既存のライトシステムと統合できているのでライティング条件が変わっても大丈夫です。

ちょっとわかりにくいですが Normal も描けています。

感想

振り返ると、結局 Sorting Layer による描画順制御に対応した GPU インスタンシング API は VFX Graph のほかに存在せず、その VFX Graph も Sprite Lit ではいまいち動かないため今回のような BatchRendererGroup と URP 改造を使った手段に頼らざるを得ませんでした。もうちょっとすんなりできるようになっているとうれしいですね。

BatchRendererGroup の API を眺めているとどうやら IndirectProcedural な描画も行えるような雰囲気があるので、Indirect を使って更なる CPU 負荷の削減を図ってみたいですね。ただ、Indirect になるといよいよサンプルコードがなく、GPU Resident Drawer の中でちょっと使われるらしいことくらいしかわからないため、手探り度は高まりそうです。

Discussion