もんりぃ is undefined.

育児ネタとか、技術ネタとか。

ごっこランドを支える技術 〜AssetBundle 編〜

はじめに

この記事は Unity Advent Calendar 2019 における14日目の記事になります。
公開がギリッギリになってしまい、本当に申し訳ありません…。

前日の記事は @Raspberly さんの「鏡に映る3Dモデルを差し替える」という記事でした。

2018年の Unity Advent Calendar からの待望の続編ということで、内容的にも「何かに使えそう…!」と思える面白い視点ですね!

既読の方はもちろん、未読の方は是非ともお読みいただき、はてなスターを付けたりツイートしたりすると筆者のラズさんも嬉しいんじゃないかと思います!

さて、私の記事はと言いますと、2018年の Unity Advent Calendar に書いた「ごっこランドを支える技術 〜ビルド編〜」のシリーズ続編として AssetBundle について綴りたいと思います。*1

なお、本記事の内容は 2019/03/24 (Sun) に開催された「Unity AssetBundle 完全に理解した勉強会」にて登壇した際の内容をテキストとして再編したものになります。

当該勉強会の動画は Unity Learning Materials に掲載されておりますので、よろしければそちらもご覧ください。

なお、諸般の事情(単なる時間不足)により文字成分多めな記事になっておりますがご容赦くださいませ。

目次

AssetBundle についておさらい

AssetBundle とは?

一言で言えば「Asset を Bundle したもの」です。

まぁ、これだと全く説明になっていないので、もう少し掘り下げましょう。

Unity で画像や音声や 3D モデルなどを取り扱う場合、PNG や MP3 や FBX などのフォーマットのデータをそのまま用いているわけではありません。

f:id:monry84:20191214231527p:plain
一度はこのダイアログを目にしたことがあるんじゃないかと思います。

この Importing Assets というプロセスによって、各種素材を Unity にとって取り扱いやすいバイナリへとインポート(変換)しており、逆にインポートされていない素材は扱えなかったり扱えてもパフォーマンス的に不利 *2 だったりするのです。

で、普通に Player をビルド *3 すると、この変換されたバイナリリソースがビルド成果物の中に含められて、Unity のランタイム実行エンジンがバイナリを読み込むことができるようになるわけです。

なぜ AssetBundle が必要なのか?

ところで、ROM に焼くにしても App StoreGoogle Play などのストアから配信するにしても、ゲーム本体のサイズには制限があることが一般的です。

このため、無闇矢鱈とリソースを Player の中に突っ込むワケにはいきません。

何なら筆者はモバイルプラットフォームを主戦場にしているので、サイズとの戦いは相当シビアなものになっています。

そこで、このサイズ制限に対して効果的なアプローチは画像や音声などの重たいリソースを DLC: ダウンロードコンテンツ化するというテクニックになります。

んで、この DLC のためのリソースとして、Unity にインポートされた Asset *4 をそのままサーバにアップロードできれば良いのですが、残念ながらそれはできません。

ここで登場するのが AssetBundle というわけです。

Unity にインポートされた Asset を LZ4 などのアルゴリズムで圧縮したファイルのことを AssetBundle と呼び、これをサーバにアップロードすることで DLC の要件を充たせるようになるわけです。

AssetBundle の構成要素

初っ端から長くなってしまいましたが、「AssetBundle とは何か?」が理解できたところで、AssetBundle という機能について学ぶには、次のような用語や構成要素への理解が不可欠になります。

  • AssetBundle
  • Streamed Scene AssetBundle
  • AssetBundle Name
  • AssetBundle Manifest
  • UnityWebRequestAssetBundle
  • AssetBundleCreateRequest
  • AssetBundleRequest
  • Caching
  • BuildPipeline.BuildAssetBundles
  • AssetBundleBuild
  • AssetBundle Dependencies

多いですねー!

めげずに一つずつ紐解いていきましょう。

AssetBundle

これは、先述の通りです。

AssetBundle を取り巻く技術に於ける最小単位です。

詳しくは後述しますが、AssetBundle として固められている Asset から他の AssetBundle 化された Asset を参照することも可能です。

後述の BuildPipeline によってビルドされたファイル郡は Amazon S3Google Cloud Storage や Microsoft Azure Storage などのオブジェクトストレージに配置され、Amazon CloudFrontGoogle Cloud CDNMicrosoft Azure CDN などの CDN を経由して配信されたりします。*5

Streamed Scene AssetBundle

ザックリ言えば、Scene を丸ごと AssetBundle 化したモノです。

読み込み方が通常の AssetBundle とは若干異なり、AssetBundle が読み込まれると SceneManager.LoadScene() 可能な Scene として展開されるようになります。

