ゲームのパフォーマンス最適化、特にCPU側のボトルネックとなる「ドローコール(Draw Call)」の削減において、Scriptable Render Pipeline (SRP) Batcherは非常に強力な機能です。同じシェーダーを使用するマテリアルであれば、パラメータが異なっていてもGPU定数バッファの高速切り替えによって一括描画できるため、描画負荷が劇的に減少します。しかし、「すべてのオブジェクトにSRP Batcher対応のシェーダーを使っているのに、複数のマテリアルが設定されたオブジェクト(マルチマテリアルメッシュ)を大量に置いた瞬間、バッチングが無効化されドローコールが爆発してしまう」という問題に直面したことはないでしょうか。本記事では、このマルチマテリアルとSRP Batcherの不整合が発生する技術的な理由と、初心者にも分かりやすい例え話、そしてドローコールを最小限に抑えるための根本的な解決手法を詳しく解説します。

1. 例え話で理解する:『ホテルの超高速一括チェックイン』と『3枚の別々の申込書』

この「マルチマテリアルによるバッチング崩壊」の仕組みを、「ホテルのフロントでのチェックイン手続き」を例えに考えてみましょう。

ホテルのフロントに、ある同一ツアー会社から来た100名の団体客(描画するオブジェクトたち)が並びました。全員が同じ「ツアーの身分証(SRP Batcher対応シェーダー)」を持っています。

もし、ツアー客一人ひとりが「1枚の部屋カード(1つのマテリアル)」を貰うだけのシンプルな手続きであれば、フロント係(GPU)は「はい、全員同じグループですね!」と、名簿(定数バッファ)をサッと流し読みするだけで、全員分の鍵をまとめて手際よく、一瞬でチェックイン(一括描画:SRP Batcher)を終わらせることができます。

しかし、このツアー客たちがそれぞれ「私は寝室用の青い申込書、バスルーム用の白い申込書、バルコニー用の木目調の申込書(マルチマテリアル)」という、3枚の全く異なる設定用紙を1人で同時に抱えていたとします。フロント係は、1人の客を処理するたびに、「ええと、青い部屋の手続きをして…(鍵発行:ドローコール1回)。次に白い部屋の手続きに切り替えて…(鍵発行:ドローコール2回)。最後に木目の部屋の手続きをして…(鍵発行:ドローコール3回)」と、窓口での作業を何度も中断・切り替えなければなりません。どれだけ身分証が同じであっても、1人あたり3回の手続きが発生するため、100人が並ぶとフロントは大渋滞(ドローコールの爆発)に陥ってしまいます。これが、マルチマテリアルによって高速レーン(SRP Batcher)が破綻してしまう正体です。

2. 根本的な原因:サブメッシュ(Submesh)とレンダーステートの分断

技術的に言うと、Unityの MeshRenderer に複数のマテリアル(例:Element 0, Element 1, Element 2)をアタッチすると、メッシュデータ内部はマテリアルごとに分割された 「サブメッシュ(Submesh)」 構造になります。グラフィックスAPI(DirectXやVulkan、Metalなど)の仕様上、マテリアル(テクスチャバインド、描画設定)が異なる領域を描画する際は、必ず DrawIndexedInstanced などの描画APIコールを別々に発行しなければなりません。

SRP Batcherは、「マテリアルのパラメータは違っていても、シェーダーの描画パイプライン(Render State)が同一であれば、GPU側のバインドを維持したまま定数バッファ(CBUFFER)のアドレスだけを書き換えて超高速に連続描画する」仕組みです。

しかし、1つのゲームオブジェクト(MeshRenderer)の中にサブメッシュが複数存在し、異なるマテリアルが割り当てられている場合、Unityの内部システムはオブジェクト単位の描画順序やブレンド順序を優先します。結果として、オブジェクトAのサブメッシュ0 ➔ オブジェクトAのサブメッシュ1 ➔ オブジェクトBのサブメッシュ0 ➔ オブジェクトBのサブメッシュ1 という順に描画が走るか、あるいはマテリアルの切り替え境界でバッチが細切れに分断されてしまいます。これにより、SRP Batcherの「定数メモリ上に並んだデータを連続してループ再生する」という前提が崩れ、バッチ処理の効果が完全に相殺されてしまうのです。

3. 実務で採用すべき3つの解決アプローチ

この問題を回避し、SRP Batcherのバッチ性能を100%発揮させるための具体的なアプローチを紹介します。

解決法A:DCCツールでの「テクスチャアトラス化」とマテリアル一元化(推奨)

