sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

Unity Assets, Resources and AssetBundles の記事(雑訳)

f:id:shikaku_sh:20210205155735p:plain

この記事は Unity Learn「Assets, Resources and AssetBundles」の内容を個人的に学習したときのメモを公開しています。

AssetBundle はすこし古いテクニックになっていますが、Resources は今でも初心者向けの教科書では常用されているかと思います。このあたりのリソース管理について、基本的な理解をするのに役立ちそうな1~3章を(おかしなところがあると思うけど、ざっくりと)和訳。

4、5 章の AssetBundle の解説が抜けていますので、そっちも気になるなら元記事参照のこと。

1. AsesetBundle と Resources のガイド

Unity エンジンにおけるアセットとリソース管理についての突っ込んだ討論を提供する連載記事です。

エキスパート開発者に Unity のアセットとシリアライズについて深いレベルのノウハウを提供することを目的としています。Unity の AssetBundle システムとそれを採用するための現在のベストプラクティスの両方を検証します。

ガイドは4つの章に分けれています:

  • Assets, Objects and serialization
    Unity がアセットをどのようにシリアライズしてアセット間の参照をハンドルするか low レベルな詳細を検討します。この章は本ガイドを通して使用される用語が定義されているため、この章からはじめることを強くオススメします。
  • Resources folder
    ビルドインの Resources API について説明します。
  • AssetBundle Fundamentals
    1章の話を基にして、AssetBundle がどのように動作するのか、AssetBundle のロード、AssetBundle からアセットをロードする両方のやり方を説明します。
  • AssetBundle usage patterns
    AssetBundle の実用的な使用方法についていろいろなトピックを検討した長い記事です。AssetBundle のアセット割り当て、ロードされたアセットの管理についてなど、AssetBundle を使用している開発者がおちいる共通した落とし穴の多くについて説明します。

Note: 本ガイドのオブジェクトとアセットの用語は Unity の public API命名規則と異なることがあります。

このガイドでオブジェクトと呼ぶデータは、AssetBundle.LoadAsset や Resources.UnloadUnusedAssets など Unity の public API では Assets と呼ばれています。本ガイドで Assets と呼ぶファイルが、public API でそのように表現されることは稀です。

通常、AssetDatabase や BuildPipeline などのビルド関連のコード内でのみです。それらの場合、これらファイルは public API ファイルと呼ばれます。

2. Assets, Objects and serialization

この章では Unity のシリアライズの内部と Unity エディターとランタイムの両方で Unity が異なるオブジェクト間で強固に参照を維持する方法について説明します。

また、オブジェクトとアセットの技術的な区別についても説明をします。トピックは、Unity でアセットを効率的にロード・アンロードする方法の基礎をカバーします。適切なアセット管理は、ロード時間を短縮しメモリー使用量を低下させるために重要です。

2.1 アセットとオブジェクトの内部

Unity でデータを適切に管理する方法を理解するためには、Unity がどのようにデータを認識しシリアライズするのかを理解することが重要です。最初のポイントは AssetsUnityEngine.Objects の区別です。

Assets とは、Unity の Assets フォルダーの中に保存されているディスク上にあるファイルのことです。テクスチャ、3Dモデル、Audio Clip などがアセットの一般的な型です。アセットの中には material などの Unity のネイティブなフォーマットのデータを含んでいるものもあります。その他のアセットは、FBX ファイルのようにネイティブなフォーマットに処理する必要があります。

UnityEngine.Object(大文字の「O」がついたオブジェクト)は、特定のインスタンスをまとめて記述したシリアル化されたデータセットです。これは、mesh, sprite, AudioClip, AnimationClip など、Unity Engine が使用するあらゆるタイプのリソースです。すべてのオブジェクトは UnityEngine.Object 基底クラスのサブクラスになります。

ほとんどのオブジェクトの型はビルドインされていますが、2つの特別な型があります。

  • ScriptableObject
    開発者が独自のデータ型を定義するために便利な機能を提供します。Unity によってネイティブにシリアライズ・デシリアライズされ、また、Unity エディターのインスペクターウィンドウで操作することができます。
  • MonoBehaviour
    MonoScript にリンクするラッパーを提供します。MonoScriptは、Unity が特定のアセンブリやネームスペース内の特的のスクリプトクラスへの参照を保持するために使用する内部のデータ型です。MonoScript は実際の実行コードは含まれていません。

