Gaudiy Tech Blog

Gaudiyの技術、開発組織、カルチャーについてお伝えするブログです

UnityのWebGLアプリ開発における"使えないライブラリ問題"の回避策

ファンと共に時代を進める、Web3スタートアップのGaudiyでUnityエンジニアをしているくりやま(@xamel7)です。

Gaudiyでは"Gaudiy Fanlink"というブロックチェーンや生成AIなどの技術を活用したファンプラットフォームで、漫画、アニメ、アイドルといったIP(知的財産コンテンツ)独自のコミュニティの開発・運営をしています。

service.gaudiy.com

このFanlinkの一機能として、現在、新たに開発を進めているのがIPのカジュアルゲームです。

「GANMA!コミュニティ」で先日公開されたカジュアルゲーム

▼登録不要で遊べます

ganma-community.com

WebサービスであるFanlinkとの連携が必要なこともあり、GaudiyのUnityチームではWebGLビルドによるアプリケーション開発を行っています。

WebGLビルドは、スタンドアロンやモバイルなどのネイティブアプリ開発とは異なる開発方法を取るケースが多々出てきますが、情報が少ないと思うので、今回はその一部を紹介したいと思います。

1. WebGLとは?

本題に入る前に、UnityにおけるWebGLの特徴について説明したいと思います。 WebGLはブラウザ上で開発したアプリを動作させることができるため、様々なメリットが得られます。

1-1. メリット

  • 一度アプリを作ってしまえば同一のビルドで複数のOS上のブラウザから動作させることができる。
  • ゲームやアプリをプレイするためにダウンロードが不要なので、ユーザーが気軽に遊ぶことができる。開発においてもGoogleやAppleなどの認証を受ける必要が無いので計画を立てやすい。
  • C#だけでなくJavaScriptの豊富なOSSも活用することができる。

一方で、以下のようなデメリットも存在します。

1-2. デメリット

  • Unityの一部標準機能やUnity向けのライブラリで使えないものがソコソコある。 
    • JavaScriptやHTMLと連携することで回避できることが多い。
  • フロントエンド開発時においても、JavaScriptなどのWeb技術の知識が必要になる場合がある。 
    • 2023年移行はChatGPT,Copilotを使えば普段使っていない言語でも案外なんとかなる。
  • モバイルプラットフォームは、2022.3LTS時点においてサポート対象外です。
    • 躓くことは多いが、ここ最近はインターネット上に情報が増えているので大体なんとかなる。
  • プラットフォームによっては利用できるリソース(特にRAM)が少ない
    • Addressablesの利用や最適化が必要になることが多い、Sceneについても無駄のない構成に設計する必要がある。

その他にもプラットフォーム特有の制限が結構あるので、気になる方は以下の公式ドキュメントをご参照ください。

docs.unity3d.com

2. WebGLを使うとエラーが発生するケース

上記デメリット内で挙げた通り、WebGLビルド時には一部のUnity向けライブラリが使えないことがあります。GaudiyのバックエンドはGCPで構成されていますが、GCPのUnity向けライブラリについてもその対象となるため、現在は利用できません。

今回紹介する例では、Firestore(NoSQL ドキュメント データベース)へのアクセス方法について紹介したいと思います。

2-1. 共通の構成

データベース構造

1ドキュメントのみのシンプルな構造にしています。

Scene構成

読み書きイベントリガー用のButtonとInputFieldを配置しています。

2-2. Unity用FirebaseSDKを利用した方法

まずは最もシンプルなUnity用のSDKを利用する方法について説明します。WebGL以外であれば、この方法が一般的です。

ソースコード

