Edited at

AssetBundle.LoadFromFileとLoadFromMemoryの挙動の違いについて


はじめに

実はAssetBundleをロードする際の挙動として、内部的には2種類の挙動があり、それぞれに特性やパフォーマンスの違いがあります。

そんな細かすぎて伝わらないAssetBundleのお話です。


きっかけ

元々DownloadHandlerAssetBundleの挙動を調べていてすこし気になった挙動があり、

調べていくと以下のことが分かりました。


  • DownloadHandlerAssetBundleはダウンロード完了後assetBundleプロパティにアクセスしたタイミングで初めてメモリ上にAssetBundleのヘッダ情報がロードされるっぽい


    • 通常、同一アセットバンドルをロードしようとした場合はエラーになるが、DownloadHandlerAssetBundleを用いて同一AssetBundleをダウンロード&キャッシュしただけではエラーにならなかった(実際この検証の時にエラーになっていなかった)

    • 試しにDL後assetBundleプロパティにアクセスするコードを追加すると、2度目でエラーが出た



  • プロパティにアクセスするまでメモリ上に乗らないという事は、アクセスされた時に随時ディスクから読み出していると考えられる。しかし、キャッシュをしない(バージョン指定しない)場合、AssetBundleはディスク上には書き込まれないはずなので、どういった挙動になるのか?


    • 試してみたら、ダウンロードした段階で(プロパティにアクセスせずとも)メモリ上にAssetBundleがまるごとロードされることが分かった

    • さらに、この場合は同一アセットバンドルをロードしてもエラーにならなかった



そんな感じで明らかに2つの異なる挙動があったので、気になって公式リファレンスを読み返したり色々調べてみたという訳です。


AssetBundleのロードの仕組み

下記でマネージドメモリアンマネージドメモリというのが出てきますが、以下のような理解をしてもらえると良いかと思います。


  • マネージドメモリ:私達が書くC#側で管理されているメモリ領域。プロファイラで見るとMonoと表記されている部分。GCによって不要なメモリは自動的に解放される。

  • アンマネージドメモリ:C#管理外の、Unityのネイティブ実装部分が使うメモリ領域。プロファイラで見るとUnityと表記されている部分。リソースが大半を占め、解放するには明示的にリソースをアンロードするか、Resources.UnloadUnusedAssetsなどを呼び出す必要がある。

プロファイラのこの部分を見ると、それぞれどちらの領域がどれだけメモリを使っているかが分かります。

参考:【Unite 2017 Tokyo】最適化をする前に覚えておきたい技術


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

AssetBundleをディスク上からロードする場合、かつAssetBundleが無圧縮LZ4圧縮の場合は、以下のような仕組みで動きます。


  • AssetBundleのヘッダ情報をアンマネージドメモリに格納


  • AssetBundle.LoadAsset時に上記ヘッダ情報を用いてディスク上から対象のアセットのみロードし、アンマネージドメモリに展開する


    • ヘッダ情報が残っている限り、同じアセットに対しては同じ参照を返す(二重にメモリ上に展開されない)




  • AssetBundle.Unloadでアンマネージドメモリにある対象AssetBundleのヘッダ情報が解放される


    • 引数をtrueにした場合、アセットまで解放される




  • AssetBundle.UnloadAllAssetBundlesで全てのAssetBundleのヘッダ情報が解放される

  • ヘッダ情報をアンマネージドメモリ上で管理する都合上、同一アセットバンドル(恐らくアセットバンドル名で識別されている?)のヘッダ情報をロードしようとするとエラーになってしまう

そしてAssetBundleがディスク上からロードされるシチュエーションは以下の2つあります。

リファレンスに書いてある通り、必要な分だけしかロードしないため、パフォーマンス的には最も良いロード方法になります。

ちなみにLZMA圧縮の場合は試していませんが、多分アンマネージドメモリにまるごとロードされる下のパターンと同じ挙動になりそうです。

この挙動についてはあまり目立って言及されていませんが、公式マニュアルをよく読むとしれっとそれっぽい記述があります。

アセットバンドルを使いこなす - Unity マニュアル


AssetBundle.LoadFromFile

この API は、非圧縮バンドルをローカルストレージから読み込む際に使用すると非常に効率的です。 LoadFromFile は、バンドルが非圧縮の場合やチャンクベースの圧縮方式(LZ4)で圧縮されている場合に、ディスクから直接バンドルを読み込みます。完全圧縮(LZMA)バンドルをこのメソッドで読み込む場合は、バンドルが解凍されてからメモリに読み込まれます。

UnityWebRequest

UnityWebRequest には、アセットバンドル専用の API コールがあります。この使用を開始するには、 UnityWebRequest.GetAssetBundle を使用してウェブ リクエストを作成する必要があります。リクエストを返した後に、そのリクエスト オブジェクトを DownloadHandlerAssetBundle.GetContent(UnityWebRequest) 内に渡します。この GetContent コールがアセットバンドル オブジェクトを返します。

また、バンドルの読み込み後に DownloadHandlerAssetBundle クラスの assetBundle プロパティを使用することで、 AssetBundle.LoadFromFile の効率性をもってアセットバンドルを読み込むことができます



ヘッダ情報だけロードした状態でディスク上のAssetBundleを削除した場合

LoadAsset時にUnable to open archive fileというエラーが出ます。但し、既にアセットがロード済みだった場合は問題ありません。

