🐡

【Unity】オブジェクト大量描画のスタート地点に立とう

2024/10/02に公開

概要:無知の状態から、大量描画で使う基礎を知り、自走できるようになろう

同じようなオブジェクトがとにかく画面に沢山ある、という絵作りができる初歩までの自分が学んだことをまとめます。
ここでの最終出力物です。↓(オブジェクト数10万。OBSで録画してたらちょっと重くなっちゃった)
通常時はrender thread0.7msくらいでした。
https://youtu.be/FgXdH-f60DU

環境

Unity: 2022.3.22f1
URP: 14.0.10
GPU RTX 3080Ti

取り上げる項目

  1. Graphics.RenderMesh*Graphics.DrawMesh*の違い
  2. Graphics.RenderMeshInstance
  3. Graphics.RenderMeshIndirect
  4. ComputeShader
  5. GraphicsBuffer

その他(実装の都合出てくるもの)
a. URP向けに、影付きでGraphics.RenderMeshIndirectするためのシェーダ
b. JobSystem

Graphics.Render系とGraphics.Draw系は、とりあえずRender系を使えばいい

メッシュのレンダリングを指示するメソッドは、GraphicsクラスにRenderMesh~という色々と、DrawMesh~という色々があります。
今回はGraphics.RenderMeshInstancedGraphics.RenderMeshIndirectという2つのメソッドを取り上げるのですが、Graphics.DrawMeshInstanced, Graphics.DrawMeshInstancedIndirectという似た名前のメソッドもあります。
参考資料の動画(RenderMeshAPI~)で高橋啓治郎さんが軽く経緯を含め説明してくれています。Draw系が低レベルAPI、Render系が高レベルAPI、つまりRender系のほうが使いやすいので、基本的にはRender系を使っておけばいいです。理解を深めてプロになったらDraw系を使いましょう。

Graphics.DrawMeshInstancedIndirect → Graphics.RenderMeshIndirect
Graphics.DrawMeshInstanced → Graphics.RenderMeshInstanced

RenderMeshInstanced

RenderMeshInstancedの特徴は、「C#側で座標、回転、スケールを指定して実行する」ところです。

Graphics.RenderMeshInstanced(
            renderParams,     // MeshRendererで指定するようなのをまとめた構造体
            _mesh,            // Mesh
            0,                // SubMeshIndex
            job.Matrix4X4S); // Matrix4x4

フルでコードを載せます。
シェーダはUnlitみたいな単色返すだけですが、オブジェクトの動きはこの記事冒頭の波みたいな動画と全く同じです。

.cs
using System.Runtime.InteropServices;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.Rendering;

/// <summary>
/// RenderMeshInstancedで頑張る
/// </summary>
public sealed class RenderMeshA : MonoBehaviour
{
    private struct UpdatePositionJob : IJobParallelFor
    {
        [WriteOnly] public NativeArray<Matrix4x4> Matrix4X4S;
        [ReadOnly] public NativeArray<Vector3> Positions;
        [ReadOnly] public float Time;
        [ReadOnly] public float Speed;
        [ReadOnly] public float Gap;

        public void Execute(int index)
        {
            var position = Positions[index];
            var deg = Time * 360 * Speed + position.magnitude * Gap;
            var height = math.sin(deg * Mathf.Deg2Rad);
            Matrix4X4S[index] = Matrix4x4.Translate(new Vector3(position.x, height, position.z));
        }
    }
    
    [SerializeField] private MeshFilter _meshFilter;
    [SerializeField] private MeshRenderer _meshRenderer;

    [SerializeField, Range(0.01f, 2)] private float _speed;
    [SerializeField, Range(0, 360)] private float _gap;
    
    [SerializeField] private ShadowCastingMode _shadowCastingMode = ShadowCastingMode.Off;
    [SerializeField] private bool _receiveShadows;

    private NativeArray<Vector3> _basePositions;
    private NativeArray<Matrix4x4> _matrix4X4S;
    private NativeArray<Color> _colors;
    