アセットとオブジェクトの間には「1対多」の関係があり、アセットファイルには1つ以上のオブジェクトが含まれます。

2.2 オブジェクト間の参照

すべての UnityEngine.Object は、他の UnityEngine.Object を参照することができます。他のオブジェクトらは、同じアセットファイル内に含まれることや、他のアセットファイルからインポートされる場合もあります。

たとえば、マテリアルのオブジェクトは、通常だとテクスチャのオブジェクトに参照を1つ以上持っています。これらのテクスチャオブジェクトは、1つ以上のテクスチャのアセットファイル(PNG, JPG など)からインポートされいます。

シリアライズされるとき、これらの参照は、「File GUID」「Local ID」という2つの別々のデータから構成されます。

  • File GUID
    ターゲットリソースが格納されているアセットファイルを識別します。
  • Local ID アセットファイルには複数のオブジェクトが含まれている場合があるので、アセットファイル内の各オブジェクトを識別します。

ローカル ID は、同じアセットファイルの中で他のすべてのオブジェクトのローカル ID において一意です。

File GUID は、.meta ファイルに格納されます。これらの .meta ファイルは Unity がアセットを最初にインポートするときに生成され、アセットと同じディレクトリーに格納されます。

fileID = local identifier in file。どっちも meta ファイルに含まれているはずです。

上述の識別・参照の仕組みは、テキストエディターでも確認することができます:

  1. 新しい Unity プロジェクトを作成して、エディター設定を変更
  2. Visible Meta Files を有効化(外部のバージョン管理システムのサポートを有効化します)
  3. アセットをテキストとしてシリアライズ
  4. マテリアルを作成してプロジェクトにテクスチャをインポート
  5. マテリアルをシーンのキューブに割り当ててシーンを保存

テキストエディターを使用してマテリアルに関連づけられた .meta ファイルを開きます。ファイルの上のほうに "guid" Local ID を見つけるには、テキストエディターでマテリアルファイルを開きます。マテリアルオブジェクトの定義は以下のようになっています:

f:id:shikaku_sh:20210309184357p:plain
サンプル

--- !u!21 &2100000
Material:
 serializedVersion: 3
 ... more data …

上述の例では、「&」ではじまる数字はマテリアルの Local ID です。このマテリアルのオブジェクトがファイル GUID「abcdefg」で識別されているアセット内にあったなら、マテリアルのオブジェクトはファイル GUID「abcdefg」+Local ID「2100000」の組み合わせで一意に識別されます。

2.3 なぜファイル GUID とローカル ID か?

なぜ Unity の File GUID と Local ID の仕組みが必要なのでしょうか? その答えは、堅牢性とプラットフォームに依存しない柔軟なワークフローを提供するためです。

File GUID は、ファイルを特定する位置の抽象化を提供します。File GUID が具体的なファイルに関連づけられる限り、そのファイルはディスク上の位置と無関係です。ファイルを参照しているすべてのオブジェクトを更新することなく、ファイルを自由に移動させることができます。

アセットファイルには複数の UnityEngine.Object リソースが含まれている(あるいはインポートで生成される)可能性があるので、各オブジェクトを明確に区別するためにローカル ID が必要になります。

アセットファイルに関連づけられた File GUID が失われると、そのアセットファイル内のすべてのオブジェクトへの参照も失われます。このため .meta ファイルは関連するアセットファイルと同じフォルダーに同じファイル名で保存しておくことが重要です。Unity は削除・配置に失敗した .meta ファイルを再生成することに注意します。

Unity エディターは、既知の File GUID から具体的なファイルパスへのマップがあります。アセットをロードまたはインポートするたびにマップエントリーが記録されます。マップエントリーは、アセットの具体的なパスとアセットの File GUID をリンクしています。

Unity エディターを開いている間に、アセットのパスを変更せずに、.meta ファイルが失われたときは、エディターはアセットが同じ File GUID を保持しています。

