Vercel と Supabase Realtime で 2 名同席のブラウザメタバースを試作してみた
はじめに
メタバースという言葉でひとくくりにされる体験のうち、アバターの位置同期と空間音響つきの音声通話を、SaaS と静的ホスティングだけで作ったらどこまで成立するのか。そんな疑問を起点に、Next.js, Three.js, Supabase Realtime, WebRTC, Web Audio API を組み合わせて 2 名向けの PoC を試作しました。
結論から書くと、思っていたよりも普通に動きました。自分のブラウザでアバターを動かしている横で、もう 1 つのブラウザに同じアバターの動きがほぼ即時に反映され、マイクから入れた声も途切れずに届きます。相手との距離に応じた音量変化や左右の振り分けも、体感として問題なく動作しました。SaaS の組み合わせだけでここまでできるのかと、素直に驚かされました。

本記事では試作した内容と、動かしてみた感想、詰まったポイントをまとめます。評価については体感ベースの主観評価が中心となります。
Vercel とは
Vercel とは、Next.js の開発元が提供するアプリケーション配信プラットフォームです。Git 連携で静的アセットとサーバ機能を配備でき、グローバルに分散した CDN とエッジ経由で配信されます。
Supabase Realtime とは
Supabase Realtime とは、オープンソースの Firebase 代替を謳う Supabase が提供するリアルタイム同期基盤です。WebSocket 上で broadcast (任意ペイロードのファンアウト), presence (入退室の共有), postgres-changes (テーブル変更の通知) の 3 機能を扱え、ブラウザ向けの SDK が用意されています。
対象読者
- Next.js または React を扱った経験があり、SaaS の組み合わせでリアルタイムな同席体験を作る方法に興味がある人
- Supabase の概要は知っているが、Realtime の broadcast と presence を実機で試した経験は無い人
- Three.js の基本的なシーン構築を試したことがあり、WebRTC と Web Audio API との組み合わせ事例を探している人
検証環境
- macOS
- Chrome ブラウザ (Firefox は別途事情があり対象外、詳しくは後述)
- Node.js 24 系
- Next.js 16.2.6
参考
作ったもの
ブラウザを 2 つ立ち上げ、それぞれで表示名を入れてルームに入ります。

同じ 3D 空間にお互いのアバターが出てきます。アバターは色つきの直方体で、頭上には表示名が浮かびます。WASD で移動、マウスで視点回転、一人称視点での操作です。

