[UE4] Asset Managerのアセットの非同期ロード機能について その1 ( 非同期ロードの解説 & レベルの裏読み編 )

# はじめに
UE4にはAsset Managerというアセット管理システムがあり、この仕組みを使うことで**「アセットの非同期ロード」**と**「DLCのID管理」**を効率よく行うことが可能です。特に「アセットの非同期ロード」に関しては様々なコンテンツにて有用な機能ですので、是非活用したい所です。しかし、各解説・資料が分散してしまっている為、具体的にどのようにして使えばいいのかがイメージし辛いという話をしばしば聞きます。ということで、本記事では各解説・資料の内容を簡単にまとめつつ、各機能をどのようにして使えばいいのかについて解説します。なお、「DLCのID管理」に関しては今回は割愛します。こちらにも触れ始めると話が終わらないからです。
なお、本記事を作成する上で検証・使用したUE4のバージョンはUE4.22.3です。

## 参考資料
### 公式ドキュメント
- アセット管理  
https://docs.unrealengine.com/ja/Engine/Basics/AssetsAndPackages/AssetManagement/index.html
- クックとチャンキング  
https://docs.unrealengine.com/ja/Engine/Basics/AssetsAndPackages/AssetManagement/CookingAndChunking/index.html
- アセットの非同期ロード  
https://docs.unrealengine.com/ja/Programming/Assets/AsyncLoading/index.html

### SlideShare
- UE4のモバイル開発におけるコンテンツアップデートの話　Chunk IDとの激闘編   
https://www.slideshare.net/EpicGamesJapan/ue4-chunk-id/36 (p36より)
- 徹底解説！UE4を使ったモバイルゲーム開発におけるコンテンツアップデートの極意！  
https://www.slideshare.net/EpicGamesJapan/ue4-95204920/13 (p13より)
- 猫でも分かる UE4の新しいサンプル「Action RPG」について  
https://www.slideshare.net/EpicGamesJapan/ue4action-rpg/49 (p49より)

### ブログ記事
- 【UE4】AssetManagerを使用したレベルストリームの高速化 ( 株式会社アンナプルナ様 )  
http://anapurna.co.jp/ue4/303/


# Asset Managerの非同期ロードのメリット
まずは「Asset Managerの非同期ロード機能を使ったらどう嬉しいのか？」について説明し…の前に、同期ロードと非同期ロードの違いについて説明しないといけませんね。（今回の本題ではないのでサクッと）

## そもそも同期・非同期ロードってなんやねん
ざっくり分けると、**ロード完了するまで待つ（他の処理を止める）のが同期ロード、ロード完了を待たない（他の処理を止めない）のが非同期ロードです。**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/09c02dc3-958d-6b6c-2d28-13c304f64058.png)
UE4ユーザ向けに分かりやすい例を挙げると…「Open Levelが同期ロード」で「Load Stream Level（Should Block on Loadが無効状態）が非同期ロード」です。どちらもレベルアセットのロードに関する処理ですが、前者はレベルのロードが完了するまで処理が中断します（ブロッキング）。そして、完了後にPersistent Levelを指定したレベルに入れ替えます。一方、後者は**他の処理を中断させず裏でロード処理を行い、ロードしたレベルアセットをメモリ上に確保します**
(メモリ確保と聞くと難しそうと感じる方がいると思いますが…ロード済みのレベルアセットをいつでも使えるようにキープしておくイメージで大丈夫です！）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/82a9301e-af65-0fd3-3ada-396ab6654db3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/fd94b43f-d77a-0d30-193c-40dbb8dd77a8.png)
この仕組みの活用例として「レベルの裏読み」が挙げられます。例えば、もうすぐゴールにたどり着く（＝次のステージが十中八九必要になる）タイミングで次のステージ用のレベルアセットを非同期ロードしメモリに確保します。（まだステージをプレイ中なので処理を止めてしまう同期ロードはこのタイミングでは使用できません）。
そして、ゴール後に次のステージを移動するタイミングではメモリ上にあるロード済みのレベルアセットを使用します。こうすることで、必要になった時にロードを開始するよりも早く次のステージのレベルアセットを使用することができます。その結果ローディング画面の表示時間が短くなるため、ユーザは快適にゲームを遊ぶことができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/c00765b8-bb55-b887-4488-36faee92d5f6.png)
同じようなことがレベル以外のアセットにも言えます。
一般的に、プロパティなどで直接指定したアセットはその大元となるアセットがロードされると同時に同期ロードされます。例えばグレイマンのSkeletalMeshアセットがロードされると、それに直接紐付けられた各アセット（マテリアル、テクスチャ、アニメーションなど）が連鎖的に同期ロードされます。その結果、ローディング時間が伸びたり、アセットをレベル上にSpawnした際にロード待ちが発生してカクツキが発生したりする場合があります。