Unity エディターを閉じている間に、.meta ファイルが失われた場合、アセットのパスが .meta ファイルと一緒に移動させなかった場合、アセット内のオブジェクトへの参照はすべて破棄されます。

2.4. 合成アセットとインポーター

「アセットとオブジェクトの内部」のセクションで述べたように、ネイティブではないアセット型は Unity にインポートする必要があります。これはアセットインポーターを介して実行されます。これらのインポーターは通常では自動的に呼び出されますが、AssetImporter API を介するスクリプトも公開されています。

たとえば、TextureImporter API は、PNG ファイルなどの個々のテクスチャアセットをインポートする際に使用する設定にアクセスすることができます。

インポート処理の結果、1つまたは複数の UnityEngine.Objects が生成されます。たとえば、スプライトアトラスとしてインポートされたテクスチャのアセットの中で入れ子になっている複数のスプライトなどは、親アセットの中にある複数のサブアセットとして Unity エディターに表示されます。

これらのオブジェクトは、ソースデータが同じアセットファイル内に保存されているためファイル GUID を共有しますが、インポートされたテクスチャアセット内で Local ID によって区別されます。

インポートの過程では、ソースアセットを Unity エディターで選択されたターゲットプラットフォームに適した形式に変換します。(テクスチャの圧縮など、重たい操作の多くを実行できます)ここは時間のかかるプロセスが多いので、インポートされたアセットはライブラリーフォルダーにキャッシュされるので、次回のエディター起動時にアセットを再インポートする必要がなくなります。

具体的には、インポートのプロセスの結果はアセットの File GUID の最初の2桁の数字に対応するフォルダーに保存されます。このフォルダーは Library\metadata\ の中に保存されます。アセットの個別オブジェクトは、アセットの File GUID と同じ名前を持つ単一のバイナリファイルとしてシリアライズされます。

このプロセスは、非ネイティブアセットに限らず、すべてのアセットに適用されます。ネイティブアセットは、長い変換処理や再シリアライズを必要としません。

2.5 シリアライズインスタンス

File GUID と Local ID は堅牢ですが、GUID の比較は時間がかかり、実行時にはよりパフォーマンスの高いシステムが必要になります。Unity は File GUID と Local ID を単純なセッション固有の整数に変換するキャッシュを内部的に維持しています。(このキャッシュは内部的に PersistentManager と呼ばれています)これらの整数は Instance ID と呼ばれ、新しいオブジェクトがキャッシュに追加されると単純な一本調子で増加する番号を割り当てられます。

キャッシュはオブジェクトのソースデータの位置を定義する Instance ID、File GUID, Local ID とメモリ上のオブジェクトのインスタンス(存在する場合)の間のマッピングを保持します。これにより、UnityEngine.Objects はお互いの参照を強固に維持することができます。

Instance ID の参照を解決すると、その Instance ID で表されるロードされているオブジェクトを素早く返却することができます。対象のオブジェクトがまだロードされていないときは、File GUID と Local ID でオブジェクトのソースデータを解決することで、ジャストインタイム(必要なものを、必要なときに、必要なぶんだけ)ロードすることができます。

AssetBundle のアンロードの意味については「AssetBundle Usage Patterns」ステップの「Managing Loaded Assets」セクションを参照してください。

プラットフォームによっては、特定のイベントによってオブジェクトが強制的にメモリーからアンロードされることがあります。たとえば iOS では、アプリがサスペンドされると画像のアセットがグラフィックメモリーからアンロードされることがあります。

これらのオブジェクトがアンロードされた AssetBundle に由来する場合、Unity はオブジェクトのソースデータをリロードすることができませんし、オブジェクトへの既存の参照も無効になります。たとえば、シーンにメッシュやテクスチャが見えないように表示されるかもしれません。

実装上の注意として、実行時には、上述の制御フローは文字通り正確ではないです。実行時に File GUID と Local ID を比較しても十分なパフォーマンスは得られません。Unity プロジェクトを構築する際には、File GUID と Local ID はより単純な形式でマッピングされます。しかし、概念は変わらないので、File GUID と Local ID の観点であれば、実行時の有用なアナロジーであることに変わりありません。(これは、アセットの File GUID は実行時に問い合わせできない理由でもあります)

