DMM.comの、一番深くておもしろいトコロ。

UnityでマルチプレイヤーVRチャットアプリが作れるDMM VR Connect #5

UnityでマルチプレイヤーVRチャットアプリが作れるDMM VR Connect #5

  • このエントリーをはてなブックマークに追加

DMM VR Connectを使うとマルチプレイヤーアプリが実装できるって知ってました?

弊部署のリリースしている Connect Chat(以下コネチャ) は、 DMM VR ConnectとPhoton サービスを組み合わせて作られた、VR向けソーシャルサービスです。 今回は、Connect SDK を使ってユーザーのアバターの動きをマルチプレイ同期に対応させる方法を紹介します。

あらゆるVRアプリに好きな姿でダイブできるDMM VR Connect連載 #5

あらゆるVRアプリに好きな姿で飛び込みたい! そう思ったこと、一度はありますよね? そんな世界を実現するDMM VR ConnectとそのSDKについて、技術者目線で開発メンバーが全六回の連載でご紹介します。 五回目となる今回は、DMM VR Connectの認証とアバターロードについて、前回から引き続きあきらと、Connect Chat開発チームの小笠原がご紹介します。

目次

自己紹介

github.com

Connect Chat開発チームでクライアントサイドを中心に担当している小笠原 (sa_w_ara) です。 2020年からDMM VR labにJoinして、以降Connect Chat中心にVR向けサービス開発をしています。 アバターはお手製のオニオオハシ。

DMM VR ConnectはあらゆるVRアプリに好きな姿で飛び込めるサービス

www.youtube.com connect.vrlab.dmm.com

Connect Chatの紹介

store.steampowered.com

DMM VR Connect SDK 使ってマルチプレイヤーアプリを作る手順

はじめに:VRでアバター使ってマルチプレイ、したいよね?

さて、今回はConnect Chatみたいにアバターでマルチプレイできるアプリ増えたら嬉しいよね! 簡単な作り方話しちゃって! ということで、ざっくりプロトタイプ作り大好き人間である私が本領発揮したいと思います。

マルチプレイでのアプリ間の状態を合わせることを「ネットワーク同期」と呼びます。 ネットワーク同期を実装するためには、サーバーを用意して、サーバのプログラム作って、サーバに接続するクライアントプログラム書いて、UnityならGameObject、Componentの状態をサーバー経由で各アプリに送って、送られてきた状態をもとにGameObject,Componentの状態を更新して...と、やるべきことが多々あります。

これをまとめて提供してくださってるのが Exit Games さんによるPhoton Cloudサービスです。 今回は DMM VR Connect と Photon Cloudサービスのなかから PUN2 Free版 を使ったアプリの作り方を紹介したいと思います。

早速作ってみる

VRMアバターを使ってマルチプレイできるアプリを作ってみましょう。

今回は Inside#02 で作ったプロジェクトを改造して作ります。

前提

  • 今回はUnity向け定番有料アセットである FinalIK が必要です。 assetstore.unity.com

手順

  • 今回は DMM VR Connect #2 の記事で作られるプロジェクトを改造して作るので、まずはそちらを参照して準備をしてください。 inside.dmm.com

  • Photon Cloud のアプリAPIを準備

    • PhotonCloudのサイトからユーザー登録、アプリを作成してAPIキーを取得しておきます。
    • (一定利用量までは無料でサービス提供されています) www.photonengine.com

f:id:uisawara:20210511203135p:plain

f:id:uisawara:20210511203351p:plain

f:id:uisawara:20210511203425p:plain

f:id:uisawara:20210511203453p:plain

  • アプリケーション IDの欄をクリックすると全文が表示されるので、メモ帳などに保存しておく。

  • UnityプロジェクトにPUN2 Freeをインポート

    • AssetStoreからPUN2 Freeをインポートする。

assetstore.unity.com

* Photonの設定Wizardが表示されるので、事前に取得しておいた アプリケーション IDを設定する。
  • FinalIKをインポート

  • 作ってみる

    • 新規シーンを作成してください。これにサンプルシーンを構築します。
  • サンプルUIの作成

    • GameObject ”SampleUI” を作成して、以下のスクリプトをAddComponentします。
    • UIという名前がついていますが、DVRSDKを使ったConnectへのログイン・ログアウト、アバター読込をおこなうComponentです。

