🚀

C# Job System と GPU インスタンシングを使って高速に大量のオブジェクトを処理 & 描画する

に公開
  • 2025-07-06:
    • スプライトの上下が反転していたので uvRect = new Vector4(... の行を修正。
    • エディタでプレイ中にコードを変更するとテクスチャがおかしくなっていたので material.SetTexture を毎フレーム呼び出すように修正。
    • Clear を毎フレーム呼ぶことで GC Alloc が走ることについてコメントを追加。
    • GC が発生していた原因は Clear ではなく Sprite.uv へのアクセスだったので、そこを修正 & 誤ったコメントを削除。

現在制作中のゲームで、 敵弾を GameObject としてインスタンス化 & オブジェクトプールで管理していたが、 GameObject の非アクティブ化 & アクティブ化や Transform へのアクセスがボトルネックとなり、 エディタ実行時に大量の敵弾を出した時にエディタのフレームレートが著しく低下してしまった。

そのため、 敵弾を GameObject ではなく構造体として定義して NativeList に格納し、 IJobParallelFor を使って処理することにした。 また、 GameObject を使わない都合で SpriteRenderer は使えないため Graphics.DrawMeshInstancedProcedural を使って GPU インスタンシングを行い、 一括で描画することにした。

シェーダーのコードは以下の通り。

Shader "Custom/DrawEnemyBullet" {
    Properties { }
    SubShader {
        Tags {
            "RenderType"     = "Transparent"
            "Queue"          = "Transparent"
            "RenderPipeline" = "UniversalPipeline"
        }
        Blend SrcAlpha OneMinusSrcAlpha
        ZWrite Off
        Pass {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing

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

            struct Attributes {
                // 頂点の位置座標や頂点の UV 座標の代わりに
                // 頂点 ID のみを転送する.
                //float4 positionOS   : POSITION;
                //float2 uv           : TEXCOORD0;
                uint vertexID       : SV_VertexID;
                uint instanceID     : SV_InstanceID;
            };

            struct Varyings {
                float4 positionHCS  : SV_POSITION;
                float2 uv           : TEXCOORD0;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);
            StructuredBuffer<float4> _Positions;    // xyz = position, w = angle
            StructuredBuffer<float4> _UVRects;      // xy = offset, zw = scale
            StructuredBuffer<float2> _Scales;

            // 頂点の位置情報や頂点の UV 座標を転送しない代わりに
            // シェーダー上で定義しておく.

            static const float3 offsets[4] = {
                float3(-0.5, -0.5, 0),
                float3( 0.5, -0.5, 0),
                float3(-0.5,  0.5, 0),
                float3( 0.5,  0.5, 0),
            };

            static const float2 uvOffsets[4] = {
                float2(0, 0),
                float2(1, 0),
                float2(0, 1),
                float2(1, 1),
            };

            Varyings vert(Attributes IN) {
                Varyings OUT;
                //float3 positionOS = IN.positionOS.xyz + _Positions[IN.instanceID];
                float4 positionData = _Positions[IN.instanceID];
                float3 basePosition = positionData.xyz;
                float angleRad = positionData.w;

                // 角度から回転行列を計算
                float cosAngle = cos(angleRad);
                float sinAngle = sin(angleRad);

                // 頂点オフセットに回転を適用
                float3 offset = offsets[IN.vertexID];
                float2 scale = _Scales[IN.instanceID];
                float3 scaledOffset = float3(offset.x * scale.x, offset.y * scale.y, offset.z);
                float3 rotatedOffset = float3(
                    scaledOffset.x * cosAngle - scaledOffset.y * sinAngle,
                    scaledOffset.x * sinAngle + scaledOffset.y * cosAngle,
                    scaledOffset.z
                );

                float3 positionOS = basePosition + rotatedOffset;
                OUT.positionHCS = TransformObjectToHClip(positionOS);
                float2 uvOffset = _UVRects[IN.instanceID].xy;
                float2 uvScale  = _UVRects[IN.instanceID].zw;
                //OUT.uv = uvOffset + IN.uv * uvScale;
                OUT.uv = uvOffset + uvOffsets[IN.vertexID] * uvScale;
                return OUT;
            }

            half4 frag(Varyings IN) : SV_Target {
                half4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv);
                return color;
            }
            ENDHLSL
        }
    }
}

C# 側のコードは以下の通り。

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

namespace App {
    public struct EnemyBulletData {
        public bool enabled;
        public float time;
        public Vector2 position;
        public float direction;
        public float speed;
        public Bounds boundingBox;
        public int animation;
        public EnemyBulletData(
            Vector2 position, float direction, float speed,
            Bounds boundingBox, int animation) {
            enabled = true;
            time = default;
            this.position = position;
            this.direction = direction;
            this.speed = speed;
            this.boundingBox = boundingBox;
            this.animation = animation;
        }
    }

    [DefaultExecutionOrder(ExecutionOrder.MoveNonPlayer)]
    public class MoveEnemyBullet : MonoBehaviour {
        [BurstCompile]
        public struct Job : IJobParallelFor {
            public float deltaTime;
            public Bounds workArea;
            public NativeArray<EnemyBulletData> data;