データベース内の"FieldString"フィールドの文字列を読み書きする単純な例として記載しています。 この例ではSDKで用意されているメソッドに対して、"FieldString"フィールド格納場所を指定するだけで取得することができます。

    /// <summary>
    /// SDKを利用してFirestoreからドキュメントを取得する
    /// </summary>
    private async UniTaskVoid ReadFirestore()
    {
        var db = FirebaseFirestore.DefaultInstance;
        DocumentReference docRef = db.Collection("FirestoreCollection").Document("FirestoreDocument");

        _readText.SetText("DBの読み込み開始");

        // スナップショットの取得
        var snapshot = await docRef.GetSnapshotAsync().AsUniTask();

        if (snapshot.Exists)
        {
            // スナップショットをDictionaryに変換する
            Dictionary<string, object> snapshotDict = snapshot.ToDictionary();
            
            // 取得したドキュメントのFieldString要素を取得する
            var fieldString = snapshotDict["FieldString"].ToString();
            
            if(!string.IsNullOrEmpty(fieldString))
            {
                // 取得したドキュメントのFieldString要素が存在する場合はテキストに反映する
                _readText.SetText(fieldString);
            }
            else
            {
                _readText.SetText("FieldStringは空です");
            }
        }
        else
        {
            Debug.Log($"指定したドキュメントが存在しません");
        }
    }

    /// <summary>
    /// Firestoreのドキュメントを書き込む
    /// </summary>
    private async UniTask WriteFirestore()
    {
        Dictionary<string, object> data = new Dictionary<string, object>()
        {
            { "FieldString", _writeInputField.text },
        };
        await FirebaseFirestore.DefaultInstance.Collection("FirestoreCollection").Document("FirestoreDocument")
            .SetAsync(data).AsUniTask();
        Debug.Log($"DBに\"{_writeInputField.text}\"を書き込みました。");
    }

このスクリプトをUnityEditor上で実行し、テキストを入力後に書き込み→読み込みボタンの順で押すと、読み書きに成功していることが確認できます。

ただし、このスクリプトでWebGLビルドしてみると、以下のようにエラーが大量発生してしまいます。。。

原因は以下の通りで、DLLの設定を確認するとUnity向けのFirebaseSDKはWebGLビルドには対応していないことがわかります。

よって、WebGLビルドを利用する時はUnity用のSDKを使用せず、別の方法で読み書きする必要があります。

そこで本記事では、REST API を使った方法JavaScript用のSDKを利用する方法の2パターンを紹介したいと思います。

3. 使えないライブラリ問題の回避策(GCP編)

3-1. REST API を使った方法

REST APIは多くのプラットフォームで利用できます。WebGLビルドもその対象で、他プラットフォームと同様に利用することができるので、FireStore用にAPIが公開されていれば、UnityWebRequest(HTTPリクエスト)の機能を使用することで読み書きがすることができます。

また、今回のFirebaseSDKに限らず、他サービス向けのUnity用SDKでもWebGLに対応していないことは多々あるので、REST APIに対応している場合は同様の方法で対処することができます。

ソースコード

Firestoreに対してREST APIで読み書きする場合はJson形式でやり取りすることになるので、Json変換用のクラスを定義しておきます。

/*
 応答データの例
 
{
    "name": "projects/【プロジェクト名】/databases/(default)/documents/FirestoreCollection/FirestoreDocument",
    "fields": {
        "FieldString": {
            "stringValue": "テスト"
        }
    }
}
*/

using System;
using Newtonsoft.Json;

// 応答データ構造に基づいたクラスを定義
[Serializable]
public class FirestoreResponse
{
    [JsonProperty("name")] public string Name { get; set; }
    [JsonProperty("fields")] public Fields Fields { get; set; }
}

[Serializable]
public class Fields
{
    public FieldString FieldString { get; set; }
}

[Serializable]
public class FieldString
{
    [JsonProperty("stringValue")] public string StringValue { get; set; }
}

API情報を読み書き用のスクリプトから取得できる場所に設定しておきます。

    // Firestore APIのエンドポイント。プロジェクトID、コレクション名、ドキュメント名を適切に設定する必要があります。
    public const string URL =
        "https://firestore.googleapis.com/v1/projects/【プロジェクト名】/databases/(default)/documents/【コレクション名】/【ドキュメント名】";

    public const string APIKey = 【APIキー】;

※ 分かりづらいですが (default) の部分はそのまま (default) と記載する必要があります。 ※【】部分の情報は、FirebaseSDK利用時にStreamingAssetsに格納していた google-services.json に記載されている情報を転記します。

次に読み込み用のスクリプトを準備します。取得後のデータは先程作成したJson変換用クラスを使い、デシリアライズして目的のフィールド要素を受け取ります。