入室と同時に WebRTC で P2P の音声通話が確立し、相手の声が距離と方向に応じた空間音響で聞こえます。相手のアバターが画面の左に立っていれば左から、後ろに回り込めば後ろから声がします。ヘッドホンで聞くと位置の手がかりがよく出ます。
仕組み
構成は次の図の通りです。
静的アセットを Vercel から配信し、位置同期と音声接続のシグナリングを Supabase Realtime のチャネルに任せます。音声の本体は WebRTC で 2 名のブラウザ間を直接やり取りします。
クライアントは Vercel から静的アセットを受け取り、ルームに入ると Supabase Realtime のチャネルを購読します。アバターの位置と視点角度は broadcast で配信し、入退室は presence で共有します。音声接続を確立するときの SDP と ICE 候補のやり取りも、同じチャネル上で broadcast を使います。音声本体は確立した RTCPeerConnection 経由でブラウザ間を直接流れます。受信した音声は Web Audio API の PannerNode に通し、アバター間の相対位置に応じて距離減衰と左右の振り分けを付与します。
実装
ここでは主な 3 点を取り上げます。
位置同期の取りこぼし対策
broadcast は到達保証がないと公式ドキュメントで説明されています。試作中、2 名がほぼ同時に入室するケースで、相手のアバターが画面に出てこないことが起きました。後から subscribe を完了した側が、相手の最初のブロードキャストを受信しそびれている状態です。
対策として、自分が presence の join イベントを受け取ったときに、直前に送ったブロードキャストの記録 (lastSent) をリセットする実装にしました。リセット後の次のフレームで、位置と視点を強制的に再ブロードキャストします。これにより、少なくとも入室直後の初回ブロードキャストの取りこぼしに対しては、次フレーム以降に最新位置を再送できるようになります。今回の検証範囲では、相手のアバターが表示されない症状は再現しなくなりました。
なお、アバターの位置メッセージにはシーケンス番号を入れています。受信側は同じユーザーから古いシーケンスのメッセージが届いても無視するようにして、順序逆転による位置の戻りを防いでいます。
Supabase Realtime のチャネル購読
export async function joinRoom(options: JoinRoomOptions): Promise<RoomConnection> {
const supabase = createClient(options.url, options.anonKey, {
realtime: { params: { eventsPerSecond: 30 } },
});
const channel = supabase.channel(`room-${options.roomId}`, {
config: {
broadcast: { self: false },
presence: { key: options.userId },
},
});
channel.on('broadcast', { event: 'position' }, ({ payload }) => {
const parsed = parsePositionMessage(payload);
if (parsed) options.onMessage(parsed);
});
channel.on('presence', { event: 'join' }, ({ key }) => {
if (typeof key !== 'string' || key === options.userId) return;
options.onPeerJoin?.(key);
});
// ... leave と signaling のハンドラ、subscribe の SUBSCRIBED 判定は略
}
位置メッセージの受信処理
export function applyPositionMessage(
state: RemoteAvatars,
message: PositionMessage,
selfUserId: string,
receivedAt: number,
): RemoteAvatars {
if (message.userId === selfUserId) return state;
const existing = state.get(message.userId);
if (existing && message.sequence <= existing.lastSequence) return state;
const updated = new Map(state);
updated.set(message.userId, {
userId: message.userId,
name: message.name,
position: { ...message.position },
yaw: message.yaw,
pitch: message.pitch,
lastSequence: message.sequence,
lastTimestamp: message.timestamp,
receivedAt,
});
return updated;
}
WebRTC シグナリングを状態機械として書く
WebRTC の接続確立は、SDP の交換と ICE 候補の交換が並行で進む割と複雑な手順です。順序や状態を間違えるとつながりません。
これを素直なコードで書こうとすると条件分岐が増えて見通しが悪くなったので、接続状態を idle, offering, answering, connected, disconnected の 5 つに分けて状態機械として実装しました。イベント (相手の入退室、オファー受信、アンサー受信、ICE 受信、接続確立、接続失敗) を入れると次の状態とアクションが決まる、という素朴な構造です。
オファー側とアンサー側の決定は、お互いの userId を文字列比較して、若い方をオファー側とするだけの単純なルールにしました。2 名がほぼ同時に入室したときに、両者が同時にオファーを投げ合うのを避けるためのタイブレーカーです。
シグナリング状態機械の遷移関数
export type WebRTCState =
| 'idle' | 'offering' | 'answering' | 'connected' | 'disconnected';
export function shouldBeOfferer(myUserId: string, peerUserId: string): boolean {
return myUserId < peerUserId;
}
export function transition(
state: WebRTCState,
event: SignalingEvent,
ctx: SignalingContext,
): Decision {
if (event.type === 'peer-leave' || event.type === 'connection-failed') {
if (state === 'idle' || state === 'disconnected') return { state, actions: [] };
return { state: 'disconnected', actions: [{ type: 'close-peer' }] };
}
if (event.type === 'connection-established') {
if (state === 'offering' || state === 'answering') {
return { state: 'connected', actions: [] };
}
return { state, actions: [] };
}
// ... ice-received, peer-join, offer-received, answer-received の遷移は略
}
空間音響で受信した音声を PannerNode に通す
受信した音声を空間音響に通す部分も、試行錯誤がありました。単純に WebRTC の MediaStream から MediaStreamAudioSourceNode を作って PannerNode 経由で AudioContext.destination につなぐだけだと音が鳴らない現象に当たりました。
最終的に、peer ごとに display: none の <audio> 要素を別途用意し、同じ MediaStream を srcObject に代入して muted のまま autoplay 再生する、という方法で動作するようになりました。これがないと、内部の再生パイプラインが起動せず、PannerNode を通った音が出てこないようでした。
距離減衰モデルは inverse、パンニングモデルは equalpower を採用しました。
PannerNode 自体のパラメータ更新は単純で、アニメーションループ内で、リスナーの位置と向きを自分のカメラから、パナーの位置を相手アバターのワールド座標から、それぞれ setValueAtTime で書き込みます。
リスナーの前方/上方ベクトルの計算
export function computeListenerForward(yaw: number, pitch: number): Vector3 {
const sy = Math.sin(yaw);
const cy = Math.cos(yaw);
const sp = Math.sin(pitch);
const cp = Math.cos(pitch);
return { x: -sy * cp, y: sp, z: -cy * cp };
}
export function computeListenerUp(yaw: number, pitch: number): Vector3 {
const sy = Math.sin(yaw);
const cy = Math.cos(yaw);
const sp = Math.sin(pitch);
const cp = Math.cos(pitch);
return { x: sy * sp, y: cp, z: cy * sp };
}
動かしてみた感想
Vercel に本番デプロイし、ブラウザを 2 つ立ち上げて両方から入室して触ってみました。結論として、触ってみたら普通に動くレベルでした。
遅延
アバターの動きはほぼ即時に反映されました。自分が前に出れば相手の画面でもすぐ前に出ますし、視点を回せば相手の画面のアバターもこちらを向きます。数フレームのズレも体感ではほとんど気になりませんでした。
音声と空間音響
WebRTC P2P の音声は、途切れずクリアに届きました。相手のアバターが正面なら音が真ん中、右に立てば右から声がしました。距離に応じた音量変化も自然で、遠ざかると小さくなって近づくと大きくなる、という当然の振る舞いがブラウザ標準だけで成立しました。ヘッドホンの方が立体感はより分かりやすいですが、スピーカーでも左右の振り分けは体感できました。
描画
今回試した Three.js のプリミティブ図形だけのシーンでは、描画負荷が軽く、短時間の動作確認の範囲ではカクつきはありませんでした。
全体の印象
「ブラウザの中でメタバース」と聞いたときに、もっとリッチな構成 (専用のシグナリングサーバ、中継サーバ、ゲームエンジンの組み込みなど) が必要かと身構えていたのですが、SaaS と Web 標準だけで思いのほか普通に動くものになりました。リアルタイム同期のところに Supabase Realtime を据え、音声は P2P に逃がし、空間音響はブラウザ標準の Web Audio API、という分担で十分実用感のあるものができます。
詰まったポイント
きれいに動いた一方で、試作から公開までで何度か詰まったポイントがあったので、同じ轍を踏む方の助けになればと書き残しておきます。
Vercel の環境変数に末尾改行が混入していた
Vercel に本番デプロイした直後、ブラウザでロビーに入って表示名を入れて入室しても、マイク許可ダイアログが出ない症状が出ました。ローカルでは普通に動くのに、本番だけ駄目です。
ブラウザの DevTools を開いてコンソールを見ると、Supabase Realtime の WebSocket 接続が transport failure で切れていました。さらにネットワークタブで WebSocket の URL を見ると、末尾のクエリ apikey=<redacted>%0A&eventsPerSecond=... のように、apikey の値の末尾に %0A が付いていました。これは LF (改行) の URL エンコードです。
Vercel に環境変数を登録する際、シェル経由で値を流し込むときに値の末尾の改行を消し忘れていたのが原因でした。シェル系のコマンドで取り出した値をそのまま vercel env add の標準入力に渡すと、末尾改行も値の一部として登録されてしまいます。Realtime のサーバはこの末尾改行を拒否して 401 を返し、結果としてチャネルが確立せず、音声接続用の getUserMedia まで処理が進まないので、マイク許可ダイアログも出ないという連鎖でした。
回避は単純で、環境変数として登録する前に末尾改行を tr -d '\n' で除去するだけです。シェルでシークレットを流すときの典型的なハマりどころなので、Vercel ダッシュボードでコピペするときも、値の末尾に改行を含めないように注意するのが安全です。
Firefox では空間音響の左右の振り分けが効かない
検証中に、受信側のブラウザを Firefox に切り替えたところ、距離による音量減衰は効くのに左右の振り分けだけが効かないという現象に当たりました。Chrome に戻すと同じシーンで左右の振り分けが期待通りに出るので、ブラウザ依存です。
切り分けに使ったのは、アプリ内に組んだ L/R モニタです。マスタの GainNode の後段で ChannelSplitter と AnalyserNode を分岐させ、L と R それぞれの RMS をリアルタイムに UI 上にバー表示しました。Chrome では L と R の値に明確な差が出ますが、Firefox では L = R で動きません。
AudioListener の AudioParam 系 API (positionX, forwardX, upX 等) は、ブラウザ間の互換性差が残る領域です。検証環境の Firefox では、Chromium 系と同じ更新処理を流しても、左右のパンニングが期待通りに反映されませんでした。
リアルタイム空間音響を試作する場合は、検証ブラウザを Chromium 系 (Chrome / Edge) に揃えるのが安全です。本検証もそのように方針を切り替えました。
MediaStream を PannerNode に通すだけでは音が鳴らない
実装の節で触れた件ですが、WebRTC の MediaStream を Web Audio API のチェーンに乗せても、Chromium ではそのままだと音が出ないのは、詰まった当初は理由がわかりませんでした。Three.js のシーン更新やパナーのパラメータ書き込みは正常に動いており、ログを見ても何もエラーが出ません。
peer ごとに <audio muted autoplay> を併設して同じストリームを srcObject に流すと音が出始める、という挙動から、ブラウザ内部で音声トラックの再生パイプラインを起動するためのトリガが必要なのだと推測しています。MediaStreamAudioSourceNode だけだと再生パイプラインが起動せず、パナーを通った音も出てこないようです。同じ症状で手間取っている方がいたら、muted な <audio> 要素を併設する方法を試してみてください。
本番運用するときに気をつけたいこと
PoC のスコープで普通に動いたとはいえ、そのまま本番運用に持ち込むには、別途検討すべきことが残っています。
-
長時間セッションでの安定性
PoC では短時間の動作確認しかしていません。30 分を超える連続セッションで Realtime チャネルが切れずに維持されるか、アバター描画のフレーム時間に劣化が出ないかは、別途実測する必要があります。 -
3 名以上のスケール
PoC は 2 名前提で組んでいます。3 名以上に拡張すると WebRTC の P2P 接続数が二次的に増えるので、SFU (LiveKitやmediasoupなど) を経路に挟む構成や、broadcastの流量増を受け止められる料金プランの試算が必要になります。 -
NAT 越え
TURN サーバを入れていないので、企業 NAT やモバイルキャリア NAT を挟むと WebRTC P2P が成立しないケースが残ります。本番運用では TURN サーバ (coturnの自前運用または TURN as a Service) を経路に組み込み、接続成功率を実測する必要があります。 -
モバイルとブラウザ網羅
PC ブラウザの Chromium 系しか試していません。iOS Safari は AudioContext の autoplay 制約や WebRTC 挙動が PC とは異なります。サポート対象を決めてマトリクスで動作確認するのが現実的です。 -
セキュリティ
本検証はルーム ID をクエリパラメータで分けただけの簡易隔離です。URL を知っている人なら誰でも入れる状態なので、本番運用では Supabase Auth でログインを設計し、ルーム入室の認可と、クライアントから送られる値の検証、レート制限を別途用意する必要があります。
まとめ
ブラウザの中で 2 名がメタバース体験を共有する、という題材を、Vercel + Supabase Realtime + WebRTC + Three.js + Web Audio API の組み合わせで試作しました。触ってみると、SaaS と Web 標準の組み合わせだけで思いのほか普通に動きます。アバターの動きはほぼ即時に反映され、音声はクリアに届き、位置に応じた空間音響もきれいに効きます。
一方で、本番運用に持ち込むためには、長時間セッション、スケール、NAT 越え、モバイル対応、セキュリティといった観点で別途検討が必要です。
ブラウザでリアルタイムな同席体験を作るとなると、専用基盤や有料の SaaS をフルセットで使う発想になりがちですが、静的ホスティングと Supabase Realtime と Web 標準の組み合わせでも、ここまでの試作はできるようです。似たような題材を試そうとしている方の参考になれば嬉しいです。