SampleUI.cs

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DVRSDK.Auth;
using DVRSDK.Avatar;
using DVRSDK.Serializer;
using DVRSDK.Utilities;
using UnityEngine;
using DVRSDK.Auth.Okami.Models;
using DVRSDK.Test;

namespace DVRSDK.Examples
{

    public delegate void AuthEventHandler(bool isOnline);
    public delegate void UserModelEventHandler(string userId);
    public delegate void AvatarModelEventHandler(bool isSuccess, string userId, VRMLoader vrmLoader, GameObject avatarModelObject);

    public sealed class SampleUI : MonoBehaviour
    {
        [SerializeField]
        private bool _useAutoLogin;

        private Dictionary<ApiRequestErrors, string> _apiRequestErrorMessages = new Dictionary<ApiRequestErrors, string> {
            { ApiRequestErrors.Unknown,"Unknown request error" },
            { ApiRequestErrors.Forbidden, "Request forbidden" },
            { ApiRequestErrors.Unregistered, "User unregistered" },
            { ApiRequestErrors.Unverified, "User email unverified" },
         };

        public CurrentUserModel _currentUser;

        private readonly string _avatarPageUrl = "https://connect.vrlab.dmm.com/user/avatars/";
        private readonly string _userSignupPageUrl = "https://connect.vrlab.dmm.com/user/signup/";

        private UIPanelType _uiPanelType;
        private string _statusText;
        private Texture2D _thumnbnailTexture;
        private string _userNameText;
        private string _codeText;

        private void OnGUI()
        {
            GUILayout.BeginVertical();
            GUILayout.Label($"status: {_statusText}");
            
            switch (_uiPanelType)
            {
                case UIPanelType.Login:
                    if (GUILayout.Button("Login"))
                    {
                        DoLogin();
                    }
                    break;
                case UIPanelType.Code:
                    GUILayout.Label($"Code: {_codeText}");
                    break;
                case UIPanelType.Logout:
                    if (GUILayout.Button("Logout"))
                    {
                        DoLogout();
                    }
                    break;
                case UIPanelType.NoAvatar:
                    break;
                case UIPanelType.NoUser:
                    break;
                case UIPanelType.AvatarSelect:
                    break;
                default:
                    throw new ArgumentOutOfRangeException();
            }
           
            GUILayout.BeginHorizontal();
            if (_thumnbnailTexture)
            {
                GUILayout.Box(_thumnbnailTexture, GUIStyle.none, GUILayout.Width(64), GUILayout.Height(64));
            }
            GUILayout.Label(_userNameText);
            GUILayout.EndHorizontal();

            GUILayout.EndVertical();
        }

        private void ChangePanel(UIPanelType type)
        {
            _uiPanelType = type;
        }

        private void SetLog(string message)
        {
            _statusText = message;

            Debug.Log(message);
        }

        private void Awake()
        {
            InitializeAuth();
            SetLog("");
        }

        private void Start()
        {
            ChangePanel(UIPanelType.Login);

            if (_useAutoLogin)
            {
                TryAutoLogin();
            }
        }

        private void InitializeAuth()
        {
            var sdkSettings = Resources.Load<SdkSettings>("SdkSettings");
            var client_id = sdkSettings.client_id;
            var config = new DVRAuthConfiguration(client_id, new UnitySettingStore(), new UniWebRequest(), new NewtonsoftJsonSerializer());
            Authentication.Instance.Init(config);
        }

        public async void TryAutoLogin()
        {
            var canAutoLogin = await Authentication.Instance.TryAutoLogin(onAuthSuccess: OnAuthSuccess);

            Debug.Log("LoginTest: " + canAutoLogin);
        }

        public void DoLogin()
        {
            SetLog("Wait a moment, please");

            Authentication.Instance.Authorize(
                openBrowser: (OpenBrowserResponse response) =>
                {
#if !UNITY_ANDROID
                    Application.OpenURL(response.VerificationUri);
#endif
                    SetLog("");
                    _codeText = response.UserCode;
                    ChangePanel(UIPanelType.Code);
                },
                onAuthSuccess: OnAuthSuccess,
                onAuthError: exception =>
                {
                    SetLog(exception.Message);
                    ChangePanel(UIPanelType.Login);
                });
        }