Scene 内に AssetBundle 化された Asset への依存がある場合、それらが事前に読み込まれていないと Scene 時に参照が失われてしまいます。

AssetBundle Name

AssetBundle の名称です。

………。

「それくらい分かるわ!なめとんのか!」という声が聞こえた気がするので、深掘りします。

AssetBundle Name は Asset を AssetBundle として束ねる際のキーとして用いられます。

Inspector の最下部に設定するためのフォームがあり、ここから新規名称を追加して設定するか、既存の名称を設定するかを選択します。

f:id:monry84:20191214232928p:plain

ビルドの仕方にも依存しますが、AssetBundle Name が設定されている Asset は AssetBundle 化する対象となると考えて良いでしょう。

同一の AssetBundle Name が設定されている Asset は一つの AssetBundle として固められるので、Asset を読み込む際には AssetBundle.LoadAsset<T>() による型ベースの取り出しや AssetBundle.LoadAsset(string assetName) による取り出しにより読み分けます。

AssetBundle Manifest

近代 AssetBundle (とは?w)に於ける最重要項目の一つ。

ザックリ言えば AssetBundle の目録。

ビルド毎に生成される「今回作った AssetBundle 達はこんな感じだよ」というファイルで、AssetBundle の Hash や依存関係を管理している。

管理される Hash には、AssetFileHash という「(厳密には違うけど)Asset を Serialize した値に基づく Hash」と TypeTreeHash という「スクリプトの型情報に基づく Hash」の2種類がある。

このファイルを AssetBundle として読み込むと AssetBundleManifest という型のクラスのインスタンスが得られるので、AssetBundle Name を用いて Hash や依存関係を問い合わせることが可能になる。

UnityWebRequestAssetBundle

AssetBundle をサーバなどから取得するための UnityWebRequest クラスを拡張したクラス。

AssetBundle のダウンロードに特化したメソッドが生えており、基本的には UnityWebRequestAssetBundle.GetAssetBundle() を使えば事足りるはず。 *6

利用するオーバーロードに依っては、ヨシナにキャッシュをしてくれるので、基本的には便利です。

AssetBundleCreateRequest

AssetBundle そのものを非同期に読み込む際の AsyncOperation なクラス。

AssetBundle.LoadFromFileAsync() やら AssetBundle.LoadFromMemoryAsync() やらの戻り値。

ダウンロード進捗などが受け取れて、コイツの .isDonetrue になると .assetBundle プロパティから AssetBundle のインスタンスが取得出来ます。

AssetBundleRequest

AssetBundle から Asset を読み込む際の AsyncOperation なクラス。

AssetBundle.LoadAssetAsync<T>() などの戻り値。

読み込み進捗などが受け取れて、コイツの .isDonetrue になると .asset.allAssets プロパティからアセットのインスタンスが取得出来ます。

Caching

UnityWebRequestAssetBundle.GetAssetBundle() 時にヨシナに生成してくれるキャッシュを操作するためのクラス。

このクラスに AssetFileHash を渡すことで、端末にキャッシュがあるか?などを確認できる。

古代はキャッシュの全削除しかできずボロクソに言われていたが、近代に於いては Hash を用いた個別削除に対応するなど、汚名返上に成功しているとかなんとか。

BuildPipeline.BuildAssetBundles

AssetBundle をビルドするための(ほぼ)唯一の方法。

引数の渡し方によって挙動は異なるが、大まかに

  • AssetBundle Name を設定したすべての Asset をビルド
  • 後述の AssetBundleBuild 型の配列を渡してビルド対象を選択してビルド

の2通りのビルド方法がある。

どちらも、前述の Hash 値に基づいて、良い感じの差分ビルドを実施してくれる。*7

AssetBundleBuild

この構造体に AssetBundle のビルド方法を詳細に指定して、前述の BuildPipeline.BuildAssetBundles() の引数に渡すことで、柔軟な AssetBundle ビルドを行えます。

「Inspector を使わずにエディタスクリプトでゴリゴリ頑張るんやー!」という貴方のための仕組みと言えるでしょう。

Asset/AssetBundle の依存

ちょっと話題は変わって、Asset 同士の関係について考えてみましょう。

Asset の依存関係

そもそも、Unity の Asset *8 は、別の Asset への参照(依存)を持つコトができます。

public class Bar : MonoBehaviour
{
    [SerializeField] private Texture2D hoge;
}

例えば上記のようなスクリプトがアタッチされた Foo という名前の Prefab があったとして、Inspector 上で Bar コンポーネントhoge フィールドに fuga という名前の Texture2D Asset を Drag & Drop すると、「 Foo (Prefab) は fuga (Texture2D) に依存している」と表現されたりします。

