1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PUN 2 と Photon Voice 2 を利用して、MR マルチプレイのプロトタイプを作る

Last updated at Posted at 2025-08-31

はじめに

MRのマルチプレイヤーゲームをいろいろな技術を試しながら作っています。
せっかくなら、なるべくデバイス依存を少なくしたいと思い、OpenXR を利用したものにしたいと考え、作りはじめました。

PUN2は長年の実績がある安定した技術ですが、最終的には、より新しい技術である Photon Fusion も視野に入れています。
ここでは、検証利用のため、さくっと作れるものとして、PUN 2 + Photon Voice 2 を利用して、VRM アバターを動かすことをしてみました。

下記のようなものになっております。

なお、今回試しているデバイスは、Meta Quest 3 となります。

この記事のベース環境は、所属会社のブログで公開した以下の記事の「2章 OpenXR:Meta パッケージを利用した開発環境構築」までをすませていることを前提としています。

https://zenn.dev/meson_tech_blog/articles/openxr-meta-immersal

VRM アバターの設定

1. XR Interaction Toolkit / XR Hands パッケージのインストール

スクリーンショット 2025-08-30 232550.png
スクリーンショット 2025-08-30 233531.png

2. 各種サンプルのインポート

HandVisualizer
スクリーンショット 2025-08-30 233831.png
Startar Assets / Hands Interaction Demo
スクリーンショット 2025-08-30 233139.png

3. カメラオブジェクトの再設定

  1. XR Origin (Mobile AR) オブジェクトを1度削除し、再度追加
  2. MainCamera の Camera コンポーネントの Clear Flags を Solid Color に変更し、Background Color を黒(0, 0, 0, 0)に設定

これにより、XR Interaction Toolkitに対応したコンポーネントが設定されます。
手順の詳細は、上記で前提とさせて頂いた記事を参考にしてください。

4. ハンドトラッキングの設定

4.1. Project Settings > XR Plug-in Management > OpenXR で、ハンドトラッキングの有効化

スクリーンショット 2025-08-31 004024.png

Hand Tracking Subsystem / Meta Hand Tracking Aim が対象です。

4.2. XR Origin (Mobile AR) オブジェクトにコンポーネントを追加

インポートしたサンプルの「XR Origin Hands (XR Rig)」Prefabから該当のオブジェクトをコピー
スクリーンショット 2025-08-31 000803.png

ここでは、Camera Offset オブジェクトの子供に Left Hand / Right Hand / Hand Visualizer を配置しました。
また、XR Origin (Mobile AR) オブジェクトの子供に Hands Smoothing Post Processor を配置しています。

5. Uni VRM のインストール

PackageManager > Install Package form git URL... から以下のパッケージをインストール

6. アバターとする VRM ファイルをプロジェクトにインポート

7. アセットストアから、Final IK をインポート

8. 上記でインポートして作成されたVRM の Prefab をシーンに配置

9. VRM の Prefab のトップレベルに VRIK コンポーネントをアタッチ

スクリーンショット 2025-08-31 014009.png

以降の手順でPUN 2との連携をさせるため、Avatar > Visuals の子供のオブジェクトとして、VRM の Prefab を配置しています。
ここでは、banana_avatar オブジェクトが、VRM の Prefab です。

10. VRIK の Solver に設定する空のゲームオブジェクトを作成

ここでは、Head_SyncVRIK / LeftHand_SyncVRIK / RightHand_SyncVRIK という空のゲームオブジェクトを作成
スクリーンショット 2025-08-31 015027.png

頭(デバイス)、左手、右手の動きをトラッキングするために、画像の位置に空のオブジェクトを配置しています。
これにより、VRIKのターゲットがトラッキングされた体の部位となります。
必ずしも、ここに配置しなければいけないというわけではなく、あくまで例となります。

また、Transform を以下のようにしています。
LeftHand_SyncVRIK : Rotation (0, 90, 0)
RightHand_SyncVRIK : Rotation を (0, -90, 0)
これは、アバターに動きを連動させる際の調整値となり、これもあくまで例となります。

11. VRIK の Solver に上記で作成した空のオブジェクトを設定

スクリーンショット 2025-08-31 021412.png

アバターと実際の身長の差は、XR Origin (Mobile AR) > Camera Offset の Transform > Position などで調整可能です。

PUN 2 の設定

1. Photon 公式サイトのダッシュボードで Pun アプリを作成

スクリーンショット 2025-08-31 023619.png

2. アセットストアから Photon Voice 2 をインポート