(これを検証する際、Finderやエクスプローラー上で操作すると、UnityEditorに戻った時に諸々の参照がクリアされて正しく再現できないので、C#上でファイル操作を行う必要がある)

以下のようなコードで再現できます。

using UnityEngine;

using System.IO;

public class AssetBundleLoadTest : Monobehaviour
{
AssetBundle assetBundle;

void Start()
{
// AssetBundleの配置場所
var path = Application.streamingAssetsPath + "/test";
// ヘッダ情報だけメモリ上に読まれる
this.assetBundle = AssetBundle.LoadFromFile(path);
}

// 適当なボタンに登録する
public void LoadAsset()
{
// AssetBundleからアセットのロードを試みる
var asset = this.assetBundle.LoadAsset<TextAsset>("Assets/test.bytes");
Debug.Log(asset.bytes.Length);
}

// 適当なボタンに登録する
public void MoveAsset()
{
// AssetBundleのファイル名を変える(削除するのと同じ効果が得られるはず)
var path = Application.streamingAssetsPath + "/test";
File.Move(path, path + "2");
}
}

先にLoadAssetを呼び出した場合、MoveAssetを呼び出した後に再度LoadAssetを呼び出しても正常にDebug.Logが出力されます。

しかし、先にMoveAssetを呼び出した場合、LoadAsset時にエラーが発生します。


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

上記以外の場合は、AssetBundleのデータがまるごとアンマネージドメモリ上にロードされます。LoadAsset時には、ヘッダ情報を元に既にロード済みのアセットの参照の位置が返されるようなイメージです。それ以外は上記と同じです。

また、AssetBundle.LoadFromMemoryを使う場合、C#側(マネージドメモリ)で確保したbyte[]を渡す形になり、内部的にはそれがアンマネージドメモリに(LZ4の場合は)そのままコピーされる形になるため、一時的にメモリのスパイクが発生すると考えられます。

(DownloadHandlerAssetBundleを使用する場合はUnityネイティブ内で完結するのでその心配は無いはず)

また、AssetBundle.LoadFromStreamも同じ挙動だと考えられますが、こちらはストリームで決められたバッファしか確保しないため、LoadFromMemoryよりはメモリ効率は良さそうです。


DownloadHandlerAssetBundleの怪しい挙動

AssetBundleがディスク上にキャッシュされない場合(DL時にバージョン指定しない場合)はまるごとメモリ上にAssetBundleが乗るのですが、この時、DL完了時には既にメモリ上にAssetBundleのデータが乗っているものの、AssetBundleのヘッダ情報が認識されるのはDownloadHandlerAssetBundle.assetBundleにアクセスしたタイミングになります。

実際どういう事が起こるかというと、以下のような事が起こります。

// 仮に100MBのAssetBundleをロードするとする

var url = Application.streamingAssetsPath + "/test";
// バージョン指定しない=キャッシュされない=即メモリ上にまるごとロードされる
var handler = new DownloadHandlerAssetBundle(url, 0);

var request = new UnityWebRequest(
uri,
UnityWebRequest.kHttpVerbGET,
handler,
null
);

yield return request.SendWebRequest();

// このタイミングで100MBがアンマネージドメモリに乗っかる

// 通常これで全てのロード済みのAssetBundleがアンロードできるはずだが、ヘッダ情報がまだ認識されていないため、上でロードしたAssetBundleはアンロードされない!
AssetBundle.UnloadAllAssetBundles(true);

// なんでもいいのでassetBundleプロパティにアクセスするとこのタイミングでヘッダ情報が認識される
var ab = handler.assetBundle;

// ここでこれを呼ぶとアンロードできる
AssetBundle.UnloadAllAssetBundles(true);

DL時に読み込まれたAssetBundleを解放するには、ヘッダ情報を認識させてからUnloadを行うしか手段が無く、仮にassetBundleプロパティにアクセスしなかった場合、UnityWebRequestやDownloadHandlerを明示的にDisposeしようがGCを発生させようがResouces.UnloadUnusedAssetsを呼ぼうがどう足掻いても解放されません。

またヘッダ情報が認識されていない場合、同一AssetBundleもロードできてしまうため、上記コードのSendWebRequestまでを無限ループさせると、無限に解放できないメモリ確保が発生します。

まあキャッシュもしないのにDLだけしてassetBundleにアクセスしないシチュエーションは無いと思うのでまず実際に問題になる事はないと思いますが、微妙にバグっぽい挙動な気がしました。流石にDispose時に解放されるべきなのでは…?


まとめ


  • AssetBundleはディスクから読むのが一番効率が良い


    • キャッシュを使うかLoadFromFileを使うと、無圧縮かLZ4の場合はいい感じに必要になったタイミングで必要な分だけディスクから読んでくれる

    • 逆に言えば初回LoadAsset時にディスクI/Oが発生するため、既にメモリ上に読まれている場合よりかは遅くなることが考えられる。LoadAssetを最速にしたいなら、必要なアセットだけ事前にメモリに乗せておくなどの工夫が必要かもしれない



  • DownloadHandlerAssetBundleには若干怪しい挙動があるが、まああまり気にしなくても良い


参考資料

最近Unity公式のマニュアルがかなり内部仕様にまで迫った上級者向けの記事が増えてきていて大変良いですね。