Unityエディタの強力な機能の一つが、ゲーム内のあらゆるオブジェクトをパーツ化して再利用できる「Prefab(プレハブ)システム」です。開発の規模が大きくなると、「プロジェクト内にある数百個のPrefabに特定のコンポーネントを一括で追加したい」「特定のパラメータを一括で書き換えたい」という要望が生まれ、Editor拡張スクリプト(C#)を作成して自動化を図ることが一般的になります。
しかし、スクリプトから AssetDatabase.LoadAssetAtPath などでPrefabオブジェクトを直接取得し、値を書き換えて EditorUtility.SetDirty や EditorUtility.SetDirty、AssetDatabase.SaveAssets を実行したにもかかわらず、「Prefabを開いても値が元に戻っている」「アセットファイル自体は更新されているが、シーン上の配置済みインスタンスに変更内容が反映されない」「Prefabの親子関係(ネスト)やバリアント構造が破壊された」という深刻な不具合に遭遇することがあります。
本記事では、Unity 2018.3以降の「新プレハブシステム」に対応した、C#スクリプトからPrefabアセットを安全かつ確実に編集・保存するための正しい手順と PrefabUtility の使い方を詳しく解説します。
1. 根本原因:新しいプレハブ構造とメモリ上のアセット保護
Unity 2018.3において、プレハブシステムは「ネストされたプレハブ(Nested Prefabs)」および「プレハブバリアント(Prefab Variants)」をサポートするために根本から再構築されました。これ以降、プロジェクト内のプレハブアセット(.prefabファイル)は、単なるシリアライズされたアセットデータではなく、一種の「閉じたシーン」として管理されるようになりました。
古いUnityバージョン(2018.2以前)では、Prefabのアセット参照を直接ロードし、メモリ上のコンポーネントの値を直接書き換えて EditorUtility.SetDirty を呼ぶだけでアセットファイルに即座に反映されていました。しかし現在のシステムでは、この直接的なアセット編集は禁止されているか、あるいは動作してもシリアライズ関係(インスタンス接続情報)を壊す原因になります。
図:アセット参照の直接書き換え(破棄・伝播不可)と EditPrefabContentsScope(正常に伝播)の違い
現在のアセット参照を直接編集した場合の主な不具合の理由は以下の通りです:
- 仮想シーンの未展開: プレハブアセットは編集用シーンに展開されないとシリアライズデータが正しく構築されません。直接編集すると、Unityは編集内容を「一時的なメモリ上の変更」とみなし、プレイモードの切り替えやスクリプトの再コンパイル時にすべて初期化(破棄)してしまいます。
- オーバーライド情報の不整合: シーン上に配置されたPrefabインスタンスは、プレハブアセットのシリアライズID(FileID)を参照し、個別の「差分(Overrides)」のみを保持します。アセットを直接書き換えると、このFileIDの接続が破損したり、差分情報の再計算が行われないため、すでにシーンに配置されたインスタンス側への変更伝搬が完全にストップしてしまいます。
2. 解決策①:EditPrefabContentsScope を使用したPrefabアセットの安全な編集(推奨)
Prefabアセットそのものの値を直接スクリプトから編集・保存したい場合、最も安全でモダンな解決策は PrefabUtility.EditPrefabContentsScope(C#の using ステートメントと併用)を使用することです。
このAPIは、Unityエディタが内部的にPrefabアセットを一度「非表示の仮想編集シーン」にロードし、編集可能なGameObjectツリーとして展開します。using ブロックを抜ける際(Dispose時)に、加えられた変更を自動的に元のPrefabファイルへ書き戻し、シリアライズ情報とシーン上のインスタンス関係を正しく再構築(シリアライズ情報の再マップ)します。
実装例:Prefabのコンポーネント情報を一括編集するエディタスクリプト
using UnityEditor;
using UnityEngine;
public class BatchPrefabModifier
{
[MenuItem("Tools/Batch Modify Prefab Properties")]
public static void ModifyPrefabs()
{ string prefabPath = "Assets/Prefabs/MyEnemy.prefab";
// 1. Prefabアセットを仮想シーンに展開してロード
using (var editingScope = new PrefabUtility.EditPrefabContentsScope(prefabPath))
{ // 仮想シーンにロードされたプレハブのルートオブジェクトを取得
GameObject rootPrefabObj = editingScope.prefabContentsRoot;
// 例:特定のコンポーネント(例:EnemyController)を取得して編集
EnemyController enemy = rootPrefabObj.GetComponent<EnemyController>();
if (enemy != null)
{ // 値の変更
enemy.maxHp = 150;
enemy.attackPower = 25;
// 2. 仮想オブジェクトのコンポーネントが変更されたことをUnityにマーク
EditorUtility.SetDirty(enemy);
Debug.Log($"Prefab: {prefabPath} の EnemyController パラメータを更新しました。");
}
else
{ Debug.LogWarning($"Prefab: {prefabPath} に EnemyController が見つかりませんでした。");
}
} // 3. usingスコープを抜ける際、自動的にアセットファイルへ保存・シリアライズ更新が実行される
// 念のためデータベースをリフレッシュ
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
}
このスコープを使用することで、裏で自動的に EditorSceneManager.MarkSceneDirty などが動き、プレハブバリアントやネストされたプレハブの階層木構造を破壊することなく、シーン上のインスタンスに対しても即座に変更が伝搬・反映されるようになります。
3. 解決策②:シーン上のPrefabインスタンスのプロパティ書き換えとApply(適用)
「すでにシーン上に配置されている特定のPrefabインスタンス」をスクリプトで書き換え、それをアセット(元データ)に適用(Apply)したい場合は、プレハブインスタンス独自の変更管理とApplyフローを実行する必要があります。
ステップ1:インスタンスのプロパティ変更をオーバーライドとして記録する
シーン上のPrefabインスタンスのプロパティを直接書き換えるだけでは、シリアライズの「オーバーライド差分」として正しく登録されず、ゲーム実行時や再コンパイル時に値が戻ってしまいます。必ず PrefabUtility.RecordPrefabInstancePropertyModifications を呼び出して、変更を「オーバーライド情報」として登録します。
// シーン上のプレハブインスタンスを取得
GameObject instanceObj = GameObject.Find("MyEnemyInstance");
EnemyController enemy = instanceObj.GetComponent<EnemyController>();
if (enemy != null)
{
enemy.maxHp = 200; // 値の書き換え
// 変更をシリアライズオーバーライドとしてUnityに登録
PrefabUtility.RecordPrefabInstancePropertyModifications(enemy);
}
ステップ2:書き換えたオーバーライド内容を元のPrefabアセットに適用する
インスタンスの変更内容を、元のPrefabアセット(ファイル)側へスクリプトから「Apply」したい場合は、PrefabUtility.ApplyPrefabInstance を使用します。これにより、インスペクターの「Overrides -> Apply All」をスクリプトで自動実行したのと同じ状態になります。
GameObject instanceObj = GameObject.Find("MyEnemyInstance");
if (PrefabUtility.IsPartOfPrefabInstance(instanceObj))
{
// インスタンスの変更をアセット側へ適用(Apply All)
PrefabUtility.ApplyPrefabInstance(instanceObj, InteractionMode.AutomatedAction);
Debug.Log("シーン上のプレハブインスタンスの変更をアセットに適用しました。");
}
4. 解決策③:新規作成したGameObjectをPrefabアセットとして新規保存・上書き
シーン上で新しく構築したGameObjectをPrefabアセットとして新規保存、または既存のPrefabアセットファイルを完全に上書きしたい場合は、PrefabUtility.SaveAsPrefabAsset を使用します。
GameObject tempObj = new GameObject("NewCoolItem");
tempObj.AddComponent<BoxCollider>();
string savePath = "Assets/Prefabs/NewCoolItem.prefab";
// 新しいPrefabアセットとして保存
GameObject prefabAsset = PrefabUtility.SaveAsPrefabAsset(tempObj, savePath);
// シーン上のtempObjをPrefabインスタンスとして接続させたい場合はこちらを使用
// GameObject connectedAsset = PrefabUtility.SaveAsPrefabAssetAndConnect(tempObj, savePath, InteractionMode.AutomatedAction);
// 不要になったシーン上のオブジェクトを破棄
GameObject.DestroyImmediate(tempObj);
※すでに存在するPrefabアセットパスに対して SaveAsPrefabAsset を呼ぶと、上書き保存されます。この際、アセットのGUID(グローバルユニークID)は維持されるため、他のシーンでこのプレハブを参照していたコンポーネントのリンクが切れることはありません。
5. プレハブ書き換えAPIの使い分け一覧表
| 用途 | 推奨されるAPI・手法 | 注意点 |
|---|---|---|
| Prefabアセット(ファイル)の内容を直接・一括編集したい | using (var scope = new PrefabUtility.EditPrefabContentsScope(path)) |
仮想シーンを内部ロードするため、ループ内で大量に実行すると処理が重くなる。 |
| シーン上のプレハブインスタンスの値をスクリプトから書き換えたい | プロパティ変更後に PrefabUtility.RecordPrefabInstancePropertyModifications(target) |
呼び忘れると、シーン保存時や再コンパイル時に変更値が失われる。 |
| シーン上のインスタンスの変更内容を、Prefabアセット(ファイル)側に同期させたい | PrefabUtility.ApplyPrefabInstance(instance, InteractionMode.AutomatedAction) |
ネストされたプレハブの奥深くにあるコンポーネントを適用する場合、親プレハブへの影響を考慮する必要がある。 |
| シーン内の新規オブジェクトから新規Prefabを作成、またはアセットを完全上書きしたい | PrefabUtility.SaveAsPrefabAsset(gameObject, path) |
既存アセットに上書きする場合でもGUIDは維持されるが、オブジェクト構造が丸ごと変わるため、変更履歴の差分が大きくなる。 |
6. 大量一括編集時のパフォーマンス最適化のコツ
ゲームの制作終盤などで、プロジェクト内の数百・数千のPrefabアセットを一括編集する場合、EditPrefabContentsScope を素朴にループで回すと、Prefabを保存するたびにUnityの「アセット再インポート(Asset Import)」および「シリアライズ再計算」が走り、処理時間が数十分におよんだり、最悪の場合はメモリ不足でUnityエディタがクラッシュします。
これを防ぐためには、以下のように「アセットデータベースの自動インポート更新を一時的に無効化」する処理を追加します。
using UnityEditor;
using UnityEngine;
using System.IO;
public class BulkPrefabModifier
{
[MenuItem("Tools/Bulk Modify Enemy Prefabs")]
public static void ModifyAllEnemies()
{ // プロジェクト内のプレハブ一覧を取得
string[] allPrefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Prefabs/Enemies" });
try
{ // ★コツ1:アセットのインポート処理を一時的に保留・ブロックする
AssetDatabase.StartAssetEditing();
int total = allPrefabGuids.Length;
for (int i = 0; i < total; i++)
{ string path = AssetDatabase.GUIDToAssetPath(allPrefabGuids[i]);
// ★コツ2:進捗ダイアログを表示して進捗をユーザーに通知(エディタのフリーズ感覚を軽減)
EditorUtility.DisplayProgressBar("Prefab一括編集中", $"{Path.GetFileName(path)} を処理中... ({i + 1}/{total})", (float)i / total);
using (var scope = new PrefabUtility.EditPrefabContentsScope(path))
{ GameObject root = scope.prefabContentsRoot;
EnemyController enemy = root.GetComponent<EnemyController>();
if (enemy != null)
{ enemy.isBoss = false;
EditorUtility.SetDirty(enemy);
} }
}
}
finally
{ // ★重要:アセットインポートを再開し、一括でインポートを実行(ドローコールやDB更新が1回で済むため劇的に高速化)
AssetDatabase.StopAssetEditing();
// 進捗ダイアログを閉じる
EditorUtility.ClearProgressBar();
}
// 最後に一度だけアセット保存・リフレッシュ
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("すべてのプレハブの一括編集が正常に完了しました。");
}
}
この AssetDatabase.StartAssetEditing() および AssetDatabase.StopAssetEditing() を使用したラッピングテクニックを導入することで、編集中の1件ごとのディスク書き込み・リインポート処理を完全にバイパスし、ループ処理時間を1/10以下(数分から数秒レベル)に短縮することが可能になります。エディタ拡張やアセット管理ツールを構築する際は、必ず try-finally とセットで組み込むようにしましょう。