    private GraphicsBuffer _colorsBuffer;
    
    private Mesh _mesh;

    private const int XCount = 200;
    private const int YCount = 200;
    
    private const int TotalCount = XCount * YCount;
    
    private Material _material;

    private void Start()
    {
        var startPosition = -XCount / 2;
        _basePositions = new NativeArray<Vector3>(TotalCount, Allocator.Persistent);
        _matrix4X4S = new NativeArray<Matrix4x4>(TotalCount, Allocator.Persistent);
        _colors = new NativeArray<Color>(TotalCount, Allocator.Persistent);

        for (int i = 0; i < XCount; i++)
        {
            for (int j = 0; j < YCount; j++)
            {
                var position = new Vector3(startPosition + i, 0, startPosition + j);
                _basePositions[i * XCount + j] = position;
                _colors[i * XCount + j] = GetRandomColor();
            }
        }

        _mesh = _meshFilter.sharedMesh;
        
        _colorsBuffer  = new GraphicsBuffer(GraphicsBuffer.Target.Structured, TotalCount, Marshal.SizeOf<Color>());
        _colorsBuffer.SetData(_colors);

        _material = _meshRenderer.sharedMaterial;

        _material.SetBuffer("_Colors", _colorsBuffer);
    }

    private void Update()
    {
        var job = new UpdatePositionJob()
        {
            Matrix4X4S = _matrix4X4S,
            Positions = _basePositions,
            Time = Time.time,
            Speed = _speed,
            Gap = _gap
        };
        var jobHandle = job.Schedule(TotalCount, 8);
        jobHandle.Complete();
        
        var renderParams = new RenderParams
        {
            shadowCastingMode = _shadowCastingMode,
            receiveShadows = _receiveShadows,
            material = _material
        };

        Graphics.RenderMeshInstanced(renderParams, 
            _mesh, 
            0, 
            job.Matrix4X4S, 
            TotalCount);
    }

    private void OnDestroy()
    {
        _basePositions.Dispose();
        _matrix4X4S.Dispose();
        _colors.Dispose();
    }

    private Vector4 GetRandomColor()
    {
        var r = UnityEngine.Random.Range(0f, 1f);
        var g = UnityEngine.Random.Range(0f, 1f);
        var b = UnityEngine.Random.Range(0f, 1f);
        return new Vector4(r, g, b, 1f);
    }
}
.shader
Shader "Custom/RenderMeshInstanced"
{
    Properties
    {
    }
    SubShader
    {
        Tags
        {
            "RenderType"="Opaque"
        }
        LOD 200
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing

            #include "UnityInstancing.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                uint instanceId : SV_InstanceID;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float4 color : COLOR;
            };

            uniform StructuredBuffer<float4> _Colors;

            v2f vert(appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);

                // インスタンスIDを使ってバッファから自分用の色を取得
                float4 color = _Colors[v.instanceId];
                o.color = color;
                o.pos = UnityObjectToClipPos(v.vertex);
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                return i.color;
            }
            ENDCG
        }
    }
}

ポイント1: GPU Instancingを有効にする

シェーダ側に、#pragma multi_compile_instancingを書きましょう。マテリアル側にEnable GPU Instancingというチェックボックスが出てくるので、これを有効化することでRenderMeshInstancedで使えるようになります。

ポイント2: GraphicsBufferを使って、GPU側に各オブジェクトの色を渡す

ただRenderMeshInstancedを使うだけだと、全てのオブジェクトが同じマテリアルで全く同じ見た目で描画されてしまいます。それでは面白くないので、オブジェクト1つ1つ用のパラメータを渡したいですね。
そういうときにはGraphicsBufferを使用します。
C#側で、Graphics.Target.Structuredを指定して、連続するメモリ領域であるNativeArrayGraphicsBufferクラスで包んでやると、GPU側ではStructuredBuffer<T>という型で受け取れます。シェーダのSetFloatとかと同じ感じですね。とはいえサイズが大きいので、転送が結構なコストであることは注意が必要です。

