Unity を検索

Spotlight チームのベストプラクティス ― GUID ベースの参照

2018年7月19日 カテゴリ: Engine & platform | 8 分 で読めます
取り上げているトピック
シェア

Is this article helpful for you?

Thank you for your feedback!

Spotlight チームでは、野心溢れる Unity デベロッパーの方々との共同作業により、Unity ゲームの可能性の限界を押し上げる取り組みを進めています。私達は日々、複雑なグラフィックス、パフォーマンス、デザインの問題に対する、革新的な優れたソリューションを目にしています。また、同じ問題が同じソリューションで繰り返し解決されている状況も目の当たりにしています。

本ブログシリーズでは、Unity の顧客の皆様と連携して取り組む中で、最も頻繁に発生している問題のいくつかについてご紹介していきます。Unity と共に取り組んだ様々なチームが多大な労力の末に獲得した知恵を、ここですべてのユーザーの皆様に共有できることを嬉しく思います。

これらの問題の多くは、実際にゲーム機やスマートフォンでの作業を開始した後や、膨大な量のゲームコンテンツを扱う段階になってから初めて発覚するものです。ここでご紹介する教訓を開発の早期の段階から考慮しておくことで、制作の進行がよりスムーズになり、ゲームはさらに高みを目指せるはずです。

スケーラビリティ

マルチシーン編集が Unity に組み込みになったことで、複数のシーンを使用してゲームプレイの特定のユニットや機能を定義するチームが増えて来ています。これに伴い、Unity が長年抱える「別のシーン内にあるオブジェクトを参照できない」という制約が、以前にも増して負担となる状況になりました。

現在は、この制約は様々な方法で回避できるようになっています。多くのチームが、オブジェクトを直接参照する必要性を抑えるために、スポナーやプレハブ、プロシージャルコンテンツ、あるいはイベントシステムを多用しています。最も多く見られる回避方法は、ゲームオブジェクトに永続的なグローバル一意識別子(GUID)を与えるというものです。一意な識別子があれば、ゲームオブジェクトの既知のインスタンスを、それがどこに存在していても、(それが読み込まれていない場合や、セーブしたゲームデータを実際のランタイムのゲーム構造にマップする場合にも)参照できるようになります。

非常に多くのクライアントが同じ問題を同じ方法で解決されていたので、参照の実装に踏み切りました。様々な選択肢を検討の末、ユーザー視点に立った、公開 API と C# のみを使用するアプローチを採ることにしました。これには Unity 社内で有益なテストケースになるというだけでなく、コードを皆様に直接シェアできるという利点があります。ビルドの提供をお待ちいただく必要はありません。GitHub からコードをダウンロードしてすぐに使用を開始し、ご自身のニーズに合わせて変更を行っていただけます。

こちらでソリューション全体をご確認いただけます。

このソリューションの基本的な構造は非常に単純です。GUID ですべてのオブジェクトを参照できる、グローバルで静的なディクショナリが 1 つあります。特定のオブジェクトが必要で GUID がある場合は、ディクショナリ内を検索します。そのオブジェクトが存在していればそれが取得され、存在しなければ null が取得されます。このソリューションは多種多様なユーザーのプロジェクトに導入されることになるので、可能な限りシンプルにすることを目指しました。上記のリンクから、GUIDManager.cs の中で、どのようにしてゲームオブジェクトによるオーバーヘッドが一切生じない形で静的なマネージャーをセットアップしているか、ご確認していただけます。

永続性

最初に乗り越えるべき課題は、System.GUID から取得する GUID を、Unity が MonoBehaviour の一部としてシリアライズできる形式にすることでした。私は以前、これを自分で試してみましたが、幸いなことにその後 ISerializationCallbackReceiver が追加されたので、これは比較的簡単に行えました。簡単なパフォーマンステストを行ったところ、.ToByteArray() は .ToString() に比較して 2 倍速く、外部のメモリは一切割り当てられないことが分かりました。Unity は byte[] を問題なく扱えるので、補助記憶装置用には明らかに .ToByteArray() が適していると分かりました。

System.Guid guid = System.Guid.Empty;
[SerializeField]
private byte[] serializedGuid;

public void OnBeforeSerialize()
{
   if (guid != System.Guid.Empty)
   {
       serializedGuid = guid.ToByteArray();
   }
   }

// 読み込み時に、システム GUID を後の使用に備えて修復できます。
public void OnAfterDeserialize()
   {
   if (serializedGuid != null && serializedGuid.Length == 16)
   {
       guid = new System.Guid(serializedGuid);
   }
}

GUID が適切に保存されるようになったのですが、残念なことに、これだけでは不十分でした。プレハブの複製、およびコンポーネントの複製は、両方とも GUID のコリジョンを発生させます。このケースでは、その検知と修復は可能ですが、始めから重複した GUID が一切作成されないようにするほうが明らかに効率的です。ありがたいことに PrefabUtility は、扱っているゲームオブジェクトの種類を特定して、適切に反応するようにする方法をいくつか提供しています。

#if UNITY_EDITOR
// これにより、プレハブインスタンスであるかプレハブアセットであるかを特定できます。
// プレハブアセットは GUID を含むことはできません。インスタンス化された際に重複することになるからです。
PrefabType prefabType = PrefabUtility.GetPrefabType(this);
if (prefabType == PrefabType.Prefab || prefabType == PrefabType.ModelPrefab)
   {
    serializedGuid = new byte[0];
    guid = System.Guid.Empty;
   }
else
#endif

