🔨

unityroomでStreamingAssetsを使えるライブラリを作った

2024/12/21に公開

UGDGアドベントカレンダー21日目です

https://adventar.org/calendars/10069

つくったもの

https://unityroom.com/games/streamingassetsimportertest

https://github.com/KurisuJuha/StreamingAssetsInjector

このライブラリを導入するだけで謎の技術によりunityroomでもAddressablesやLocalizationが使えるようになります。
この記事では、この謎の技術について解説しようと思います。

なぜつかえないのか

AddressablesやLocalizationはStreamingAssetsという機能を使用しています。
通常アセット類はビルド時プロジェクトに結合しますが、StreamingAssetsはビルド時にプロジェクトに結合することなく、そのままビルドディレクトリに吐かれます。

このStreamingAssetsをunityroomにアップロードできないというのがこの問題の原因です。

無理やり使う方法としては、別のサーバーにアップロードしてそっちにアクセスするようにする方法があったりします。
具体的な方法↓

https://zenn.dev/uimss/articles/9912f9d7147853

が、こんな面倒くさいことはしたくない!
とくになんもしなくても動いてほしい!!!
パフォーマンスとかどうでもいいからAddressablesとかLocalizationとか使いたい!
できます。StreamingAssetsInjectorを使いましょう。

しくみ

前章でも話しましたが、AddressablesやLocalizationはStreamingAssetsにアクセスしています。
WebGL上でStreamingAssetsにアクセスするには以下のようなコードを実行する必要があります。

// リクエストの作成
var request = UnityWebRequest.Get($"{Application.streamingAssetsPath}/text.txt");

// 送信&待機
await request.SendWebRequest();

// 表示
Debug.Log(request.downloadHandler.text);

WebAssemblyに変換されたUnityWebRequestからはloader.jsからfetchを通して指定したURLにアクセスしています。

つまり、ここで呼び出されるfetchをoverrideし、
StreamingAssetsにアクセスしようとしている場合のみURLにアクセスするのではなく、こちら側で用意したデータをさも「サーバーにアクセスしてきましたよー」という顔をしながら渡すことで別のサーバーにアクセスさせることなくStreamingAssetsのデータを渡すことができます。

実際のoverrideするコードは下のようになっています。
上の説明通りです。

function OverrideFetch() {
    const originalFetch = window.fetch;
    window.fetch = function (url, options) {
        if (url.startsWith(window.location.origin + "/StreamingAssets/")) {
            var relativeUrl = url
                .replace(window.location.origin, "")
                .replace("/StreamingAssets/", "");

            // バイナリデータに変換
            const binaryData = atob(streamingAssetsTable[relativeUrl]);
            const arrayBuffer = new Uint8Array(binaryData.length);

            for (let i = 0; i < binaryData.length; i++) {
                arrayBuffer[i] = binaryData.charCodeAt(i);
            }

            const blob = new Blob([arrayBuffer]);

            return Promise.resolve(
                new Response(blob, {
                    status: 200,
                })
            );
        }

        return originalFetch(url, options);
    };
}

バイナリデータの格納部分は下のようになっています。
やっていることは単純で、ファイルパスをキーとした辞書を用意しているだけです。
バイナリデータはBASE64にして文字列形式に変換しています。