_colors = new NativeArray<Color>(TotalCount, Allocator.Persistent);
_colorsBuffer  = new GraphicsBuffer(GraphicsBuffer.Target.Structured,
    TotalCount,
    Marshal.SizeOf<Color>()); // GPUに、受け渡しするメモリサイズを正確に教える必要がある
_colorsBuffer.SetData(_colors); // GPUとやりとりするためにGraphicsBufferに包む
_material.SetBuffer("_Colors", _colorsBuffer); // マテリアルが参照できるよう渡す
uniform StructuredBuffer<float4> _Colors; // ここに入ってる

ポイント3: 頂点シェーダの引数の構造体にInstanceIDを追加して、受け取れるようにする

struct appdata
{
    float4 vertex : POSITION;
    uint instanceId : SV_InstanceID;  // IDが入るセマンティックSV_InstanceID
    UNITY_VERTEX_INPUT_INSTANCE_ID    // おまじないマクロ
};

v2f vert(appdata v)
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v); <- これで、v.instanceIdに自分の番号を書きこみ
    float4 color = _Colors[v.instanceId]; // instanceIdは配列インデックスとして使える
    ...

これで、ポイント2で貰った色情報が、ちゃんと各々のオブジェクトに渡されます。

ポイント4: 毎フレーム位置情報を更新して、RenderMeshInstancedに渡そう

手段は何でもいいんですが、今回は、大量の計算を行う一例としてJobSystemを使って毎フレームの位置計算をしましょう。
Matrix4X4Sが計算結果なのでこれだけはWriteOnlyで定義して、Job.CompleteしたらそのままRenderMeshInstancedの引数に渡します。

結果と問題点


25万個の描画で50FPSほど。CPU,GPUともにそこそこ時間がかかっていて、バッチ数も495と多いですね。これはRenderMeshInstancedが、1度のドローコール(バッチ)に1023個までしか描画できないために、25万個一気に描画しておらず、結構小分けに描画命令が送られてしまっているのが一つの原因です。あとは、さすがにJobSystemと言えど25万は数が多すぎますね。
この2つの問題点を、次のRenderMeshIndirectとComputeShaderで改善しましょう。

RenderMeshIndirect + ComputeShader

RenderMeshInstancedでは座標や回転、スケールはC#側から渡していましたが、RenderMeshIndirectではそれすらもシェーダ側で処理します。
まずは全文を載せます。

.cs
using System.Runtime.InteropServices;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Rendering;

public class ComputeIndirect : MonoBehaviour
{
    [SerializeField] private MeshFilter _meshFilter;
    [SerializeField] private MeshRenderer _meshRenderer;
    
    [SerializeField] private ComputeShader _computeShader;

    [SerializeField, Range(0.01f, 2)] private float _speed;
    [SerializeField, Range(0, 360)] private float _gap;

    [SerializeField] private ShadowCastingMode _shadowCastingMode = ShadowCastingMode.On;
    [SerializeField] private bool _receiveShadows = false;

    [SerializeField] private Vector3 _cullingCenter;
    [SerializeField, Range(0, 200)] private float _cullingDistance = 200;
    
    
    
    private NativeArray<Vector3> _updatePositions;
    private NativeArray<Color> _colors;
    
    private GraphicsBuffer.IndirectDrawIndexedArgs[] _drawIndexArgs;
    private GraphicsBuffer _indirectBuffer;
    
    private GraphicsBuffer _positionBuffer;
    private GraphicsBuffer _colorBuffer;
    
    private Mesh _mesh;
    private RenderParams _renderParams;

    private int _kernelId;

    private const int XCount = 500;
    private const int YCount = 500;

    private const int CommandCount = 1;

