Unity製Androidアプリのパフォーマンスチューニングをしていた時に、画像読み込みの負荷が気になり色々と試したのでまとめてみました。参考になることがあれば幸いです。
画像を読み込むと言うと、ビルドインアセットなどいくつか方法がありますが、内容としてはダウンロードしてローカルに保存してある画像を読み込む時のお話です。
環境
macOS Catalina 10.15
Unity 2018.4.9f1
Pixel3(検証端末)
経緯
画像はUnityWebRequestなどでローカルに取得済みとして、読み込みは簡単に以下のようになっていました。
string filePath = Application.persistentDataPath + "/image/hoge.png";
byte[] byteData = File.ReadAllBytes(filePath);
Texture2D texture = new Texture2D(0, 0, TextureFormat.RGBA32, false);
texture.LoadImage(byteData);
そこで、画面遷移時など負荷のかかるタイミングをプロファイラで見てみます。
リソースの展開やデータの整形など様々な処理が含まれますが、大きなスパイクがあり処理落ちしていることが分かります。
詳細を確認すると、気になったのはGC.Allocというやつです。
メモリ確保が行われているわけですが、追っていくと原因はTexture2Dを生成する過程で、一時的に読み込んだ画像データをbyte配列で保持してしまうためでした。
ローディング中などの待ち時間であっても、可能な限り画面のカクつきは防ぎたいところです。
GC.Allocを避ける理由
https://qiita.com/mao_/items/8f95bc9dbeb3179ba4b9
こちらの記事にメモリのことがとても分かりやすく書かれていますが、「大きなGC.Alloc → ヒープ領域が拡張される → GCのパフォーマンスが悪くなる」ということがあるようです。
GC(ガベージコレクション)が搭載されている言語で開発する際は、なるべくそれが発生しないように意識するかと思います。それに加えて、ヒープ領域が無駄に拡張されないように、過度なメモリ使用を抑えることも考える必要がありそうです。
このことから、大きいサイズになりがちである画像の扱いは、マネージドヒープと相性が悪そうな印象を受けます。
対策
https://qiita.com/mao_/items/8f95bc9dbeb3179ba4b9
こちらの記事に全面的にお世話になりますが、、
マネージドヒープではなくアンマネージドメモリで処理できれば、不要なメモリ管理の負荷を回避できます。
少々リスクは感じますが、メモリの確保と解放を自分で行うことで、マネージドヒープがやっていた管理のコストを無くします。
また、上記記事で紹介されているAsyncReadManagerを使用して、ファイル読み込みを非同期にします。
最初に載せたサンプルコードだと、File.ReadAllBytesでのファイル読み込みを非同期で処理するため、1フレームにかけてしまっていた負荷の分散が期待できます。
- テクスチャ生成時に発生する一時的なメモリ使用をアンマネージドメモリで処理する
- ファイル読み込みを非同期で行う
これらを実装したサンプルプロジェクトを作成してみました。Android向けにビルドしても動作します。(iOSも一応対応していますが動作未確認です。) ※ iOSも動作確認できました。
プラットフォームをAndroidかiOSのどちらかに切り替えて確認してください。
https://github.com/Nox-Lib/AsyncTextureReader
成果
サンプルをAndroid端末(Pixel3)で実行してみます。
最適化前と後とで1000x1000の画像を100回読み込んで表示を繰り返し、負荷やかかった時間を見ます。
静止画だと分かりませんが、画面左下の白い四角は処理落ちを視覚的に確認するため、くるくると回っています。続いてプロファイラです。
レンダリングなどのノイズを除くため「Scripts」の項目のみを表示していますが、最適化後では負荷がしっかりと分散されていることが分かります。
ざっくりまとめると以下になります。
FPS | 1フレームあたりの負荷 | 全体の処理時間 | |
---|---|---|---|
最適化前 | 40 | 16ms以上 | 2.6秒 |
最適化後 | 60 | 1ms 〜 2ms | 3.3秒 |
最適化後は読み込みを非同期にしたためか、処理時間は少し伸びてしまっています。しかし、嬉しいことに処理中でも60fpsを余裕で維持できました。
ちなみに、更に大きい画像で試してみたところ、最適化後の方が短い時間で処理できる結果になりました。
解説
AsyncTextureReader.Readメソッドで画像を読み込みます。
AsyncTextureReader.Read(
new AsyncTextureReader.TextureData {
filePath = Application.persistentDataPath + "/image/hoge.png",
format = TextureFormat.ETC2_RGBA8,
width = 1000,
height = 1000
},
texture => {
this.rawImage.texture = texture;
this.rawImage.SetNativeSize();
}
);
第一引数はパスなどの画像情報、第二引数には読み込んだテクスチャを受け取るコールバックを指定して使用します。連続でコールした場合は順番に処理されます。
画像情報にはパス以外にフォーマットやテクスチャサイズが必要で、AsyncTextureReader.TextureData構造体でまとめて渡すようにしています。これらの情報が必要なのはTexture2Dクラスの仕様によるものですが、アンマネージドメモリを活かそうとすると通常の形式の画像が扱えなかったためです。
どういうことかというと、AsyncReadManagerでファイルを読み込むとバッファへのポインタを取得できるのですが、Texture2Dの生成に使用していたLoadImageメソッドはbyte配列のデータでしか使用できません。
ポインタからbyte配列に直すことも可能ですが、byte配列を生成してしまうとそれはマネージドヒープに載ってしまい、せっかくアンマネージドメモリで処理している意味がなくなります。
そこで、Texture2D.LoadImageメソッドの代わりにTexture2D.LoadRawTextureDataメソッドを使用しており、このメソッドにはポインタが渡せます。
しかし、このメソッドは扱える画像の形式が異なっており、LoadImageメソッドがpngやjpgの画像をそのまま読み込むのに対して、LoadRawTextureDataメソッドは圧縮された画像のバイナリデータを読み込みます。そのため、フォーマットやテクスチャサイズの情報も必要となります。
- 圧縮したテクスチャのバイナリデータが必要(PVRTC、ETC2、ASTC、など)
- 圧縮したテクスチャのフォーマットとサイズの情報が必要
圧縮したテクスチャのバイナリデータを用意する
Unityに画像をインポートして、インポーター設定で圧縮設定を行います。
その後、スクリプトからTexture2D.GetRawTextureDataメソッドでバイナリデータを取得し、File.WriteAllBytesメソッドで書き出します。
サンプルプロジェクトでは初期化の時、この方法でビルドインアセットからバイナリデータの作成を行なっています。
メモリ効率
Texture2D.LoadImageメソッドはRGBA32のフルカラーでテクスチャを読み込むため、メモリに優しくありません。そこが圧縮したテクスチャであれば改善されます。
以下は、1000x1000のテクスチャをRGBA32とETC2_RGBA8で読み込んだ時の比較です。
課題
プロファイラから分かるように負荷が減り、画像を多く読み込んでも画面がカクつかなくなりました。また、テクスチャが圧縮されることでメモリ効率も良くなり、改善が見られる結果が得られました。
しかし、課題もあります。
- 圧縮したバイナリデータを用意する必要があるため、画像を変換する一手間が必要となる。
- もともと減色処理などして容量削減をしていた場合、おそらくそれよりも容量が大きくなる。
- 圧縮形式がプラットフォームごとに違う場合、OSごとにリソースを用意する必要がある。
- 圧縮した画像はOSデフォルトのビューワーで確認できないため、ビューワーを用意した方が効率的。
- 画像を暗号化する場合、復号化でマネージドヒープを使用してしまうとパフォーマンスが低下する。良い解決法がないか検討の必要があるが、OSデフォルトのビューワーで開けなくなるため、暗号化するかはプロジェクトの方針次第。
導入や運用のコストは上がりますが、とはいえ自動化やツールの作成でカバーできる範囲かと思います。何より画像を多く読み込むシーンで効果が発揮されるかと思います。
アセットバンドル
ここまでやるのであればアセットバンドルで良いのでは?となるので試してみます。下記コードはローカルからアセットバンドルを読み込んで画像を取り出すサンプルです。
アセットバンドルが暗号化されることを想定して、バイナリデータをbyte配列で読み込み、そこからLoadFromMemoryAsyncで読み込みます。
private IEnumerator LoadAssetBundle()
{
string filePath = Application.persistentDataPath + "/AssetBundle/android/test.unity3d";
byte[] byteData = File.ReadAllBytes(filePath);
// decryption
AssetBundleCreateRequest createRequest = AssetBundle.LoadFromMemoryAsync(byteData);
yield return createRequest;
AssetBundle assetBundle = createRequest.assetBundle;
AssetBundleRequest request = assetBundle.LoadAssetAsync<Texture2D>("hoge");
yield return request;
Texture2D texture = request.asset as Texture2D;
assetBundle.Unload(false);
}
一時的なbyte配列の保持はできれば避けたいので、そこを工夫した復号化の方法があればそちらが良いです。暗号化しないのであれば、LoadFromFileAsyncで読み込んだほうが軽くて良いかと思います。
これを同じ条件(1000x1000の画像)で100回繰り返して試してみます。
FPS | 1フレームあたりの負荷 | 全体の処理時間 | |
---|---|---|---|
アセットバンドル | 60 | 1ms 〜 2ms | 4.8秒 |
比較すると負荷はAsyncTextureReaderとあまり変わらず、処理時間が少し伸びる結果となりました。とはいえ十分に軽いです。
また、リソースがアセットバンドルで更に圧縮されるため、ファイルサイズの削減が期待できます。
まとめ
テクスチャフォーマット | FPS | 1フレームあたりの負荷 | 全体の処理時間 | ファイルサイズ | メモリサイズ | |
---|---|---|---|---|---|---|
Texture2D.LoadImage | RGBA32 | 40 | 16ms以上 | 2.6秒 | 692KB | 7.6MB |
AsyncTextureReader | ETC2_RGBA8 | 60 | 1ms 〜 2ms | 3.3秒 | 977KB | 1.9MB |
AssetBundle | ETC2_RGBA8 | 60 | 1ms 〜 2ms | 4.8秒 | 175KB | 1.9MB |
まず、Texture2D.LoadImageが重いことはよく分かりました。小さな画像であれば気にならないかもしれませんが、どうしても負荷が気になるシーンが出てきそうです。
アセットバンドルはファイルサイズが小さく負荷も軽かったです。また、Unityアセットであれば等しく扱えるため、画像以外も含めてリソースの管理方法を一本化できるのが利点です。
しかし、リソースが頻繁に増えるような場合で全部アセットバンドルにしていると、手間やビルド時間が気になってくるかもしれません。そういった場合に、AsyncTextureReaderのような仕組みが検討できるのかなと思います。
また、Unityのメモリ管理のことも少し知れて良かったです。今後は、今まで以上にメモリに優しいコーディングを意識できそうです。
ちなみに、結局今回どのような対応にしたのかですが、フレームワークの改修が容易ではなかったため、効果が出やすい大きな画像をアセットバンドルにすることで対応を行いました。