AsyncReadManagerを使って処理負荷軽減! 1度しかできない体験を最高のものにする
概要
AsyncReadManagerとは、アンマネージドなネイティブ側の機能を利用したUnityのAPIです。これを利用して画像読み込み・表示の処理負荷を減らし、AR/VRの天敵であるFPSを改善した話をします。
弊社MESONでは、2021/04/09 ~ 04/18にdocomoと協業で開発した「RESHAPE YOUR VIEW」と呼ばれるイベントが開催されました。(プレスリリースは以下をご覧ください)
RESHAPE YOUR VIEWとは
横浜のスカイガーデンタワーの展望台から見える景色をARによって近未来の体験に置き換えるイベントです。体験者はMagic Leapと呼ばれるARデバイスを使って、展望台から遠くに見える景色を目の前に引き寄せ、その観光スポットのCGを目の前で見ることができます。
この体験を作るにあたって最も気にしたのが体験者の体験を最大化するということです。開発者にとっては何度も何度も、それこそ100回以上目にする体験です。しかし、一般の体験者は人生で1度しかこれを体験しません。つまり、ただの1回の体験がすべての感想になるわけです。だから、1度きりの体験を最大化する必要があるのです。
体験最大化の課題 ― 処理負荷
今回のイベントは横浜のスカイガーデンに期間限定で常設され、一般の来場者の方に体験いただく展示型のコンテンツでした。このイベントを開催するにあたって、開発時の課題がいくつもありました。その中でも特に公開直前まで戦っていたのが処理負荷の問題です。
処理負荷が体験の質を左右する
処理負荷が高まるとFPS(Frame Per Second)が低下します。つまり負荷によって1秒間に更新できる画面の回数が減ってしまいます。この数値が低ければ低いほど体験の質が下がります。これはAR/VR関係者であれば周知の事実です。AR/VRコンテンツを制作する場合は一番にこれを解決し効率化する必要があります。
上がらないFPS
今回の開発では演出を多く盛り込み、観光スポットの画像も多用していたことからFPSが向上せず、イベント開催当日まで最適化と戦うことになりました。VRではFPSは最低でも90FPS以上必要とされています。これはVR酔いに直結するためです。ARの場合は現実世界が見えているため、酔いは限定的ですがやはり同様にFPSが高ければ高いほうがいいのは変わりません。つまりなんとしても解決しないとならない問題だということです。
ボトルネックの調査
最適化の際に必須で行わなければならないのはボトルネックの調査です。それこそ、FPSが改善しない理由は無数にあります。例えば、扱う3Dオブジェクトが多く、描画するものが多い場合。あるいは、オブジェクト数が少ないが、オブジェクトひとつひとつの描画に時間がかかる場合。これは主に、3Dモデルに原因がある場合です。
RESHAPE YOUR VIEWでは展望台から見える主な12スポットを扱っていました。当初はこれらのモデルに問題があるのではと調査をしていましたが、なにをしても大きな改善は見られませんでした。
ボトルネックは画像だった
色々と調査していくうちに、ボトルネックが画像であることを突き止めました。今回のコンテンツでは、12の観光スポットの魅力を知ってもらうために各スポットごとに最大で15枚のスポット写真を掲載していました。結果的に、この画像が処理負荷を高め、コンテンツ全体の処理負荷を上げていたのが原因だと分かりました。主な原因は、最大で15枚の写真を12スポット分用意し、それをすべて最初に読み込んでいたのが原因でした。実に180枚もの写真を一度に読み込んでいたのです。そのせいでメモリを消費し、結果的にパフォーマンス低下につながっていたというわけです。
動的ロードをするも課題が
前述のように、多量な画像を最初に読み込んでいたのが原因だったので、それではと、体験者が選択したスポットの画像を選択された際に読み込むように修正を加えました。これによって全体のパフォーマンスは向上しました。しかし、選択した際に画像を読み込むため、「読み込む」という処理負荷を後回しにしただけで、読み込む際の一時的なFPSの大幅な低下、いわゆる「カクつき」が発生するようになってしまいました。
画像のメモリ展開に課題
主な原因は、体験者が選択した際に画像を読み込みそれを画面に表示するための「メモリへの展開」の処理負荷が高いことでした。実装当初はUnityの標準の機能でこれを行っていました。具体的には以下のコードです。
byte[] data = GetImageRawData();
Texture2D tex = new Texure2D(1, 1);
tex.LoadImage(data);
これはとてもシンプルなAPIですが問題がありました。前述のように、取得したデータのメモリ展開(テクスチャへの適用)が重かったことです。
Native側処理で負荷軽減
主な原因は、マネージドなUnity APIによる画像へのメモリ展開にありました。そもそも1枚の画像を展開する処理が重いのに、1スポットあたり15枚もの画像があるため、結果としてカクつきが起きていたというわけです。
そこで、これをなんとかできないかと調査したところ、以下の記事を見つけました。
結論から言えば、重かったUnityのマネージド領域での画像展開をやめ、より高速に動作するネイティブ側で処理を行うことにしました。ドキュメントにはこう記載があります。
> With the AsyncReadManager, you can perform asynchronous I/O operations through Unity's virtual file system. You can perform these operations on any thread or job.
つまり、重かった処理を別スレッドに委譲することができるというわけです。Unityエンジニアには常識となっている、Unity APIがメインスレッド以外から呼べない、という問題を見事に解決してくれるAPIです。(最初に使用していたAPIがまさにUnity API)
これを利用することで無事に高速化を行うことができました。コード断片は以下の通りです。
using System;
using System.IO;
using System.Threading.Tasks;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.IO.LowLevel.Unsafe;
using UnityEngine;
/// <summary>
/// Read an image from a file with async.
/// </summary>
public class AsyncReader
{
private ReadHandle _readHandle = default;
private NativeArray<ReadCommand> _readCommands = default;
private long _fileSize = 0;
private Texture2D _texture = null;
private Action<Texture2D> _callback = null;
public AsyncReader(Texture2D outTexture, Action<Texture2D> callback)
{
_texture = outTexture;
_callback = callback;
}
public unsafe void Load(string path)
{
FileInfo info = new FileInfo(path);
_fileSize = info.Length;
_readCommands = new NativeArray<ReadCommand>(1, Allocator.Persistent);
_readCommands[0] = new ReadCommand
{
Offset = 0,
Size = _fileSize,
Buffer = (byte*)UnsafeUtility.Malloc(_fileSize, UnsafeUtility.AlignOf<byte>(), Allocator.Persistent),
};
_readHandle = AsyncReadManager.Read(path, (ReadCommand*)_readCommands.GetUnsafePtr(), 1);
CheckLoop();
}
private async void CheckLoop()
{
while (true)
{
if (_readHandle.Status == ReadStatus.InProgress)
{
await Task.Delay(Mathf.FloorToInt(Time.deltaTime * 1000));
continue;
}
if (_readHandle.Status != ReadStatus.Complete)
{
Dispose();
NotifyCallback();
break;
}
ReadTexture();
NotifyCallback();
break;
}
}
private void NotifyCallback()
{
_callback?.Invoke(_texture);
}
private unsafe void ReadTexture()
{
IntPtr ptr = (IntPtr)_readCommands[0].Buffer;
try
{
_texture.LoadRawTextureData(ptr, (int)_fileSize);
_texture.Apply();
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
finally
{
Dispose();
}
}
private unsafe void Dispose()
{
_readHandle.Dispose();
UnsafeUtility.Free(_readCommands[0].Buffer, Allocator.Persistent);
_readCommands.Dispose();
}
}
上記は `AsyncReadManager` を扱うための最低限のコードです。(実際にプロジェクトで利用したものではありません)
LoadRawTextureDataで生のデータを読み込む
上記コードで扱っているのは、ネイティブ領域で画像を読み込み、さらにそれをメモリに展開するまでを非同期で行っている処理です。これにより、UnityのAPIの制限である「メインスレッド上でのみ動作する」という制限を外れ、別スレッドで読み込むことができるようになります。
そしてこの処理によってロード、展開されたものを `LoadRawTextureData` を使ってUnityに反映することで今回の最適化ができているというわけです。 `LoadRawTextureData` はその名の通り、テクスチャへの画像読み込みをネイティブ側で確保したテクスチャへのポインタを利用することができるUnity APIです。
画像領域の確保にメタ情報が必要
上記対応を入れることで無事に処理負荷軽減の目処が立ちました。しかし、最初に利用していたAPIは画像のサイズを気にせずとも使えていました。別の言い方をすれば、そうした柔軟性が速度低下を招いていたとも言えます。
しかしネイティブで確保したデータをUnity側で扱える形にするためには、事前に画像のサイズとフォーマット、つまりメタ情報が必要となります。的確に設定したメモリに、ネイティブ側のデータを読み込まないと行けないわけです。
しかしネイティブ側で確保した情報というのは、ロードした時点ではわかりません。そこで今回の取った対策が、画像をファイルとして保存する際に、そのメタデータも保存するという方法です。
Unityでも `.meta` ファイルが作られ、そこに必要なデータが書き込まれていると思います。今回はその仕組みを参考に、1画像につき1メタデータを保存する形にしました。そのメタデータには画像サイズと画像のフォーマットを記述しています。
このメタデータを同時に読み込み、それを元にUnity側で必要なメモリを割り当てることでこの問題を回避しました。メタデータ自体は数byte程度のサイズなので問題になりません。これで無事、画像を高速に、かつ適切に読み込むことができるようになりました。
まとめ
画像はUnityでよく扱うものである一方で、処理負荷の増大に比較的簡単に関与してしまうものなので適切な取り扱いが必要となります。特に今回は、ネイティブ側で展開するという方法を取って解決したように、Unityのより深い部分を知ることでさらなる最適化できる余地があります。
Unityはとても便利なツールです。しかし、その中身を知っていないと問題が発生した際に適切に対処できなくなってしまうので、こうしたネイティブ周りの知識を手に入れておくと今後の役に立つと思います。
エンジニア募集中!!
MESONではエンジニアを募集しています! MESONではAR技術を使った新しい体験作りに力を入れています。そうした技術や体験を作ることが好きな方、興味のある方は、ぜひお気軽に会社サイトやTwitterから連絡ください!
この記事が気に入ったらサポートをしてみませんか?