            [BurstCompile]
            void IJobParallelFor.Execute(int index) {
                var item = data[index];
                if (!item.enabled) {
                    return;
                }
                Vector2 position;
                {
                    var speed = item.speed * deltaTime;
                    var cos = Mathf.Cos(item.direction);
                    var sin = Mathf.Sin(item.direction);
                    var v = new Vector2(
                        cos * speed,
                        sin * speed);
                    position = item.position + v;
                }
                var boundingBox = item.boundingBox;
                boundingBox.center += (Vector3)position;
                if (boundingBox.Intersects(workArea)) {
                    item.position = position;
                    item.time += deltaTime;
                } else {
                    item.enabled = false;
                }
                data[index] = item;
            }
        }

        NativeList<EnemyBulletData> data;

        public NativeArray<EnemyBulletData> Data => data.AsArray();

        public void Add(EnemyBulletData data) => this.data.Add(data);

        #region MonoBehaviour
        void Awake() {
            data = new NativeList<EnemyBulletData>(1024, Allocator.Persistent);
        }
        void OnDestroy() {
            data.Dispose();
        }
        void FixedUpdate() {
            var job = new Job {
                deltaTime = Time.deltaTime,
                workArea = new Bounds(Vector3.zero, 20 * Vector3.one),
                data = data.AsArray(),
            };
            var handle = job.Schedule(data.Length, 64);
            handle.Complete();

            {
                var index = 0;
                for (var i = 0; i < data.Length; i++) {
                    if (data[i].enabled) {
                        data[index++] = data[i];
                        continue;
                    }
                }
                data.Resize(index, NativeArrayOptions.UninitializedMemory);
            }
        }
        #endregion
    }
}
using System.Runtime.InteropServices;
using Unity.Collections;
using UnityEngine;

namespace App {
    [DefaultExecutionOrder(ExecutionOrder.Draw)]
    public class DrawEnemyBullet : MonoBehaviour {
        static Mesh CreateMesh() {
            var mesh = new Mesh {
                name = "DummyQuad"
            };

            var vertices = new Vector3[] {
                default, default, default, default,
            };
            mesh.SetVertices(vertices);

            // UV はシェーダーで固定値を使うので設定しなくてよい.
            //var uv = new Vector2[] {
            //    new Vector2(0, 0),
            //    new Vector2(1, 0),
            //    new Vector2(0, 1),
            //    new Vector2(1, 1),
            //};
            //mesh.SetUVs(0, uv);

            var indices = new int[] {
                0, 2, 1, // First triangle
                2, 3, 1, // Second triangle
            };
            mesh.SetIndices(indices, MeshTopology.Triangles, 0, true);

            // 法線はシェーダー側で使わないので, 計算しなくてよい.
            //mesh.RecalculateNormals();

            return mesh;
        }

        [SerializeField] MoveEnemyBullet moveEnemyBullet;
        [SerializeField] Material material;
        [SerializeField] Sprite[] sprites;
        [SerializeField] AnimationCurve[] animations;
        Mesh mesh;
        Material materialInstance;
        int count;
        NativeList<Vector4> positions;
        NativeList<Vector4> uvRects;
        NativeList<Vector2> scales;
        GraphicsBuffer positionBuffer;
        GraphicsBuffer uvRectBuffer;
        GraphicsBuffer scaleBuffer;