    private void Start()
    {
        var startPosition = -XCount / 2;
        _updatePositions = new NativeArray<Vector3>(XCount * YCount, Allocator.Persistent);
        _colors = new NativeArray<Color>(XCount * YCount, Allocator.Persistent);
        for (int i = 0; i < XCount; i++)
        {
            for (int j = 0; j < YCount; j++)
            {
                var position = new Vector3(startPosition + i, 0, startPosition + j);
                _updatePositions[i * XCount + j] = position;
                _colors[i * XCount + j] = GetRandomColor();
            }
        }

        _mesh = _meshFilter.sharedMesh;
        
        _drawIndexArgs = new GraphicsBuffer.IndirectDrawIndexedArgs[CommandCount];
        _drawIndexArgs[0].indexCountPerInstance = _mesh.GetIndexCount(0);
        _drawIndexArgs[0].baseVertexIndex = _mesh.GetBaseVertex(0);
        _drawIndexArgs[0].startIndex = _mesh.GetIndexStart(0);
        _drawIndexArgs[0].instanceCount = (uint)_updatePositions.Length;
        _indirectBuffer = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments, CommandCount, GraphicsBuffer.IndirectDrawIndexedArgs.size);
        _indirectBuffer.SetData(_drawIndexArgs);
        
        _positionBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured,
            XCount * YCount, Marshal.SizeOf<Vector3>());
        _positionBuffer.SetData(_updatePositions);
        
        _colorBuffer  = new GraphicsBuffer(GraphicsBuffer.Target.Structured, 
            XCount * YCount, 
            Marshal.SizeOf<Color>());
        _colorBuffer.SetData(_colors);
        
        _meshRenderer.sharedMaterial.SetBuffer("_Positions", _positionBuffer);
        _meshRenderer.sharedMaterial.SetBuffer("_Colors", _colorBuffer);
        
        _kernelId = _computeShader.FindKernel("CSMain");
        _computeShader.SetInt("_width", XCount);
        _computeShader.SetFloat("_amplitude", 2);
    }

    private void Update()
    {
        _computeShader.GetKernelThreadGroupSizes(_kernelId, out var x, out var y, out _);
        
        _computeShader.SetFloat("_time", Time.time * _speed);
        _computeShader.SetFloat("_gap", _gap * Mathf.Deg2Rad);
        _computeShader.SetBuffer(_kernelId, "_Positions", _positionBuffer);
        _computeShader.Dispatch(_kernelId, Mathf.CeilToInt(XCount / (float)x), Mathf.CeilToInt(YCount / (float)y), 1);
        if (_receiveShadows)
        {
            _meshRenderer.material.DisableKeyword("_RECEIVE_SHADOWS_OFF");
        }
        else
        {
            _meshRenderer.material.EnableKeyword("_RECEIVE_SHADOWS_OFF");
        }
        _renderParams = new RenderParams
        {
            shadowCastingMode = _shadowCastingMode,
            receiveShadows = _receiveShadows,
            material = _meshRenderer.sharedMaterial,
            worldBounds = new Bounds(_cullingCenter, Vector3.one * _cullingDistance),
            renderingLayerMask = 1,
        };
        Graphics.RenderMeshIndirect(_renderParams, _mesh, _indirectBuffer);
    }

    private void OnDestroy()
    {
        _positionBuffer.Dispose();
        _updatePositions.Dispose();
        _indirectBuffer.Dispose();
        _colors.Dispose();
        _colorBuffer.Dispose();
    }

    private Color GetRandomColor()
    {
        var r = Random.Range(0f, 1f);
        var g = Random.Range(0f, 1f);
        var b = Random.Range(0f, 1f);
        return new Color(r, g, b, 1f);
    }
}