最も本質的で推奨されるアプローチは、3Dモデルの制作段階(BlenderやMayaなど)で、複数のマテリアルを1つの統合マテリアルに統合することです。

  1. テクスチャアトラスの作成: 屋根、壁、ドアに使っていた個別のテクスチャ画像を、1枚の大きな画像(例:2048x2048)にパズル状に並べて結合(パッキング)します。
  2. UV座標の再配置: 3DモデルのUVマップを展開し、アトラス画像の対応する位置に収まるようにレイアウトし直します。
  3. マテリアル数の統合: DCCツール上でのマテリアル定義を 1つだけ にし、その統合マテリアルにアトラステクスチャを設定します。
  4. Unityへのエクスポート: メッシュをエクスポートしてUnityに配置します。これにより、マテリアルスロットが1つになり、オブジェクトを何百個コピーしても完全にSRP Batcherで一括描画(ドローコールが極小)されます。

解決法B:Unityのアセットによる自動アトラス化(コード記述不要)

大量の既存アセットを保有しており、Blender等に戻って作業するのが困難な場合は、Unity Asset Storeの「Mesh Baker」や「Easy Mesh Combiner」などのツールを利用します。これらはUnityエディタ上で選択したオブジェクト群のマテリアル(テクスチャ)を自動的にアトラス化し、サブメッシュを統合した単一マテリアルのメッシュへ自動変換してくれます。

解決法C:C#スクリプトを用いた実行時のメッシュ結合(ランタイム最適化)

ステージ生成時やランタイムに動的に配置された複数のオブジェクトを1つにまとめてドローコールを削減したい場合は、C#から Mesh.CombineMeshes を使用してメッシュとサブメッシュを1つのマテリアルに物理統合します。

以下は、同一マテリアルを共有するアトラス化されたオブジェクト群を、実行時に1つの巨大なメッシュに結合するスクリプトの実装例です。

using UnityEngine;

[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
public class RuntimeMeshCombiner : MonoBehaviour
{
    void Start()
    {
        // 子オブジェクトからすべての MeshFilter を取得
        MeshFilter[] meshFilters = GetComponentsInChildren<MeshFilter>();
        CombineInstance[] combine = new CombineInstance[meshFilters.Length - 1]; // 自身は除外

        int combineIndex = 0;
        MeshRenderer parentRenderer = GetComponent<MeshRenderer>();
        Material sharedMaterial = null;

        for (int i = 0; i < meshFilters.Length; i++)
        {
            if (meshFilters[i].gameObject == gameObject) continue; // 自身はスキップ

            // 最初に見つけた子オブジェクトのマテリアルを結合用マテリアルとして使用
            if (sharedMaterial == null)
            {
                MeshRenderer childRenderer = meshFilters[i].GetComponent<MeshRenderer>();
                if (childRenderer != null) sharedMaterial = childRenderer.sharedMaterial;
            }

            combine[combineIndex].mesh = meshFilters[i].sharedMesh;
            // 子オブジェクトのワールド行列を、この親オブジェクトのローカル座標空間に変換して適用
            combine[combineIndex].transform = transform.worldToLocalMatrix * meshFilters[i].transform.localToWorldMatrix;
            
            // 結合元のメッシュレンダラーを非表示にして描画から除外
            MeshRenderer r = meshFilters[i].GetComponent<MeshRenderer>();
            if (r != null) r.enabled = false;

            combineIndex++;
        }

        // 新しいメッシュを作成して結合実行
        Mesh combinedMesh = new Mesh();
        combinedMesh.name = "Combined_Static_Mesh";
        
        // 第1引数:結合インスタンス配列、第2引数:mergeSubMeshesを true にすることでサブメッシュを1つに統合!
        // ※これにより、アトラス化された1つのマテリアルで全メッシュが1ドローコールで描画されます
        combinedMesh.CombineMeshes(combine, true, true);

        // 親のMeshFilterとRendererに割り当て
        GetComponent<MeshFilter>().mesh = combinedMesh;
        parentRenderer.sharedMaterial = sharedMaterial;

        Debug.Log($"{combineIndex} 個のメッシュを 1 つのサブメッシュに結合完了しました。");
    }
}

4. SRP Batcher最適化のための実務チェックリスト

プロジェクト全体の描画パフォーマンスを維持するため、アセット制作のワークフローに以下のチェック項目を設けてください。

  • マテリアルスロット数の原則: キャラクターや背景アセットなど、配置頻度が高いオブジェクトは、マテリアルスロット数を「原則1」に抑えるように仕様化する。
  • Frame Debuggerの活用: Window > Analysis > Frame Debugger を開き、Draw Callを選択した際の「Why this draw call could not be batched with the previous one」を確認し、マルチマテリアルやシェーダーミスマッチによるバッチ切断が発生していないか常時監視する。
  • 静的オブジェクトのStatic設定: 動かないアセットはインスペクターで Static にチェックを入れ、Unityの静的バッチング(Static Batching)機能によって自動的に内部メッシュ結合が行われるようにする。