unityroomでパフォーマンスよくStreamingAssets/Addressablesを使えるライブラリを作った
はじめに
この記事では私が作成したunityroomでパフォーマンスよくStreamingAssets/Addressablesを使えるライブラリEmbeddedStreamingAssetsについての解説をしていきます。
juhaさんのこちらのライブラリの改良版になります。
しくみ
StreamingAssetsInjectorの解説記事と重複する部分は概要だけで詳細は割愛。
- AddressablesはStreamingAssetsに依存
- StreamingAssetsはビルドデータには埋め込まれないのでunityroomでは使えない(正確には未対応)
- WebビルドではStreamingAssetsの取得に
fetchが使われる - それを上書きすれば、自由に返すものを変えられる
Unityのビルド内かjsか
さて、上書きするとしてどこにデータを埋め込むかが重要ですね。
StreamingAssetsInjectorではjsに埋め込んでいましたが、それだとバイナリをそのままというわけにはいかず、base64というエンコード法になっていました。
しかし、これにはもちろんデメリットが存在します。
- ビルドサイズが生データと比べて4/3倍
- jsはUTF-16なのでメモリに乗るのはビルドサイズの2倍の8/3倍
- jsの実行時コンパイルに時間がかかる
- base64からのデコード処理
UTF-16に最適化してbase32768をにしてもビルドサイズは少し増加しますし、その他デメリットは残ります。
ということでUnity/C#側で保存し、jsに渡すことになります。
データの受け渡しjs -> C# -> js
一旦具体的な埋め込みは跳ばして、データの受け渡しの話です。
初期化と関数ポインタ呼び出し
まずfetchからC#を呼びたいですね。
つまりC#から関数ポインタを渡すことになります。
重要な部分を切り出すとこのような形です。
[DllImport("__Internal")]
static extern void EsaLib_Init(Action<string> fnPtr);
[MonoPInvokeCallback(typeof(Action<string, string>))]
static void HandleRequestFromJS(string relPath){}
static void Init()
{
EsaLib_Init(HandleRequestFromJS);
}
EsaLib_Init: function (fnPtr){
//中略 上書きするfetch内
const bufferSize = lengthBytesUTF8(relativeUrl) + 1
const buffer = _malloc(bufferSize)
stringToUTF8(relativeUrl, buffer, bufferSize)
try {
Module.dynCall_vi(fnPtr, [buffer]);
} finally {
_free(buffer);
}
// get data
const blob = new Blob([data]);
return Promise.resolve(new Response(blob, {status: 200}));
}
初期化時にデリゲートを渡すと自動的に関数ポインタに変換されます。
jsからC#を呼び出すときは文字列をUTF-8のポインタに変換[1]し、dynCall_vi()[2]で関数ポインタを叩きます。
参考:
データをC#からjsへ
HandleRequestFromJSの実装とjsでの受取り方を解説します。
保存方法によらない実装で説明します。
interface IData
{
public ReadOnlySpan<byte> GetData();
}
static IData? Load(string path);
まずどうにかしてデータをロードできるとします。
[MonoPInvokeCallback(typeof(Action<string, string>))]
unsafe static void HandleRequestFromJS(string relPath){
try{
var asset = Load(relPath);
if (asset != null){
var data = asset.GetData();
fixed((byte*)ptr = &data){
EsaLib_Resolve(ptr, data.Length, asset is IDisposable);
}
(asset as IDisposable)?.Dispose();
}
else{
EsaLib_Reject("Not found: " + relPath);
}
}
catch (Exception e){
EsaLib_Reject(e.Message);
}
}
[DllImport("__Internal")]
static extern void EsaLib_Resolve(IntPtr dataPtr, int length, bool isTemp);
[DllImport("__Internal")]
static extern void EsaLib_Reject(string message);
ポインタと長さとこのデータが破棄されるかを渡します。
EsaLib_Resolve: function (dataPtr, length, isTemp) {
try {
var src = new Uint8Array(Module.HEAPU8.buffer, dataPtr, length);
if(isTemp){
var copy = new Uint8Array(length);
copy.set(src);
src = copy;
}
Module.EsaLib.result = src
} catch (e) {
console.error('EsaLib_Resolve error', e);
}
}
,
EsaLib_Reject: function (messagePtr) {
var message = UTF8ToString(messagePtr);
Module.EsaLib.result = message;
}
jsではModule.HEAPU8.bufferでviewを生成し、もし元データが破棄されるならコピーを作成します。
これにより効率的にデータの受け渡しができるようになりました。
Unity/C#でのデータの埋め込み方
選択肢
- Unity
- PreloadedAsset (TextAsset, byte[] in ScriptableObject)
- Resources(TextAsset, byte[] in ScriptableObject)
- C#
- EmbeddedResources
- RVA field
C#のdll側での保存は採用せず、本題ではないので今回は解説しません。
また別の機会に紹介するかもしれません。(ざっくりいうとどちらもMemoryMappedFilesを使うようですが、検証したところUnity Webビルドではメモリ消費量的な利点がなさそうでした。)
それぞれの詳細とバイナリデータとしての扱い方を紹介していきます。
どの型を使うか
まずUnityのアセットでのバイナリの型レベルでの保存方法として2つあります。
TextAsset、byte[]をScriptable Objectに保存する方法です。
TextAsset
TextAssetは文字列だけでなくバイナリの保存もできます。さらにGetData<byte>を使えば、アロケーションなしで読み取ることもできます。
どのファイルでもTextAssetとして使うために拡張子として.bytesなどをつける必要があります。
byte[]
byte[]として保存する方法ももちろんあります。
UnityEditor上でのシリアライズの遅さとサイズの増大を防ぐためにPreferBinarySerialization属性をつけるとよいです。
採用した方法
GCへの影響、管理のしやすさからTextAssetを採用しました。
保存&ロード方法
PreloadedAssets
PreloadedAssetsはシーンから参照せずともビルドに含まれるアセットでScriptableObjectを登録し、OnEnableでインスタンスをstaticに登録することでシングルトンとしてどこからでもアクセスできるようにできます。
public class EmbeddedAssets : ScriptableObject{
public static EmbeddedAssets Instance { get; private set; }
[SerializeField] private TextAsset textAsset;
public static void LoadInstanceFromPreloadAssets(){
if (Instance != null)
return;
var preloadAsset = UnityEditor.PlayerSettings.GetPreloadedAssets().FirstOrDefault(x => x is EmbeddedAssets);
if (preloadAsset is EmbeddedAssets instance){
instance.OnEnable();
}
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void RuntimeInitialize(){
// For editor, we need to load the Preload asset manually.
LoadInstanceFromPreloadAssets();
}
[InitializeOnLoadMethod]
static void EditorInitialize(){
LoadInstanceFromPreloadAssets();
}
void ClearAssets(){
entries = null;
}
void OnEnable(){
Instance = this;
}
}
これによりいつでもバイナリデータにアクセスできるようになりました。
細かくは省略しますが、パスからバイナリ範囲の検索することができます。
実装:
Resources
Resoucesフォルダ以下のアセットをビルドに埋め込みます。
埋め込みたいファイルをResourcesに移すことで、Resources.Loadで読み込めます。利点としてResources.Unloadでメモリに残らないことがあります。管理がしづらいと言われていますが、寿命がスコープ内だけなので欠点になりません。
採用した方法
Preloadedで通常のアセットとして読み込むとResourcesで全てロードした状態と同じメモリ使用量になったので、Resourcesを採用しました。
ちなみにResourcesを採用したところUnityのどこからもライブラリのAssemblyが参照されなくなり、Strippingされてしまうようになったのですが、AlwaysLinkAssemblyAttributeをつけることで解決しました。
ビルドパイプライン
さて保存とロードができましたが、どのようにResourcesにファイルをつくるかを話していません。
当初IPreprocessBuildWithReportを使ってResourcesに移す処理をしていましたが、残念ながらAddressablesのビルドはOnPreprocessBuildでは行えないため、エディタ拡張からPlayerビルドごと呼び出せるようにしました。
そこからAddressablesのビルドフォルダとStreamingAssetsのフォルダからResourcesに(.bytesつけながら)コピーする形です。
(ビルド後のStreamingAssetsからBuild.dataに埋め込むとエディタ拡張からのビルドもいらなくなるので、その方法に変えるかもしれません。)
まとめ
全てのアセットをTextAssetとしてResourcesに保存という形でStreamingAssetsを埋め込むことができました。
パフォーマンスはよいですが、Resourcesの保存データが常にメモリにのってしまうので、本物のStreamingAssetsが使えるのが一番です。
対応してほしいですね。
Discussion