この問題を回避するために、直接紐付ける（**直接参照**）のではなく間接的に紐付ける（**間接参照**）ことで連鎖的なロードを回避したり、レベルと同様に事前に非同期ロードしたり、すぐ必要ではないアセットは非同期ロードに、などの対策が必要になります。詳細は以下の資料にて。

- 公式ドキュメント「アセットの参照」  
https://docs.unrealengine.com/ja/Programming/Assets/ReferencingAssets/index.html
- 「Nintendo Switch『OCTOPATH TRAVELER』はこうして作られた」  
https://www.slideshare.net/EpicGamesJapan/nintendo-switchoctopath-traveler/82 (p82より)

サクッと説明といった割には少し長くなりましたが…ひとまず**「他の処理を止めたり、ロード待ち時間が発生しないようにするためには非同期ロードが大事」**ということだけ頭の片隅に入れておいてください。

## Asset Managerの非同期ロード機能をおすすめする理由
実はAsset Manager以外にもこの非同期ロードを行うための機能がいくつかあります。例えば、先程紹介したLoad Stream Levelや 非同期ロード処理に関する様々な機能を持つ[ StreamableManager ] 
 (https://docs.unrealengine.com/ja/Programming/Assets/AsyncLoading/index.html)があります。しかし、前者に関してはレベル単位になるため細かい管理が難しく、後者に関してはC++でゴリゴリ組まないといけないなどの課題がありました。加えて、ロード対象のアセットをグループ・タグ分けする仕組みを自前で用意する必要がありました。

そこで実装されたのがAsset Managerによるアセット管理・非同期ロードの仕組みです。Streamable ManagerをBPから簡単に使えるようにしつつ、非同期ロードの対象となるアセットのグループ・タグ分けを行うことができます。その他にも、ロードしたアセットがガベージコレクションで回収されないように保持したりなど、アセットの非同期ロードを行う上で必須な機能が一通り網羅されています。さらに、「エンジンコードを弄らずに」よりプロジェクトのワークフローに適した形にカスタマイズすることが可能です。

> 新規：アセット管理フレームワーク (正式版)
4.16 に早期アクセス機能として初めて導入されたアセット マネージャからブループリントへアクセス可能になり、正式機能と なりました。アセット マネージャは新規のグローバル オブジェクトです。エディタ内またはランタイム時に、マップおよびゲーム 固有のアセットタイプの発見、ロード、監査を行うことができます。クエスト、武器、ヒーローなどの作成、およびそれら のオンデマンド ロードを簡単にする フレームワークを提供し、ゲームのパッケージ化およびリリース処理時のクックお よびチャンク ルール設定に使用することもできます。[プロジェクトの設定] の [Asset Manager] タブで、ゲームのルールを設定 することができます。

> アセット マネージャがスキャンしたプライマリ アセット タイプは、ロードする前にランタイム時にクエリして、オンデマンド で同期しながらロードすることができます。アセット マネージャを使用するには、ネイティブの UAssetManager クラス上で関数を コールするか (ゲーム用にサブクラス化が可能)、または Asset Manager カテゴリのブループリント関数 (Async Load Primary Asset など) をコールします。
UE4.17 リリースノート：https://www.unrealengine.com/ja/blog/unreal-engine-4-17-released

ただ多機能かつ強力な分、少しとっつきにくく感じるかと思います。そこで、以降は実際にどのようにして使うのかをお見せつつ、各機能について説明していきます。

# Asset Managerを使ったレベルの非同期ロード(裏読み)
まずは最も基本的な「レベルアセットの非同期ロード」をAsset Managerを使う場合はどうするのかについてです。ここでは、本記事を読んだ方が同じ手順を踏めるように以下の処理を実装します。

1. 空のレベルでStarterContentsの`/Game/StarterContent/Maps/StarterMap`を非同期ロード
2. ロード完了後、`/Game/StarterContent/Maps/StarterMap` を `Open Level`

先に結果から。
`Open Level`を呼んでからPersistant Levelの`BeginPlay`が呼ばれるまでの時間を計測しました。プロジェクトを配置したストレージはHDD(WD30EZRZ-RT)です。

**裏読みなし：0.55秒**
**裏読みあり：0.05秒**

レベル遷移時間がなんと1/10に！…え、0.5秒が0.05秒になっても体感時間はそんなに変わらない？
では、もう少しヘビーなレベルで試してみましょう。以下はElemental Demoで検証した結果です。

**裏読みなし：6秒
裏読みあり（Persistantのみ）：0.88秒
裏読みあり（全レベル）：0.35秒**

どうでしょうか？6秒が0.35秒になるとは結構インパクトあるかなと思います！
少し古い調査ですが、ユーザの大半が我慢できるロード時間は5秒までなのでこの改善はかなり魅了的でです！
https://www.cri-mw.co.jp/event/2008/2nt4hm0000004x3w.html

それでは、具体的にどのようにして裏読みしたか説明します。

## 対象のレベルを非同期ロードする処理をつい…かできない？？？
早速Asset Managerでレベルの非同期ロード処理を書いてみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/118ade66-8bd5-4cae-12e2-c4b5ab485176.png)
Asset Managerが用意している非同期ロード用のノードは上図の4つです。**Primary AssetはAsset Managerが管理しているAssetのこと**を指し、Primary Asset単体またはその配列をノードに指定する形になっています。~Assetと~Asset Classの2種類がありますが、内部処理は殆ど変わらず返り値の型が異なるだけです( 参考：`UAsyncActionLoadPrimaryAsset::HandleLoadCompleted, UAsyncActionLoadPrimaryAssetClass::HandleLoadCompleted` )。どちらを使えばいいか判断出来ない場合は前者の~Assetを使うのが良いかと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/80995125-a314-416f-399e-99ae64f54c45.png)
今回はレベルを一つ非同期ロードするだけなので、`Async Load Primary Asset`を使うのが良さそうです。早速空のレベルBPに配置して、Primary Assetを指定し…あれ？候補が何も出てこない！？