        private async void OnAuthSuccess(bool isSuccess)
        {
            if (isSuccess)
            {
                try
                {
                    await GetUserInformation();

                    ChangePanel(UIPanelType.Logout);

                    CurrentUserChanged?.Invoke(_currentUser.id);
                    Loggedin?.Invoke(true);
                }
                catch (ApiRequestException ex)
                {
                    SetLog(_apiRequestErrorMessages[ex.ErrorType]);
                    OnUserNotSignup();
                }
            }
            else
            {
                OnLoginFailed();
            }
        }

        public void DoLogout()
        {
            CurrentUserChanged?.Invoke(null);
            Loggedin?.Invoke(false);

            Authentication.Instance.DoLogout();
            SetLog("Logout");
            ChangePanel(UIPanelType.Login);
        }

        private void OnLoginFailed()
        {
            SetLog("Login Failed");
            ChangePanel(UIPanelType.Login);
        }

        private void OnUserNotSignup()
        {
            SetLog("Please user signup");
            ChangePanel(UIPanelType.NoUser);
        }

        private async Task GetUserInformation()
        {
            _currentUser = await Authentication.Instance.Okami.GetCurrentUserAsync();
            if (_currentUser != null)
            {
                var imageBinary = await Authentication.Instance.Okami.GetUserThumbnailAsync(_currentUser);
                if (imageBinary != null)
                {
                    var texture = new Texture2D(1, 1);
                    texture.LoadImage(imageBinary);
                    _thumnbnailTexture = texture;
                }

                _userNameText = _currentUser.name;
            }
        }

        public bool IsOnline => !string.IsNullOrEmpty(Authentication.Instance.GetAccessToken());

        public event AuthEventHandler Loggedin;
        public event UserModelEventHandler CurrentUserChanged;

        public async void LoadCurrentAvatarModelByUserId(string userId, AvatarModelEventHandler avatarModelLoadCompleted)
        {
            try
            {
                AvatarModel avatarModel;
                if (_currentUser.id == userId)
                {
                    avatarModel = _currentUser.current_avatar;
                }
                else
                {
                    var userModel = await Authentication.Instance.Okami.GetUserAsync(userId);
                    avatarModel = userModel.current_avatar;
                }

                VRMLoader vrmLoader = new VRMLoader();
                GameObject avatarModelObject =
                    await Authentication.Instance.Okami.LoadAvatarVRMAsync(avatarModel,
                        vrmLoader.LoadVRMModelFromConnect) as GameObject;
                
                avatarModelLoadCompleted?.Invoke(true, userId, vrmLoader, avatarModelObject);
            }
            catch (ApiRequestException ex)
            {
                SetLog(_apiRequestErrorMessages[ex.ErrorType]);
                avatarModelLoadCompleted?.Invoke(false, userId, null, null);
            }
        }

        public void UnloadModel(VRMLoader vrmLoader, GameObject avatarModelObject)
        {
            Destroy(avatarModelObject);
            vrmLoader.Dispose();
        }
    }
}
  • 続けてPhotonのクライアントを作ります。
    • GameObejct "PhotonClient" を作成して、以下のPhotonClientをAddComponentします。
    • PhotonClientは、Photonサーバを利用して複数クライアントアプリ間のオブジェクト状態を同期してくれます。
    • アプリ起動したらすぐにPhotonロビーサーバへ接続、ランダムでルームへ入室します。これで起動しているアプリ間が自動で同期できる状態になります(便利)。
    • 今回のサンプルでは、自分のアバターをキーボード操作で移動できるようなコントローラの役割も担っています。

PhotonClient.cs

using DVRSDK.Examples;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;
using UnityEngine.Assertions;

public sealed class PhotonClient : MonoBehaviourPunCallbacks
{
    private SampleUI _sampleUI;

    private GameObject _selfPhotonObject;

    private float _speed = 1f;

    private void Start()
    {
        _sampleUI = FindObjectOfType<SampleUI>();
        Assert.IsNotNull(_sampleUI);

        PhotonNetwork.ConnectUsingSettings();
    }

