ノートの端の書き残し

UnityやらC#やら。設計が得意かもしれない。

BatchRendererGroup

出力

これは何

DOTSで使われている描画システムのようです。 同じメッシュ、マテリアルであればインスタンス描画を行うのが基本ですが、DOTSの場合同様のデータ構造はメモリ上でも綺麗に並べますから、それをGPU側でも使用するという発想と思われます。 カリングと描画コマンドの発行の部分をアプリケーションプログラマがカスタマイズし、軽量化を図ることを目的としているようです。

コード

docs.unity3d.com

公式ドキュメントに載っているコードの断片をつなぎ合わせたもの。 Unity6000.2.28f1のエディタ上で動作確認。

using System;
using System.Linq;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Jobs;
using UnityEngine;
using UnityEngine.Rendering;

public sealed class SimpleBRGExample : MonoBehaviour
{
    [SerializeField] private Mesh mesh;
    [SerializeField] private Material material;

    [Header("Count")]
    [SerializeField] private int countX = 10;
    [SerializeField] private int countZ = 10;
    [SerializeField] private float spacing = 2f;

    private BatchRendererGroup m_BRG;

    private GraphicsBuffer m_InstanceData;
    private BatchID m_BatchID;
    private BatchMeshID m_MeshID;
    private BatchMaterialID m_MaterialID;

    // 計算を便利にするいくつかのヘルパー定数。
    private const int kSizeOfMatrix = sizeof(float) * 4 * 4;
    private const int kSizeOfPackedMatrix = sizeof(float) * 4 * 3;
    private const int kSizeOfFloat4 = sizeof(float) * 4;
    private const int kBytesPerInstance = (kSizeOfPackedMatrix * 2) + kSizeOfFloat4;
    private const int kExtraBytes = kSizeOfMatrix * 2;

    private int kNumInstances;

    // PackedMatrix は、行列を Unity 提供の SRP シェーダーが想定する形式に変換する
    // 便利な型です。
    struct PackedMatrix
    {
        public float c0x, c0y, c0z;
        public float c1x, c1y, c1z;
        public float c2x, c2y, c2z;
        public float c3x, c3y, c3z;

        public PackedMatrix(Matrix4x4 m)
        {
            c0x = m.m00; c0y = m.m10; c0z = m.m20;
            c1x = m.m01; c1y = m.m11; c1z = m.m21;
            c2x = m.m02; c2y = m.m12; c2z = m.m22;
            c3x = m.m03; c3y = m.m13; c3z = m.m23;
        }
    }

    private void OnEnable()
    {
        m_BRG = new BatchRendererGroup(this.OnPerformCulling, IntPtr.Zero);
        m_MeshID = m_BRG.RegisterMesh(mesh);
        m_MaterialID = m_BRG.RegisterMaterial(material);

        kNumInstances = countX * countZ;

        AllocateInstanceDateBuffer();
        PopulateInstanceDataBuffer();
    }

    private void AllocateInstanceDateBuffer()
    {
        m_InstanceData = new GraphicsBuffer(GraphicsBuffer.Target.Raw,
            BufferCountForInstances(kBytesPerInstance, kNumInstances, kExtraBytes),
            sizeof(int));
    }

