はじめに
こんにちは、SGEコア技術本部(コアテク)のプロジェクトサポートチームに所属している天野です。
プロジェクトサポートチームでは社内のプロジェクトに対してパフォーマンスチューニング、不具合調査、設計サポートなどを行なっています。
この記事ではパフォーマンスチューニングの一環としてモバイル環境(Metal・Vulkan)でのGraphicsStateCollection(GSC)を使ったシェーダーウォームアップについての知見を紹介します。
GSCを使ってシェーダーウォームアップを行うことでシェーダーコンパイルによる処理落ち(いわゆるシェーダーコンパイルスタッター)を防げますが、プロジェクトのアセット設計やシェーダーキーワード構成によってはうまく機能しないケースもあります。
本記事では基本的な使い方から、Unity 6.3 LTS(6000.3)で実際に経験した落とし穴とその解決策やワークアラウンドを紹介します。
GraphicsStateCollectionとは
GraphicsStateCollection (以下GSC)は Unity 6 で使えるようになった API で、ゲームプレイ中に使用されるシェーダーバリアントとグラフィックス状態の組み合わせを保存し、まとめてPipeline State Objectを生成、つまりウォームアップする仕組みです。Modern グラフィックスAPI(Metal・Vulkan・D3D12)向けの ShaderVariantCollection の代替として位置づけられています。
GraphicsStateCollection - Scripting API

Pipeline State Object とは
Pipeline State Object(以下PSO)は、シェーダーバリアントとレンダリング設定(頂点レイアウト・レンダーパスアタッチメント(カラーフォーマット・MSAA 等)・ブレンドステート・デプステスト・ステンシルステートなど)を一体として、GPU ドライバが GPU 向けマシン命令にコンパイルしたオブジェクトです。生成された PSO は端末にキャッシュされ、同じレンダリング設定で再利用されます。
なぜシェーダーコンパイルスタッターが発生するのか
シェーダーコンパイルスタッターの原因はランタイムでのコンパイル処理です。GPU が初めてシェーダーバリアントを描画するとき、Shader.CreateGPUProgram(Main Thread 上での SPIR-V/MSL レベルのシェーダーバリアントのコンパイル処理)と PSO 生成(Render Thread 上での GPU ドライバによるマシン命令へのコンパイル処理)の 2 段階で処理負荷が発生し、スタッターの原因になります。
Unity Profiler では、Main Thread の Shader.CreateGPUProgram と Render Thread の GpuProgramMetal.GetCachedPipeline(iOS)/ CreateGraphicsPipelineImpl(Android)として観測できます。
Metal(iOS)・Vulkan(Android)では PSO 生成コストが大きく、初回描画時に数 ms〜数十 ms のスタッターになります。GSC を使ったシェーダーウォームアップはこの PSO 生成を「ゲーム起動直後」などの任意のタイミングで行う手段です。
※ SPIR-V は Vulkan が採用する中間シェーダー表現、MSL(Metal Shading Language)は Metal 用のシェーダー言語です。詳細は Khronos SPIR-V Overview や Metal Shading Language Specification を参照してください。