    private void Update()
    {
        if (_selfPhotonObject == null)
        {
            return;
        }
        
        Vector3 move = Vector3.zero;
        if (Input.GetKey(KeyCode.W)) move += Vector3.forward * _speed;
        if (Input.GetKey(KeyCode.S)) move -= Vector3.forward * _speed;
        if (Input.GetKey(KeyCode.D)) move += Vector3.right * _speed;
        if (Input.GetKey(KeyCode.A)) move -= Vector3.right * _speed;
        _selfPhotonObject.transform.position += _selfPhotonObject.transform.rotation * move * Time.deltaTime;
    }

    public override void OnConnectedToMaster()
    {
        PhotonNetwork.JoinOrCreateRoom("Room", new RoomOptions(), TypedLobby.Default);
    }

    public override void OnJoinedRoom()
    {
        _selfPhotonObject = PhotonNetwork.Instantiate("PhotonAvatar", Vector3.zero, Quaternion.identity);
    }
}
  • 最後にPhotonサービスの同期対象となるPhotonAvatarオブジェクトを作ります。
    • ここだけちょっと手順が必要です。
    • ProjectウィンドウからResourcesフォルダを作成します。
    • GameObject ”PhotonAvatar” を作成して、以下のPhotonAvatarをAddComponentします。
    • 作成したPhotonAvatarオブジェクトをhierarchyウィンドウからProjectウィンドウのResourcesフォルダへドラッグ&ドロップして、PhotonAvatar.prefabを作成します。
    • これはPhotonSDKが同期対象オブジェクトをprefab名で参照するためで、これがないと上手く動きません。

PhotonAvatar.cs

using DVRSDK.Avatar;
using DVRSDK.Examples;
using Photon.Pun;
using RootMotion.FinalIK;
using UnityEngine;
using UnityEngine.Assertions;

[RequireComponent(typeof(PhotonView))]
public sealed class PhotonAvatar : MonoBehaviour
{
    [SerializeField]
    private PhotonView _photonView;

    private string _userId;
    
    private Transform _targetRootTransform;
    private Transform _targetHeadTransform;
    private Transform _targetLeftHandTransform;
    private Transform _targetRightHandTransform;

    private Transform _photonRootTransform;
    private Transform _photonHeadTransform;
    private Transform _photonLeftHandTransform;
    private Transform _photonRightHandTransform;

    private bool _enableAvatar;
    private VRMLoader _vrmLoader;
    private GameObject _avatarModelObject;
    
    private SampleUI _sampleUI;

    private void Awake()
    {
        _sampleUI = FindObjectOfType<SampleUI>();
        Assert.IsNotNull(_sampleUI);

        if (_photonView.IsMine)
        {
            _sampleUI.CurrentUserChanged += OnCurrentUserChanged;
        }
        else
        {
            _sampleUI.Loggedin += OnLoggedin;
        }
        
        _photonRootTransform = AddPhotonTransformView("root");
        _photonHeadTransform = AddPhotonTransformView("head");
        _photonLeftHandTransform = AddPhotonTransformView("leftHand");
        _photonRightHandTransform = AddPhotonTransformView("rightHand");
        
        _targetRootTransform = NewGameObjectWithParent("root", transform).transform;
        _targetHeadTransform = NewGameObjectWithParent("headTarget", _targetRootTransform).transform;
        _targetLeftHandTransform = NewGameObjectWithParent("leftHandTarget", _targetRootTransform).transform;
        _targetRightHandTransform = NewGameObjectWithParent("rightHandTarget", _targetRootTransform).transform;
        
        _targetHeadTransform.localPosition = new Vector3(0, 1.5f, 0);
        _targetLeftHandTransform.localPosition = new Vector3(-0.3f,0.7f, 0.05f);
        _targetRightHandTransform.localPosition = new Vector3(+0.3f,0.7f, 0.05f);
        _targetLeftHandTransform.localRotation = Quaternion.Euler(-15f, -10f, 90f);
        _targetRightHandTransform.localRotation = Quaternion.Euler(-15f, -10f, -90f);
    }

    private void OnDestroy()
    {
        Detach();
        
        if (_photonView.IsMine)
        {
            _sampleUI.CurrentUserChanged -= OnCurrentUserChanged;
        }
        else
        {
            _sampleUI.Loggedin -= OnLoggedin;
        }
    }