    private void PopulateInstanceDataBuffer()
    {
        // インスタンスデータバッファの先頭にゼロ行列を置くことで、アドレス 0 からのロードが 0 を返すようにします。
        var zero = new Matrix4x4[1] { Matrix4x4.zero };

        var matrices = new Matrix4x4[kNumInstances];
        int k = 0;
        for (int z = 0; z < countZ; z++)
        {
            for (int x = 0; x < countX; x++)
            {
                matrices[k++] = Matrix4x4.Translate(new Vector3(x * spacing, 0, z * spacing));
            }
        }

        // トランスフォーム行列を、シェーダーの想定するパックされた形式に変換します。
        var objectToWorld = matrices.Select(m => new PackedMatrix(m)).ToArray();

        // パックされた形式の逆行列も作成します。
        var worldToObject = matrices.Select(m => new PackedMatrix(m.inverse)).ToArray();

        // すべてのインスタンスに固有の色を持たせます。
        var colors = Enumerable.Range(0, kNumInstances)
        .Select(_ => new Color(
            UnityEngine.Random.Range(0, 1f),
            UnityEngine.Random.Range(0, 1f),
            UnityEngine.Random.Range(0, 1f),
            1
        )).ToArray();

        // この単純な例では、インスタンスデータは以下のようにバッファ内に配置されます。
        // オフセット | 説明
        //      0 | 64 バイトの 0。つまりアドレス 0 からの読み込みは 0 を返す。
        //     64 | 初期化されていない 32 バイト。SetData を扱いやすくするもので、それ以外には不要。
        //     96 | unity_ObjectToWorld。3 つのパックされた float3x4 行列。
        //    240 | unity_WorldToObject。3 つのパックされた float3x4 行列。
        //    384 | _BaseColor。3 つの float4。

        // インスタンス化された各種プロパティーの開始アドレスを計算します。unity_ObjectToWorld は
        // アドレス 64 ではなくアドレス 96 から開始するので、32 ビットが初期化されずに残ります。これは、
        // computeBufferStartIndex パラメーターが、"開始オフセットがソース配列の要素の型のサイズで割り切れる"
        // ことを必要とするためです。ここでは、これは PackedMatrix のサイズ、つまり 48 です。
        uint byteAddressObjectToWorld = kSizeOfPackedMatrix * 2;
        uint byteAddressWorldToObject = byteAddressObjectToWorld + kSizeOfPackedMatrix * (uint)kNumInstances;
        uint byteAddressColor = byteAddressWorldToObject + kSizeOfPackedMatrix * (uint)kNumInstances;

        // インスタンスデータを GraphicsBuffer にアップロードしてシェーダーがそれらを読み込めるようにします。
        m_InstanceData.SetData(zero, 0, 0, 1);
        m_InstanceData.SetData(objectToWorld, 0, (int)(byteAddressObjectToWorld / kSizeOfPackedMatrix), objectToWorld.Length);
        m_InstanceData.SetData(worldToObject, 0, (int)(byteAddressWorldToObject / kSizeOfPackedMatrix), worldToObject.Length);
        m_InstanceData.SetData(colors, 0, (int)(byteAddressColor / kSizeOfFloat4), colors.Length);

        // Set up metadata values to point to the instance data. Set the most significant bit 0x80000000 in each
        // このインスタンスデータを指すメタデータ値を設定します。それぞれ最上位ビット 0x80000000 を設定します。
        // これは  "このデータは、インスタンスインデックスによってインデックスされる、インスタンス毎に 1 つの値を持つ配列である" とシェーダーに指示します。
        // シェーダーが使用するメタデータ値でここに設定されていないものはすべて 0 になります。このような値が
        // UNITY_ACCESS_DOTS_INSTANCED_PROP (つまりデフォルトなし) に使用された場合、シェーダーは
        // 0x00000000 のメタデータ値を解釈してバッファの先頭から読み込みます。バッファの先頭はゼロ行列なので、
        // このような読み込みは必ず 0 を返します。これは合理的なデフォルト値です。
        var metadata = new NativeArray<MetadataValue>(3, Allocator.Temp);
        metadata[0] = new MetadataValue { NameID = Shader.PropertyToID("unity_ObjectToWorld"), Value = 0x80000000 | byteAddressObjectToWorld, };
        metadata[1] = new MetadataValue { NameID = Shader.PropertyToID("unity_WorldToObject"), Value = 0x80000000 | byteAddressWorldToObject, };
        metadata[2] = new MetadataValue { NameID = Shader.PropertyToID("_BaseColor"), Value = 0x80000000 | byteAddressColor, };

        // 最後に、これらのインスタンスのバッチを作成し、そのバッチに、インスタンスデータを持つ GraphicsBuffer と
        // プロパティの場所を指定するメタデータ値を使用させます。
        m_BatchID = m_BRG.AddBatch(metadata, m_InstanceData.bufferHandle);
    }

    // Raw バッファは int で割り当てられます。これはデータに必要な int の数を計算する
    // ユーティリティメソッドです。
    int BufferCountForInstances(int bytesPerInstance, int numInstances, int extraBytes = 0)
    {
        // バイト数を int の倍数に丸めます。
        bytesPerInstance = (bytesPerInstance + sizeof(int) - 1) / sizeof(int) * sizeof(int);
        extraBytes = (extraBytes + sizeof(int) - 1) / sizeof(int) * sizeof(int);
        int totalBytes = bytesPerInstance * numInstances + extraBytes;
        return totalBytes / sizeof(int);
    }

    private void OnDisable()
    {
        m_BRG.Dispose();
        m_InstanceData.Dispose();
    }