さらにテストを行った結果、「元になるプレハブの壊れたプレハブインスタンスが、新しい GUID のインスタンスデータを保存しない」という稀なエッジケースがあることが分かりました。ここでも PrefabUtility が役に立ちます。CreateGuid 関数の内部で以下の処理を行って、このエッジケースに対処しています。

#if UNITY_EDITOR
// 特定のプレハブのプレハブインスタンス用に新しい GUID を作成していて、何らかの理由でプレハブの接続が失われた場合は、
// 修正されたプレハブインスタンスのプロパティを強制保存します。
PrefabType prefabType = PrefabUtility.GetPrefabType(this);
if (prefabType == PrefabType.PrefabInstance)
   {
   PrefabUtility.RecordPrefabInstancePropertyModifications(this);
   }
#endif

ツール

すべてが適切に保存されたところで、次に考えるべきは、ユーザーがこのシステム内にどのような方法でデータを取り込むかでした。新しい GUID の作成は簡単でしたが、それらを参照するのは少し困難なことが分かりました。複数のシーンにわたる参照を設定するためには、ユーザーに両方のシーンを読み込んでもらうというのは非常に理に適っています。したがって初期設定は単純に、通常のオブジェクト選択フィールドに GuidComponent を設定するだけで済みました。しかし、その参照は保存されないようにする必要がありました。異なるシーンにまたがっているため、Unity のエラーが出て、後に無効化されてしまうからです。GuidReference をあたかも通常のオブジェクトフィールドのような作りとしながらも、参照は保存せずに GUID だけを保存する必要がありました。

幸い、特定のタイプ用にカスタムの Drawer をセットアップするのは簡単です。これがまさに [PropertyDrawer] タグの目的です。ピッカーを設定し、結果を取得して、必要な GUID データをシリアライズするだけです。しかし、読み込まれていないシーンの中にターゲットのゲームオブジェクトがある場合はどうするのでしょうか。ユーザーに対して、値のセットがあることを通知し、そのオブジェクトに関するできる限り多くの情報を提供する必要があります。

結果、以下のようなソリューションになりました。

複数のシーンに格納された同一オブジェクトへの参照

上記で設定された GuidComponent を指定する、無効化されたオブジェクトセレクター、ターゲットの含まれるシーンを含んだ無効化されたフィールド、そして現在選択されているターゲットをクリアできるボタンが 1 つあります。これが必要だった理由は、ユーザーがオブジェクトピッカー内で None を選択しているのか、あるいはターゲットのオブジェクトが読み込まれていないために値が null になっているのか、どちらであるか検知することができないからです。しかしこれには大きな利点があります。シーン参照が Asset フィールドになっているので、実際のターゲットオブジェクトが必要な場合に読み込む必要のあるシーンが、以下のように強調表示されることです。

ターゲットのあるシーンが強調表示された状態

すべてのコードは GitHub の GuidReferenceDrawer.cs 内にあります。フィールドを編集不可にしながらも実際のフィールドのような挙動を実装する方法は、以下の通りです。

bool cachedGUIState = GUI.enabled;
GUI.enabled = false;
EditorGUI.ObjectField(position, sceneLabel, sceneProp.objectReferenceValue, typeof(SceneAsset), false);
GUI.enabled = cachedGUIState;

テスト

すべてが正しく機能するようになったところで、次に必要となったのは、正常な機能が確実に維持されるようにすることでした。私は、Unity 社内で使用するためのテストを記述したことはありましたが、ユーザーコード用の記述経験はありませんでした。お恥ずかしながら、組み込みの Test Runner に関しては知識が乏しかったのです。ですが、Test Runner は驚くほど便利なのです!

上のような状態を作ることが目的です。

「Window」メニューからアクセス可能な Test Runner は、再生モードと編集モードの両方で、テストを作成できる便利な UI を表示します。テストの作成は比較的簡単に行うことができました。

例えば以下は、GUID が重複した時にそれを知らせるメッセージが出るようにするテストの全体です。

[UnityTest]
public IEnumerator GuidDuplication()
   {
   LogAssert.Expect(LogType.Warning, "Guid Collision Detected while creating GuidTestGO(Clone).\nAssigning new Guid.");

   GuidComponent clone = GameObject.Instantiate(guidBase);

   Assert.AreNotEqual(guidBase.GetGuid(), clone.GetGuid());

   yield return null;
}

このコードは、出るべき警告が取得されない場合は失敗するようにテストハーネスに伝え、私が事前に作成した GuidComponent のクローンを作成し、GUID コリジョンが発生しないようにした上で終了します。

guidBase は、[OneTimeSetUp] 属性をつけたセットアップ関数を、このファイル内のテストを開始する前に 1 度だけ呼び出して生成しています。このため、guidBase の存在は保証されています。詳細はこちらのドキュメンテーションでご覧ください。このテストの記述は予想した以上に簡単でした。また、テスト記述の思考プロセスをなぞっただけでコード自体が各段に改善されたことにも驚かされました。皆様も、ご自身のチームで使用しているツールのテストをぜひ行ってみてください。

今後の計画

この参照のためのソリューションは、ユーザーの皆様に厳しくテストしていただき、実際の使用に耐え得ることが実証されたら、GitHub ではなくアセットストアでの無料ダウンロードあるいはパッケージマネージャー経由で入手可能になる予定です。差し当たっては、改善の余地がある要素や問題に関して、ぜひフィードバックをお寄せください。また今後も Spotlight チームから、Unity を最大限に活用するためのヒントを本ブログでお届けしてまいりますので、お楽しみに!

2018年7月19日 カテゴリ: Engine & platform | 8 分 で読めます

Is this article helpful for you?

Thank you for your feedback!

取り上げているトピック
関連する投稿