    private void Update()
    {
        if (!_enableAvatar)
        {
            return;
        }

        if (_photonView.IsMine)
        {
            CopyWorldPositionAndRotation(_photonRootTransform, _targetRootTransform);
            CopyWorldPositionAndRotation(_photonHeadTransform, _targetHeadTransform);
            CopyWorldPositionAndRotation(_photonLeftHandTransform, _targetLeftHandTransform);
            CopyWorldPositionAndRotation(_photonRightHandTransform, _targetRightHandTransform);
        }
        else
        {
            CopyWorldPositionAndRotation(_targetRootTransform, _photonRootTransform);
            CopyWorldPositionAndRotation(_targetHeadTransform, _photonHeadTransform);
            CopyWorldPositionAndRotation(_targetLeftHandTransform, _photonLeftHandTransform);
            CopyWorldPositionAndRotation(_targetRightHandTransform, _photonRightHandTransform);
        }
    }

    public void OnValidate()
    {
        if (_photonView == null)
        {
            _photonView = gameObject.GetComponent<PhotonView>();
        }
    }

    private void OnCurrentUserChanged(string userId)
    {
        SendUserChanged(userId);
    }

    private void SendUserChanged(string userId)
    {
        _photonView.RPC("UserChanged", RpcTarget.AllBuffered, userId);
    }
    
    [PunRPC]
    private void UserChanged(string userId)
    {
        _userId = userId;
        ReloadUserAvatarModel();
    }
    
    private void OnLoggedin(bool online)
    {
        ReloadUserAvatarModel();
    }

    private void ReloadUserAvatarModel()
    {
        if (_enableAvatar)
        {
            Detach();
            return;
        }

        if (!_sampleUI.IsOnline)
        {
            return;
        }

        _sampleUI.LoadCurrentAvatarModelByUserId(_userId, OnAvatarLoaded);
    }

    private Transform AddPhotonTransformView(string name)
    {
        var rootTransform = new GameObject(name);
        rootTransform.transform.SetParent(transform);
        _photonView.ObservedComponents.Add(rootTransform.AddComponent<PhotonTransformView>());
        return rootTransform.transform;
    }

    private void CopyWorldPositionAndRotation(Transform dst, Transform src)
    {
        dst.SetPositionAndRotation(src.position, src.rotation);
    }

    private GameObject NewGameObjectWithParent(string name, Transform parent)
    {
        var newGameObject = new GameObject(name);
        newGameObject.transform.SetParent(parent);
        return newGameObject;
    }

    private void OnAvatarLoaded(bool isSuccess, string userId, VRMLoader vrmLoader, GameObject avatarModelObject)
    {
        Detach();

        if (!isSuccess)
        {
            return;
        }

        Attach(vrmLoader, avatarModelObject);
    }

    private void Attach(VRMLoader vrmLoader, GameObject avatarModelObject)
    {
        vrmLoader.ShowMeshes();
        vrmLoader.AddAutoBlinkComponent();

        avatarModelObject.transform.SetParent(transform, false);
        avatarModelObject.transform.localPosition = Vector3.zero;
        avatarModelObject.transform.localRotation = Quaternion.identity;

        var vrik = avatarModelObject.AddComponent<VRIK>();
        vrik.solver.locomotion.footDistance = 0.06f;
        vrik.solver.locomotion.stepThreshold = 0.2f;
        vrik.solver.locomotion.angleThreshold = 45f;
        vrik.solver.locomotion.maxVelocity = 0.04f;
        vrik.solver.locomotion.velocityFactor = 0.04f;
        vrik.solver.locomotion.rootSpeed = 40;
        vrik.solver.locomotion.stepSpeed = 2;
        vrik.solver.locomotion.weight = 1.0f;

        vrik.solver.spine.headTarget = _targetHeadTransform;
        vrik.solver.leftArm.target = _targetLeftHandTransform;
        vrik.solver.rightArm.target = _targetRightHandTransform;

        _enableAvatar = true;
        _vrmLoader = vrmLoader;
        _avatarModelObject = avatarModelObject;
    }

