こんにちは。ミラティブUnityエンジニアの菅谷(tetsujp84)です。
ミラティブのライブゲームはUnityで開発し、MirrativアプリのWebGLで動いています。 ライブゲームを支える仕組みと実際のライブゲーム開発で使用した技術については過去の記事で紹介しています。
UnityはWebGL向けにもビルドでき、モバイルのWebGLであってもUnity製プロダクトが動くようになっています。一方で、Unityは正式にはモバイルWebGL対応をサポートしておらず、ネイティブのモバイル開発と比べると開発の難易度は高いです。その難易度の高さは具体的にどこにあったのか、実際にモバイルWebGL向けのゲームを開発した経験を元に知見を共有します。
パフォーマンス編
モバイルWebGLの開発は常にパフォーマンスの意識が必要となります。特にメモリ使用量に対しては敏感でなければなりません。サンプルゲームを1つ作ってモバイルWebGLで動かすと、意外と動くことに驚くかもしれません。しかし、それが罠でもありました。実際に開発を進めていくと多くの課題に直面しました。(by ChatGPT)
メモリリークによって発生するクラッシュ
iOSの場合はエラーメッセージ等が出ずにブラウザがリロードされるため原因のキャッチが難しいです。リロードが入る=ネイティブでのクラッシュが起きた、と考えるとよいでしょう。Androidではエラーメッセージが表示されますが、エラーのトレースはWASM(WebAssembly)に変換されたあとでのエラーとなっており、元のC#コードを直接示していないので原因の特定が難しいです。特定端末やスペックの低い端末で必ず起きるような場合はメモリの使いすぎを疑うべきで、計測を行い、メモリの使用量をおさえるような一般的なアプローチが有効となります。
Androidでクラッシュが起きると下記のようなメッセージとエラーのログが表示されますが、.wasmファイル内でのログになってしまうため原因の特定が難しくなります。ほとんどはメモリの使いすぎによるエラーでした。
.wasm.gz:wasm-function[28232]:0xaa6283 at URL .wasm.gz:wasm-function|25208]:0×85c3cd at URL .wasm.gz:wasm-function[25207]:0x85bc46 at URL .wasm.gz:wasm-function[2503]:0xcc8ba at URL...
UniTaskのCancelはSupressCancellationThrowを使う
WebGLではtry-catchを使った例外処理の負荷が無視できないほど大きいため、例外を抑える対策を入れています。 実際の対策方法はこちらをご覧ください。
Photon Fusionのメモリ使用量
Photon Fusionのデフォルト設定では多くのメモリを使用してしまいます。Photon FusionのNetworkProjectConfigのHeapの設定値を最低値にすることで対策を行いました。大量のオブジェクトを同期する必要がなかったため、最低値の設定でも十分な同期が取れました。ただし、設定方法についてドキュメントに情報がなかったのでPhoton Fusionのフォーラム(Discord)で聞きました。それ以降はPhoton Fusionの機能不足や不具合で問題となるようなことはなく、Photon Fusionは十分使用に耐えられると評価しています。
Visual Scriptingの使用メモリ
Visual Scriptingのノードが肥大化して多くのメモリを使用していたため、最適化を行う必要がありました。実際の対策方法はこちらをご覧ください。
画像の圧縮とSpriteAtlas化
一般的な負荷対策ではありますが、画像には圧縮をかけ、画面単位でSpriteAtlas化してバッチングの最適化を行いました。モバイルWebGLのみがターゲットの場合、ASTCの使用が筋がよく、今回は6×6blocksで圧縮しておくと一番コストパフォーマンスがよさそうです。
Addressablesを使った動的なリソースの読み込み
最初はキャラクターデータも含めたすべてのオブジェクトをSerializeFieldで指定していましたが、特にモデルデータなどの3Dオブジェクトはデータサイズが大きく、無駄にメモリに乗ってしまったため、Addressablesでの動的ロードに切り替えました。これにより、必要なオブジェクトだけをメモリに載せられるようになりました。画像データは、簡単に使用や破棄ができるようにUIのオブジェクトに紐付ける形でAddressableの寿命を管理するようにしました。また、グループ設定でPack Separatelyを指定してオブジェクトごとにバンドルデータを分割しています。
OSによるパフォーマンス変化
WebGLはブラウザベースで動きますが、端末のOSによってパフォーマンスが向上・低下します。例えばiOS16.4以降ではWebGLの動作が向上し、起動後のFPSが安定するようになりました。一方でiOS15.4ではUnity製のWebGLゲームが軒並み起動しなくなる不具合も発生しました。これはWebKitの不具合が原因で、Unity社の調査の結果、UnityがWebGLビルド時に使用しているcppファイルを一時的に修正することで対応しました(参考フォーラム)。このようにOSのアップデートにより動作が変わってしまうことがあるので常に最新のOS情報のキャッチアップや検証が重要です。
上記のように、モバイルWebGLであってもパフォーマンスで注意する点は通常のモバイルアプリの開発と大きな違いはありません。ただしメモリやリソースの制限は厳しいため、しっかりとパフォーマンスを計測し都度対策をしていくことが重要です。
キャッシュ編
WebGLのコンテンツは起動するURLを指定してWebView上で取得し表示しています。その際にゲームのバージョンアップ等でコンテンツを更新することがありますが、既存のデータに上書きすると前のバージョンのキャッシュが残ってしまうことがあります。そのため一度ゲームデータを取得していたユーザーはコンテンツが更新されません。これを回避するには、例えば起動する際のURLを都度別のアドレスに更新することが挙げられます。ただしPlayerPrefsはURLごとに保存されるため、この方法だとPlayerPrefsがリセットされてしまいます。そのためミラティブでは以下の方法を用いて、起動URLを変えずに更新する方法を取っています。
ProjectSettingからNameFilesAsHashesを有効にする
このオプションを有効にすると、.wasmや.dataなど、Buildフォルダ以下に生成されるビルド成果物のファイル名をランダムにハッシュ化できます。これによりビルドごとに別のファイルとして生成されます。また、index.html内のdataUrlなどのアドレスの指定箇所もこのハッシュ化したファイル名が指定されるようになります。起動URLが同じでも、上記index.htmlファイル更新時には成果物のファイル名も変わり、別ファイルとなることで正しく最新のファイルがロードされるようになります。
index.htmlはCDN側でキャッシュをさせない。
CDNの設定でキャッシュを無効にすることで、ユーザーは起動時に常に最新のindex.htmlファイルを取得するようになります。
Addressablesは毎回別の成果物としてビルドする
AddressablesをLocalとしてビルドするとStreamingAssetsのフォルダにビルド成果物が生成されます。ビルド時に生成されるcatalog.jsonやsetting.jsonの名前が固定となってしまいキャッシュの問題にひっかかってしまったため、通常のビルド後にAddressablesの生成先であるStreamingAssetsのフォルダ自体の名前を変えるアプローチをとりました。
具体的にはビルド後に以下のようにしてStreamingAssetsの名前そのものを変えます。
public static void Build() { // ビルド実行処理 // ~~ // ビルド実行処理後に以下をそのまま実行する // 上書きによりStreamingAssetsがキャッシュされてしまうのでStreamingAssetsのフォルダをビルドごとに変える string buildPath = report.summary.outputPath; string streamingAssetsPath = Path.Combine(buildPath, "StreamingAssets"); string indexPath = Path.Combine(buildPath, "index.html"); PostEditStreamingAssets(streamingAssetsPath, indexPath); } private static void PostEditStreamingAssets(string streamingAssetsPath, string indexPath) { string randomHash = GenerateRandomHash(); Debug.Log($"StreamingAssetsのフォルダに{randomHash}に追加します"); string newStreamingAssetsPath = streamingAssetsPath + "_" + randomHash; Directory.Move(streamingAssetsPath, newStreamingAssetsPath); if (File.Exists(indexPath)) { string indexContents = File.ReadAllText(indexPath); indexContents = indexContents.Replace("StreamingAssets", "StreamingAssets" + "_" + randomHash); File.WriteAllText(indexPath, indexContents); } } private static string GenerateRandomHash() { using var rng = new RNGCryptoServiceProvider(); byte[] randomBytes = new byte[16]; rng.GetBytes(randomBytes); return ByteArrayToString(randomBytes); } private static string ByteArrayToString(byte[] byteArray) { StringBuilder sb = new StringBuilder(byteArray.Length * 2); foreach (byte b in byteArray) { sb.AppendFormat("{0:x2}", b); } return sb.ToString(); }
StreamingAssetsのフォルダ名にランダムなハッシュ値を加えて、StreamingAssets_ハッシュ値としてフォルダをリネームします。StreamingAssetsのフォルダ名はindex.html内でstreamingAssetsUrlとして指定しているので、この指定先も更新することで、StreamingAssets_ハッシュ値となったフォルダからAddressablesのデータをロードすることができるようになります。
デメリットとしては同じファイルを元にビルドしキャッシュされていたとしても、次にビルドすると別のファイル名になってしまっていることから、別ファイルとみなされ再度ダウンロードが入ってしまい、そのため無駄にダウンロード回数が増えてしまいます。
モバイルアプリとの違い編
キーボードの扱い
Unity2021までのWebGLではInputFieldを使用しても入力用のキーボードが表示されません。今回はよく使われているOSSを元にMirrativ向けに修正したものを作成しました。
これはInputFieldへのイベントを検知したらブラウザ側にinput属性のアイテムを生成し、そのアイテムを選択した扱いとすることでキーボードを表示させるものです。その上で入力内容をJavaScriptのプラグインを通してC#側に伝える仕組みになっています。また、モバイルアプリ版のキーボード入力では入力中の内容がキーボード上部に表示されますが、これはUnity側でネイティブキーボードの処理を上書きすることで実現されています。そのため、WebGLでは入力中の内容は自分で表示するようコントロールしなければなりません。今回はキーボードが表示されるであろう領域に画像を差し込んで入力中の内容を表示させました。
モバイルアプリでは下記のように入力候補が自動で表示されます。入力中の内容がキーボードで隠れないようにUnityが表示をコントロールしています。
WebGLでは入力中の内容が自動では表示されません。そのため下記の赤枠のようにゲーム側で入力中の内容を表示させます。
また、キーボード入力時に画面がズームしてしまうことがあります。対策として以下のように追記しておきます。
<meta name="viewport" content="user-scalable=no">
更には、Androidではキーボード入力時にゲーム画面とキーボードが独立して表示されてしまうことがあります。上記に追加して、initial-scale=1.0を指定するとChromeのブラウザ上で動かした際は直りますが、WebView上だと直りません。これはAndroidのWebViewでのみ発生する仕様になっており、こちらの設定を変えてしまうとアプリケーション全体に影響してしまいます。そのため以下のようにリサイズ時に全画面に拡大する処理を入れることで、画面サイズが変わらないようゲームごとに対策を入れています。
// body内 <style id="unity-mobile-mirrativ"></style> // 追加する <div id="unity-container" class="unity-desktop"> // script内 const mirrativAppStyle = document.getElementById('unity-mobile-mirrativ') const initialHeight = window.innerHeight window.addEventListener('resize', () => { mirrativAppStyle.innerHTML = ` #unity-container.unity-mobile { width: 100%; height: ${initialHeight / window.innerHeight * 100}%; } ` })
FirestoreによるPub/Subメッセージングの実現
コメントやギフトなど、サーバーとのリアルタイムの通信が必要な箇所ではポーリングを使用せずにFirestoreを用いました。FirestoreはNoSQLのデータベースであり、クライアントがデータベースの追加・更新を検知できます。 これまではサーバーでの追加・更新時はクライアントがポーリングを行い定期的に変更を検知していました(Pull型)。今回はFirestoreを使用することでクライアントがサーバーの更新を検知でき、リアルタイムに反映できるようになりました(Push型)。
Firestoreと連携する場合、モバイルアプリではFirebaseのネイティブプラグインとしてdll形式のSDKが提供されていますが、これはWebGLで使用できません。WebGLではモバイルのアプリと異なりJavaScriptのネイティブプラグインしか読み込めないためです。.jsファイルとして提供されているプラグインをゲーム実行時に取得するようにします。
index.html内のheadに以下を追加して起動時にfirestoreのプラグインを取得します。
<script src="https://www.gstatic.com/firebasejs/7.2.3/firebase-app.js"></script> <script src="https://www.gstatic.com/firebasejs/7.2.3/firebase-firestore.js"></script>
UnityプロジェクトのPluginsフォルダに以下の.jsファイルを作成して、Firestoreの初期化とコメントの取得を行えるようにしておきます。また、SendMessageを用いてゲーム本体であるC#側にコメント内容を渡せるようにしています。
mergeInto(LibraryManager.library, { // firestore StartFirestore: function (liveId, apiKey, authDomain, projectId, storageBucket, messagingSenderId, appId, loginAt) { // firebaseのconfig var firebaseConfig = { apiKey: Pointer_stringify(apiKey), authDomain: Pointer_stringify(authDomain), projectId: Pointer_stringify(projectId), storageBucket: Pointer_stringify(storageBucket), messagingSenderId: Pointer_stringify(messagingSenderId), appId: Pointer_stringify(appId), } // firebaseの初期化 firebase.initializeApp(firebaseConfig); var db = firebase.firestore(); // 配信ごとに固有のIDで取得するドキュメントをフィルタリング let id = Pointer_stringify(liveId); var doc = db.collection("live").doc(id); // ログイン以降の配信のコメントを取得する doc.collection("comment") .where("sent_at", ">=", loginAt) .onSnapshot((snapshot) => { snapshot.docChanges().forEach((change) => { if (change.type === "added") { var d = JSON.stringify(change.doc.data()); // ゲーム側にあるFirestoreFetcherオブジェクトのAddComment関数を呼び出す SendMessage('FirestoreFetcher', 'AddComment', d); } }); }); } });
また、この.jsファイルとの連携のため、以下のC#スクリプトも作成します。StartFirestoreでFirestoreの起動を行い、AddCommentにコメントを受け取った際のゲーム内の処理を実装します。
public class FirestoreFetcher : MonoBehaviour { [DllImport("__Internal")] private static extern void StartFirestore(string liveId, string apiKey, string authDomain, string projectId, string storageBucket, string messagingSenderId, string appId, long startAt); // Firestoreの起動 public void StartStore(string liveId, string apiKey, string authDomain, string projectId, string storageBucket, string messagingSenderId, string appId, long startAt) { StartFirestore(liveId, apiKey, authDomain, projectId, storageBucket, messagingSenderId, appId, startAt); } public void AddComment(string text) { // コメントを受け取った際の実際の処理を記述する } }
このようにFirestoreとJavaScriptが連携し、JavaScriptからC#を通してゲームにデータを渡していくことでサーバーからクライアントへのリアルタイムな通信を実現しています。
参考: UnityのWebGLビルドでFirestoreからリアルタイムにデータを取得して反映する - Qiita
開発情報の収集編
UnityでのWebGL開発は情報が少なく、ノウハウも広く存在しているわけではありません。そのためどこから正しい情報を手に入れるかが重要です。以下に実際に利用し、ためになったドキュメントやWebサイトを紹介します。
Unityの公式ドキュメント
Unityの公式ドキュメントは、WebGLの開発に関する基本的な情報から高度なテクニックまで幅広くカバーしています。開発を始める際は隅々まで読んでおきたい情報です。
UnityのWebGLフォーラム
Unityのフォーラムでは日々WebGLに携わる開発者による議論が行われています。特にOSアップデートに伴う不具合の情報や、最新の対応状況は一番更新が速いです。コミュニティの知見を活用することで解決の糸口が見つかります。
フレームシンセシスさんのブログ
こりんさん(@korinVR)のブログです。開発を始める上での基礎的な情報から、各種検証の結果、最新の不具合情報までを都度まとめていただいています。開発を進める上でのハマりやすい点や注意すべき点、ノウハウが多く載せられているので開発中に何度もお世話になりました、本当にありがとうございます!
We are hiring!
UnityでのモバイルWebGLは挑戦者が少なく業界としてのノウハウも多いわけではありません。しかしながら、同一アプリ上で複数のゲームを簡単に動かせ、ライブゲームやUGC(User Generated Content)との親和性も高いです。難易度は高いですがその分得られる価値も高いことから、今後も積極的に挑戦していきたいですね。 ミラティブではWebGLの技術を一緒に探求してくれる仲間を募集しています!!