5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「QualiArts様の描画最適化術」を試してみる Part.1

Last updated at Posted at 2025-09-11

はじめに

QualiArts様が公開されている

「IDOLY PRIDE」における描画最適化術

こちらの記事では、大量オブジェクト描画を軽量化するために

  • JobSystemによる画面内判定(カリング)
  • ComputeShaderによる遮蔽判定
  • DrawProceduralによるインスタンシング描画

と、Unityの機能を最大限に利用して最適化を行ったことが記載されています。
(詳細については、ぜひ元記事をご参照ください。)

今回自身でロジックの再現を試して見ました。

DrawProcedural

一先ず、描画の確認のため DrawProcedural を利用した画面描画を試してみます。

DrawProceduralを利用することで、メッシュデータは不要で頂点データを直接GPUに渡すことで一回のドローコールで高速描画が可能です。

利用するメソッドは以下

public static void DrawProcedural (Material material, Bounds bounds, MeshTopology topology, int vertexCount, int instanceCount, Camera camera, MaterialPropertyBlock properties, Rendering.ShadowCastingMode castShadows, bool receiveShadows, int layer);

引数には描画で利用するマテリアルを渡します。

描画に必要なデータはCPUで構築(ComputeBuffer)し、それをマテリアルに送ります。
シェーダー側からは StructuredBuffer で利用。

以下は三角形を描画するシェーダーです。

Shader "Custom/01_Sample"
{
    Properties
    {
    }
    
    SubShader
    {
        Tags 
        { 
            "RenderType"="Opaque" 
            "RenderPipeline"="UniversalPipeline"
            "Queue"="Geometry"
        }
        
        Pass
        {
            Name "ForwardLit"
            Tags {"LightMode" = "UniversalForward"}
            
            Cull Off
            ZWrite On
            ZTest LEqual
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            
            // CPUから送られてきたデータを格納するバッファ
            StructuredBuffer<float3> _PositionBuffer;
            StructuredBuffer<float4> _ColorBuffer;
            
            float _TriangleSize;
            
            struct Attributes
            {
                uint vertexID : SV_VertexID;        // 頂点Index(三角形なので、0,1,2)
                uint instanceID : SV_InstanceID;    // インスタンスIndex
            };
            
            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float4 color : COLOR;
            };
            
            Varyings vert(Attributes input)
            {
                Varyings output;
                
                // 三角形の頂点を定義(ローカル座標)
                // input.vertexID [三角形の頂点Index(0, 1, 2)] を利用して頂点を決定
                float3 triangleVertices[3];
                triangleVertices[0] = float3(0, 0.5f, 0);      // 上
                triangleVertices[1] = float3(-0.5f, -0.5f, 0); // 左下
                triangleVertices[2] = float3(0.5f, -0.5f, 0);  // 右下
                float3 localVertex = triangleVertices[input.vertexID];
                
                // インスタンスのIndexを使って位置バッファから位置を取得
                float3 instancePosition = _PositionBuffer[input.instanceID];
                
                // ワールド位置を計算
                float3 worldPosition = instancePosition + localVertex;
                
                // クリップ空間に変換
                output.positionCS = TransformWorldToHClip(worldPosition);
                
                // 色も同じくバッファから取る
                output.color = _ColorBuffer[input.instanceID];
                
                return output;
            }
            
            float4 frag(Varyings input) : SV_Target
            {
                // 色返すだけ
                return input.color;
            }
            
            ENDHLSL
        }
    }
}

Shader内で三角形のローカル座標を定義。CPUから送られてきたWorld座標を移動させています。

以下のScriptを作成し、上記のShaderを付けたMaterialをアタッチします

using System;
using UnityEngine;

public class SimpleDrawProcedural : MonoBehaviour
{
    [SerializeField] private Material _material;
    [SerializeField] private int _count = 3;