Unity の仕様として、この Foo Prefab を AssetBundle 化する場合、メインの Asset となる Prefab と一緒に fuga Texture2D も固められることになります。

そのため、読み込み時は Texture2D は Prefab と一緒に展開されることになり、問題なくテクスチャを利用することができるわけです。

AssetBundle の依存関係

さて、ココで問題になるのは、 fuga (Texture2D) が他の Asset からも依存されている場合です。

先述の通り、fuga は各 Asset の AssetBundle に固められる形になるため、結果として fuga が大量にコピーされているのと同じような状態となり、各 AssetBundle 読み込み時にも当然別のインスタンスとして展開されることになってしまいます。

これは Texture2D に限らず、あらゆる Asset で起きうる問題なので、チョットした軽い ScriptableObject とかならまだ何とかなるかもですが、Texture2D とか AudioClip のようにサイズが大きくなりがちなバイナリデータだとメモリやストレージ的にシンドイことになりがちです。

この問題に効果的なアプローチが 依存される Asset を単独の AssetBundle としてビルド するというテクニックです。

これまでに書いてきたように、 AssetBundle 化された Asset は他の AssetBundle 化された Asset への参照を持つコトができます。

そして、AssetBundle 化された Asset への依存を持つ場合は、依存する Asset のコピーは AssetBundle に含まれなくなりストレージ的に有利で、更に読み込み時も同一の Asset と見なせる場合にはインスタンスを使い回してくれたりするのでメモリ的にも有利です。

ただし、こういった構造の場合、本体の AssetBundle を読み込む前に依存先の AssetBundle を読み込んでおく必要があるため、先述の AssetBundle Manifest を用いて依存関係を取得して、先読みをしておくなどの対応が必要になります。

ごっこランドでの事例

さて、ドチャクソ長い前置きはこのくらいにして、そろそろ本題に入りたいと思います。*9

株式会社キッズスターごっこランドで、どのように AssetBundle を活用しているのかをご紹介しましょう。

ごっこランドの構成

そもそも、ごっこランドでの AssetBundle 活用事例を語る前に、ごっこランドのドメインというかコンテキストを説明する必要があります。

ごっこランドは、App Store / Google Play / Amazon Android アプリストア で配信している子ども向け職業体験アプリです。

ごっこランドにはパビリオンと呼ばれる実在企業のお仕事を体験できるコンテンツが2019年末時点で30個以上配信されており、各パビリオンには幾つかのミニゲームが含まれているので実質的に50個以上のミニゲームが遊べるアプリとなっています。

詳しく知りたい方は、上記のリンクからアプリをダウンロードしていただければご理解頂けると思います。完全無料アプリですので是非!

