はじめに

HADO のような AR 対戦ゲームを「自分たちで、どこでも動かせる形で作れないか?」と思い、Unity + FishNet を使って LAN(Local Area Network)環境での AR マルチプレイを試してみました。

作ったのは 1 vs 1 の AR 対戦ゲームです。Android スマートフォンと XREAL グラスが同じ物理空間で「弾を撃ち合う」デモで、専用施設やクラウドサーバーなしで動きます。

この記事ではネットワーク周りの実装を中心に紹介します。

このプレイ動画はAndroidスマートフォン視点で撮影しています。相手はXREAL Air2 Ultraです。
XREAL Air2 UltraにはRGBカメラが無いため、現実の背景を映した撮影ができません。

開発環境

  • Unity 6000.3.9f1
  • FishNet 4.6.20
    • Fish-Networking-Discovery(LAN サーバー探索)
  • ARFoundation / ARCore 6.3.2
  • XREAL SDK 3.1.0
  • UniTask 2.5.10
  • R3 1.3.0

動作確認端末

  • Pixel 10
  • Galaxy S25
  • XREAL Air2 Ultra

注意事項

iOS は LAN 探索非対応

iOS は multicast entitlement の制約で UDP ブロードキャストが使えないため、LAN でのサーバー自動探索とサーバー起動には対応していません。接続はできるので、IP アドレスを直打ちすれば動作します。

FishNet 採用理由

Unity のネットワークライブラリには、主に以下の選択肢があります。

ライブラリ特徴
FishNetOSS(MIT)、自己ホスト、活発な開発
MirrorOSS(MIT)、歴史が長い、資産が豊富
Netcode for GameObjectsUnity 公式、Relay サービス前提
Photon PUN/Fusionクラウド前提、CCU 課金

今回の要件は「LAN 内で完結、クラウド不要、Dedicated Server あり」だったので、Photon・Netcode for GameObjects は候補から外れました。残った Mirror と FishNet の比較で FishNet を選んだ理由は次のとおりです。

Mirror より FishNet を選んだ理由

Mirror では [Command][ClientRpc] を手動で組み合わせる必要があります。

// Mirror: 手動で Command → ClientRpc を繋ぐ
[Command]
private void CmdMove(Vector3 pos, Quaternion rot) { ... }

[ClientRpc]
private void RpcSyncedPos(Vector3 pos, Quaternion rot) { ... }

FishNet では SyncVar に値を書くだけで全クライアントへの配信が自動化されます。

// FishNet: SyncVar に書くだけで全クライアントに自動配信
[ServerRpc]
private void CmdUpdateLocalPos(Vector3 localPos)
{
    _syncedLocalPosition.Value = localPos;
}

SyncVar は変化分だけを内部で差分管理するため、毎フレーム送信しなくても帯域への影響が限定的です。また、FishNet は設計当初から Dedicated Server を想定しており、#if UNITY_SERVER での分岐や Headless ビルドのサポートが素直に使えました。

部屋の作成と入室

LAN サーバーの自動探索

IP アドレスを手打ちさせるのは体験として良くないので、UDP ブロードキャストでサーバーを自動探索するようにしました。Fish-Networking-Discovery を使うと、サーバー広告・クライアント探索が数行で書けます。

// サーバー側: 部屋を作ったら広告開始
_discoveryService.StartAdvertise();

// クライアント側: 探索開始、見つかったら接続
_discoveryService.StartSearch();
_discoveryService.OnServerFound.Subscribe(async address =>
{
    _discoveryService.StopSearch();
    await _networkService.ConnectAsClientAsync(address, port: 7777, ct);
});

部屋の作成(ホスト側)

タイトル画面の「部屋を作る」ボタンを押すと以下の順で処理が走ります。

  1. サーバー起動(StartServer)
  2. Discovery でアドバタイズ開始(他の端末から見えるようにする)
  3. 自分自身も 127.0.0.1 にクライアントとして接続(リッスンサーバー)
  4. 相手の接続を待機
  5. 相手が入室したら「対戦開始」ボタンを有効化
  6. 「対戦開始」押下 → 全クライアントに GameStartBroadcast を送信
async UniTask StartServerAndListenAsync(CancellationToken ct)
{
    // 1. サーバー起動
    _networkManager.TransportManager.Transport.SetPort(7777);
    _networkManager.ServerManager.StartConnection();

    // IsServerStarted になるまでポーリングで待機
    await UniTask.WaitUntil(() => _networkManager.IsServerStarted, cancellationToken: ct);

    // 2. アドバタイズ開始(ゲストが LAN で探索できるようにする)
    _networkDiscovery.AdvertiseServer();

    // 3. ホスト自身もクライアントとして接続(リッスンサーバー)
    _networkManager.TransportManager.Transport.SetClientAddress("127.0.0.1");
    _networkManager.ClientManager.StartConnection();

    await UniTask.WaitUntil(() => _networkManager.IsClientStarted, cancellationToken: ct);

    // 4. 相手の入室を ServerManager のイベントで検知
    _networkManager.ServerManager.OnRemoteConnectionState += OnRemoteConnectionState;
}