実は、これから説明する作業をしないと非同期ロード対象であるアセットがAsset Managerが管理するAssetであるPrimary Assetとして認識されません。その結果、`Async Load Primary Asset`の引数として指定することができません。
## 非同期ロードしたいレベルをAsset Managerの管理対象に
それでは対象のレベルをAsset Managerの管理対象にしていきます。この作業を怠ると、上述の通りAsset Managerによる非同期ロードの対象として指定することができません。逆に言うと、対象として指定できないときはこれから説明する箇所を確認することをオススメします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/7f5e9f07-f207-eb40-f51f-34c04c430b52.png)
**Asset Managerの管理対象は、プロジェクト設定のAsset Managerカテゴリにて設定します。**この中で特に重要なのが**「Primary Asset Types to Scan」**です。Asset ManagerはこのPrimary Asset Types to Scanで設定したパラメータに基づいて各アセットを抽出し管理します。Primary Asset Types to Scanには初期状態でレベルアセット用と後述するPrimaryAssetLabelアセット用の2項目が登録されており、実際に運用する際は更に項目を追加していくことになります。今回はレベルアセットを非同期ロードしたいだけなので、デフォルトで用意されているレベルアセットに関する項目を弄っていきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/86f5198e-05ec-55f7-8a60-329fba6e4be1.png)
上の画像が、Primary Asset Types to Scanにデフォルトで登録されているレベルアセットに関する項目です。（レベルについてなのにMapとかWorldとかややこしいですね！）

各項目についてガッツリ説明してもいいのですが、1つ目の記事なので軽くで説明します。

- Primary Asset Type
    - 管理用のタグ・グループ名です。どんな種類のアセットを管理しているのかが分かりやすい名前にするのが良いと思います。…と聞いて、じゃあ「Level」などの別の名前に変えようとする人がいるかもしれませんが…ちょっと待って下さい！それをすると、「パッケージで」正常に動作しなくなります！**実はここでつける名前はあるルールを守らないと「パッケージで」正常に動作しません**（エディタではいい感じでしてくれて動作する場合があります。たち悪いですね）。レベルアセットの場合は「Map」にする必要があるので今はそのままにしましょう。今後の記事で詳細を解説するのでお楽しみに。

- Asset Base Class
    - 管理対象にするアセットのベースクラスをここで指定します。ベースクラスなので、もちろん派生クラスも対象になります。
        - 例：Actorを指定すると、その派生クラスであるPawnやCharacterなども管理対象になります
    - 今回管理対象であるレベルアセットはUWorldクラスで管理されているのでWorldに指定されています