.shader
Shader "Custom/RenderMeshIndirect"
{
    Properties
    {
        _BaseMap("Texture", 2D) = "white" {}
        _BaseColor("Color", Color) = (1, 1, 1, 1)
        _Metallic("Metallic", Range(0.0, 1.0)) = 0.0
        _Smoothness("Smoothness", Range(0.0, 1.0)) = 0.5
    }

    SubShader
    {
        Tags {"RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "Queue"="Geometry"}

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags {"LightMode" = "UniversalForward"}

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            // Shadow-related pragmas
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
            #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
            #pragma multi_compile _ _ADDITIONAL_LIGHT_SHADOWS
            #pragma multi_compile _ _SHADOWS_SOFT
            #pragma multi_compile _ _RECEIVE_SHADOWS_OFF
            #pragma multi_compile_instancing

            #define UNITY_INDIRECT_DRAW_ARGS IndirectDrawIndexedArgs

            struct Attributes
            {
                float4 positionOS   : POSITION;
                float3 normalOS     : NORMAL;
                float2 uv           : TEXCOORD0;
                uint instanceId : SV_InstanceID;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct Varyings
            {
                float4 positionHCS  : SV_POSITION;
                float2 uv           : TEXCOORD0;
                float3 normalWS     : TEXCOORD1;
                float3 positionWS   : TEXCOORD2;
                float4 color        : COLOR;
            };

            TEXTURE2D(_BaseMap);
            SAMPLER(sampler_BaseMap);

            uniform StructuredBuffer<float3> _Positions;
            uniform StructuredBuffer<float4> _Colors;

            CBUFFER_START(UnityPerMaterial)
                float4 _BaseMap_ST;
                float4 _BaseColor;
                float _Metallic;
                float _Smoothness;
                float _VertexOffsetAmount;
            CBUFFER_END

            Varyings vert(Attributes IN)
            {
                Varyings OUT;
                UNITY_SETUP_INSTANCE_ID(IN);

                float3 position = IN.positionOS.xyz + _Positions[IN.instanceId];
                
                VertexPositionInputs positionInputs = GetVertexPositionInputs(position);
                OUT.positionHCS = positionInputs.positionCS;
                OUT.positionWS = positionInputs.positionWS;

                VertexNormalInputs normalInputs = GetVertexNormalInputs(IN.normalOS);
                OUT.normalWS = normalInputs.normalWS;

                OUT.color = _Colors[IN.instanceId];

                OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap);
                return OUT;
            }

            half4 frag(Varyings IN) : SV_Target
            {
                half4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv);
                half4 color = baseMap * _BaseColor * IN.color;

                float3 normalWS = normalize(IN.normalWS);
                float3 viewDirectionWS = GetWorldSpaceNormalizeViewDir(IN.positionWS);

                InputData inputData = (InputData)0;
                inputData.positionWS = IN.positionWS;
                inputData.normalWS = normalWS;
                inputData.viewDirectionWS = viewDirectionWS;
#if _RECEIVE_SHADOWS_OFF
                inputData.shadowCoord = 0;
#else
                inputData.shadowCoord = TransformWorldToShadowCoord(IN.positionWS);
#endif


                SurfaceData surfaceData = (SurfaceData)0;
                surfaceData.albedo = color.rgb;
                surfaceData.alpha = 1.0;
                surfaceData.metallic = _Metallic;
                surfaceData.smoothness = _Smoothness;
                surfaceData.normalTS = float3(0, 0, 1);

                half4 finalColor = UniversalFragmentPBR(inputData, surfaceData);
                return finalColor;
            }
            ENDHLSL
        }

        // Shadow casting support
        Pass
{
    Name "ShadowCaster"
    Tags{"LightMode" = "ShadowCaster"}

    ZWrite On
    ZTest LEqual

    HLSLPROGRAM
    #pragma vertex ShadowPassVertexCustom
    #pragma fragment ShadowPassFragment
    
    #include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"

    StructuredBuffer<float3> _Positions;

    struct ShadowAttributes
            {
                float4 positionOS   : POSITION;
                float3 normalOS     : NORMAL;
                float2 uv           : TEXCOORD0;
                uint instanceId : SV_InstanceID;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

    Varyings ShadowPassVertexCustom(ShadowAttributes input)
    {
        Varyings output;
        UNITY_SETUP_INSTANCE_ID(input);

        // インスタンスごとの位置を適用
        Attributes buf;
        buf.positionOS.xyz = input.positionOS + _Positions[input.instanceId];
        buf.normalOS = input.normalOS;
        buf.texcoord = input.uv;
        output.positionCS = GetShadowPositionHClip(buf);
        return output;
    }
    ENDHLSL
}
    }
}
.compute
#pragma kernel CSMain