PUN 2 と Photon Voice 2 を利用する場合、Photon Voice 2 アセットに PUN 2 も含まれますので、こちらを利用する必要があります。

3. Photon Cloud の設定

PhotonServerSettings の Server/Cloud Settings > App Id PUN に上記で作成した Pun アプリの App ID と Fixed Region を入力
スクリーンショット 2025-08-31 031009.png

4. Photon Cloud への接続・ルームへのログインスクリプトを作成

PhotonLogin
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

namespace b0bmat0ca.OpenXr.Multiplayer.Photon
{
    public class PhotonLogin : MonoBehaviourPunCallbacks
    {
        private readonly RoomOptions ROOM_OPTIONS = new RoomOptions()
        {
            MaxPlayers = 20, 
            IsOpen = true, 
            IsVisible = true
        };

        private void Start()
        {
            Debug.Log("PhotonLogin: Connecting Master Server...");
            
            PhotonNetwork.ConnectUsingSettings();
        }

        public override void OnConnectedToMaster()
        {
            Debug.Log("PhotonLogin: Connected to Master Server.");
            Debug.Log("PhotonLogin: Joining or creating a room...");

            PhotonNetwork.JoinOrCreateRoom("OpenXrRoom", ROOM_OPTIONS, null);
        }

        public override void OnJoinedRoom()
        {
            Room room = PhotonNetwork.CurrentRoom;
            global::Photon.Realtime.Player player = PhotonNetwork.LocalPlayer;
            
            Debug.Log("PhotonLogin: Joined room successfully.");
            Debug.Log("PhotonLogin: Room Name: " + room.Name);
            Debug.Log("PhotonLogin: Player Info: Player No: " + player.ActorNumber + " User ID: " + player.UserId + " Room Master: " + player.IsMasterClient);
        }

        public override void OnJoinRandomFailed(short returnCode, string message)
        {
            Debug.Log("PhotonLogin: Failed to join a random room. Creating a new room...");
            
            PhotonNetwork.CreateRoom(null, ROOM_OPTIONS);
        }

        public override void OnCreateRoomFailed(short returnCode, string message)
        {
            Debug.Log("PhotonLogin: Failed to create a room.");
        }
    }
}

5. シーンに Photon 関連のコンポーネントを追加するための空のオブジェクトを作成し、PhotonLogin コンポーネントを追加

スクリーンショット 2025-08-31 032214.png

6. VRM アバター(Avatar オブジェクト)を PUN2 向けに変更

6.1. Head_SyncVRIK / LeftHand_SyncVRIK / RightHand_SyncVRIK オブジェクトを XR Origin (Mobile AR) から Avatar に移動

スクリーンショット 2025-08-31 032823.png

この3つのオブジェクトのローカル座標、回転をアバター同期のオフセット値として利用しますので、調整してください。
この例では、前章で設定した以下を保持しています。

LeftHand_SyncVRIK : Rotation (0, 90, 0)
RightHand_SyncVRIK : Rotation を (0, -90, 0)

6.2. Avatar オブジェクトに PhotonView コンポーネントをアタッチ

スクリーンショット 2025-08-31 033255.png

6.3. Head_SyncVRIK / LeftHand_SyncVRIK / RightHand_SyncVRIK オブジェクトに PhotonTransformView コンポーネントをアタッチ

スクリーンショット 2025-08-31 033432.png

6.4. 頭(デバイス)と両手のトラッキングを簡単に取得するためのスクリプトとアバター用のスクリプトを作成

PlayerSyncHelper
using UnityEngine;

namespace b0bmat0ca.OpenXr.Player
{
    /// <summary>
    /// トラッキングデータを簡単に取得するためのヘルパークラス
    /// </summary>
    public class PlayerSyncHelper : MonoBehaviour
    {
        public Transform cameraOffsetTransform;
        public Transform headTargetTransform;
        public Transform leftHandTargetTransform;
        public Transform rightHandTargetTransform;
    }
}
PhotonAvatar
using Photon.Pun;
using RootMotion.FinalIK;
using UnityEngine;

namespace b0bmat0ca.OpenXr.Multiplayer.Photon
{
    using Player;
    
    public class PhotonAvatar : MonoBehaviourPun, IPunObservable
    {
        [SerializeField] private float  _heightOffset = -0.3f;
        [SerializeField] private Transform _headSyncTransform;
        [SerializeField] private Transform _leftHandSyncTransform;
        [SerializeField] private Transform _rightHandSyncTransform;
        [SerializeField] private VRIK _vrik;

        private Transform _xrOriginTransform;
        
        private Transform _headTargetTransform;
        private Transform _leftHandTargetTransform;
        private Transform _rightHandTargetTransform;
        private Vector3 _offsetHeadToOriginPosition;
        private Vector3 _offsetHeadToOriginRotation;
        private Vector3 _offsetLeftHandToOriginPosition;
        private Vector3 _offsetLeftHandToOriginRotation;
        private Vector3 _offsetRightHandToOriginPosition;
        private Vector3 _offsetRightHandToOriginRotation;
        
        private bool _isInitialized = false;

        public void Start()
        {
            if (photonView.IsMine)
            {
                photonView.RPC("SetParent", RpcTarget.AllBuffered);
                
                // アバターPrefabに作成されているトラッキングデータを同期するオブジェクトの初期位置・回転を保存
                _offsetHeadToOriginPosition = _headSyncTransform.localPosition;
                _offsetHeadToOriginRotation = _headSyncTransform.localEulerAngles;
                _offsetLeftHandToOriginPosition = _leftHandSyncTransform.localPosition;
                _offsetLeftHandToOriginRotation = _leftHandSyncTransform.localEulerAngles;
                _offsetRightHandToOriginPosition = _rightHandSyncTransform.localPosition;
                _offsetRightHandToOriginRotation = _rightHandSyncTransform.localEulerAngles;
                
                InitializeForLocalPlayer();
            }
        }

        private void LateUpdate()
        {
            UpdateForLocalPlayer();
        }

        /// <summary>
        /// ヒエラルキー上でのオブジェクト名を同期する
        /// この処理は通常不要ですが、このようにすることで、例えば、プレイヤーの名前を各クライアントに同期して、表示することが可能です
        /// </summary>
        /// <param name="stream"></param>
        /// <param name="info"></param>
        public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
        {
            if (stream.IsWriting)
            {
                string gameObjectName = "PhotonAvatar " + photonView.OwnerActorNr;

                this.gameObject.name = gameObjectName;
                stream.SendNext(gameObjectName);
            }
            else
            {
                this.gameObject.name = stream.ReceiveNext().ToString();;
            }
        }
        
        private void InitializeForLocalPlayer()
        {
            int actorNumber = PhotonNetwork.LocalPlayer.ActorNumber;
            this.gameObject.name = "PhotonAvatar:" + actorNumber;
            
            Debug.Log("PhotonAvatar: Initializing avatar for local player " + actorNumber);
            
            PlayerSyncHelper playerSyncHelper = FindAnyObjectByType<PlayerSyncHelper>();
            if (playerSyncHelper != null)
            {
                _xrOriginTransform = playerSyncHelper.transform;
                
                // アバターと実際のプレーヤーの目線の高さを合わせるために、カメラオフセットに高さのオフセットを適用
                playerSyncHelper.cameraOffsetTransform.position += new Vector3(0, _heightOffset, 0);
                
                // VRIKのターゲットをPlayerSyncHelperのトラッキングデータに設定
                _headTargetTransform = playerSyncHelper.headTargetTransform;
                _leftHandTargetTransform = playerSyncHelper.leftHandTargetTransform;
                _rightHandTargetTransform = playerSyncHelper.rightHandTargetTransform;
                
                _isInitialized = true;
            }
            else
            {
                Debug.LogError("PhotonAvatar: PlayerSyncHelper not found in the scene.");
            }
        }

        private void UpdateForLocalPlayer()
        {
            if (!_isInitialized) return;
            
            transform.position = _xrOriginTransform.position;
            transform.rotation = _xrOriginTransform.rotation;
            
            // オフセット値を適用した、頭、両手のトラッキングデータの同期
            _headSyncTransform.position = _headTargetTransform.position + _offsetHeadToOriginPosition;
            _headSyncTransform.rotation = _headTargetTransform.rotation * Quaternion.Euler(_offsetHeadToOriginRotation);
            _leftHandSyncTransform.position = _leftHandTargetTransform.position + _offsetLeftHandToOriginPosition;
            _leftHandSyncTransform.rotation = _leftHandTargetTransform.rotation * Quaternion.Euler(_offsetLeftHandToOriginRotation);
            _rightHandSyncTransform.position = _rightHandTargetTransform.position + _offsetRightHandToOriginPosition;
            _rightHandSyncTransform.rotation = _rightHandTargetTransform.rotation * Quaternion.Euler(_offsetRightHandToOriginRotation);
        }

        /// <summary>
        /// アバターをヒエラルキー上で整理するために、PlayersParentオブジェクトの子に設定する
        /// この処理は通常不要ですが、このようにすることで、例えば、適切なタイミングでプレイヤーのHPを減らしたりなどに利用できます。
        /// ここでは、Start() メソッドで1回だけ実行されています。
        /// </summary>
        [PunRPC]
        private void SetParent()
        {
            Debug.Log("PhotonAvatar: Setting parent to " + this.gameObject.name);
            
            Transform playersParentTransform = GameObject.FindWithTag("PlayersParent")?.transform;

            if (playersParentTransform == null)
            {
                Debug.LogError("PhotonAvatar: PlayersParent not found in the scene.");
                return;
            }
            
            transform.SetParent(playersParentTransform);
        }
    }
}

6.5. PhotonAvatar スクリプトを Avatar オブジェクトにアタッチしてインスペクタを設定

スクリーンショット 2025-08-31 040436.png

6.6. Avatar オブジェクトを "PhotonAvatar" という名称で、「Resources」フォルダに Prefab 化

スクリーンショット 2025-08-31 041159.png

ヒエラルキーの Avatar オブジェクトは、Prefab 化後に削除する

7. アバターをヒエラルキー上整理するための空のゲームオブジェクトを作成し、"PlayersParent" タグを作成・設定

スクリーンショット 2025-08-31 044058.png

8. アバターを生成する場所として、空のゲームオブジェクトを作成し、その子供に生成位置の Transform を持つ空のゲームオブジェクトを複数作成

スクリーンショット 2025-08-31 044436.png

9. アバターを生成するスクリプトを作成し、Photon オブジェクトにアタッチ

PhotonPlayer
using Photon.Pun;
using UnityEngine;

namespace b0bmat0ca.OpenXr.Multiplayer.Photon
{
    public class PhotonPlayer : MonoBehaviourPunCallbacks
    {
        [SerializeField] private Transform _xrOriginTransform;
        [SerializeField] private Transform[] _playerSpawnPoints;
        private int _spawnIndex = -1;
        
        public override void OnJoinedRoom()
        {
            Debug.Log("PhotonPlayer: Joined room, setting up avatar...");
            
            _spawnIndex = Random.Range(0, _playerSpawnPoints.Length);
            
            CreateAvatar();
        }

        public override void OnPlayerEnteredRoom(global::Photon.Realtime.Player newPlayer)
        {
            Debug.Log("PhotonPlayer: Player " + newPlayer.ActorNumber + " entered the room with spawn index " + _spawnIndex);
        }
        
        private void CreateAvatar()
        {
            int actorNumber = PhotonNetwork.LocalPlayer.ActorNumber;
            Debug.Log("PhotonPlayer: Creating avatar for player " + actorNumber);

            _xrOriginTransform.position = _playerSpawnPoints[_spawnIndex].position;
            _xrOriginTransform.rotation = _playerSpawnPoints[_spawnIndex].rotation;
            
            PhotonNetwork.Instantiate("PhotonAvatar", _playerSpawnPoints[_spawnIndex].position, _playerSpawnPoints[_spawnIndex].rotation, 0);
            
            Debug.Log("PhotonPlayer: Player " + actorNumber + " entered the room with spawn index " + _spawnIndex);
        }
    }
}

スクリーンショット 2025-08-31 044737.png

10. XR Origin (Mobile AR) オブジェクトに 上記で作成した、PlayerSyncHelper コンポーネントをアタッチ

スクリーンショット 2025-08-31 045238.png

Photon Voice 2 の設定

1. Photon 公式サイトのダッシュボードで Voice アプリを作成

スクリーンショット 2025-08-31 023619.png

2. Photon Cloud の設定

PhotonServerSettings の Server/Cloud Settings > App Id Voice に上記で作成した Voice アプリの App ID を入力
スクリーンショット 2025-08-31 030516.png

3. Speaker コンポーネント用の空のオブジェクトを作成、「Resources」フォルダにPrefab 化

スクリーンショット 2025-08-31 052241.png

ヒエラルキーの Speaker オブジェクトは、Prefab 化後に削除する

4. Photon オブジェクトに PunVoiceClient と Recorder コンポーネントをアタッチ

スクリーンショット 2025-08-31 052801.png

5. Photon Avatar Prefab に PhotonVoiceView コンポーネントをアタッチ

スクリーンショット 2025-08-31 053058.png

これで、Meta Quest 3を利用して、現実空間に単純にアバターが生成される最低限のアプリケーションができると思います。

各スクリプトの詳しい解説は割愛しますが、分かりにくい部分はコード内のコメントで補足しています。
参考になれば、幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?