    // CPU -> GPUへデータを送るための入れ物
    private ComputeBuffer _positionBuffer;
    private ComputeBuffer _colorBuffer;

    private void Start()
    {
        if (_material == null) return;
        
        // 適当にCount分の位置をずらしたバッファを作成
        var positions = new Vector3[_count];
        for (var i = 0; i < _count; ++i)
        {
            // ちょい右にずらす
            positions[i] = transform.position + new Vector3(2f, 0, 0) * i;
        }
        
        // 入れ物の作成。三角形なので[float x 3]の頂点データを個数分用意
        _positionBuffer = new ComputeBuffer(_count, sizeof(float) * 3);
        _positionBuffer.SetData(positions);

        // 色定義。Colorはfloat4
        var colors = new Vector4[_count];
        colors[0] = Color.red;
        colors[1] = Color.blue;
        colors[2] = Color.green;

        _colorBuffer = new ComputeBuffer(_count, sizeof(float) * 4);
        _colorBuffer.SetData(colors);
        
        // シェーダーにデータを送る
        _material.SetBuffer("_PositionBuffer", _positionBuffer);
        _material.SetBuffer("_ColorBuffer", _colorBuffer);
    }
    
    private void Update()
    {
        if (_material == null) return;
        
        // DrawProceduralを利用して三角形を描画。頂点は3つなのでx3のデータを渡す
        Graphics.DrawProcedural(
            _material,
            new Bounds(Vector3.zero, Vector3.one * 1000f), // 覆い隠す範囲を指定
            MeshTopology.Triangles,
            3,
            _count);
    }

    private void OnDestroy()
    {
        // 開放は忘れずに
        _positionBuffer?.Dispose();
        _colorBuffer?.Dispose();
    }
}

上記を実行した結果は以下のようになり、メッシュデータがなくとも画面に描画が出来ました。

image.png

JobSystem

次に、JobSystemを利用して画面内に収まっているオブジェクトのみ描画の実装です。

この計算を、CPUの複数コアを利用して大量のデータを並列に、高速に処理してもらいます。

Job構造体を作成し、その中にロジックを収めることで安全に処理が可能です。
(公式ドキュメントやサンプルがわかりやすいので是非ご確認ください)

JobSystem構造体を以下の形で作成

    [BurstCompile]
    public struct TransformAndCullJob : IJobParallelFor
    {
        public void Execute(int index) // Indexが配列の各要素のIndex
        {}
    }

IJobParallelFor を利用することで、渡した配列ごとにExecute実行メソッドを一回ずつ呼び出されます

BurstCompile属性をつけることでUnityが自動的に最適化されたCPU命令コードを生成し、大量のデータに対して同じ計算処理をするロジックに絶大な高価を発揮。

次に利用する一つのオブジェクトのデータ構造体を宣言します。

メモリレイアウトが重要になるため、 StructLayout を付けて最適化により自動的にメンバの順序を入れ替えることを防止。

    /// <summary>
    /// オブジェクト一つのデータ構造
    /// </summary>
    [StructLayout(LayoutKind.Sequential)]
    public struct ProceduralParam
    {
        public float3 WorldPosition;    // 表示座標
        public float2 ScreenPosition;   // スクリーン上の座標
        public float4 Color;            // 色
        public int IsVisible;           // 可視状態
        public int IsInScreen;          // スクリーン内の判定
    };

この構造体をJobに宣言します。publicにすることで外部簡単にデータを渡してます。
今回は

  • 座標計算 + スクリーン内判定

