Edited at

AssetBundleを完全に理解する


はじめに

前回の記事では、Unityにおけるリソース読み込みについての基本的な知識を総ざらいし、ResourcesやAssetBundleの特徴や違いについて取り上げ、AssetBundleの必要性について述べました。

今回は、残念ながらAssetBundleを使うことになってしまった人たち向けに、AssetBundleの仕組みや使い方、注意点などについて学び、完全に理解しましょうという記事になります。


想定する読者層


  • AssetBundleなんもわからん人

  • AssetBundleを雰囲気でやっている人

  • AssetBundleと向き合う覚悟ができた人

  • AssetBundleを完全に理解したい人

  • オレオレAssetBundleManagerを作りたい人

  • Addressable Asset Systemの利用を検討している人

AssetBundleの概念や必要性についてまだ理解していない方は、前回の記事から読むことをオススメします。

今更誰も教えてくれない、Unityにおけるリソース読み込みについての基礎知識


この記事で分かること

現時点(Unity 2018.2)における、以下の最新の知見が得られます。


  • AssetBundleの(より深い)仕組み、動作仕様

  • AssetBundleの仕様上の注意点

  • AssetBundleを使うにあたって考慮すべき事柄

  • 既存のライブラリについての情報


AssetBundleの仕組み

AssetBundleの仕組みについては前回簡単に説明したが、今回はもう少し深掘りしていく。


AssetBundleは、簡単に言えば「Resourcesではアプリのビルド時に行っている処理を事前に行っておく事により、実行時に外部からアセットがロードできるようになる」という仕組み。


AssetBundleからのアセットのロードはメタ情報が肝となっている。そこで、実際にロード時にどのようにメタ情報が取り扱われているか、例を挙げて説明する。


AssetBundleをロードした時の実際の内部動作について

AssetBundleをロードした際、そのAssetBundleが「ディスク上に存在するかしないか」(ディスク上からロードするかどうか)で挙動が変わる。

それぞれのパターンについて解説を行う。


補足:UnityNativeメモリとMonoメモリについて

以下の解説で「UnityNativeメモリ」と「Monoメモリ」という単語が出てくる。簡単にまとめると、以下のような違いがある。


  • Monoメモリ:私達が書くC#側で管理されているメモリ領域。プロファイラで見るとMonoと表記されている部分。GCによって不要になったメモリは自動的に解放される。(厳密にはIL2CPP環境の場合Monoメモリと言うのは正しくない気がする)

  • UnityNativeメモリ:C#管理外の、Unityのネイティブ実装部分が使うメモリ領域。プロファイラで見るとUnityと表記されている部分。ResourcesやAssetBundleからロードしたアセットはここに読み込まれる。解放するには明示的にリソースをアンロードするか、Resources.UnloadUnusedAssetsなどを呼び出す必要がある。


AssetBundleをディスク上からロードする場合

AssetBundleがディスク上からロードされる条件は以下。

この場合、AssetBundleのロード段階ではメタ情報のみがロードされ、アセット本体はLoadAssetされたタイミングで初めてロードされる


例えばAssetBundle.LoadFromFileを使ってAssetBundleをロードしたとする。

その段階ではAssetBundleのメタ情報のみがUnityNativeメモリ上にロードされる。

LoadFromFileの返り値となるMonoメモリ上にあるAssetBundleオブジェクトは、UnityNativeメモリ上にあるメタ情報への参照を持っているようなイメージ。

var assetBundleA = AssetBundle.LoadFromFile(path);

ここで、assetBundleAからテクスチャをロードする。すると、その段階で初めてディスク上からアセットをUnityNativeメモリ上に読み込む。

var textureA = assetBundleA.LoadAsset<Texture2D>("textureA");

また、この状態でもう一度同じアセットをロードすると、既にメモリ上に読み込まれているアセットの参照を返す


  • ディスクIOが発生しない

  • アセットがメモリ上に重複しない

var textureA2 = assetBundleA.LoadAsset<Texture2D>("textureA");

この状態でAssetBundle.Unload(false)を呼ぶと、AssetBundleのメタ情報がアンロードされる。

これ以降、assetBundleAからLoadAssetすることはできなくなる。