MonoScript

MonoBehaviour は MonoScript への参照を持ち、MonoScript は特定のスクリプトクラスを見つけるために必要な情報を含んでいるだけである、と理解することが重要です。どちらのオブジェクトにもスクリプトクラスの実行できるコードは含まれていません。

MonoScript には3つの string が含まれています:

プロジェクトのビルド中に Unity は Assets フォルダー内のすべての自由なスクリプトファイルを Mono アセンブリーにコンパイルします。Plugins フォルダー外の C# スクリプトAssembly-CSharp.dll に配置されます。Plugins サブフォルダー内のスクリプトAssembly-CSharp-firstpass.dll に配置されます。さらに Unity 2017.3 では「Assembly Definition」の機能が導入されています。 これらのアセンブリーはビルド済のアセンブリ DLL ファイルと同様に Unity アプリケーションの最終ビルドに含まれます。これらのアセンブリーは MonoScript が参照するアセンブリーでもあります。他のリソースとは異なり Unity アプリケーションに含まれるすべてのアセンブリーはアプリケーションの起動時にロードされます。

MonoScript オブジェクトは、AssetBundle(または Scene や Prefab)が AssetBundle、Scene、プレハブのいずれの MonoBehaviour コンポーネントにも実行可能なコードを含まない理由になっています。これにより、MonoBehaviour が異なる AssetBundle にある場合でも異なる MonoBehaviours が特定の共有クラスを参照することができます。

2.7 リソースのライフサイクル

ロード時間を短縮し、アプリケーションのメモリーのフットプリントを管理するためには UnityEngine.Objects のリソースのライフサイクルを理解することが重要です。オブジェクトは特定の時間にメモリー上にロード/アンロードされたりします。

オブジェクトは次のようなときに自動的にロードされます:

  1. オブジェクトにマッピングされた Instance ID が参照された
  2. オブジェクトがメモリーに現在ロードされていない
  3. オブジェクトのソースデータを配置することができる

オブジェクトはスクリプトで明示的にロードすることもできます。オブジェクトを作成するか Resource-Loding API(AssetBundle.LoadAsset など)を呼び出してロードします。オブジェクトがロードされると、Unity は各参照の File GUID と Local ID を Instance ID に変換することで参照の解決をテストします。

最初に Instance ID が参照されたとき、2つの基準が true ならオブジェクトはオンデマンド(ユーザー要求があった際にその要求に応じてサービス提供をすること)でロードされます:

  • Instance ID は、現在ロードされていないオブジェクトを参照している
  • Instance ID はキャッシュに登録されている有効な File GUID と Local ID がある

通常だと、参照自体がロード/解決した直後に発生します。

File GUID と Local ID が Instance ID を持たない、または、ロードされていないオブジェクトの持つ Instance ID が無効な File GUID と Local ID を参照している場合(参照は保存されますが)実際のオブジェクトはロードされません。

Unity エディターの画面では (Missing) と表示されます。実行中のアプリケーションやシーンビューでも (Missing) と表示されます。オブジェクトは、そのタイプに応じて異なる表示になります。たとえば、メッシュは見えないように表示され、テクスチャはマゼンタの単色になって見えるかもしれません。

オブジェクトは3つのシナリオでアンロードされます:

  • 未使用のアセットのクリーンアップが発生すると、オブジェクトは自動的にアンロードされます。このプロセスは、シーンに破壊的な変更があったとき(SceneManager.LoadScene が呼び出されたときなど)、または、スクリプトResources.UnloadUnusedAssets API を呼び出したときに自動的にトリガーが発生します。
    このプロセスは参照されていないオブジェクトのみをアンロードします。オブジェクトは Mono 変数がオブジェクトへの参照を保持しておらず、オブジェクトへの参照を保持している他のオブジェクトが存在しない場合にのみアンロードされます。
    さらに、HideFlags.DontUnloadUnusedAsset および HideFlags.HideAndDontSave がマークされたものはアンロードされません。
  • Resources フォルダーから取得したオブジェクトは、Resources.UnloadAsset API を呼び出すことで明示的にアンロードすることができます。これらのオブジェクトの Instance ID は有効なままなので、有効な File GUID と Local ID が含まれますが、Mono 変数などのオブジェクトが Resources.UnloadAsset でアンロードされたオブジェクトへの参照を保持している場合、そのオブジェクトは参照のいずれかが逆参照されるとすぐに再ロードされます。
  • AssetBundle.Unload(true) API を呼び出すと AssetBundle からソースを取得したオブジェクトは自動的にアンロードされます。これによって、オブジェクトの Instance ID の File GUID と Local ID が無効になり、アンロードされたオブジェクトの参照は (missing) になります。C# スクリプトからアンロードされたオブジェクト、メソッド、プロパティにアクセスすると NullReferenceException が発生します。