        #region MonoBehaviour
        void Awake() {
            mesh = CreateMesh();
        }
        void Start() {
            // マテリアルにスプライトのテクスチャを設定.
            // スプライトが複数ある場合は、最初のスプライトのテクスチャを使用.
            if (material && sprites.Length > 0 && sprites[0] && sprites[0].texture) {
                materialInstance = new Material(material);
            }

            if (moveEnemyBullet) {
                var data = moveEnemyBullet.Data;
                if (data.IsCreated) {
                    count = System.Math.Max(128, data.Length);
                }
            }

            var allocator = Allocator.Persistent;
            positions = new NativeList<Vector4>(count, allocator);
            uvRects = new NativeList<Vector4>(count, allocator);
            scales = new NativeList<Vector2>(count, allocator);

            var target = GraphicsBuffer.Target.Structured;
            positionBuffer = new GraphicsBuffer(target, count, Marshal.SizeOf<Vector4>());
            uvRectBuffer = new GraphicsBuffer(target, count, Marshal.SizeOf<Vector4>());
            scaleBuffer = new GraphicsBuffer(target, count, Marshal.SizeOf<Vector2>());
        }
        void OnDestroy() {
            Destroy(mesh);
            if (materialInstance) {
                Destroy(materialInstance);
            }
            positions.Dispose();
            uvRects.Dispose();
            scales.Dispose();
            positionBuffer.Dispose();
            uvRectBuffer.Dispose();
            scaleBuffer.Dispose();
        }
        void Update() {
            positions.Clear();
            uvRects.Clear();
            scales.Clear();
            if (!materialInstance || !moveEnemyBullet) {
                return;
            }
            var data = moveEnemyBullet.Data;
            if (!data.IsCreated || data.Length == 0) {
                return;
            }
            for (var i = 0; i < data.Length; i++) {
                var item = data[i];
                Vector4 uvRect;
                Vector2 scale;
                {
                    var index = (int)animations[item.animation].Evaluate(item.time);
                    var sprite = sprites[index];
                    // Sprite.uv のアクセスは GC Alloc が走るので控える.
                    //var uv = sprite.uv;
                    var rect = sprite.rect;
                    var texture = sprite.texture;

                    uvRect = new Vector4(
                        // Sprite.uv を使う場合は上下反転が必要.
                        // Sprite.rect の場合は不要.
                        //uv[0].x,
                        //uv[3].y,
                        //uv[3].x - uv[0].x,
                        //uv[0].y - uv[3].y);
                        rect.xMin / texture.width,
                        rect.yMin / texture.height,
                        rect.width / texture.width,
                        rect.height / texture.height);

                    // スプライトのサイズを使用してスケール値を計算
                    scale = new Vector2(rect.width, rect.height) / sprite.pixelsPerUnit;
                }
                if (item.enabled) {
                    var p = item.position;
                    positions.Add(new Vector4(p.x, p.y, 0, item.direction));
                    uvRects.Add(uvRect);
                    scales.Add(scale);
                }
            }
            if (count < positions.Length) {
                count = positions.Capacity;
                positionBuffer.Dispose();
                uvRectBuffer.Dispose();
                scaleBuffer.Dispose();
                var target = GraphicsBuffer.Target.Structured;
                positionBuffer = new GraphicsBuffer(target, count, Marshal.SizeOf<Vector4>());
                uvRectBuffer = new GraphicsBuffer(target, count, Marshal.SizeOf<Vector4>());
                scaleBuffer = new GraphicsBuffer(target, count, Marshal.SizeOf<Vector2>());
            }
            positionBuffer.SetData(positions.AsArray());
            uvRectBuffer.SetData(uvRects.AsArray());
            scaleBuffer.SetData(scales.AsArray());
            materialInstance.SetTexture("_MainTex", sprites[0].texture);
            materialInstance.SetBuffer("_Positions", positionBuffer);
            materialInstance.SetBuffer("_UVRects", uvRectBuffer);
            materialInstance.SetBuffer("_Scales", scaleBuffer);
            Graphics.DrawMeshInstancedProcedural(mesh, 0, materialInstance, mesh.bounds, positions.Length);
        }
        #endregion
    }
}

今回、 メッシュの形状は常に固定なので C# 側のコードで頂点の位置座標や、 各頂点の UV 座標は設定せず、 以下のように頂点の個数と各ポリゴンが参照する頂点 ID の設定のみを行い、 頂点の位置座標と UV 座標の設定はシェーダー側で定数として定義した。 (頂点の個数自体は必要なので、 設定時に位置の値を default にした。)

var mesh = new Mesh {
    name = "DummyQuad"
};
var vertices = new Vector3[] {
    default, default, default, default,
};
mesh.SetVertices(vertices);
var indices = new int[] {
    0, 2, 1, // First triangle
    2, 3, 1, // Second triangle
};
mesh.SetIndices(indices, MeshTopology.Triangles, 0, true);
static const float3 offsets[4] = {
    float3(-0.5, -0.5, 0),
    float3( 0.5, -0.5, 0),
    float3(-0.5,  0.5, 0),
    float3( 0.5,  0.5, 0),
};
static const float2 uvOffsets[4] = {
    float2(0, 0),
    float2(1, 0),
    float2(0, 1),
    float2(1, 1),
};

同じシェーダーを他の場面でも使用し、 かつその際に個別にメッシュの設定を行う場合はシェーダー側に定数としてメッシュの形状を設定せず、 C# のスクリプト側からその値を設定すると良い。 (コメントアウトされたコードを参照。)

シェーダーの基本的な定義については、 以下を参考にした。

今回は基本的に URP を使用することを想定し、 "UnityCG.cginc" ではなく、 "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" をインクルードした。

以下の記事では "UnityCG.cginc" をインクルードしているが、 自分のプロジェクトに合わせてうまく調整する必要があると思われる。

今回 Unity の自動的な GPU インスタンシングの機構は利用せず Graphics.DrawMeshInstancedProcedural を使っているため UNITY_VERTEX_INPUT_INSTANCE_ID は使わなくて良い。

同様にマテリアルの Enable GPU Instancing プロパティtrue にしなくてよい。

尚、 今回 ECS を採用しなかった理由は以下の通り。

  • ECS は大規模なシステムのパフォーマンスを最適化することに向いたシステムであり、 今回の目的に対しては過剰であると判断した。
  • ECS を使用する場合、 必要なコード量が膨らむ傾向にあり、 必要なパフォーマンスに対して割にあわないと判断した。
  • シーン上に配置していないオブジェクトから、 システムに補足されない Entity を作成し、 それを複製して使用するようなワークフローをうまく構築できなかった。
  • ウェブ上に知見が少なく、 自分で検索しても AI にコード生成させても、 既に廃止された古い API を使った方法ばかりが出てきてしまうため、 実プロジェクトで運用するためにはコミュニティを含めて成熟度が足りないと判断した。

Discussion