    public unsafe JobHandle OnPerformCulling(BatchRendererGroup rendererGroup, BatchCullingContext cullingContext, BatchCullingOutput cullingOutput, IntPtr userContext)
    {
        // UnsafeUtility.Malloc() はアラインメントを必要とするので、
        // (合理的なデフォルトとして) 最大の int 型のアラインメントを使用してください。
        int alignment = UnsafeUtility.AlignOf<long>();

        // BatchCullingOutputDrawCommands 構造体へのポインターを取得し、
        // それを簡単に直接変更できるようにします。
        var drawCommands = (BatchCullingOutputDrawCommands*)cullingOutput.drawCommands.GetUnsafePtr();

        // 出力配列にメモリを割り当てます。より複雑な実装では、
        // 何が可視であるかに応じて動的に割り当てるメモリのサイズを計算します。
        // この例では、すべてのインスタンスが可視であると仮定するので、
        // そのそれぞれにメモリを割り当てます。必要な割り当ては以下の通りです。
        // - 単一の描画コマンド (kNumInstances インスタンスを描画する)
        // - 単一の描画範囲 (単一の描画コマンドをカバーする)
        // - kNumInstances 可視インスタンスのインデックス
        // 必ず Allocator.TempJob を使用して配列を割り当てる必要があります。
        drawCommands->drawCommands = (BatchDrawCommand*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<BatchDrawCommand>(), alignment, Allocator.TempJob);
        drawCommands->drawRanges = (BatchDrawRange*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<BatchDrawRange>(), alignment, Allocator.TempJob);
        drawCommands->visibleInstances = (int*)UnsafeUtility.Malloc(kNumInstances * sizeof(int), alignment, Allocator.TempJob);
        drawCommands->drawCommandPickingInstanceIDs = null;

        drawCommands->drawCommandCount = 1;
        drawCommands->drawRangeCount = 1;
        drawCommands->visibleInstanceCount = kNumInstances;

        // この例ではデプスソートを使用していないため、instanceSortingPositions は null のままになります。
        drawCommands->instanceSortingPositions = null;
        drawCommands->instanceSortingPositionFloatCount = 0;

        // この単一の描画コマンドを、"配列内でオフセット 0 から開始し、Start() メソッド内に登録されたバッチ ID と
        // マテリアル ID とメッシュ ID を使用して kNumInstances インスタンスを描画する" ように設定します。
        // 特別なフラグは設定しません。
        drawCommands->drawCommands[0].visibleOffset = 0;
        drawCommands->drawCommands[0].visibleCount = (uint)kNumInstances;
        drawCommands->drawCommands[0].batchID = m_BatchID;
        drawCommands->drawCommands[0].materialID = m_MaterialID;
        drawCommands->drawCommands[0].meshID = m_MeshID;
        drawCommands->drawCommands[0].submeshIndex = 0;
        drawCommands->drawCommands[0].splitVisibilityMask = 0xff;
        drawCommands->drawCommands[0].flags = 0;
        drawCommands->drawCommands[0].sortingPosition = 0;

        // オフセット 0 にあるこの単一の描画コマンドをカバーするように描画範囲を設定します。
        // is at offset 0.
        drawCommands->drawRanges[0].drawCommandsBegin = 0;
        drawCommands->drawRanges[0].drawCommandsCount = 1;

        // この例ではシャドウやモーションベクトルは考慮してないので、すべてがデフォルトの 0 の値のままになります。
        // ただし、すべての 1 に設定される renderingLayerMask は除きます。これによって
        // Unity がマスクの設定に関わらずインスタンスをレンダーするようになります。
        drawCommands->drawRanges[0].filterSettings = new BatchFilterSettings
        {
            renderingLayerMask = 1,
            layer = 0,
            motionMode = MotionVectorGenerationMode.Camera,
            shadowCastingMode = ShadowCastingMode.On,
            receiveShadows = true,
            staticShadowCaster = false,
            allDepthSorted = false
        };

        drawCommands->drawRanges[0].drawCommandsType = BatchDrawCommandType.Direct;

        // 最後に、実際の可視のインスタンスのインデックスを配列に書き込みます。
        // より複雑な実装では、この出力は何か可視であるかによって変わりますが、
        // この例ではすべてが可視であると仮定しています。
        for (int i = 0; i < kNumInstances; ++i)
        {
            drawCommands->visibleInstances[i] = i;
        }

        // この単純な例はジョブを使用しないので、空の JobHandle を返します。
        // パフォーマンス負荷の高いアプリケーションの場合は、Burst ジョブを使用して
        // カリングと描画コマンドの出力を実装することが推奨されます。その場合、この関数は
        // Burst ジョブの終了時に完了するハンドルをここに返します。
        return new JobHandle();
    }
}

所感

Indirect描画も可能なようですが、サンプルコードが見当たらず、難航しています。 正直ここまでデータ構造を整理するならばIndirect描画してしまったほうがよくないかと思います。

RenderMeshIndirect等と比べ、レンダリングパイプラインに組み込める、というのは確かに正しい気はするものの、BatchRendererGroupはエンジン側のサポートがまだ貧弱で、業務で使うようなものではないかな、という風に思っています。

発展に期待というところでしょうか。