Jsonの変換にはNewtonSoft.Jsonを利用しています。

    /// <summary>
    /// Firestoreからドキュメントを取得する
    /// </summary>
    private async UniTaskVoid ReadFirestore()
    {
        UnityWebRequest request = UnityWebRequest.Get(FirebaseManager.URL + "?key=" + FirebaseManager.APIKey);

        await request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError)
        {
            Debug.Log("Error: " + request.error);
        }
        else
        {
            // JSONを取得してFieldString要素を取り出す
            var jsonData = request.downloadHandler.text;
            FirestoreResponse response = JsonConvert.DeserializeObject<FirestoreResponse>(jsonData);
            string fieldString = response.Fields.FieldString.StringValue;
            
            if(!string.IsNullOrEmpty(fieldString))
            {
                // 取得したドキュメントのFieldString要素が存在する場合はテキストに反映する
                _readText.SetText(fieldString);
            }
            else
            {
                _readText.SetText("FieldStringは空です");
            }
        }
    }

書き込み時は、Bodyに変換済みのJsonを格納する形でリクエストを送ります。

    /// <summary>
    /// REST APIを利用してFirestoreのドキュメントを書き込む
    /// </summary>
    private async UniTask WriteFirestore()
    {
        // 送信するJSONデータ。この例では、"FieldString"というフィールドに"MyValue"という値を設定しています。
        var writeText = _writeInputField.text;
        string json = GenerateJson(writeText);

        // リクエストの設定
        UnityWebRequest request = new UnityWebRequest(FirebaseManager.URL + "?key=" + FirebaseManager.APIKey, "PATCH");
        byte[] bodyRaw = Encoding.UTF8.GetBytes(json);
        request.uploadHandler = new UploadHandlerRaw(bodyRaw);
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");

        // リクエストの送信
        await request.SendWebRequest();

        // レスポンスの処理
        if (request.result == UnityWebRequest.Result.ConnectionError ||
            request.result == UnityWebRequest.Result.ProtocolError)
        {
            Debug.Log(request.error);
        }
        else
        {
            Debug.Log($"DBに\"{writeText}\"を書き込みました。");
        }
    }

    /// <summary>
    /// 送信用Jsonオブジェクトを生成する
    /// </summary>
    private static string GenerateJson(string inputText)
    {
        FirestoreResponse data = new FirestoreResponse
        {
            Fields = new Fields
            {
                FieldString = new FieldString { StringValue = inputText }
            }
        };
        return JsonConvert.SerializeObject(data);
    }

これでFirestoreに対して読み書きをすることが可能になります。

課題として、REST APIでは単純な読み書きは可能ですが、バッチ処理(トランザクション)や複雑なクエリに対応することができません。 それらの処理を行いたい場合は、次に紹介するJavaScript向けのSDKを利用する必要があります。

3-2. JavaScript向けのSDKを使った方法

この方法ではWebフロントエンドの機能を利用するため、jslibというJavaScriptに近い形式のファイルを利用してJavaScript用のSDKを扱います。

WebGL固有の方法でFirestoreとやり取りすることになるため、ソースコードを他のプラットフォームと共有する場合は #if UNITY_WEBGL && !UNITY_EDITOR などを追加し、他プラットフォームビルド時に参照されないようにする必要があります。

本手順は、これまでの方法と比べ少し複雑な手順となるため、最初におおまかな流れを説明します。

  • JavaScript向けSDKの読み込み
  • C#からjslibへ初期化メソッドの呼び出し
  • jslib内でSDKインスタンスの初期化
  • C#からjslibへ読み込みメソッドの呼び出し
  • jslib内で読み込み or 書き込みの実行 (以降は読み込みの場合のみ)
  • jslibからC#へ読み込み結果の通知
  • C#でUnityのテキストUIに結果を表示

事前準備

テンプレートの修正

ビルド時に生成されるindex.html等でSDKの読み込みを行う必要があるのですが、デフォルトの状態だとビルド時に出力されるHTMLファイルを編集することができません。

HTMLファイルを編集するためには Assets/WebGLTemplates のディレクトリにテンプレートファイルを格納する必要があります。 Unityで用意されているデフォルトテンプレートは以下に格納されているため、 Assets/WebGLTemplates にディレクトリごとコピーして格納します。

※ Macの場合は以下のディレクトリに格納されています。 /Applications/Unity/Hub/Editor/【Unityバージョン】/PlaybackEngines/WebGLSupport/BuildTools/WebGLTemplates/Default/