を実行したいので、必要な行列も合わせて渡してみる

    [BurstCompile]
    public struct TransformAndCullJob : IJobParallelFor
    {
        // ReadOnlyにすることで安全性が高いことを伝える
        [ReadOnly] public NativeArray<float3> WorldPositions;   // オブジェクトの座標配列
        [ReadOnly] public float4x4 ViewMatrix;
        [ReadOnly] public float4x4 ProjectionMatrix;
        [ReadOnly] public float2 ScreenSize;

        public NativeArray<ProceduralParam> Params;
        ...

安全&高速に処理してもらう為、配列は NativeArray の利用が必須となるなど記述方法が普段と少し異なります

後はExecute内で

1. オブジェクトごとにスクリーン座標を計算
2. カメラの範囲に収まっているか判定。収まってなければ isInScreen を0に
3. 結果をCPUに返すので Params に格納

を行います。
全体の処理は以下になりました

    /// <summary>
    /// 一つのオブジェクトのパラメータが画面内に収まっているかを並列で処理する
    /// </summary>
    [BurstCompile]
    public struct TransformAndCullJob : IJobParallelFor
    {
        // ReadOnlyにすることで安全性が高いことを伝える
        [ReadOnly] public NativeArray<float3> WorldPositions;   // オブジェクトの座標配列
        [ReadOnly] public float4x4 ViewMatrix;
        [ReadOnly] public float4x4 ProjectionMatrix;
        [ReadOnly] public float2 ScreenSize;

        public NativeArray<ProceduralParam> Params;
  
        public void Execute(int index) // Indexが配列の各要素のIndex
        {
            // オブジェクト一つが画面内に収まっているか確認。もし収まっていなければ描画しなくて良いフラグを立てる
            var worldPos = WorldPositions[index];
            var viewPos = math.mul(ViewMatrix, new float4(worldPos, 1.0f));
            var clipPos = math.mul(ProjectionMatrix, viewPos);
            
            // 画面内判定
            var isInScreen = 1;
            if (clipPos.w <= 0.001f) // そもそもカメラの後方にいるなら
            {
                isInScreen = 0;
            }
            else
            {
                // 正規化デバイス空間に収まっていなかったらNG
                var ndc = clipPos.xyz / clipPos.w;
                if (math.abs(ndc.x) > 1.0f || math.abs(ndc.y) > 1.0f || ndc.z < 0.0f || ndc.z > 1.0f)
                {
                    isInScreen = 0;
                }
            }

            // ndcPos.z が深度バッファに書き込まれる値(0.0:ニア 〜 1.0:ファー)。この値を利用して遮蔽判定する
            var ndcPos = clipPos.w > 0 ? clipPos.xyz / clipPos.w : float3.zero;
            
            // 画面内での座標を計算
            var screenPos = new float2(
                (ndcPos.x * 0.5f + 0.5f) * ScreenSize.x, // X: [-1,+1] -> [0,width]
                (1.0f - (ndcPos.y * 0.5f + 0.5f)) * ScreenSize.y // Y: [-1,+1] -> [0,height] (Y軸反転)
            );
            
            Params[index] = new ProceduralParam
            {
                WorldPosition = worldPos,
                ScreenPosition = screenPos,
                Color = new float4(1.0f, 0.0f, 0.0f, 1.0f), // 色は適当
                IsVisible = isInScreen,  // 今回は画面に収まってないに対して、非表示フラグも立てる
                IsInScreen = isInScreen,
            };
        }
    }

これをスクリプトから利用します。

変数定義では、JobSystemに渡すため NativeArray と、Job実行用のJobHandleを定義


        private ComputeBuffer _paramBuffer; // Shader内部で利用するのでComputeBuffer
        private NativeArray<ProceduralParam> _params;   // JobSystemで利用する配列はNativeArrayで定義
        private NativeArray<float3> _worldPositions;
        private JobHandle _jobHandle;
        

UpdateメソッドでJobSystemを実行。

Job構造体を作成し、publicな変数に情報を詰め込む。
あとは Scheduleメソッドを呼び出すだけです。

            // Jobを実行する
            var job = new TransformAndCullJob()
            {
                WorldPositions = _worldPositions,
                ViewMatrix = camera.worldToCameraMatrix,
                ProjectionMatrix = camera.projectionMatrix,
                ScreenSize = new float2(camera.pixelWidth, camera.pixelHeight),
                Params = _params,   // これに書き込んでもらう
            };

            // Count分処理してもらう。第二引数はバッチ数で1回の処理単位(1回のCPUコアに渡される数)。
            _jobHandle = job.Schedule(_count, 1);
            _jobHandle.Complete(); // 計算が終わるまで同期的に待機

Scheduleメソッドの第一引数では、処理する配列の数。第二引数では一回の処理のバッチ数を渡します。

バッチ数は多いほうが良いわけではなく、一度に渡す数が多いとその分待機状態になるコアが生まれてしまうため効率的とは言えません。

4コアCPUで、50個のオブジェクトに対し、バッチ10を指定した場合

コア1 -> 10, 50
コア2 -> 20
コア3 -> 30
コア4 -> 40

とコア1だけ二回目のバッチ処理が走りますが、その間コア2〜4が無駄になっています。

公式によると
「まずは1を指定し、パフォーマンスがわずかに良くなるまでバッチ数を増やして確認する」
が最も確実なアプローチとのことです。

後は計算した結果をサンプル1と同じくShaderに渡して描画してもらいます

◆コード全体

    public class JobSystemTest : MonoBehaviour
    {
        [SerializeField] private Material _material;
        [SerializeField] private int _count = 100;
        
        private ComputeBuffer _paramBuffer; // Shader内部で利用するのでComputeBuffer
        private NativeArray<ProceduralParam> _params;   // JobSystemで利用する配列はNativeArrayで定義
        private NativeArray<float3> _worldPositions;
        private JobHandle _jobHandle;
        private Camera _camera; // カメラをキャッシュ
        
        private void Start()
        {
            _paramBuffer = new ComputeBuffer(_count, Marshal.SizeOf<ProceduralParam>());
            _params = new NativeArray<ProceduralParam>(_count, Allocator.Persistent);
            _worldPositions = new NativeArray<float3>(_count, Allocator.Persistent);
            
            // 一つの頂点座標を画面中央からランダムに散らして生成
            for (var i = 0; i < _count; ++i)
            {
                var t = (float)i / _count;
                
                // ランダムな角度と距離で画面中央から散らす
                var angle = UnityEngine.Random.Range(0f, 2f * Mathf.PI);
                var distance = UnityEngine.Random.Range(0f, 10f);
                
                var x = Mathf.Cos(angle) * distance;
                var z = Mathf.Sin(angle) * distance;
                var y = UnityEngine.Random.Range(-10f, 10f); // Y軸もランダムに

                _worldPositions[i] = new float3(x, y, z);
            }
            
            _camera = Camera.main; // カメラをキャッシュ
        }

        private void Update()
        {
            if (_paramBuffer == null) return;
            if (_camera == null) return; // カメラがnullの場合は処理を停止
            
            // Jobを実行する
            var job = new TransformAndCullJob()
            {
                WorldPositions = _worldPositions,
                ViewMatrix = _camera.worldToCameraMatrix,
                ProjectionMatrix = _camera.projectionMatrix,
                ScreenSize = new float2(_camera.pixelWidth, _camera.pixelHeight),
                Params = _params,   // これに書き込んでもらう
            };

            // Count分処理してもらう。第二引数はバッチ数で1回の処理単位(1回のCPUコアに渡される数)。
            _jobHandle = job.Schedule(_count, 1);
            _jobHandle.Complete(); // 計算が終わるまで同期的に待機
            
            // JobSystemで計算した結果をComputeBufferに転送
            _paramBuffer.SetData(_params);
            
            // Sample01と同じように描画
            _material.SetBuffer("_ParamBuffer", _paramBuffer); // パラメータ構造体を渡す
            
            // DrawProceduralを利用して三角形を描画。頂点は3つなのでx3のデータを渡す
            Graphics.DrawProcedural(
                _material,
                new Bounds(Vector3.zero, Vector3.one * 10000f), // 覆い隠す範囲を雑に指定
                MeshTopology.Triangles,
                3,
                _count);
        }

        private void OnDestroy()
        {
            // 後処理を忘れずに
            _paramBuffer?.Release();
            
            if (_params.IsCreated) _params.Dispose();
            if (_worldPositions.IsCreated) _worldPositions.Dispose();
        }
    }

シェーダー側は大きな変更はないですが、C#側の構造体を受け取るようにStructuredBufferの宣言が変更しています


            // オブジェクトデータ構造(C#側の構造体と並びを一致させる)
            struct ProceduralParam
            {
                float3 worldPosition;
                float2 screenPosition;
                float4 color;
                int isVisible;
                int isInScreen;
            };

画面内フラグが立っていない場合、Shader内で色を暗くしてみます

// 可視性チェックをデバッグで色を暗くしてみる
if (param.isVisible == 0)
{
   output.color *= 0.3; // 暗くする
}

◆Shader全体

Shader "Custom/02_Sample"
{
    Properties
    {
    }
    
    SubShader
    {
        Tags 
        { 
            "RenderType"="Opaque" 
            "RenderPipeline"="UniversalPipeline"
            "Queue"="Geometry"
        }
        
        Pass
        {
            Name "ForwardLit"
            Tags {"LightMode" = "UniversalForward"}
            
            Cull Off
            ZWrite On
            ZTest LEqual
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            // オブジェクトデータ構造(C#側の構造体と並びを一致させる)
            struct ProceduralParam
            {
                float3 worldPosition;
                float2 screenPosition;
                float4 color;
                int isVisible;
                int isInScreen;
            };
            StructuredBuffer<ProceduralParam> _ParamBuffer;
            
            struct Attributes
            {
                uint vertexID : SV_VertexID;        // 頂点Index(三角形なので、0,1,2)
                uint instanceID : SV_InstanceID;    // インスタンスIndex
            };
            
            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float4 color : COLOR;
            };
            
            Varyings vert(Attributes input)
            {
                Varyings output;
                
                // 三角形の頂点を定義(ローカル座標)
                // input.vertexID [三角形の頂点Index(0, 1, 2)] を利用して頂点を決定
                float3 triangleVertices[3];
                triangleVertices[0] = float3(0, 0.5f, 0);      // 上
                triangleVertices[1] = float3(-0.5f, -0.5f, 0); // 左下
                triangleVertices[2] = float3(0.5f, -0.5f, 0);  // 右下
                float3 localVertex = triangleVertices[input.vertexID];
                
                // 一つのパラメータデータを取得
                ProceduralParam param = _ParamBuffer[input.instanceID];
                
                // ワールド位置を計算
                float3 worldPosition = param.worldPosition + localVertex;
                
                output.positionCS = TransformWorldToHClip(worldPosition);
                output.color = param.color[input.instanceID];

                // 可視性チェックをデバッグで色を暗くしてみる
                if (param.isVisible == 0)
                {
                    output.color *= 0.3; // 暗くする
                }
                
                return output;
            }
            
            float4 frag(Varyings input) : SV_Target
            {
                // 色返すだけ
                return input.color;
            }
            
            ENDHLSL
        }
    }
}

実行結果は以下になりました。

image.png

image.png

ざっくりですがカメラに写っていないものは色が暗くなり、問題なくカリングされているのがわかります

Statsを確認してもインスタンスが効いており一回の描画で済まされているのがわかります

描画前 描画後
image.png image.png

今回は以上となります。

DrawProceduralによる描画と、JobSystemによるカリングを利用することで
「大量のオブジェクトを単一ドローコールで、かつCPU負荷を抑えた描画をする」 という見通しが立てられました。

パフォーマンス結果は最後に纏めてみる予定で、次回は ComputeShader による遮蔽判定を実装してみます。

ここまで見ていただきありがとうございました。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?