    public void Detach()
    {
        _enableAvatar = false;
        if (_vrmLoader != null)
        {
            _sampleUI.UnloadModel(_vrmLoader, _avatarModelObject);
            _vrmLoader = null;
            _avatarModelObject = null;
        }
    }
}
  • ここでUnityで実行してみると DMM VR Connect へのログイン、アバターの読込ができているはずです。

f:id:uisawara:20210512163834p:plain f:id:uisawara:20210512163919p:plain

  • ビルドして単独のクライアントアプリとして実行してみましょう。
  • できあがった実行ファイルを複数起動すると、起動したアプリ間でアバター位置が同期されるのが分かると思います!

f:id:uisawara:20210512163932p:plain

サンプルを発展させる

今回の記事では入りきりませんでしたが、PhotonにはボイスチャットができるPhoton Voiceがあり、Connect Chatではこれを使っています。自分のア プリにボイスチャット導入したい場合はこれを使うのも良いと思います。

DVRSDKに入っている UnityXRExample, SteamVRExample, OculusVRExample, DVRAvatarCalibrator などと組み合わせれば、VRHMDでアバター操作 しつつマルチプレイもできちゃいます。また、今回のサンプルではVRIK設定は簡易なものを設定してますが、DVRAvatarCalibrator を使った場合プレ イヤーの体格とアバター体格のキャリブレーションが付いてくるうえに、トラッキングできる関節数も増えちゃったりします。サンプルを改造すればフ ルトラッキングでマルチプレイなんていうのも実現できちゃったりするかも..!?

気を付けなきゃいけないことと、 "ぶっこ抜き" に対する考え方

さて、これまでは実装方法の紹介でしたが、 ここから安心して一般公開できるようになるまでには、まだ手間がかかるのがマルチプレイ・アバター利用するアプリの特徴です。

  • ユーザー向けに利用規約で違反しちゃダメよ?を義務付け

    • 最近は、開発者が作ったアプリを使ったユーザーたちが違反・違法行為などをした時、開発者・製作者側が責任を問われる世間の流れがあります。
    • ゆえに開発者は、自身を守るためにも利用規約でNG事項を明示しておくのが良いです(Connectを使ってくれてれば、利用者はConnectの利用規約に同意してくれてるはずなので、最低限の制約はされてはいますが)。
  • ぶっこぬき対策

    アバターは各クライアントアプリにダウンロードして使われます。 ここでアバターデータを盗み出す、俗に言う ”ぶっこ抜き”が行われる危険性があります。 DVRSDKではぶっこ抜かれにくいように対策がされています(堅牢性維持のため詳細への言及は避けます)。 しかし・デバッグツール、アプリ改造などを通してのぶっこ抜き行為は、クライアントサイドでデータがある以上、完全に防ぐことは残念ながらできません。 アプリ開発者にとってぶっこ抜きユーザーとそこへの対策は古くから延々繰り返されているイタチごっこごっこなのですが、 基本的に利用規約で違反行為として禁止しておく、容易に取り出せないように複雑にしておく、目視で違反者をチェックして利用を止めることをセットで行う必要があります。

  • ユーザー向けに自身のアバター利用時のリスクについて啓蒙

    基本的に、マルチプレイでユーザーアップロードのアバターを使う場合、アバターデータは再生する側の他の人のクライアント上にダウンロードされ て使用されます。 そのため、悪意のあるユーザーがそのクライアントアプリからアバターデータをぶっこ抜こうとした場合、厳密にはそれを防ぐ手段はありません。 このことから、本当に大切なアバターを使うのはプライベートなコミュニティのみに絞り、パブリックな空間へアクセスする際は最悪ぶっこ抜かれて も大丈夫なアバターにするなど、自衛策を取るケースも見られます。

    Connectでは、より健全に使いやすいサービスにするべくアップデートしていきたいです。

まとめ

DMM VR ConnectとDVRSDK、それと今回はPhoton SDKを使えば、VRMアバターを使ったマルチプレイアプリが割と気軽に作ることができ、自分のアプリ内でコミュニケーションできます!

ぜひ皆さんもデベロッパー登録してアプリを作ってみてください! devs.connect.vrlab.dmm.com

もし実装で困った時は、DMM VR lab公式DiscordサーバーのDMM VR lab Communityで気軽にお問い合わせください! discord.gg