assetBundleA.Unload(false);

残ったアセットは、Texture2Dからの参照が切れた後にResources.UnloadUnusedAssetsを呼び出すなどで解放することができる。

ちなみに、Unload(true)の場合、読み込み済みのアセットまで強制的に解放される

この場合、ゲーム内で表示中のTextureはmissing状態になる。

assetBundleA.Unload(true);


AssetBundleをメモリ上からロードする場合

AssetBundleがディスク上からロードされない場合(上記以外の場合)はメモリ上からロードされていると言える。

この場合、AssetBundleのロード段階でメタ情報と含まれるアセット全てがUnityNativeメモリ上にロードされる。


WebからDLしてきてキャッシュしない場合は、そのままメモリ上にAssetBundleが展開される。

// バージョン等を指定しなければキャッシュされない

var request = UnityWebReqeust.GetAssetBundle(url);
// 例によってWebRequestはawait可能にしてあることとする
await request.SendWebRequest();
var assetBundleA = DownloadHandlerAssetBundle.GetContent(request);

LoadAssetすると既にロード済みのアセットへ参照が貼られる。

assetBundleA.LoadAsset("textureA");

その他はディスク上から読んだ場合と同じ。


AssetBundleの仕様上の注意点


メタ情報は明示的にUnloadしないと解放されない

どういうことかというと、AssetBundle.Unloadを呼び出さなかった場合、C#側でAssetBundleオブジェクトが解放されても、UnityNativeメモリ上のメタ情報は残り続ける

メタ情報はUnityNative側なためSystem.GC.Collect();でGCを明示的に発生させてももちろん解放されず、リソースとは別の扱いのため、Resources.UnloadUnusedAssetsでも解放されない

この場合、AssetBundle.UnloadAllAssetBundlesで全てのメタ情報をUnloadするしか解放する手段が無くなってしまう。

メタ情報が残り続ける事によって、メモリ使用量を圧迫する他、次の問題にも繋がる。


ロード済みのAssetBundleをロードしようとするとエラーになる

例えば以下のコードは確実にエラーになる。

var assetBundle = AssetBundle.LoadFromFile(path);

// 2回目にロードするとエラー
var assetBundle2 = AssetBundle.LoadFromFile(path);

正確に言えば、メタ情報がロード済みのAssetBundleをもう一度ロードしようとするとエラーになると言える。

メタ情報の同一性の判定がどのようにされているかは不明(AssetBundle名あたりかな…?)。

メタ情報がバッティングしなければエラーにならないので、つまり次のコードならばエラーにはならない。

var assetBundle = AssetBundle.LoadFromFile(path);

// メタ情報をUnload
assetBundle.Unload(false);
var assetBundle2 = AssetBundle.LoadFromFile(path);

但し、もう一つ注意点として、「再びロードされたAssetBundleから同じアセットをロードすると、メモリ上に同じアセットが二重にロードされてしまう」という点がある。

具体的には、以下のようなイメージ。

var assetBundle = AssetBundle.LoadFromFile(path);

var texture = assetBundle.LoadAsset<Texture2D>("textureA");
assetBundle.Unload(false);
var assetBundle2 = AssetBundle.LoadFromFile(path);
var texture2 = assetBundle2.LoadAsset<Texture2D>("textureA");
assetBundle2.Unload(false);

同じtextureAをロードしているが、一度メタ情報をUnloadした上で再び読み込んでいるため、同じテクスチャのデータがメモリ上に重複してロードされてしまい、メモリを無駄に消費してしまう。

参考:AssetBundle usage patterns - Unity


この仕様をクリアするため、ロード済みのAssetBundleオブジェクトを管理する仕組みが必要となる。

簡単に言えば、AssetBundleのロードをラップするためのクラスを用意して、未ロードならWebからDLしてきてロードし内部のDictionaryに格納、ロード済みならDictionaryに格納してあるAssetBundleオブジェクトを返す、というような実装になる。


AssetBundle同士に依存関係が存在する場合

例えば、PrefabとPrefabが参照しているテクスチャをそれぞれ別のAssetBundleとしてビルドした場合、AssetBundle同士に依存関係が発生する。