const streamingAssetsTable = {
    "SampleTest.txt": "aG9nZWhvZ2Vob2dlCmZvb29vb29vb29vbwo=",
    "Square.png":
        "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAACXBIWXMAAAsTAAALEwEAmp・・・
    "aa/catalog.json":
        "eyJtX0xvY2F0b3JJZCI6IkFkZHJlc3NhYmxlc01haW5Db250ZW50Q2F0YWxvZyIsIm1fQn・・・
    "aa/settings.json":
        "eyJtX2J1aWxkVGFyZ2V0IjoiV2ViR0wiLCJtX1NldHRpbmdzSGFzaCI6Ijc4MGM3OTU4YW・・・
    "aa/AddressablesLink/link.xml":
        "PGxpbmtlcj4NCiAgPGFzc2VtYmx5IGZ1bGxuYW1lPSJVbml0eS5BZGRyZXNzYWJsZXMsIF・・・
    "aa/WebGL/localization-assets-shared_assets_all.bundle":
        "VW5pdHlGUwAAAAAINS54LngAMjAyMi4zLjE0ZjEAAAAAAAAACigAAABBAAAAWwAAAkMAAA・・・
    "aa/WebGL/localization-locales_assets_all.bundle":
        "VW5pdHlGUwAAAAAINS54LngAMjAyMi4zLjE0ZjEAAAAAAAAACaAAAABBAAAAWwAAAkMAAA・・・
    "aa/WebGL/localization-string-tables-english(en)_assets_all.bundle":
        "VW5pdHlGUwAAAAAINS54LngAMjAyMi4zLjE0ZjEAAAAAAAAACfUAAABBAAAAWwAAAkMAAA・・・
    "aa/WebGL/localization-string-tables-japanese(ja)_assets_all.bundle":
        "VW5pdHlGUwAAAAAINS54LngAMjAyMi4zLjE0ZjEAAAAAAAAACgMAAABBAAAAWwAAAkMAAA・・・
    "aa/WebGL/testgroup_assets_all_9757e8b2a7771ac2a866db6037217b51.bundle":
        "VW5pdHlGUwAAAAAINS54LngAMjAyMi4zLjE0ZjEAAAAAAAAAGiwAAABBAAAAWwAAAkMAAA・・・
};

UnityにはIPostprocessBuildWithReportという機能があり、下のようなコードを書くことで簡単にビルド後に独自の処理を挟むことができるようになっています。

public class SampleBuildPostProcessor : IPostprocessBuildWithReport
{
    public int callbackOrder { get; }
    public void OnPostprocessBuild(BuildReport report)
    {
        // PostProcess
    }
}

このポストプロセス時に、override用コードとテーブルをloader.jsに追記、override用コード呼び出しを先頭に書き込みます。

以上です。簡単だね。

簡単なはずなのにこのライブラリを作り始めてからなぜ1年も経っているのか、
次の章でその話をします。

1年かかった話

前章で説明した方法はとても分かりやすく、とても簡単で、かなりバグに強い方法でした。

当初は別のもっと難しい方法を考えていて、
そのころ僕はILPostProcessにお熱でした。

C#やF#などはIL(CIL)というコードに変換され、.NETランタイム上で実行されます。
dotnet buildやUnityのコンパイル中に行われているのはこの変換作業です。
ILPostProcessは、変換後のILに手を加えることで、「何もしていないはずのメソッドがログを吐いたり、呼んでいないはずのメソッドが呼ばれていたり」などのとんでもないことができます。

つまりこの技術を使えば、StreamingAssets取得に使われるUnityWebRequest.Getを書き換えることが可能になり、そこにstreamingAssetsの場合の分岐を付けることでStreamingAssetsを組み込むことができる。

思ったより簡単に動かすことができました。

関係のないすべてのプロジェクトも含めて😱

そうです。UnityWebRequestレベルでエンジンに直結しているものはエンジン本体のモジュールに含まれていて、同じバージョンのプロジェクトすべてで共有されます。
それを書き換えるとどうなるか。同じバージョンのエンジンで作られたすべてプロジェクトでも実行されることになります。

さすがにやばいよね

ということで1回目の挑戦は失敗しました。

UnityWebRequset.Getの中身を書き換えるのではなく呼び出す側を書き換え、CustomWebRequest.Getを呼び出すように書き換えるも試しました。
が、すべての呼び出し側を網羅できる自信が無かったためこの挑戦も諦めました。

~やる気を失い5か月経過~

ここで次の方法を思いつきます。
①「StreamingAssetsをbase64にしてloader.jsに入れればいいんじゃね?」
②「ついでにloader.jsの中でffi用のfetch()customFetch()に書き換えればいいんじゃね?」
すごく惜しい。

①はファイルを全部舐めてバイナリ取ってエンコードして整形するだけなので実装できました。
ただ②が難しくて、minifyされたloader.jsが全然読めず。
そもそもjsがあまり好きではなく、ほとんど書くことができなかったのも相まってやる気が急速に失われました。

~やる気を失い6か月経過~

ここで今の、fetch()自体を書き換える方法を思いつきました。

まとめ

StreamingAssetsInjectorでunityroomでAddressablesやLocalizationがつかえるようになります。

Discussion