void OnRemoteConnectionState(NetworkConnection conn, RemoteConnectionStateArgs args)
{
    if (args.ConnectionState == RemoteConnectionState.Started)
        OnRemotePlayerJoined(); // 5. ボタン有効化
}

void OnBattleStartClicked()
{
    // 6. 全クライアントにゲーム開始を通知
    _networkManager.ServerManager.Broadcast(new GameStartBroadcast());
}

FishNet のサーバーは StartConnection() を呼ぶだけで起動し、ホスト自身も 127.0.0.1 にクライアントとして接続することでリッスンサーバーとして機能します。

部屋の入室(クライアント側)

「部屋を探す」ボタンを押すと Discovery で LAN 内を検索し、見つかったら即接続します。

  1. Discovery で検索開始(サーバーからの応答を待つ)
  2. サーバーアドレスを取得したら検索停止
  3. 取得したアドレスに接続
  4. ホストの GameStartBroadcast を待機
async UniTask FindAndJoinRoomAsync(CancellationToken ct)
{
    // 1. サーバー探索
    string serverAddress = null;
    _networkDiscovery.ServerFoundCallback += endPoint =>
    {
        if (serverAddress == null) serverAddress = endPoint.Address.ToString();
    };
    _networkDiscovery.SearchForServers();

    // 2. アドレスが取得できるまで待機
    await UniTask.WaitUntil(() => serverAddress != null, cancellationToken: ct);
    _networkDiscovery.StopSearchingOrAdvertising();

    // 3. 接続
    var transport = _networkManager.TransportManager.Transport;
    transport.SetClientAddress(serverAddress);
    transport.SetPort(7777);
    _networkManager.ClientManager.StartConnection();

    await UniTask.WaitUntil(() => _networkManager.IsClientStarted, cancellationToken: ct);

    // 4. ホストの開始ブロードキャストを待つ
    _networkManager.ClientManager.RegisterBroadcast<GameStartBroadcast>(OnGameStartReceived);
}

AR空間の共有(キャリブレーション)

スマートフォンや AR グラスは自己位置を SLAM で推定しているので、起動した時点では 2 台が全く別々の座標系を持っています。「自分の目の前 1m」が相手のデバイスでも同じ場所を指さないと、お互いの表示がバラバラになってしまいます。

AR マーカーを基準にする

解決策として AR マーカー(画像マーカー)を使いました。床に印刷したマーカーを置いて、全デバイスがそれを基準点として座標系を統一します。

ARFoundation がマーカーのワールド座標(位置・回転)を検出
FieldAnchor(コンテンツの親オブジェクト)の位置を逆算して更新
以降の座標は FieldAnchor のローカル座標に変換して同期

マーカーを追跡し続けるのではなく、「マーカーが見えた瞬間に FieldAnchor の位置を補正する」設計にしています。マーカーが見えなくなった後は各デバイスの SLAM が位置を維持してくれます。

実装

ARFoundation の ARTrackedImageManager を使います。ARTrackedImageManager は ARCore・ARKit・XREAL SDK(ARFoundation サブシステムとして実装されています)すべてで動くので、プラットフォーム固有の分岐なしに共通実装ができます。

void OnTrackedImagesChanged(ARTrackedImagesChangedEventArgs args)
{
    foreach (var image in args.added.Concat(args.updated))
    {
        if (image.trackingState != TrackingState.Tracking) continue;
        if (image.referenceImage.name != _config.TargetMarkerName) continue;

        // マーカー位置から FieldAnchor のワールド座標を逆算
        var (pos, rot) = _markerAnchor.CalculateFieldAnchorPose(
            image.transform.position,
            image.transform.rotation);

        _fieldAnchorProvider.SetFieldAnchorPosition(pos, rot);
        onUpdated?.Invoke(new CalibrationTransform(pos, rot));
    }
}

CalculateFieldAnchorPose の中では ARFoundation の座標規約(Y 軸が法線)とエディタの規約(Z 軸が法線)の変換を行っています。

public (Vector3 position, Quaternion rotation) CalculateFieldAnchorPose(
    Vector3 markerWorldPosition, Quaternion markerWorldRotation)
{
    // ARFoundation(Y=法線)→ エディタ(Z=法線)に変換
    var correctedRotation = markerWorldRotation * Quaternion.Euler(90f, 0f, 0f);

    var markerWorldMatrix  = Matrix4x4.TRS(markerWorldPosition, correctedRotation, Vector3.one);
    var localOffsetMatrix  = Matrix4x4.TRS(_localPositionOffset, _localRotationOffset, Vector3.one);
    var fieldAnchorMatrix  = markerWorldMatrix * localOffsetMatrix.inverse;

    return (fieldAnchorMatrix.GetPosition(), fieldAnchorMatrix.rotation);
}