この時、LoadAssetする前に依存しているAssetBundle(のメタ情報)を全てロードしておく必要がある。

例えば、「AssetBundleAに含まれるprefabA」が「AssetBundleBに含まれるtextureB」と「AssetBundleCに含まれるtextureC」の参照を持っているとする。

この時、AssetBundleAはAssetBundleBとAssetBundleCに対して依存関係を持っていることになる。

そのため、以下のようにして事前に全てのAssetBundleをロードしておく必要がある。

var assetBundleA = AssetBundle.LoadFromFile(pathA);

var assetBundleB = AssetBundle.LoadFromFile(pathB);
var assetBundleC = AssetBundle.LoadFromFile(pathC);

var prefab = assetBundleA.LoadAsset<GameObject>("prefab");

ロードする順序は特に問われない。


この仕様をクリアするため、AssetBundle同士の依存関係を保持したリストと、それを利用して自動的に依存関係のあるAssetBundleをロードするような仕組みが必要になる。

AssetBundle同士の依存関係は、ビルド時に生成されるAssetBundleManifestファイルから取得することができる。

そのまま実行時にAssetBundleManifestファイルを使っても良いが、これだけでは他の要件を満たせない(後述)ため、独自にjsonなどにシリアライズしたファイルを用いるのが一般的1

また、他のロード済みAssetBundleから依存されているAssetBundleが不用意にUnloadされないようにする仕組みも必要となる。

例えば、以下のような事例が考えられる。

// AがBとCに依存している

var assetBundleA = AssetBundle.LoadFromFile(pathA);
var assetBundleB = AssetBundle.LoadFromFile(pathB);
var assetBundleC = AssetBundle.LoadFromFile(pathC);

// Aからprefabをロード
var prefab = assetBundleA.LoadAsset<GameObject>("prefab");

// ~~~ //

// Cからテクスチャをロード
var textureC = assetBundleC.LoadAsset<Texture2D>("textureC");
// 不要だと思ってUnloadする
assetBundleC.Unload(false);

// ~~~ //

// 再びロードしようとするとエラーになる
var prefab = assetBundleA.LoadAsset<GameObject>("prefab");

これについては参照カウントを用いて管理する方法が一般的。LoadとUnloadをラップし、Load時にカウントを+1、Unload時に-1し、カウントが0になった段階で実際にUnloadを呼び出す。

Unity公式のデモ実装が参考になる。


ロード時のMono(C#)側のメモリ確保について

昔はAssetBundleをWebからDLする際はWWW.LoadFromCacheOrDownloadを使っていたが、これを使うとMono(C#)側のメモリが使われてしまうという問題があった。Monoメモリは一度増大すると予約済みとなってしまい、アプリ終了までOSに返還されない。

UnityWebReqeust(+DownloadHandlerAssetBundle)を使えばUnityNativeメモリ側で完結するため、Monoのメモリ領域が不要に確保されることは無い。

今ではWWWクラスは廃止(いつからか分からないが内部的にはUnityWebRequestに投げている)されているため、基本的に気にする必要はない。

但し、AssetBundle.LoadFromMemoryを使う場合は勿論Mono側にAssetBundleのデータをbyte[]で確保する必要があるため、その分メモリ領域が増大する。


その他の要件

実際にAssetBundleを使うにあたっては、以下のような要件についても考慮が必要。


AssetBundleのディスクキャッシュ

AssetBundleをWebサーバー上に配置してロードする事を考えた時、毎回サーバーからDLしてくるのは明らかに通信量的に不味いため、一度DLしたものはディスク上にキャッシュしておきたいという要件が発生する。

そこで、UnityWebRequestには標準でAssetBundleをキャッシュするための仕組みが備わっている。

それについては以下の記事に詳細にまとめているため、ここでは割愛する。

[Unity 2018.2] AssetBundleのキャッシュを完全に理解する - Qiita

ここでさらに発生する要件が以下。


  • 改竄検出のためのAssetBundleのCRCのリストが必要

  • キャッシュされているものと比較してDLが必要かどうか判定するためのAssetBundleのバージョン情報のリストが必要

  • 古いバージョンのキャッシュを削除する機能

バージョン情報についてはAssetBundleManifestから取得できるハッシュ値が使えるが、CRCについてはAssetBundleManifestから取得することができない。


AssetBundleのビルドの簡略化

前回の記事では、スクリプトでAssetBundleをビルドする簡単な例を挙げた。(以下再掲)

var builds = new List<AssetBundleBuild>();

// AssetBundle名とそれに含めるアセットを指定する
var build = new AssetBundleBuild();
build.assetBundleName = "Hoge/texture";
build.assetNames = new string[1] { "Assets/AssetBundleResources/Hoge/texture.png" };

builds.Add(build);

// 成果物を出力するフォルダを指定する(プロジェクトフォルダからの相対パス)
var targetDir = "AssetBundle/Android";
if (!Directory.Exists(targetDir)) Directory.CreateDirectory(targetDir);

// Android用に出力
var buildTarget = BuildTarget.Android;

// LZ4で圧縮するようにする
var buildOptions = BuildAssetBundleOptions.ChunkBasedCompression;

BuildPipeline.BuildAssetBundles(targetDir, builds.ToArray(), buildOptions, buildTarget);

しかし、上記のようにAssetBundleBuildの配列を構築する処理を手書きするのは効率が悪い。

そこで、アセットのパスから正規表現を用いてAssetBundle名を決定付けるような実装が必要になる。

これについては、AssetGraphという便利なライブラリを使うのが良い。(後述)


AssetBundleとアセットの対応関係の解決

AssetBundleからアセットをロードする際、当然まずはロードしたいアセットが含まれているAssetBundleをロードし、そのAssetBundleからアセットをロードする必要がある。

これを愚直に書けば以下のようなコードになる。

// AssetBundleをロード

var request = UnityWebReqeust.GetAssetBundle(url);
await request.SendWebRequest();
var assetBundle = DownloadHandlerAssetBundle.GetContent(request);

// アセットをロード
var texture = assetBundle.LoadAsset<Texture2D>("Assets/AssetBundleResources/Hoge/texture.png");

が、アセットをロードするためだけに「アセットがどのAssetBundleに格納されているか」「AssetBundleの配置先URLはどこか」を実装時に考えて書かないといけないのは難しく、非現実的である。

そのため、アセットのパス(=アセットを一意に特定するための文字列)から、そのアセットが格納されているAssetBundleとそのURLを特定し、自動的にDLする仕組みが必要になる。

以下のように書けると理想的。

var texture = AssetBundleManager.LoadAsset<Texture2D>("Assets/AssetBundleResources/Hoge/texture.png");


AssetBundleシミュレーター

AssetBundleを使うことを前提にしてしまうと、アセットを変更した際に毎回AssetBundleをビルドし直してサーバー上に反映する必要がある。これでは開発時には若干不便になってしまう。

そこで、ResourcesやAssetDatabase.LoadAssetAtPathを代替に使うことで、ビルドせずに簡易的にシミュレーションできるような仕組みがあると便利になる。


CDNの反映遅延への対処

AssetBundleをWebで配信するにあたって、それなりの規模のゲームであれば、高トラフィックに耐えるためにCDNを用いるのが一般的である。

しかし、CDNを使う場合、その特性上から必ず反映遅延が発生する。つまり、AssetBundleを上書き更新した場合、CDNに反映されるまでの間、CDNにキャッシュされた更新前のAssetBundleがロードされる可能性がある。これは予期せぬ不具合に繋がる可能性がある。

そこで、古いキャッシュがロードされないようにするため、以下のような対応が必要になる。


  • AssetBundleを上書き更新しない(アップロード時にファイル名の末尾にハッシュ値を付けるなど)

  • クエリによってキャッシュをコントロールする(CDN側でクエリごとにキャッシュされるように設定し、アプリ側からはDL時にクエリにハッシュ値を入れるなどする)


AssetBundleのダウンロードサイズの表示について

App Storeの審査ガイドラインによって、追加リソースをDLする時には事前にダウンロードサイズを開示する義務があると定められている。

そのため、AssetBundleのダウンロードサイズを取得する仕組みが必要となる。

参考:新 App Store 審査ガイドライン 翻訳&差分ガイド 2018年6月号 - Qiita


AssetBundleManager(仮)の要件まとめ

上記のことから、AssetBundleを使う際には、AssetBundleManager(仮)のような便利クラスが必須と言える。

まとめると、AssetBundleManagerに求められる要件は以下。


  • ロード済みのAssetBundleオブジェクトを管理する仕組み

  • AssetBundle同士の依存関係を保持したリストと、それを利用して自動的に依存関係のあるAssetBundleをロードするような仕組み

  • キャッシュの更新判定をするためのバージョン情報、改竄検出のためのCRCを管理するための仕組み

  • アセットのパス(=アセットを一意に特定するための文字列)から、そのアセットが格納されているAssetBundleとそのURLを特定し、自動的にDLする仕組み

  • ResourcesやAssetDatabase.LoadAssetAtPathを代替に使うことで、ビルドせずに簡易的にシミュレーションできるような仕組み

  • AssetBundleのダウンロードサイズを取得する仕組み

上記の要件を満たそうと思うと、大体以下のような実装になる。


  • アセットとAssetBundleの対応関係、AssetBundle同士の依存関係、バージョン情報(ハッシュ値)、CRCを格納したファイルを最初にロードする

  • アセットのロード時はアセットのパス(と型)を指定すれば、上記データを元によしなに処理してくれる


既存のライブラリについて

AssetBundle関連の既存のライブラリについて紹介していく。


AssetBundleManager

Unity-Technologies / AssetBundleDemo — Bitbucket

Unity謹製のAssetBundleManagerの実装デモ。WWWクラスを使っていたりして若干レガシー。また既にメンテ停止がアナウンスされている。実装の参考にするのは良いが、そのまま使うことはオススメしない。


Autoya

sassembla/Autoya: thin framework for Unity.

AssetBundleの他に認証系やその他諸々の便利な機能が入ったライブラリ。

概ね要件を満たしているが、見た所恐らく以下の機能が無い。


  • 依存関係がある際の参照カウンタによるUnload管理

  • ResourcesやAssetDatabase.LoadAssetAtPathを用いたシミュレーション機能

  • 古いキャッシュのみを削除する機能


AssetGraph

Unity-Technologies / AssetBundleGraphTool — Bitbucket

AssetBundleのビルドを効率化してくれるツール。

ノードを構築することで、フォルダ構造に応じて柔軟にAssetBundle化の設定を行うことができる。

詳しくは別途紹介記事を書く予定。


AssetBundleBrowser

Unity の Asset Bundle Browser ツール - Unity マニュアル

Unity2018以降ならPackageManagerから導入することが可能。

AssetBundleのメタ情報を見るのに便利。

ビルドの機能は一応付いているが、AssetGraphを用いる方がより柔軟に設定ができて良い。逆にAssetGraphほどの柔軟性が必要無ければこれで十分でもある。


Addressable Asset System(AAS)について

上記のAssetBundleManager(仮)の要件を満たしてくれる(とされている)のが、皆さん待望のAddressable Asset Systemです。素晴らしいですね!

この記事の内容を理解した上であればAASが何をしてくれるのか・何故便利なのか、というのが分かるかなと思います。逆に理解していないとどう使ったらいいのかもよく分からない可能性があります。

AASについては以前解説記事を書いたので、これが参考になるかと思います。(ちょっとバージョンが古いですが、大枠は変わっていないはず)

Addressable Assets Systemを完全に理解する - Qiita

まだまだ開発途上といった雰囲気ですが、暖かく見守っていきたいですね。


おわりに

長くなりましたが、以上でAssetBundleを使うに当たって必要な知識は一通り網羅できたかなと思います。AssetBundle、完全に理解した

正直言って複雑で面倒くさいと思います。が、そもそもWebからの外部リソースの読み込みというのは本質的に難しい問題だと思います。個人的には、一概にAssetBundleが悪いとは思いません。(アップデートによって大分改良されてきたというのもありますが)


参考資料

以下は独自AssetBundleManagerを作った事例の紹介。





  1. Addressable Asset SystemでもAssetBundleManifestは使わず独自のクラスをJSONにシリアライズして使っている。これはコンテンツカタログと呼ばれている。