格納後は PlayerSettings > Player > Resolusion and Presentation > WebGLTemplate に格納したディレクトリの名前でテンプレートが登録されています。

今回はCustomという名前でディレクトリを作成しています。 ※反映されない場合はUnityを再起動してみてください。

JavaScript用SDKの導入

次にHTMLファイル(今回はindex.html)に以下のタグを追加してください。 これを追記することで後述するjslibファイル内からFirebaseインスタンスを参照することが可能になります。

  <script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-firestore-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-auth-compat.js"></script>
  <script>
    window.firebase = firebase; // グローバルスコープにFirebaseインスタンスを公開
  </script>

ソースコード(jslib)

jslibファイル名自体は拡張子 .jslibであればファイル名は自由ですが、 Pluginsという名前のディレクトリに格納する必要があるので注意してください。 普段Unityを扱っていると見慣れない記述だらけのため、コメントを多めに記載しています。

var FirebasePlugin = {
    // IndexDBのパラメータを保持するオブジェクト、ダッシュボードで取得した値を転記してください。
    $FirebaseConfig: {
        apiKey: "xxxxxxx",
        authDomain: "xxxxxxx",
        projectId: "xxxxxxx",
        storageBucket: "xxxxxxx",
        messagingSenderId: "xxxxxxx",
        appId: "xxxxxxx",
        measurementId: "xxxxxxx"
    },
    $DB: null
    ,
    FirebaseInit: function () {
        window.firebase.initializeApp(FirebaseConfig); // windowを付与することでグローバルな値を取得することができます。
        DB = window.firebase.firestore();
    },
    ReadFirestoreJS: function (instanceID, callback) {
        // instanceIDは本メソッドを呼び出したインスタンスのID
        // callbackはC#側で定義したコールバック関数
        
        // ドキュメントの取得
        var docRef = DB.collection("FirestoreCollection").doc("FirestoreDocument");

        docRef.get().then(function (doc) {
            if (doc.exists) {
                
                var json = JSON.stringify(doc.data()); // jsonを文字列に変換してポインタに渡す
                var bufferSize = lengthBytesUTF8(json) + 1; // バッファするサイズを取得
                var buffer = _malloc(bufferSize); // バッファ用メモリを確保
                stringToUTF8(json, buffer, bufferSize); // 文字列をUTF8に変換してポインタに渡す
                Module.dynCall_vii(callback, instanceID, buffer); // C#で定義したコールバック関数を呼び出す
            } else {
                console.log("ドキュメントが見つかりません");
            }
        }).catch(function (error) {
            console.log("Error:", error);
        });
    },
    WriteFirestoreJS: function (keyPtr, valuePtr) {
        // 文字列はポインタとして渡されるので文字列に変換する
        var key = UTF8ToString(keyPtr);
        var value = UTF8ToString(valuePtr);

        // Firestoreドキュメントの取得
        var docRef = DB.collection("FirestoreCollection").doc("FirestoreDocument");
        
        docRef.update({
            [key]: value
        })
        .then(function () {
            console.log("ドキュメントの更新に成功しました");
        })
        .catch(function (error) {
            console.error("Error: ", error);
        });
    },
};
autoAddDeps(FirebasePlugin, '$DB'); // $DBのコードストリップ防止
autoAddDeps(FirebasePlugin, '$FirebaseConfig'); // $FirebaseConfigのコードストリップ防止
mergeInto(LibraryManager.library, FirebasePlugin); // LibraryManagerにFirebasePluginを統合

