この記事はVR法人HIKKY Advent Calendar 2025 11日目の記事です。
フロントエンド開発チームの伊織ユキが担当します。
はじめに
HIKKYでは、ブラウザ上で手軽に3Dアバターが作成できる『Avatar Maker』というサービスを提供しています。
このサービスでは、3Dビューを提供するUnity WebGLと、Web UIを提供するフロントエンド(TypeScript)が密に連携する必要があります。
本記事では、この連携を実装するにあたって直面した課題と解決方法について紹介します。
課題
1. Unityから送られるデータがunknown
Avatar Makerでは.jslibファイルを使ってUnityからJavaScriptへのブリッジを実装しています。
フロントエンドとの疎通にあたり、処理の全パターンに対して専用の関数を実装することは手間が多いため、UnityToFrontendという関数にまとめる形としました。
// Plugins/WebGL/bridge.jslib
mergeInto(LibraryManager.library, {
UnityToFrontend: function (message) {
// Unityから受け取ったメッセージをJavaScript側に渡す
window.avatarmaker.dispatchIngameMessage(UTF8ToString(message));
},
});
Unity(C#)側からは、この関数をDllImportで呼び出します。
// Unity (C#) 側
[DllImport("__Internal")]
private static extern void UnityToFrontend(string message);
public void SendProfileRequest()
{
UnityToFrontend("{\"type\":\"getUserProfile\",\"requestId\":\"123\",\"content\":{\"userId\":\"123\"}}");
}
そしてJavaScript側では、このメッセージを受け取ります。
// フロントエンド / JavaScript側
function dispatchIngameMessage(message: unknown) {
// messageの中身は...?
// - 文字列かもしれない
// - オブジェクトかもしれない
// - 期待したプロパティがあるかも分からない
}
window.avatarmaker.dispatchIngameMessage = dispatchIngameMessage
JavaScript側では、Unityから何が送られてくるか分かりません。TypeScriptで型を書いても、実行時には何の保証もありません。
つまり、実行時にデータの形を検証し、型を確定させる仕組みが必要になります。
2. 非同期処理の連携
Avatar Makerでは、例えば「ユーザーのプロフィール情報を取得してUnityに返す」という処理があります。
こういった処理にはAPIリクエストをはじめとする非同期処理を含む場合もあり、実行完了までUnityが待機できる仕組みも必要となります。
解決に向けてやったこと
メッセージのフォーマット化
始めに、UnityとJavaScript間でやり取りするメッセージは、以下のJSON文字列に統一しました。
{
"type": "eventType",
"requestId": "abc-123",
"content": { "userId": "456" }
}
| フィールド | 役割 |
|---|---|
type |
どの処理を実行するか指定する |
requestId |
リクエストとレスポンスを紐付けて実行完了を識別するID(非同期処理用) |
content |
処理に必要な引数データ |
メッセージの検証とビジネスロジックの分離
以下のように処理を分離しました。
Unity WebGL (C#)
│
│ dispatchIngameMessage(message: unknown) // 前述のフォーマット
▼
┌─────────────────────────────────┐
│ useIngameInterface.ts │ ← メッセージの受付窓口
│ - メッセージのパース │
│ - 処理の振り分け │
└─────────────────────────────────┘
│
│ dispatch
▼
┌─────────────────────────────────┐
│ avatarMakerService.ts │ ← ビジネスロジック
| - 型の検証 │
│ - API通信 │
| - UI操作 │
└─────────────────────────────────┘
│
│ return result
▼
┌─────────────────────────────────┐
│ useIngameInterface.ts │
└─────────────────────────────────┘
│ sendResponse(result)
▼
Unity WebGL (C#)
-
useIngameInterface.ts- Unity WebGLから送られてくるメッセージを検証し、適切な関数にルーティングする関数
- 前述のメッセージフォーマットに関する検証・ルーティングのみ実装する
-
avatarMakerService.ts- Unity WebGLに依頼された処理を実行する関数群
- メッセージ内の
contentの型検証もする
この二つに分割することで、ビジネスロジックの実装時にはUnityのインターフェースを意識せずに済み、テストもしやすくなりました。
useIngameInterface.tsはUnityとのやり取りが仕事であり、ビジネスロジックの影響を受けるcontentの型検証はservice側で実施しています。
メッセージの検証
メッセージフォーマットの検証のサンプルはこちらです。
簡略化して載せているので、実際はもう少しエラーハンドリングなどがあります。
// useIngameInterface.ts
// typeごとのハンドラーを定義
const handlers = {
getUserProfileRequest: async (json) => {
// FIXME: handlers[type]で呼び出すと型推論が途切れるので再検証が必要。スマートじゃない。
const { content, requestId } = messageSchema.parse(json)
const profile = await getMyProfile(content) // Service層の関数を呼ぶ
sendResponse(requestId, profile)
},
backToAvatarListCommand: () => {
backToAvatarList() // Service層の関数を呼ぶ
},
// ... 他のハンドラー
}
// メッセージを受け取る関数
async function dispatchIngameMessage(message: unknown) {
if (typeof message !== 'string') {
throw new Error('Invalid message type')
}
// 1. 文字列をJSONオブジェクトに変換
const json: unknown = JSON.parse(message)
// 2. Zodでバリデーション & 型を確定
const { type } = messageSchema.parse(json)
// 3. typeに対応するハンドラーを実行
const handler = handlers[type]
await handler(json)
}
ビジネスロジック
ビジネスロジック実装のサンプルはこちらです。
こちらも簡略化して載せているので、実際はもっと色々とロジック実装があります。
// avatarMakerService.ts - Service層
export const getMyProfile = async (content: unknown) => {
// 1. contentの型検証
const { userId } = myProfileRequestSchema.parse(content)
// 2. プロフィール取得する処理(詳細略)
const result = await fetchProfile(userId)
return result
}
export const backToAvatarList = () => {
// Web UI操作やページ遷移も請け負います
location.href = `${location.origin}/avatar`
}
エラーハンドリング
Avatar Makerでは、エラーをUnityとユーザー(Web UI)の両方に伝える必要があります。
そこで、専用のエラーハンドラーを用意しました。
function handleError(error: Error, requestId?: string) {
// 1. ユーザーにはToastで通知
toast.error('エラーが発生しました')
// 2. Unity側にはエラーレスポンスを返す
if (requestId) {
sendErrorResponse(requestId, {
statusCode: error.statusCode,
message: error.message,
})
}
}
Unityにエラーを返す際は、requestIdでエラーが発生したリクエストを識別してもらいつつ、エラーオブジェクトを渡します。これによりUnity側でもエラーの種類に応じた適切なハンドリングが可能になります。
おわりに
Unity WebGLとフロントエンドの連携はプレーンなjsを使う必要があり、型安全性やエラーハンドリングなど考慮すべき点が多くあります。
本記事が同じような課題に取り組んでいる方の参考になれば幸いです。