- Has Blueprint classes
    - **管理対象がBlueprintの場合はここにチェックをいれます。逆にBlueprintではない場合はチェックをいれてはいけません。**入れてしまうと、Asset Managerの管理対象であるPrimary Assetとして認識されなくなります。
    - 今回管理対象であるレベルアセットはBlueprintではないのでチェックはしません

- Is Editor Only
    - まず結論から。**非同期ロードの対象にする場合はチェックを必ず外してください！もしチェックがつけていた場合、非同期ロード処理は行われません！**
    - 何故この項目があるかというと…かなり脱線するので気になる方は以下の折りたたみを開いてください
    - <details>
<summary>なんでIs Editor Onlyが用意されているの？</summary>
はじめに説明したとおり、Asset Managerには非同期ロード機能だけでなくアセット管理機能があります。そして、その管理機能の一部に各アセットにChunk IDという番号を割り振る機能があります。このChunk IDは主にDLCや"とある何か"向けにパッケージを分割する目的で使用されます。そして、Chunk IDの割り振りはエディタ上で行われるため、ランタイム上で割り振り用の機能・アセットは使用する必要はありません。そのため、エディタ上でしか動作しないようにこの項目が用意されています。Chunk IDについてより詳細なことを知りたい場合は参考資料のSlideShareをご覧くださいまし。
</details>

- Directories
    - ここで指定したフォルダ以下にあり、かつAsset Base Classで指定したクラスに基づくアセットが管理対象になります。
    - 今回 /Game/StarterContent/Maps に配置されている StarterMap が 非同期ロードを行うAsync Load Primary Assetの対象になっていなかったのはこの項目が原因です。そのため、Directoriesに 「/Game/」、「/Game/StarterContent」、「/Game/StarterContent/Maps」のどれかを登録する必要があります。
        - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/043184b8-ba9b-daf2-3a06-88e32ea2049b.png)


- Specific Assets
    - Directoriesのアセット直指定版です。
    - 今回のケースでDirectoriesを使わずにこの項目を使う場合は以下のように指定することになります。
        - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/cfebb7ed-8682-c3b4-5635-70b08276907a.png)

最後にRulesという項目がありますが、この項目は今回関係ない上に話すと少し長くなるので割愛します。今後の記事で解説予定です。
![図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/d983a188-d472-1409-7a2e-1e4725d0b4ef.png)
軽く説明すると言った割には少し長くなりましたが、変更点は上図の枠で囲った部分だけです。これで `/Game/StarterContent/Maps/StarterMap` をAsset Managerの管理対象であるPrimary Assetにできました!
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/2c574e22-90e6-d0fd-a305-5bc0e2a8b9cd.png)
そして、Primary Assetにできた結果、`Async Load Primary Asset`ノードの引数として指定できるようになりました！

## 今度こそレベルの非同期ロードを実現！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/1322e536-81e7-70bf-5137-0db13981fad2.png)
`Async Load Primary Asset`ノードの引数で指定できれば後は簡単です！ロードが完了すると`Completed`に繋がる処理が走るので、そこに`Open Level`ノードを入れるだけで対応完了です！~~今回は比較テストしやすいように、上図のような実装をしてみました。~~
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/45d6a807-b559-422a-6430-f5e4bc9e285a.png)
~~ちなみに、上図のようにすることで`Open Level`にレベル名を直指定する必要がなくなります。~~

### ※ 2019/10/09 追記
**なんと上記の方法ですとPIE, Standaloneでは動作しますがPackageでは動作しません…!**
最もやらかしてはいけないことをしていました。本当に申し訳ありません…

AssetManagerを用いた非同期ロードがパッケージ後正常に動作しない現象についての解決策
https://qiita.com/Naotsun/items/164375b2b1c12968b887

自分用まとめも兼ねて、まず「Get Path Name」「Get Display Name」「Get Object Name」「Primary Asset Name」がそれぞれどんな文字列を返すか確認してみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/80bdba8a-ea4d-0a7d-c363-becf60ffe711.png)

- Path Name : /Game/StarterContent/Maps/StarterMap.StarterMap
- Display Name : StarterMap
- Object Name : StarterMap
- Primary Asset Name :StarterMap

そして、Package上でPath NameでOpen Levelしたときのログがこちら