RWStructuredBuffer<float3> _Positions;

uint _width;
float _time;
float _amplitude;
float _gap;

[numthreads(32,32,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
    float3 position = _Positions[id.x * _width + id.y];
    _Positions[id.x * _width + id.y].y = sin(_time + length(position.xz) * _gap) * _amplitude;
}

ポイント1: IndirectDrawIndexedArgsをGPUに渡す

RenderMeshIndirectの引数にはGraphicsBufferがありますが、このGraphicsBufferにはIndirectDrawIndexedArgsというのを渡します。

_drawIndexArgs = new GraphicsBuffer.IndirectDrawIndexedArgs[CommandCount];
_drawIndexArgs[0].indexCountPerInstance = _mesh.GetIndexCount(0);
_drawIndexArgs[0].baseVertexIndex = _mesh.GetBaseVertex(0);
_drawIndexArgs[0].startIndex = _mesh.GetIndexStart(0);
_drawIndexArgs[0].instanceCount = (uint)_updatePositions.Length; // ここに描画数
_indirectBuffer = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments, CommandCount, GraphicsBuffer.IndirectDrawIndexedArgs.size);
_indirectBuffer.SetData(_drawIndexArgs);

Graphics.RenderMeshIndirect(_renderParams, _mesh, _indirectBuffer);

instanceCountだけ、描画したいオブジェクトの総数でカスタマイズして、他はまずはコピペでいいです。いいですっていうか僕もよくわかってないです。

ちなみに、今回使ってないですが、この引数はシェーダ側で参照できます。(GraphicsBufferに包んでいるのでそりゃそうですね)

#define UNITY_INDIRECT_DRAW_ARGS IndirectDrawIndexedArgs

ポイント2: 座標の計算をComputeShaderで行い、GPU内で完結させる

RenderMeshInstancedでは色情報をGraphicsBufferに包んでGPUに渡していました。今回は座標もです。でも、いちいちCPUで計算してGPUに渡していたらすごく無駄ですよね。なので、GPUで計算して、そのまま計算結果がGPUのメモリにあるので、それを読んで描画してもらいます。
そして、GPUで計算するために出てくるのがComputeShaderというわけです。
ComputeShaderは、Projectタブで、Create->Shader->ComuteShaderで作れるアセットです。シェーダはシェーダなんですが、Unityでは、ComputeShaderという形式のアセットがあるので、あんまりシェーダ感は無いです。

ComputeShaderの使い方

ComputeShaderクラスの参照は、SerializeFieldにでも入れて普通に取れます。
あとはプラグインのメソッドを呼び出すような感覚ですね。メソッド名からカーネル番号を取れるので、その番号をメソッド呼び出しみたいに使います。
ComputeShader内で

RWStructuredBuffer<float3> _Positions;

この_Positionsに座標を書きこんでいます。これはGPU側のメモリです。つまり、この_Positionsデータ転送無しで、描画用シェーダから読めるということですね。ちゃんとGraphicsBufferをSetして参照を渡していれば、ですが。
GPUは普段1920×1080のピクセル群も平気で処理してるので、今回の500×500くらい余裕そうです。

ThreadGroupの決め方

ComputeShaderにnumthread(8,8,1)というのがありますね。これは8×8×1=64本のスレッドで1つのスレッドグループを作る、という意味です。
そして、Dispatchメソッドにもx,y,zが引数にわたっています。これは「スレッドグループ」の数です。

_computeShader.Dispatch(_kernelId, Mathf.CeilToInt(XCount / (float)x), Mathf.CeilToInt(YCount / (float)y), 1);

今は、オブジェクトは平面上に500×500で25万個並べようとしています。この25万のオブジェクト1つ1つに対して1スレッドを割り当てようと思っています。なので、XCountをnumthreadのxで割って、YCountをnumthreadのyで割っています。スレッドグループに関しては、以下のドキュメントで図示されています。最適な数値は環境に依りますが、1スレッドグループに含められるスレッド数には環境ごとの上限があり、その上限ギリギリが良いことが多いようです。