マーカーを FieldAnchor の子オブジェクトとして配置しておくことで、エディタ上でオフセットを視覚的に調整できるようになっています。

プレイヤー座標の同期

FieldAnchor ローカル座標で送る

ワールド座標をそのまま送ると、キャリブレーションのタイミングのズレで表示がおかしくなります。代わりに FieldAnchor のローカル座標に変換してから送るようにしました。
受信側は FieldAnchor さえ正しく配置されていれば、相手の位置を正確に復元できます。

送信側:
Camera.main.position
→ InverseTransformPoint(FieldAnchor) でローカル座標に変換
→ SyncVar で全クライアントに配信
受信側:
SyncVar の値(ローカル座標)
→ FieldAnchor の子として localPosition に適用
→ FieldAnchor がキャリブレーション済みなら正しい位置に表示される

FishNet の ServerRpc + SyncVar

FishNet では SyncVar を使うと値の変更が自動的に全クライアントに配信されます。書き込みは ServerRpc で行います。

protected readonly SyncVar<Vector3> _syncedLocalPosition = new(Vector3.zero);

protected virtual void UpdateOwnerPosition()
{
    var localPos = _fieldAnchorProvider.FieldAnchor
        .InverseTransformPoint(_cameraTransform.position);

    if (Vector3.Distance(localPos, _syncedLocalPosition.Value) > 0.001f)
        CmdUpdateLocalPos(localPos);

    transform.localPosition = localPos; // ローカルは即時反映
}

[ServerRpc]
private void CmdUpdateLocalPos(Vector3 localPos)
{
    _syncedLocalPosition.Value = localPos; // 全クライアントに自動配信
}

デッドレコニングで補間

UDP はパケットロスが起きると位置がガクッと飛びます。今回は受信した速度を推定して「次の受信まで予測位置で補間する」デッドレコニングを実装しています。

protected override void UpdateNonOwnerPosition()
{
    var syncedPos = _syncedLocalPosition.Value;

    // SyncVar の更新を検知して速度を推定
    if (syncedPos != _lastKnownSyncedPos)
    {
        var dt  = Time.time - _drBaseTime;
        _drBaseVel          = dt > 0.001f ? (syncedPos - _drBasePos) / dt : Vector3.zero;
        _drBasePos          = syncedPos;
        _drBaseTime         = Time.time;
        _lastKnownSyncedPos = syncedPos;
    }

    // 最大 0.5 秒分を外挿(それ以上は誤差が大きくなる)
    var timeSince = Mathf.Min(Time.time - _drBaseTime, 0.5f);
    var predicted = _drBasePos + _drBaseVel * timeSince;

    transform.localPosition = Vector3.Lerp(
        transform.localPosition, predicted, Time.deltaTime * 12f);
}

弾の同期

弾(プロジェクタイル)の同期は、位置を毎フレーム送り続けるのではなく「発射イベント」だけを送る設計にしました。

発射位置・方向・初速が決まれば全端末で同じ物理軌道を再現できるので、NetworkObject と NetworkTransform で毎フレーム同期するより通信量がずっと少なくて済みます。

当たり判定やライフ計算もそれぞれの端末でローカル処理できるため、同期していません。

FishNet の Broadcast

FishNet には Broadcast という仕組みがあり、NetworkObject を介さずにクライアント↔サーバー間でメッセージを送れます。弾の同期に使いやすいです。

// 座標は FieldAnchor ローカル座標で送る
public struct BulletFiredBroadcast : IBroadcast
{
    public Vector3 LocalPosition;
    public Vector3 LocalDirection;
}

// 自分が発射したら送信
void BroadcastFire(Vector3 localPos, Vector3 localDir)
{
    _networkManager.ClientManager.Broadcast(new BulletFiredBroadcast
    {
        LocalPosition  = localPos,
        LocalDirection = localDir.normalized,
    });
}

// サーバー: 送信元以外の全クライアントへ中継
void OnServerReceive(NetworkConnection conn, BulletFiredBroadcast msg, Channel ch)
{
    foreach (var client in _networkManager.ServerManager.Clients.Values)
    {
        if (client == conn) continue;
        _networkManager.ServerManager.Broadcast(client, msg, channel: ch);
    }
}

// 受信側: ローカル座標 → ワールド座標に戻して弾をスポーン
void OnClientReceive(BulletFiredBroadcast msg, Channel ch)
{
    var worldPos = fieldAnchor.TransformPoint(msg.LocalPosition);
    var worldDir = fieldAnchor.TransformDirection(msg.LocalDirection);
    SpawnBullet(worldPos, worldDir);
}

まとめ

FishNet の SyncVarBroadcast を組み合わせることで、AR マルチプレイに必要な「プレイヤー座標の継続同期」と「発射イベントの単発送信」をシンプルに書き分けられました。ARマーカーで座標系を揃えてしまえば、あとは FieldAnchor のローカル座標を流すだけで複数端末の表示が合うので、思ったよりすっきり実装できたと思っています。

AR マルチプレイに興味がある方はぜひ試してみてください。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる