はじめに
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 パッケージのインストール
2. 各種サンプルのインポート
HandVisualizer
Startar Assets / Hands Interaction Demo
3. カメラオブジェクトの再設定
- XR Origin (Mobile AR) オブジェクトを1度削除し、再度追加
- 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 で、ハンドトラッキングの有効化
Hand Tracking Subsystem / Meta Hand Tracking Aim が対象です。
4.2. XR Origin (Mobile AR) オブジェクトにコンポーネントを追加
インポートしたサンプルの「XR Origin Hands (XR Rig)」Prefabから該当のオブジェクトをコピー
ここでは、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... から以下のパッケージをインストール
- https://github.com/vrm-c/UniVRM.git?path=/Assets/UniGLTF#v0.129.3
- https://github.com/vrm-c/UniVRM.git?path=/Assets/VRM#v0.129.3
-
https://github.com/vrm-c/UniVRM.git?path=/Assets/VRM10#v0.129.3
6. アバターとする VRM ファイルをプロジェクトにインポート
7. アセットストアから、Final IK をインポート
8. 上記でインポートして作成されたVRM の Prefab をシーンに配置
9. VRM の Prefab のトップレベルに VRIK コンポーネントをアタッチ
以降の手順でPUN 2との連携をさせるため、Avatar > Visuals の子供のオブジェクトとして、VRM の Prefab を配置しています。
ここでは、banana_avatar オブジェクトが、VRM の Prefab です。
10. VRIK の Solver に設定する空のゲームオブジェクトを作成
ここでは、Head_SyncVRIK / LeftHand_SyncVRIK / RightHand_SyncVRIK という空のゲームオブジェクトを作成
頭(デバイス)、左手、右手の動きをトラッキングするために、画像の位置に空のオブジェクトを配置しています。
これにより、VRIKのターゲットがトラッキングされた体の部位となります。
必ずしも、ここに配置しなければいけないというわけではなく、あくまで例となります。
また、Transform を以下のようにしています。
LeftHand_SyncVRIK : Rotation (0, 90, 0)
RightHand_SyncVRIK : Rotation を (0, -90, 0)
これは、アバターに動きを連動させる際の調整値となり、これもあくまで例となります。
11. VRIK の Solver に上記で作成した空のオブジェクトを設定
アバターと実際の身長の差は、XR Origin (Mobile AR) > Camera Offset の Transform > Position などで調整可能です。
PUN 2 の設定
1. Photon 公式サイトのダッシュボードで Pun アプリを作成
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 を入力
4. Photon Cloud への接続・ルームへのログインスクリプトを作成
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 コンポーネントを追加
6. VRM アバター(Avatar オブジェクト)を PUN2 向けに変更
6.1. Head_SyncVRIK / LeftHand_SyncVRIK / RightHand_SyncVRIK オブジェクトを XR Origin (Mobile AR) から Avatar に移動
この3つのオブジェクトのローカル座標、回転をアバター同期のオフセット値として利用しますので、調整してください。
この例では、前章で設定した以下を保持しています。
LeftHand_SyncVRIK : Rotation (0, 90, 0)
RightHand_SyncVRIK : Rotation を (0, -90, 0)
6.2. Avatar オブジェクトに PhotonView コンポーネントをアタッチ
6.3. Head_SyncVRIK / LeftHand_SyncVRIK / RightHand_SyncVRIK オブジェクトに PhotonTransformView コンポーネントをアタッチ
6.4. 頭(デバイス)と両手のトラッキングを簡単に取得するためのスクリプトとアバター用のスクリプトを作成
using UnityEngine;
namespace b0bmat0ca.OpenXr.Player
{
/// <summary>
/// トラッキングデータを簡単に取得するためのヘルパークラス
/// </summary>
public class PlayerSyncHelper : MonoBehaviour
{
public Transform cameraOffsetTransform;
public Transform headTargetTransform;
public Transform leftHandTargetTransform;
public Transform rightHandTargetTransform;
}
}
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 オブジェクトにアタッチしてインスペクタを設定
6.6. Avatar オブジェクトを "PhotonAvatar" という名称で、「Resources」フォルダに Prefab 化
ヒエラルキーの Avatar オブジェクトは、Prefab 化後に削除する
7. アバターをヒエラルキー上整理するための空のゲームオブジェクトを作成し、"PlayersParent" タグを作成・設定
8. アバターを生成する場所として、空のゲームオブジェクトを作成し、その子供に生成位置の Transform を持つ空のゲームオブジェクトを複数作成
9. アバターを生成するスクリプトを作成し、Photon オブジェクトにアタッチ
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);
}
}
}
10. XR Origin (Mobile AR) オブジェクトに 上記で作成した、PlayerSyncHelper コンポーネントをアタッチ
Photon Voice 2 の設定
1. Photon 公式サイトのダッシュボードで Voice アプリを作成
2. Photon Cloud の設定
PhotonServerSettings の Server/Cloud Settings > App Id Voice に上記で作成した Voice アプリの App ID を入力
3. Speaker コンポーネント用の空のオブジェクトを作成、「Resources」フォルダにPrefab 化
ヒエラルキーの Speaker オブジェクトは、Prefab 化後に削除する
4. Photon オブジェクトに PunVoiceClient と Recorder コンポーネントをアタッチ
5. Photon Avatar Prefab に PhotonVoiceView コンポーネントをアタッチ
これで、Meta Quest 3を利用して、現実空間に単純にアバターが生成される最低限のアプリケーションができると思います。
各スクリプトの詳しい解説は割愛しますが、分かりにくい部分はコード内のコメントで補足しています。
参考になれば、幸いです。