んで、このパビリオンは、それぞれが独立した Unity プロジェクトとして開発が行われており、最終的にストアに申請・アップロードする時に各パビリオンのソースコード (C#) と一部の Asset のみをごっこランド本体と呼ばれる Unity プロジェクトにマージしてビルドしています。

つまり、各パビリオンのソースコード (C#) と一部の Asset を除く大半の Asset は AssetBundle として提供されているわけです。

そして、可能な限りオフラインでの動作を保証するという地味にプログラマ泣かせな要件があったりもします。

この辺りを踏まえつつ、具体的な AssetBundle 活用事例の紹介に入ります。

インフラアーキテクチャ

AssetBundle そのものは Amazon S3 に AssetBundle 郡を配置して、アプリへは Amazon CloudFront を経由して配信しております。

先述の通り、各パビリオンの大半のリソースが AssetBundle として提供されており、それらは S3 の特定の Bucketディレクト*10 を分けてアップロードされています。

まぁ、特筆するようなモノは何も無いですが、トラフィックは結構な量出ているんじゃないかと。

AssetBundle 構成

AssetBundle は各パビリオンのプロジェクト側でビルドを行っています。

基本的に、全ての Scene が Streamed Scene AssetBundle 化されており、一部の複数 Scene から依存される共有 Asset には個別に AssetBundle Name を設定するようにしています。

この辺りは独自のエディタスクリプトを作成し、 AssetBundle Name の設定からビルドに至るまで一貫性のあるルールを敷いて運用しています。

AssetBundle ビルド

依存 AssetBundle が存在する以上、AssetBundle Manifest の活用は不可避です。

そのため、基本的に AssetBundle ビルド時は BuildPipeline.BuildAssetBundles()AssetBundleBuild 構造体を渡さず、AssetBundle Name が付与されている Asset を全てビルドするようにしています。

そして、BuildPipeline.BuildAssetBundles() の戻り値としてもらえる AssetBundleManifestインスタンスをもとに、アップロードするファイルを収集し S3 に全てアップロード(ただし、事故を防ぐためにも上書きはしないようにしている。)しています。

差分ダウンロード

しかし、そのビルド戦略を採ると不安になるのが、「小さい差分であってもフルビルドされてしまい、ユーザのギガデータ通信量を無駄に消費してしまうのではないか?」という点です。

この点の解消については少し工夫をしており、S3 にアップロードする際のファイル名に AssetFileHash を用いることで、事実上の差分ダウンロードを実現しています。

f:id:monry84:20191214222452p:plain
Hash の先頭2桁をディレクトリ名にしているのは視認性を優先した結果です。

AssetFileHash は Asset が変更されていない限り変化しない(はず)という特性に着目して、AssetFileHash が変化している = 差分であると見なしているわけです。

もとより UnityWebRequestAssetBundle.GetAssetBundle()Hash128 構造体を受け取るオーバーロードに AssetFileHash から構築される値を渡しているので、無駄なダウンロードは発生しないハズですが、リネームなどへの保険の意味も兼ねてこの仕様にしています。

AssetBundle Manifest

各パビリオンの AssetBundle をビルドする際に生成される AssetBundle Manifest は依存解決のために必要不可欠です。

ごっこランドでは AssetBundle のビルド毎に AssetBundle Manifest のバージョン番号的なモノ(≒ ビルド番号)をインクリメントしてバージョニングする運用にしており、S3 にもバージョン番号を含んだファイル名でアップロードしています。

そして、パビリオン毎にダウンロードすべき AssetBundle Manifest のバージョン番号を管理しつつ、Application.persistentDataPath 以下にバージョンに該当する AssetBundle Manifest が存在しないならばダウンロード処理を起動し、成功時に AssetBundle Manifest をバージョン番号を含むファイル名で保存するようにしています。

アプリの特性上、アプリバージョンが古い状態でプレイしているユーザが居る可能性を考慮する必要があり、かつ起動時点で全てのパビリオンの AssetBundle がダウンロード済になるわけではありません。(タイトル画面でボタンが押下されたパビリオンのみがダウンロードされる。)

そのため、AssetBundle Manifest 自体のバージョニングを行わないと、ソースコードよりも AssetBundle の方が新しいという状況が発生してしまうのです。

この辺の事情から、原則的には AssetBundle を更新する際には AssetBundle Manifest バージョンを上げて、かつビルドに含まれるパビリオン毎にダウンロードすべき AssetBundle Manifest バージョン情報も更新する必要がある(つまり Player のビルドが必要で、ストア側のアプデも必要というコト。)のですが、ソースコードの変更を一切伴わない場合に限り Unity RemoteSettings を用いた AssetBundle Manifest バージョンの外部更新を実現可能にしてあったりもします。
※Unity RemoteSettings については、以前発表した際の動画が Unity Learning Materials に掲載されておりますので、興味がある方はご覧ください。

おわりに

なんだかんだで超大作な記事となってしまいましたが、いかがでしたか?

AssetBundle は Unity 製のタイトルを中長期的に運用するうえで避けては通れない仕組みです。*11

この記事が少しでも貴方の運用の参考になれば幸いです。

明日の Unity Advent Calendar 2019ユニティ仮m… @wotakuro さんの「Unity Profilerからデータを取ってくる事について」です。

Unity Profiler と深く仲良くなりたい人類や🦍にとって必読の内容となっています!😉

*1:続編続きになったのは全くの偶然です。パクったわけじゃないよ!

*2:例えばランタイムで PNG 画像を Texture として変換したり、何なら Sprite に変換することも可能なんですが、かなり重い処理だったりします。

*3:各種実機プラットフォーム向けに出力すること。

*4:ここで言うAsset は「AssetStore で買ったモノ」を指すのではなく、「Unity が取り扱えるファイル」を指します。

*5:この辺のインフラアーキテクチャの詳細については本記事では言及いたしません。

*6:普通にダウンロードして Application.persistentDataPath 以下にでも保存して、 AssetBundle.LoadFromFile() で読むとかもできるかも?

*7:が、上手いこと差分検知してくれないケースが稀によくあるので注意を要する。

*8:Scene や Scene 内の GameObject 含む

*9:どうしても前置きが長くなってしまうのです。私の悪い癖。

*10:実際には Object Storage にディレクトリという概念は適用されないハズですが、便宜上そのように表現します。

*11:自前で DLC の仕組みを作る🦍も一定数居らっしゃるとは思いますが…😓