```c++
LogLoad: LoadMap: /Game/StarterContent/Maps/StarterMap.StarterMap
LogUObjectHash: Compacting FUObjectHashTables data took   1.91ms
LogStreaming: Warning: LoadPackageAsync failed to begin to load a package because the supplied package name was neither a valid long package name nor a filename of a map within a content folder: '' (/Game/StarterContent/Maps/StarterMap.StarterMap)
```

…ばっちりWarning起きてますね。これは /Game/StarterContent/Maps/StarterMap.StarterMap 末尾の「.StarterMap」が原因です。これを取り除けばPackageでも問題なくOpen Levelが成功します。

**「取り除くの面倒そう…"StarterMap"だけ指定する場合も動作するし、Path Name以外使お！」と考える方がいるかと思いますが、あまりオススメはできません。**なぜなら、指定された短いレベル名が実際にどのレベルアセットを指しているかの検索コストが発生するからです（しかも、このとき走る FPackageName::SearchForPackageOnDisk が結構重たい ）。そのため、"/Game/StarterContent/Maps/StarterMap"のように Gameフォルダからの相対パスで指定することを推奨します。

ということで、PathNameから取得できる /Game/StarterContent/Maps/StarterMap.StarterMap の末尾をどう削るかです。先程 [ご共有した記事](https://qiita.com/Naotsun/items/164375b2b1c12968b887) で紹介されている方法もありますが、せっかくなので エンジンの標準ノードでなんとかしてみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/db9df836-1d13-2fe1-2faf-54c588c6df3b.png)
**GetBaseFileNameノードを使うことで、拡張子（"."以降の文字列）を取り除くことができます。** なお、bRemovePathを有効にすると "/Game/StarterContent/Maps"も取り除かれてしまうのでご注意ください。

追記終わり！


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363889/ea836c40-04ee-31ed-61f7-7b69028664f1.png)
早速動作させて効果を実感してみましょう！ただし、その際はいつものPIE(Play In Editor)ではなくStandalone又はPackage起動で試してみてください。非同期ロードを入れた場合と入れない場合で体感ロード時間が大きく変わるはずです！

…何故PIEではだめかと言うと、PIEの場合は既に対象のレベルアセットがメモリ上に確保されている場合があるからです。この記事の前半で説明したとおり、非同期ロードを使うことで対象のアセットを事前にメモリ上に確保し実際に使われる際のロード時間を削減できます。一方、エディタ上でアセットを開いたりするとそのアセットはメモリ上に確保され、PIE実行の場合はそのメモリ上に確保されたアセットを使用します。その結果、PIE実行の場合は非同期ロードが正常に動作しない可能性があります。そのため、非同期ロード周りの検証は必ずStandaloneかPackageで行う必要があります（時間が許すなら、Packageの方がより精確な計測ができるのでオススメです）。

# ここまでのまとめ
- 非同期ロード機能を使うことで、アセットのロード処理（メモリ上への確保）を事前に行うことができる
    - その結果、実際にそのアセットを使用する際の体感ロード時間を短縮できる
- Asset Managerを使って非同期ロードをするためには、対象のアセットをAsset Managerの管理対象であるPrimary Assetにする必要がある
    - 管理対象にするかしないのかの設定は、Project設定のAsset Managerカテゴリで行う
    - レベルアセットの場合、`Primary Asset Type to Scan`の1番目の項目を編集する
        - Is Editor Only と Directoriesの設定に注意

# 次回予告
今回はレベルアセットの非同期ロードについて説明しましたが、コンテンツ内容・作り方や対象プラットフォームによってはあまり有用ではありません。なぜなら、レベルアセットを事前にロードするとメモリ使用量が急激に増加し、メモリが潤沢ではないプラットフォームの場合はクラッシュしてしまう可能性があるからです。これを避けるために複数のステージをメモリ上に確保できるように調整する手もありますが、1ステージで使用できるメモリ量が少なくなるため、レベル上に配置するオブジェクト数や演出などに大きな制限がかかってしまいます。また、レベルアセット単位での管理になってしまうのも欠点です。例えばレベルに依存せずに動的生成するエフェクトであっても、わざわざレベルアセットに紐付ける必要があります。

そのため、実際にAsset Managerの非同期ロードを運用する際はレベルアセット単位ではなくもう少し細かい単位ですることが多いかと思います。次の記事ではその辺りについて解説しようと思います。

その2( レベルアセット以外の裏読み編 )へ
https://qiita.com/EGJ-Kaz_Okada/items/7dba130c3641aa456b73