AssetBundle.Unload(false) が呼び出されたときは、アンロードされた AssetBundle からソースを取得したオブジェクトは破棄されませんが、Unity はその Instance ID の File GUID と Local ID 参照を無効にします。オブジェクトが後でメモリーからアンロードされ、アンロードされたオブジェクトの参照が残っている場合、Unity はオブジェクトをリロードすることができません。

注意:実行時にオブジェクトがアンロードされずにメモリーから削除される最も一般的なケースは、Unity がグラフィックコンテキストの制御を失ったときに発生します。これは、モバイルアプリがサスペンドされ、アプリが強制的にバックグラウンドに移行されたときに発生することがあります。この場合、モバイル OS は、GPUモリーからすべてのグラフィックリソースを削除してしまいます。アプリがフォアグラウンドに戻ってきたときに、シーンのレンダリングを再開するまえに、Unity は必要なすべてのテクスチャ、シェーダ、メッシュを GPU に再ロードしなければいけないです。

2.8 大きな階層を持つゲームオブジェクトのローディング

Prefab のシリアライズのように Unity のゲームオブジェクトの階層をシリアル化するとき、階層全体が完全にシリアライズされることになる、と理解することは重要です。つまり、階層内のすべての GameObject とコンポーネントはシリアル化されたデータで個別に表現されるので、GameObject の階層をロードしてインスタンス化するために必要な時間に重要な影響を与えます。

任意の GameObject の階層を作成する場合、CPU 時間はいくつかの異なる事情で消費されます:

  • ソースデータの読み込み(ストレージ・AssetBundle・別の GameObject などから)
  • 新しい Transform 間の親子関係を設定する
  • メインスレッドに新しい GameObject とコンポーネントを目覚める

後ろ3つの時間のコストは、(階層が既存の階層からクローン化されているか、ストレージからロードされているかに関わらず)一般的に不変です。しかし、ソースデータの読み込みにかかる時間は、階層にシリアル化されたコンポーネントや GameObject の数に比例して直線的に増加し、データソースの速度にも乗算されます。

現在のすべてのプラットフォームでは、ストレージデバイスからデータを読み込むよりもメモリー内の場所からデータを読み込むほうが高速です。さらに、利用可能な記憶媒体の性能特性はプラットフォームごとに大きく異なります。なので、ストレージが遅いプラットフォーム上で Prefab をロードする場合、ストレージから Prefab のシリアル化されたデータを読みだすために消費される時間は、Prefab をインスタンス化するために消費される時間を上回ることがあります。つまり、ロード操作の時間コストはストレージの I/O 時間に拘束されます。

前に述べたように、モノリシック(巨大な)Prefab をシリアル化する際には、GameObject やコンポーネントのデータをそれぞれシリアル化するため、データが重複している可能性があります。たとえば、30 個の同じ要素を持つ UI 画面では、同じ要素が 30 回シリアル化され、バイナリデータの巨大な blob が生成されます。

ロード時には、それら 30 子の重複要素のそれぞれにある GameObject とコンポーネントのすべてのデータをディスクから読み込んでから、新しくインスタンス化されたオブジェクトに転送しなければいけません。このファイルの読み込み時間は、大きな Prefab のインスタンス化の全体的な時間コストに大きな影響を及ぼします。大規模な階層では、モジュラーをチャンク単位でインスタンス化して、実行時に繋ぎ合わせる必要があります。

