Three.jsをCloudflareにデプロイしてWebSocketで位置を同期する方法

ローカルで動くようになったThree.jsの作品は、どこかに公開したくなりますし、キャラクターが動くならマルチプレイヤーで同期したくなるのが人情です。今回はその両方、Cloudflare PagesへのデプロイWebSocketによる位置同期をやります。

まずデモから。これはWebSocketの代わりにブラウザのタブ間通信を使った同期デモです。このページをもう1つのタブ(またはウィンドウ)で開いて、片方でWASDキーで動かすと、もう片方にオレンジの箱として現れます。

デモを別タブで開く(2つ開いて並べてみてください)

Cloudflare Pagesへのデプロイ

Three.jsのサイトは(サーバー処理がなければ)ただの静的サイトなので、ホスティングはCloudflare Pagesが手軽です。無料枠で帯域無制限、独自ドメインも無料、CDN配信で世界中から速い。このブログ自体もCloudflare Pagesで動いています。

手順はGitHub連携が楽です。

  1. プロジェクトをGitHubにプッシュ
  2. Cloudflareダッシュボード → Workers & Pages → 作成 → Pages → Gitに接続
  3. リポジトリを選んで、ビルド設定を入力
    • Viteなら: ビルドコマンド npm run build、出力ディレクトリ dist
    • ビルド無しの素のHTMLなら: ビルドコマンド空欄、出力ディレクトリ /
  4. デプロイ完了。以降はgit pushするだけで自動デプロイ

1つだけThree.js特有の注意があります。glbなどのモデルファイルはビルドに含まれるように置き場所を意識すること。Viteなら public/ フォルダに置いたものがそのまま出力にコピーされるので、モデルやテクスチャはそこに置きます。ローカルで動くのにデプロイすると404、というときはだいたいパスの問題です。

位置同期の仕組みは「送る・受け取る・反映する」だけ

マルチプレイヤー同期というと大げさに聞こえますが、最小構成の仕組みは単純です。

  1. 自分の位置を定期的にサーバーへ送る
  2. サーバーは受け取ったものを他の全員に転送する(ブロードキャスト)
  3. 受け取った側は、その人の3Dオブジェクトを動かす

デモの通信部分はこうなっています。

const myId = crypto.randomUUID();

// 送信: 位置を1秒間に20回ブロードキャスト
setInterval(() => {
  channel.postMessage({
    type: 'move',
    id: myId,
    x: player.position.x,
    z: player.position.z,
    ry: player.rotation.y,
  });
}, 50);

// 受信: 他人の位置を受け取って反映
channel.onmessage = (event) => {
  const data = event.data;
  if (data.id === myId) return; // 自分の分は無視

  const remote = getRemotePlayer(data.id); // 初見のIDならメッシュを生成
  remote.mesh.position.set(data.x, 0.5, data.z);
  remote.mesh.rotation.y = data.ry;
};

デモではこの channelBroadcastChannel(同一ブラウザのタブ間通信API)を使っているのでサーバー不要で動きますが、ここをWebSocketに差し替えれば本物のマルチプレイヤーになります。送る・受け取るの流れは1行も変わりません。

// BroadcastChannelの代わりにWebSocketを使う場合
const ws = new WebSocket('wss://your-worker.example.workers.dev/room/lobby');

// 送信
ws.send(JSON.stringify({ type: 'move', id: myId, x, z, ry }));

// 受信
ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  // あとは同じ
};

サーバー側: Cloudflare Workers + Durable Objects

WebSocketのサーバーもCloudflareで完結できます。使うのはWorkersと、接続中の全員を1ヶ所で管理するためのDurable Objectsです。通常のWorkerはリクエストごとに別々に起動するので「今誰が繋がっているか」を覚えられませんが、Durable Objectは「部屋」ごとに1つのインスタンスが状態を持ち続けられます。

ブロードキャストの核だけ抜き出すとこんな形です。

// 部屋ごとに1つ生きるオブジェクト
export class Room {
  constructor(state) {
    this.sessions = new Set(); // 接続中のWebSocket一覧
  }

  async fetch(request) {
    const [client, server] = Object.values(new WebSocketPair());
    server.accept();
    this.sessions.add(server);

    // 受け取ったメッセージを本人以外の全員へ転送
    server.addEventListener('message', (event) => {
      for (const session of this.sessions) {
        if (session !== server) session.send(event.data);
      }
    });

    server.addEventListener('close', () => this.sessions.delete(server));

    return new Response(null, { status: 101, webSocket: client });
  }
}

これを wrangler deploy でデプロイすれば、世界中どこからでも繋がる同期サーバーの完成です。WebSocketの接続時間は無料枠でもかなり余裕があるので、個人の作品レベルなら費用はほぼゼロで運用できます。

自前でサーバーを書きたくない場合は、PlayroomKitのようなマルチプレイヤー専用サービスを使う手もあります。insertCoin() を呼ぶだけでルーム管理までやってくれるので、プロトタイプにはこちらの方が速いです。私も物理エンジン入りの同期デモを作ったときはPlayroomKitで済ませました。

実運用で効く小ネタ3つ

送信は間引く。 毎フレーム(60回/秒)送るのは無駄で、デモのように50ms間隔(20回/秒)もあれば人間の目には十分です。受信側でカクつきが気になるなら、受け取った位置に lerp で補間しながら近づけると滑らかになります。

// 受信した位置をそのまま代入せず、毎フレーム少しずつ近づける
remote.mesh.position.lerp(remote.targetPosition, 0.2);

退出処理を忘れない。 切断した人の箱が残り続けると幽霊だらけになります。デモでは beforeunload で退出メッセージを送りつつ、10秒間更新のない相手を消すタイムアウト処理も入れています。通知はネットワーク断で届かないことがあるので、タイムアウト側が本命の保険です。

自分のメッセージは無視する。 ブロードキャストの実装によっては自分の送信が自分にも返ってきます。IDを付けて if (data.id === myId) return; する一行を忘れると、自分の箱が二重に動く怪現象が起きます。

まとめ

  • 静的なThree.js作品はCloudflare Pagesに無料でデプロイできる(GitHub連携で自動化)
  • 位置同期は「定期送信 → ブロードキャスト → 反映」の3ステップ
  • 通信部分を差し替えられる作りにしておけば、BroadcastChannelでローカル検証 → WebSocketで本番、が同じコードでいける
  • サーバーはWorkers + Durable Objects、手軽さ優先ならPlayroomKit
  • 送信間引き・補間・退出処理の3点セットで見た目が一気にまともになる

Three.js解説シリーズ(全7回)

  1. 【Three.js入門】ビルドツール不要、CDNだけで始める基本の使い方
  2. レンダラー・ライト・カメラの使い方と設定まとめ
  3. キーボードとNipplejsでプレイヤーを動かす
  4. GLTFLoaderで3Dモデルをインポートする
  5. ファイル分割はDIパターンがおすすめ
  6. CloudflareへのデプロイとWebSocketでの位置同期(この記事)
  7. WebGLレンダラーとCSS3Dレンダラーを共存させる