補足 ─ ShaderVariantCollectionとの違い
従来の ShaderVariantCollection(以下SVC)は「どのシェーダーバリアントをコンパイルするか」は管理できますが、レンダリング設定を持ちません。そのため SVC のウォームアップは SPIR-V/MSL レベルのシェーダーバイトコードをコンパイルするにとどまり、実際の描画時には GPU ドライバが改めて PSO を生成するため、スタッターが残ります。
GSC を使った ウォームアップのフロー
では次にGSC の使い方を紹介します。
GSC を使ったウォームアップでは「レンダリング設定収集」と「ウォームアップ(事前コンパイル)」の 2 フェーズに分かれます。
フェーズ 1: レンダリング設定収集
private GraphicsStateCollection _gsc; void Trace(){ // 収集開始 _gsc = new GraphicsStateCollection(); _gsc.BeginTrace(); // ウォームアップを行いたいオブジェクトを描画 // GSC にGraphicsStateCollection.GraphicsStateとして記録される // 収集終了・保存 _gsc.EndTrace(); _gsc.SaveToFile(path); }
グラフィックスAPI毎に生成されるGraphicsStateCollection.GraphicsStateは異なるのでビルドしたアプリを動かすデバイスで行いましょう。
フェーズ 2: ウォームアップ(事前コンパイル)
async Task WarmUpAsync(){ // ロード _gsc = await Addressables.LoadAssetAsync<GraphicsStateCollection>(address).Task; // 段階的に WarmUp(Jobs ベース。毎フレーム n 件ずつコンパイル) StartCoroutine(WarmUpCoroutine()); } IEnumerator WarmUpCoroutine() { JobHandle handle = default; while (!_gsc.isWarmedUp) { handle = _gsc.WarmUpProgressively(4, handle); handle.Complete(); yield return null; } }
- ウォームアップのポイント
- ウォームアップ関数について
_gsc.WarmUp(dependency)でもウォームアップを実行できるが一括で全てのコンパイルを行うためスパイクが発生する。WarmUpProgressively(count, dependency)は1回あたりのコンパイル件数を指定しフレームをまたいで分散させることができる。
- シェーダーアセットのロード後に実行する(インスタンスが存在しないと WarmUp がスキップされる)。
isWarmedUpによる終了判定のほか、_gsc.completedWarmupCount == _gsc.totalGraphicsStateCountでも確認できる。- ウォームアップエラーが発生すると
isWarmedUpによる終了判定が受け取れない場合があるため、コンパイル件数を自前で加算して終了判定を行うことで無限ループを回避できる。
- ウォームアップ関数について
以上がGraphicsStateCollection を使ったシェーダーウォームアップの基本的な仕組みと使い方の紹介でした。
レンダリング設定の収集例
次はGraphicsStateCollectionの収集を収集専用シーンを作成し、アセットを順次描画するシナリオを実行する機械的な収集例を紹介します。
サンプルではAddressableとUniTaskを使用しています。
簡単にフローを説明します。
- 必要なアセットをダウンロード
- Built-inアセットのみで完結する場合は不要
- 収集に必要なアセットのAddressをAddressablesのコンテンツカタログ(
IResourceLocator)から取得- シーンアセット、収集対象アセット
- 収集開始(
_gsc.BeginTrace()を実行)- QualitySettingsの切り替え(QualitySettingsによってShaderが変化しない場合は不要)
- シーンを加算ロード
- 収集対象アセットを順次ロードして描画
- モデル描画
- ParticleSystemの再生
- Timelineの再生
- シーン破棄
- [3-1. QualitySettingsの切り替え]に戻る
- 収集完了(
_gsc.EndTrace()を実行) - 不要なShaderをGraphicsStateCollectionから削除
- GraphicsStateCollectionの保存(
_gsc.SaveToFile(path)を実行)
Addressablesのコンテンツカタログからレンダリング設定に影響するアセット、キャラクターやプロップなどのモデルアセット、エフェクトアセット、Timelineアセットを取得し、レンダリング設定を切り替えながらアセットを順次描画することで網羅的に収集します。
using System.Collections.Generic; using System.Linq; using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.ResourceLocations; using UnityEngine.Experimental.Rendering; using UnityEngine.Playables; using UnityEngine.SceneManagement; public class TraceSample: MonoBehaviour { [SerializeField] private string _catalogPath; [SerializeField] private string _gscSavePathFormat; // GSCに含めたいShader名 [SerializeField] private string[] _allowShaderNames = System.Array.Empty<string>(); [SerializeField] private int _characterNum = 4; // 1Fで同時に描画するキャラクターモデルの数 [SerializeField] private int _effectNum = 4; // 1Fで同時に描画するParticleSystemの数 [SerializeField] private float _effectSimulateTime = 0.5f; // ParticleSystem再生時にシミュレートする時間 private GraphicsStateCollection _gsc = new GraphicsStateCollection(); // 各種Address // 収集対象に応じて必要なアセットのアドレスを取得してください private string[] _sceneAddressArray; private string[] _charaAddressArray; private string[] _particlesAddressArray; private string[] _timelineAddressArray; // 同時描画数を指定して順次描画する private GameObject[] _charaObjectArray; // エフェクト(ParticleSystem)を複数同時に描画 private GameObject[] _particleObjectArray; private void Start() { if (_allowShaderNames.Length <= 0) { return; } StartAsync(); } private async UniTaskVoid StartAsync() { // アセットの準備と各種Addressの取得 await AddressableSetupAsync(); var qualitySettingNames = QualitySettings.names; for (var i = 0; i < qualitySettingNames.Length; i++) { QualitySettings.SetQualityLevel(i); await TraceAsync(); } } private async UniTask TraceAsync(){ // GSC収集開始 _gsc.ClearVariants(); _gsc.BeginTrace(); // 収集シナリオを実行 await ExecuteScenarioAsync(); // GSC収集終了 _gsc.EndTrace(); // 収集対象外のShaderをGSCをから削除する RemoveUnnecessaryVariants(); var qualityLevelName = QualitySettings.names[QualitySettings.GetQualityLevel()]; var savePath = _gscSavePathFormat + $"_{qualityLevelName}"; _gsc.SaveToFile(savePath); } // アセットの準備 private async UniTask AddressableSetupAsync(){ // コンテンツカタログの取得 var catalogHandle = Addressables.LoadContentCatalogAsync(_catalogPath); await catalogHandle.ToUniTask(); IResourceLocator catalog = catalogHandle.Result; // アセットエントリー取得 var catalogEntries = catalog.Keys.Select(k => k.ToString()).ToList(); // Catalogから各種アセット一覧を取得 // アセットタイプごとのPrefixから取得 // ここはプロジェクトの実装に合わせてPrefixの変更やパスからの識別などに変更してください _sceneAddressArray = catalogEntries.Where(k => k.StartsWith("Scene_")).ToArray(); _charaAddressArray = catalogEntries.Where(k => k.StartsWith("Chara_")).ToArray(); _particlesAddressArray = catalogEntries.Where(k => k.StartsWith("Particles_")).ToArray(); _timelineAddressArray = catalogEntries.Where(k => k.StartsWith("Timeline_")).ToArray(); // 必要なアセットのダウンロード(メモリへのロードは行わない) // アプリに組み込まれているアセットのみで完結する場合は事前のダウンロードは不要です var allAddresses = _sceneAddressArray .Concat(_charaAddressArray) .Concat(_particlesAddressArray) .Concat(_timelineAddressArray) .ToList(); var downloadHandle = Addressables.DownloadDependenciesAsync(allAddresses, Addressables.MergeMode.Union); await downloadHandle.ToUniTask(); Addressables.Release(downloadHandle); } // 収集シナリオを実行 private async UniTask ExecuteScenarioAsync(){ _charaObjectArray = new GameObject[_characterNum]; _particleObjectArray = new GameObject[_effectNum]; foreach (var sceneAddress in _sceneAddressArray) { // シーンファイルのロード var sceneHandle = Addressables.LoadSceneAsync(sceneAddress, LoadSceneMode.Additive); await sceneHandle.ToUniTask(); // キャラクターモデルを収集 for (var i = 0; i < _charaAddressArray.Length; i += _characterNum) { // キャラクターモデルを生成 var batchCount = Mathf.Min(_characterNum, _charaAddressArray.Length - i); for (var j = 0; j < batchCount; j++) { var charaAddress = _charaAddressArray[i + j]; var charaHandle = Addressables.LoadAssetAsync<GameObject>(charaAddress); var charaPrefab = await charaHandle.ToUniTask(); // カメラに収まる位置に均等配置 var pos = new Vector3((j - batchCount * 0.5f) * 2f, 0f, 5f); _charaObjectArray[j] = Object.Instantiate(charaPrefab, pos, Quaternion.identity); Addressables.Release(charaHandle); } // 1フレーム描画待ち await UniTask.NextFrame(); for (var j = 0; j < batchCount; j++) { DestroyImmediate(_charaObjectArray[j]); _charaObjectArray[j] = null; } } // ParticleSystemを収集 for (var i = 0; i < _particlesAddressArray.Length; i += _effectNum) { var batchCount = Mathf.Min(_effectNum, _particlesAddressArray.Length - i); for (var j = 0; j < batchCount; j++) { var particleHandle = Addressables.LoadAssetAsync<GameObject>(_particlesAddressArray[i + j]); var particlePrefab = await particleHandle.ToUniTask(); var pos = new Vector3((j - batchCount * 0.5f) * 3f, 0f, 5f); _particleObjectArray[j] = Object.Instantiate(particlePrefab, pos, Quaternion.identity); Addressables.Release(particleHandle); // 0.5秒後の状態を同期処理で再現(Playは不要) var ps = _particleObjectArray[j].GetComponentInChildren<ParticleSystem>(); if (ps != null) { ps.Simulate(_effectSimulateTime, withChildren: true, restart: true); } } // 1フレーム描画待ち await UniTask.NextFrame(); for (var j = 0; j < batchCount; j++) { DestroyImmediate(_particleObjectArray[j]); _particleObjectArray[j] = null; } } // Timelineを収集 foreach (var timelineAddress in _timelineAddressArray) { var timelineHandle = Addressables.LoadAssetAsync<GameObject>(timelineAddress); var timelinePrefab = await timelineHandle.ToUniTask(); var timelineObj = Object.Instantiate(timelinePrefab, Vector3.zero, Quaternion.identity); Addressables.Release(timelineHandle); var director = timelineObj.GetComponent<PlayableDirector>(); if (director != null) { director.Play(); await UniTask.Delay((int)(director.duration * 1000)); } DestroyImmediate(timelineObj); } // シーンのアンロード await Addressables.UnloadSceneAsync(sceneHandle).ToUniTask(); } } // 収集対象外のShaderをGSCをから削除する private void RemoveUnnecessaryVariants(){ var variants = new List<GraphicsStateCollection.ShaderVariant>(); _gsc.GetVariants(variants); foreach(var variant in variants){ if(variant.shader == null){ _gsc.RemoveVariant(null, variant.passId, variant.keywords); continue; } if(_allowShaderNames.Contains(variant.shader.name)){ continue; } // 不要なシェーダーをGSCから削除 _gsc.RemoveVariant(variant.shader, variant.passId, variant.keywords); } } }
シナリオやアセットの管理はプロジェクトによって異なると思うので、自身のプロジェクトに合わせてシーンや収集対象の調整を行う必要があります。
以上が収集例の紹介でした。
実際に起きた問題と対処事例
ここからはGSC によるウォームアップを導入したにもかかわらずスタッターが残るケースがいくつかあったため問題の紹介と対処事例を紹介します。
【事例 1-1】Shader Stripping でバリアントがビルドに存在しない
症状
ウォームアップ時のログに variant not found エラーが発生。ランタイムでもそのバリアントのコンパイルのログは発生しない。
原因
GSC に含まれるシェーダーバリアントがアプリまたはAssetBundleに含まれていない。
Project Settings/Graphics/Shader Strippingの設定によってはビルド時に使用されないバリアントが除外される。シェーダーの変更やアセットの変更によって必要なバリアントが変更された際に発生する可能性がある。
対処
Shader Stripping設定を確認し、バリアントに影響のある変更が行われた際にGSCを再度収集する。
【事例 1-2】ウォームアップ実行時mismatchの警告がでる
症状
ウォームアップ実行時のコンソールに以下の警告が出る。
WarmUp may be inaccurate due to GraphicsDeviceType mismatch. WarmUp may be inaccurate due to RuntimePlatform mismatch. WarmUp may be inaccurate due to Quality Level mismatch.
原因
GSC ファイルに保存された graphicsDeviceType / runtimePlatform / qualityLevelName メタデータが実行環境と一致しない。
- 主な発生ケース
- 異なるプラットフォームで収集した GSC を流用している(例:iOS 実機で収集した GSC を Android ビルドで使用)
- Editor スクリプトで GSC を編集・保存した際に、メタデータがEditorのPlatform設定に上書きされた
対処
GSCファイルの設定を修正する。
qualityLevelNameはQuality Levelによってシェーダーが変化しない場合は無視できるため、ウォームアップ直前にqualityLevelNameを変更することで警告を回避できる。
// GSCロード var gsc = LoadGsc(); // qualityLevelName の変更 gsc.qualityLevelName = QualitySettings.names[QualitySettings.GetQualityLevel()]; // ウォームアップ実行 ...
【事例 1-3】iOS / Metal でのAssetBundle の重複パッキングによる重複インスタンス
症状
iOS / Metal でのみ発生。ウォームアップ時のログにエラーはなし。Profiler の Main Thread で Shader.CreateGPUProgram は発生していない。しかし、Render Thread では GpuProgramMetal.GetCachedPipeline が発生している。
原因
同一シェーダーが複数の AssetBundle に重複してパッキングされると、Unity はメモリ上に 別々のシェーダーインスタンスを生成する。
GSCが参照しているシェーダーインスタンスは PSO 作成が行われるが、同じシェーダーであっても別インスタンスになっているシェーダーのウォームアップは行われない。
Vulkan ではこの問題は発生しないため、PSOキャッシュの管理が Metal と Vulkan で異なることがわかる。
発生環境と発生の流れ
- アセット
- シェーダーX
- マテリアルA(シェーダーXを使用)
- マテリアルB(シェーダーXを使用)
- GraphicsStateCollection
- AssetBundle構成
- バンドルA
- GraphicsStateCollection
- マテリアルA(シェーダーXを使用)
- シェーダーX ← 依存関係によるパッキング
- バンドルB
- マテリアルB(シェーダーXを使用)
- シェーダーX ← 依存関係によるパッキング
(シェーダーXにはアドレスが振られていない状態)
- ランタイムの流れ
1. バンドルAとバンドルBをロード
- バンドルA のシェーダーX (instanceId=100)
- バンドルB のシェーダーX (instanceId=200)
2. GraphicsStateCollectionのウォームアップを実行
- GraphicsStateCollectionが参照しているシェーダーX (instanceId=100)を使ってウォームアップが行われる
3. マテリアルAを描画
- シェーダーX (instanceId=100)の PSO キャッシュは作成済み ← スタッターなし
4. マテリアルBを描画
- シェーダーX (instanceId=200)の PSO キャッシュはなし ← Render Thread で PSO 作成が実行されスタッターが発生
対処
シェーダーを単一の バンドル(shader-bundle のような専用グループ)にまとめ、コンテンツバンドルへの暗黙パッキングを排除する。
シェーダーが複数のバンドルに分かれている又は複製されている場合はバンドル毎にGSCを用意する。
以下はランタイムで重複しているシェーダーを調べる方法の一例。
using System.Linq; // ABロード後に実行してインスタンスの重複を確認 var all = Resources.FindObjectsOfTypeAll<Shader>(); var duplicates = all.GroupBy(s => s.name).Where(g => g.Count() > 1); foreach (var g in duplicates) foreach (var s in g) Debug.Log($"Duplicate: {s.name} | InstanceID: {s.GetInstanceID()}");
Addressables を使っている場合は Addressables Report でもシェーダーの重複が確認できる。
ここまではプロジェクト設定やバンドル設計のミスによって引き起こされる問題を紹介しました。
次はUnity GSC の仕様上発生する問題を紹介したいと思います。
【事例 2-1】iOS / Metal での SubPass 使用シェーダーのウォームアップが機能しない
症状
iOS(Metal)でのみ発生。GSC のウォームアップを実施しログにもエラーは出ていない。しかし、ゲームプレイ中に PSO コンパイルが発生する。
原因
SubPass とはタイルメモリを複数の描画ステップで使い回す仕組みで、Metal・Vulkan では Native Render Pass として実行される。
iOS(Metal)のウォームアップ処理が、この SubPass(Native Render Pass)内で描画されるシェーダーに対して想定通り機能しない。前の SubPass の出力を読み込む input attachments の有無に関わらず発生する。
ウォームアップの内部処理は非公開APIのため発生ケースの紹介のみとします。
- 影響を受ける条件: 以下を満たす場合に発生する。
- iOS / Metal ターゲット(Apple A/M チップ実機)
RenderGraph.nativeRenderPassesEnabledが有効(Unity 6000.5 以降では廃止され常時有効)- SubPass(Native Render Pass)内で描画するシェーダーを使用。input attachments の有無は問わず、単純な書き込みのみの SubPass(URP Deferred の GBuffer パス等)でも、input attachments を持つ SubPass(同 Deferred Lighting パス等)でも同様に発生する
// ScriptableRenderContext.BeginRenderPass で // SubPass に input attachments を設定するケース var inputAttachments = new NativeArray<int>(new[] { 0, 1 }, Allocator.Temp); context.BeginSubPass( colors, inputAttachments, // 前 SubPass の出力バインド // Metal では framebuffer fetch に変換されるが、 // GSC ウォームアップ時の PSO 生成には反映されない false );
対処
Unity 6000.3では根本的な対処は難しい。
SubPassの利用を見直すか、ロード画面中にプリレンダリングを行うことでプレイ中のスタッターを回避できる。
以上が私が直面した問題の事例でした。
まとめ
GSC を使ったシェーダーウォームアップは効果的ですが、プロジェクトのアセット管理設計(Addressables の構成・シェーダーキーワード定義・バンドル設計)と密接に関係するため、導入後のデバッグが必要なケースが多いです。
ウォームアップ時のログの確認や繰り返しProfilerを使った計測を行いウォームアップ漏れがないか確認しましょう。
特に以下の点を押さえておくと診断が速くなります。
- ウォームアップ時のログに ウォームアップエラー や
variant not foundは出ていないか - Profiler で Main Thread の
Shader.CreateGPUProgram、Render Thread のGpuProgramMetal.GetCachedPipeline(iOS)/CreateGraphicsPipelineImpl(Android)が出ていないか - Shader Stripping の設定は適切か
- AssetBundle のパッキングは想定通りになっているか
【付録】 GSC の Editor API を使った操作例
付録としてEditor API を使ったGSCの操作を紹介します。
A. Variant 一覧と StateCount の確認
void PrintVariantInfo() { // GSCのロード var gsc = new GraphicsStateCollection(); if (!gsc.LoadFromFile(path)) return; // バリアントの取得 var variants = new List<ShaderVariant>(); gsc.GetVariants(variants); // GSCのメタ情報を出力 Debug.Log($"[GSC] Variants={variants.Count} TotalStates={gsc.totalGraphicsStateCount} " + $"Platform={gsc.runtimePlatform} Device={gsc.graphicsDeviceType} Quality={gsc.qualityLevelName}"); // バリアントの詳細を出力 foreach (var v in variants) { int stateCount = gsc.GetGraphicsStateCountForVariant(v.shader, v.passId, v.keywords); string kws = v.keywords.Length > 0 ? string.Join(" ", v.keywords.Select(k => k.name)) : "<no keywords>"; Debug.Log($" {v.shader?.name} | states={stateCount} | {kws}"); } }
B. 特定シェーダーの Variant 削除
void RemoveVariantsForShader() { // GSCのロード // ~省略~ // バリアントの取得 var variants = new List<ShaderVariant>(); gsc.GetVariants(variants); // variantsから削除したいバリアントの情報を取得 var deleteVariant = FindDeleteVariant(); gsc.RemoveVariant(deleteVariant.shader, deleteVariant.passId, deleteVariant.keywords); // Save gsc.SaveToFile(path); }
C. GSC のマージ
void MergeGsc() { // 2つのGSCをロード var src = new GraphicsStateCollection(); var dst = new GraphicsStateCollection(); if (!src.LoadFromFile(srcPath) || !dst.LoadFromFile(dstPath)) return; // バリアントを取得 var variants = new List<ShaderVariant>(); var states = new List<GraphicsStateCollection.GraphicsState>(); src.GetVariants(variants); // SrcGSCからDstGSCに追加 foreach (var v in variants) { // バリアントに含まれるGraphicsStateを取得 src.GetGraphicsStatesForVariant(v.shader, v.passId, v.keywords, states); foreach (var s in states) { // Stateを追加 dst.AddGraphicsStateForVariant(v.shader, v.passId, v.keywords, s); } } dst.SaveToFile(dstPath); }