ソースコード(C#)

C#側では [DllImport("__Internal")] を付与することでjslibで定義(mergeInto())したメソッドの呼び出しが可能になります。

また、 [MonoPInvokeCallback(typeof(Action<int,string>))] を付与することで、jslib側にC#メソッドのポインターを渡すことができます。jslibにポインターを渡し、読み込み完了時の通知を受け取ることを実現しています。

まずは読み込み側のソースコードです。

public sealed class FirestoreReaderWebGL : MonoBehaviour
{
    [DllImport("__Internal")] // jslib内の関数を呼び出すためのattribute
    private static extern string ReadFirestoreJS(int instanceId, Action<int, string> receiveCallback); // jslib内の"ReadFirestoreJS"を呼び出す

    // 後述のコールバックで実行元のインスタンスIDとインスタンスをマッピングするためのDictionary
    private static readonly Dictionary<int, FirestoreReaderWebGL> Instances = new(); 

    [SerializeField,Header("読み込み実行ボタン")] private Button _readButton;
    [SerializeField,Header("読み込み結果反映用テキスト")] private TextMeshProUGUI _resultText;
    
    // 読み込みボタン押下イベントを購読する
    private IObservable<Unit> ReadButtonObservable => _readButton.OnClickAsObservable().ThrottleFirst(TimeSpan.FromSeconds(1));

    private void Start()
    {
        // インスタンスIDの紐づけを行う
        var instanceId = GetInstanceID(); 
        Instances.Add(instanceId, this);
        
        // 読み込みボタン押下イベントを購読する
        ReadButtonObservable
            .TakeUntilDestroy(this)
            .Subscribe(_ => ReadFirestore());
    }

    /// <summary>
    /// jslib経由でFirestoreからドキュメントを取得する
    /// </summary>
    private void ReadFirestore()
    {
        ReadFirestoreJS(GetInstanceID(),OnReadFirestore);
    }
    
    /// <summary>
    /// ReadFirestoreJSの実行結果のコールバック
    /// static関数のみ利用可能
    /// </summary>
    [MonoPInvokeCallback(typeof(Action<int,string>))] // jslibからのコールバックとして利用する際は本attributeを付与する
    private static void OnReadFirestore(int instanceId,string jsonString)
    {
        // Newtonsoft.Jsonを使用してJSONをDictionaryに変換する
        Dictionary<string, string> snapshotDict = JsonConvert.DeserializeObject<Dictionary<string, string>>(jsonString);
        
        // 取得したドキュメントのFieldString要素を取得して結果を表示する
        var fieldString = snapshotDict["FieldString"];
        if(!string.IsNullOrEmpty(fieldString))
        {
            Debug.Log($"取得したドキュメント : {fieldString}");
            Instances[instanceId].SetResultText(fieldString);
        }
        else
        {
            Instances[instanceId].SetResultText("fieldStringは空です");
        }
    }
    
    /// <summary>
    /// 結果テキストの反映
    /// </summary>
    private void SetResultText(string text)
    {
        _resultText.SetText(text);
    }
}

最後に書き込み側のソースコードです。こちらは単純に書き込む値をjslibに送っているだけのコードになります。 jslib側では文字列はpointerとして渡されるため、JavaScriptで文字列として扱えるように UTF8ToString()を通す必要があるので注意が必要です。

public sealed class FirestoreWriterWebGL : MonoBehaviour
{
    [DllImport("__Internal")] // jslib内の関数を呼び出すためのattribute
    private static extern void WriteFirestoreJS(string key, string value); // jslib内の"WriteFirestoreJS"を呼び出す

    [SerializeField, Header("書き込み実行ボタン")] private Button _writeButton;

    [SerializeField, Header("書き込む文字列登録用InputField")]
    private TMP_InputField _writeInputField;

    private IObservable<Unit> WriteButtonObservable => _writeButton.OnClickAsObservable();

    private void Start()
    {
        // 書き込みボタン押下イベントを購読
        WriteButtonObservable
            .TakeUntilDestroy(this)
            .ThrottleFirst(TimeSpan.FromSeconds(1)) // 連打防止
            .Subscribe(
                _ => WriteFirestoreJS("FieldString", _writeInputField.text) // jslib経由でFirestoreに書き込む
                );
    }
}

これでSDKを利用した読み書きが可能になります。

4. まとめ

UnityにおけるWebGLアプリ開発はブラウザ上で動作するため、他プラットフォームと異なる振る舞いをすることが多いのですが、今回説明したような工夫をすることでトラブルを回避する方法があったりします。

私はこれまでWebGLでのアプリ開発をいくつか経験してきましたが、今のところUnity非サポートのモバイルでも、最終的には(意味深)なんとかなることが多いので、WebGL未経験のエンジニアの方にもぜひ挑戦してもらいたいです!

そしてまだまだUnityWebGLの情報が少ないのでシェアしてほしいですw

長文となりましたが最後まで読んでいただき有難うございました!!興味ある方は、ぜひカジュアルにお話ししましょう!

recruit.gaudiy.com

くりやま (@xamel7) / X

Unityエンジニア以外も、全方位で採用強化中です!

recruit.gaudiy.com