📦

unityroomでパフォーマンスよくStreamingAssets/Addressablesを使えるライブラリを作った

に公開

はじめに

この記事では私が作成したunityroomでパフォーマンスよくStreamingAssets/Addressablesを使えるライブラリEmbeddedStreamingAssetsについての解説をしていきます。

https://github.com/Akeit0/EmbeddedStreamingAssets

juhaさんのこちらのライブラリの改良版になります。
https://github.com/KurisuJuha/StreamingAssetsInjector

しくみ

StreamingAssetsInjectorの解説記事と重複する部分は概要だけで詳細は割愛。
https://zenn.dev/juha/articles/9d6fd4d205fc38

  • 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をにしてもビルドサイズは少し増加しますし、その他デメリットは残ります。
https://github.com/qntm/base32768?tab=readme-ov-file#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);
}
jslib 
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]で関数ポインタを叩きます。

参考:
https://docs.unity3d.com/6000.2/Documentation/Manual/web-interacting-browser-js-to-unity.html

https://qiita.com/gtk2k/items/1c7aa7a202d5f96ebdbf

データを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>を使えば、アロケーションなしで読み取ることもできます。

https://docs.unity3d.com/6000.2/Documentation/Manual/class-TextAsset.html
https://docs.unity3d.com/6000.2/Documentation/ScriptReference/TextAsset.GetData.html
どのファイルでもTextAssetとして使うために拡張子として.bytesなどをつける必要があります。

byte[]

byte[]として保存する方法ももちろんあります。
UnityEditor上でのシリアライズの遅さとサイズの増大を防ぐためにPreferBinarySerialization属性をつけるとよいです。
https://docs.unity3d.com/6000.2/Documentation/ScriptReference/PreferBinarySerialization.html

採用した方法

GCへの影響、管理のしやすさからTextAssetを採用しました。

保存&ロード方法

PreloadedAssets

PreloadedAssetsはシーンから参照せずともビルドに含まれるアセットでScriptableObjectを登録し、OnEnableでインスタンスをstaticに登録することでシングルトンとしてどこからでもアクセスできるようにできます。
https://docs.unity3d.com/ScriptReference/PlayerSettings.GetPreloadedAssets.html

https://docs.unity3d.com/ScriptReference/PlayerSettings.SetPreloadedAssets.html

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;
    }
}

これによりいつでもバイナリデータにアクセスできるようになりました。
細かくは省略しますが、パスからバイナリ範囲の検索することができます。
実装:
https://github.com/Akeit0/EmbeddedStreamingAssets/blob/4632b456e322937e5b3df1327b485b0810b335cd/Packages/EmbeddedStreamingAssets/EmbeddedAssets.cs

Resources

https://docs.unity3d.com/6000.2/Documentation/ScriptReference/Resources.html

Resoucesフォルダ以下のアセットをビルドに埋め込みます。
埋め込みたいファイルをResourcesに移すことで、Resources.Loadで読み込めます。利点としてResources.Unloadでメモリに残らないことがあります。管理がしづらいと言われていますが、寿命がスコープ内だけなので欠点になりません。

採用した方法

Preloadedで通常のアセットとして読み込むとResourcesで全てロードした状態と同じメモリ使用量になったので、Resourcesを採用しました。

ちなみにResourcesを採用したところUnityのどこからもライブラリのAssemblyが参照されなくなり、Strippingされてしまうようになったのですが、AlwaysLinkAssemblyAttributeをつけることで解決しました。
https://docs.unity3d.com/6000.2/Documentation/ScriptReference/Scripting.AlwaysLinkAssemblyAttribute.html

ビルドパイプライン

さて保存とロードができましたが、どのようにResourcesにファイルをつくるかを話していません。

当初IPreprocessBuildWithReportを使ってResourcesに移す処理をしていましたが、残念ながらAddressablesのビルドはOnPreprocessBuildでは行えないため、エディタ拡張からPlayerビルドごと呼び出せるようにしました。
そこからAddressablesのビルドフォルダとStreamingAssetsのフォルダからResourcesに(.bytesつけながら)コピーする形です。

https://docs.unity3d.com/6000.2/Documentation/ScriptReference/Build.IPreprocessBuildWithReport.html

https://github.com/Akeit0/EmbeddedStreamingAssets/blob/31285aafb30d3a68b8ed679e502edb75c0be6caa/Packages/EmbeddedStreamingAssets/Editor/EmbeddedStreamingAssetsBuildWindow.cs#L123-L142

(ビルド後のStreamingAssetsからBuild.dataに埋め込むとエディタ拡張からのビルドもいらなくなるので、その方法に変えるかもしれません。)

まとめ

全てのアセットをTextAssetとしてResourcesに保存という形でStreamingAssetsを埋め込むことができました。

パフォーマンスはよいですが、Resourcesの保存データが常にメモリにのってしまうので、本物のStreamingAssetsが使えるのが一番です。
対応してほしいですね。

脚注
  1. jsもC#もUTF-16なのにな... ↩︎

  2. viはvoid(int) ↩︎

Discussion