ComputeShaderのCSMainの引数にあるuint3 id : SV_DispatchThreadIDって何が渡ってくるの? というのは、以下の図からわかります。
簡単に言えば、各スレッド固有の3次元ID、ということです。今回は2次元に合わせているので座標と同一視できます。
https://learn.microsoft.com/ja-jp/windows/win32/direct3dhlsl/sm5-attributes-numthreads

こちらの記事もとてもわかりやすく、参考にさせていただきました。
https://edom18.hateblo.jp/entry/2017/05/10/083421

ポイント3: 描画用シェーダ内で、座標を反映しよう。影を落とす場合、影用の座標にも反映しよう。

StructuredBufferの扱い方はRenderMeshInstancedのときと全く同じです。

Varyings vert(Attributes IN)
{
    Varyings OUT;
    UNITY_SETUP_INSTANCE_ID(IN); // ここでinstanceIdにインデックスを入れる
    float3 position = IN.positionOS.xyz + _Positions[IN.instanceId]; // 自分の座標を取って、オフセットとして足す

注意点として、影を扱いたい場合、影用のパスでも座標を反映させる必要があります。これはIndirectの面倒なポイントです。

Varyings ShadowPassVertexCustom(ShadowAttributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);

    // インスタンスごとの位置を適用
    Attributes buf; // unity標準の構造体。UnityAPIに頼りたいので、型を合わせる。
    buf.positionOS.xyz = input.positionOS + _Positions[input.instanceId];
    buf.normalOS = input.normalOS;
    buf.texcoord = input.uv;
    output.positionCS = GetShadowPositionHClip(buf); // unity標準のメソッドに頼る
    return output;
}

結果と残課題


影無しの場合、25万個の描画で100FPSほど出ています。CPUはそこそこ時間がかかっており、GPUは速い、ように見えますが、実際のところCPUの時間の大半はgfx wait for present on gfx threadだったので、RenderMeshIndirectそのものの重さです。CPU今回データの転送以外何もやってないですし、CPU単独で重くはならないですね。
実は影を有効化すると50fpsくらいに落ちます。かなり頑張っていますが、工夫無しではIndirectと言えども25万は大きいようです。

その足りない工夫というのがカリングです。今回一切カリングが働いていません。RenderParamでそれっぽいのを渡していますが……ちょっと数値いじって、みなさんの環境で見てみてください。期待外れだと思うんで。
さて、カリングが利かないと、実際には描画しなくていいところまで律儀に描画しています。カリングができればかなり描画数も少なくなるはずで、かなり軽くなりそうですね。いよいよ完全体に近づくって感じです。
カリングもComputeShaderでやるのがよさそうで、もうComputeShaderの使い方の基礎もわかったので、あとはただの数学の問題かな? という気がしています。

感想

実際動かしてみて、結構感覚は掴めたと思います。
とにかくGPUInstancingの重要なポイントは、各オブジェクトに対してGraphicsBufferの要素を正しく渡すことです。その方法が単純な形で用意されているので、ならあとはだいたいGraphicsBufferに包んで渡せばできないことは無いだろ、って気がしています。
ComputeShaderもやってみると使うのは簡単ですね。純粋数学って感じで、未経験から入ってひーこら言ってる私のような職業プログラマでもさほど理解には苦労しなさそうな感じがします。

まぁ最適化を突き詰めていくと全然簡単なこと無いんでしょうが、スタートラインに立つのがまずは大事だからヨシです。

参考資料

これを目指したかった

https://www.docswell.com/s/UnityJapan/KVD3QK-sync2022_day1_track2_1940

パクり元。いつもありがとうございます。

https://www.youtube.com/watch?v=d6i9hvJ-xcM
https://www.youtube.com/watch?v=5BPezehWwOA

Discussion