Unity 5.4 では、メモリー内の変換の表現が変更されました。各ルートの Transform の子階層全体がメモリーの連続したコンパクトな領域に格納されるようになりました。別の階層にすぐ交換するような新しい GameObject のインスタンスを作成する場合は、親引数を受け入れる新しい GameObject.Instantiateオーバーロード (overloaded variants) の使用を検討してください。このオーバーロードを使用することで、新しい GameObject のルート変換の階層割り当てを回避することができます。テストでは、これによりインスタンス化処理に要する時間が 5-10% 程度短縮しました。

Resources folder

この章では Resources の仕組みについて説明します。これは開発者が Resources という名前のフォルダー内にアセットを保存して実行時に Resources API を使用してアセットからオブジェクトをロード/アンロードできる仕組みのことです。

3.1 Resources を使った仕組みのベストプラクティス

使わない。(Don't use it.)

使わないことを強く推奨するには、いくつかの理由があります:

  • Resources フォルダーを使用すると、細かいメモリー管理が難しくなる
  • Resources フォルダーを不適切に使用すると、アプリケーションの起動時間とビルドの時間が長くなります
    • Resources フォルダーの数が増えると、アセットの管理がとても難しくなります
  • Resources の仕組みは、特定のプラットフォームでコンテンツを配信する能力を低下させ、また、コンテンツをアップデートで追加する余地を排除します
    • AssetBundle はデバイスごとにコンテンツを調整する Unity の主要なツールです

3.2 Resources の仕組みの適切な利用

適切な開発プラクティスの邪魔をせずに Resources の仕組みが役立つ可能性がある具体的なユースケースは2つあります:

  • Resources フォルダーは使い勝手がよいので、手早くプロトタイプを作るために優れた仕組みです。しかしながら、プロジェクトが本格的な開発に移ったときは、Resources フォルダーの使用を排除する必要があります。
  • Resources フォルダーは、コンテンツの内容がすくない場合には有用なことがあります:
    • 通常、プロジェクトのライフタイムを通じて必要
    • コンテンツのパッチ追加をしない、または、プラットフォームやデバイスに違いがない
    • ミニマムなブートストラップに使用する(アプリケーションを起動してから利用可能な状態になるまでの処理で利用すること?)

2つ目のケース例を挙げると、Prefab をホストするために使用される MonoBehaviour (singleton) や、FaceBook App ID などのようなサードパーティの設定データを含む ScriptableObject です。

3.3 Resources のシリアライズ

プロジェクトがビルドされると Resorces という名前のフォルダーにあるアセットとオブジェクトは、1つのシリアル化されたファイルにまとめられます。このファイルには、AssetBundle と同じでメタデータとインデックスの情報が含まれています。

AssetBundle のドキュメントで説明されているように、このインデックスにはシリアル化されたルックアップの木構造(lookup tree)が含まれているので、これを使用してオブジェクトの名前から File ID と Local ID を解決します。また、シリアル化されたファイルの中の特定のバイトオフセットからオブジェクトを見つけるためにも使用されます。

ほとんどのプラットフォームでは、ルックアップのデータ構造は平衡二分探索木であり、構築時間は O(log({n}) ) のレートで大きくなります。これは、Resources フォルダー内のオブジェクトの数が増えると、読み込み時間は直線的異常に大きくなる原因です。

この時間はスキップできず、アプリケーションの起動時となる最初のスプラッシュ画面を表示されている間に発生します。10,000 個のアセットを含む Resources フォルダーに含まれるオブジェクトのほとんどは最初のシーンで必要となることはありませんが、Resources の仕組みの初期化では、アセットをロードをするために(ローエンドのモバイルデバイスでは)数秒の時間を消費することが確認されています。

あとの2章は、AssetBundle についての記事なので割愛しています。

参考

Unity ゲームエフェクト マスターガイド

Unity ゲームエフェクト マスターガイド

  • 作者:秋山 高廣
  • 発売日: 2019/07/19
  • メディア: 単行本(ソフトカバー)

UnityC#ゲームプログラミング入門 2020対応

UnityC#ゲームプログラミング入門 2020対応