LoginSignup
7

More than 1 year has passed since last update.

Unity+MagicOnionでメタバース空間を作ってみる(第三回)

Posted at

はじめに

本記事は、複数回に分けてUnityとMagicOnionを用いてメタバース空間を構築する内容(備忘録)となっています。
前回は、サーバー側のアプリをコンテナ化し、AWS上で起動できるようにしました。

第三回目は、前回までデスクトップアプリだったものをOculus Quest2用のVRアプリに修正します。
具体的には、クライアント側のVR用の設定が中心となります。
なお、筆者はUnityやサーバーサイドの経験は浅く現在も学習中であるため、間違っている部分がある場合は教えて下さると幸いです。


動作環境や使用したアセットなど

  • Windows 10
  • Meta Quest2
  • Unity 2021.3.5f1
  • Visual Studio 2019 16.11
  • MagicOnion 4.5.1
  • MessagePack 2.3.85
  • gRPC 2.47.0
  • Oculus Integration 42.0
  • UnityOpus 1.1.0

また、前回まではUnity-Chan!を使用していましたが、IK等の設定が必要なHumanoid型のモデルではなく、頭・胴体・両手の三つのみのシンプルなモデル(Mozilla Hubsのアバター等)を使用します。
今回は、こちらのモデルを使用させていただきます。

1. Unityのプロジェクト設定

1.1. Build Settings

Build Settingsを開き、下の画像のように設定します。
スクリーンショット 2022-08-27 035909.png

1.2. Project Settings

続いてProject Settingsを開き、以下の通りに設定します。

  • Company Nameを任意の名前に変更
  • Minimum API LevelをAndroid 8.0 'Oreo'に変更
  • Target ArchietecturesのARMv7にチェック
  • XR Plugin Managementをインストールし、AndroidタブからOculusをチェック
  • XR Plugin ManagementのPCタブからOculusをチェック
  • Oculus Integrationをインポート
    なお、Oculus Integrationをインポートする際にいくつかポップアップが表示されますが、以下のように選択すると良いと思います。
  • Update Oculus Utilities Pluginは「Yes」を選択
  • OpenXR Backendは「Use OpenXR」を選択

2. Sceneの修正

2.1. OVRPlayerController・InputOVR

プロジェクト自体の設定は完了しましたが、現状のSceneにはVR用のオブジェクトが一つもないため、まずはこれを設置していきます。
Assets/Oculus/VR/Prefabsの中から、OVRPlayerControllerのPrefabをScene上の原点に設置します。
次に、OVRPlayerController内のOVRCameraRigオブジェクトのOVRManagerコンポーネントを開きます。
沢山項目がありますが、その中でも主に設定するのは以下の項目です。

  • Tracking Origin Typeを「Floor Level」に変更
  • Quest Features内のGeneralタブを画像のように変更

スクリーンショット 2022-08-27 041427.png
また、このまま実行すると永遠に落下し続けることになるため、Plane等の床をScene上に設置しておきましょう。
以上のように設定ができたら、Quest2をOculus Linkで繋ぎ実行してみます。無事にVRで実行できれば、必要最低限のVR開発環境が構築されています。

しかしこのままではVRでよく見かけるRay操作やオブジェクトとのインタラクションができないため、OVRPlayerController内にInputOVRを入れます。InputOVRは、Assets/Oculus/Interaction/Runtime/Prefabs内にあります。
・・・が、少々面倒なのでAssets/Oculus/Interaction/Samples/Scenes/Examples/RayExamplesを開き、Scene内にあるInputOVRの設定を参考にするか、もしくはそのままコピペでも良いかと思います(再度OVRCameraRig等の関連付けは必要)。
なお、今回作成しているアプリはOculus TouchによるRay操作のみとなるため、Hands等のオブジェクトは不要です。

余談ですが、OculusのInteraction系で困った時にはサンプルで用意されているSceneファイル等を覗いたりすると幸せになれるかもしれません。

2. クライアント側のプログラム

ここで一旦クライアント側のプログラムの方を修正していきたいと思います。「修正」とは言いましたが、実際はMagicOnionでサーバー側と通信する基盤は前回までにできているので、加筆がほとんどだと思います。
プログラム全体はかなり冗長なので所々かいつまんで説明していきたいと思います。
まず最初に、大雑把ですが修正内容を列挙しておきます。

  • ClientPlayerクラスの修正
  • アバターのVR対応
  • 位置同期の修正(頭・両手の位置同期を追加)
  • ボイスチャット機能の追加
  • テキストチャット機能の修正(VR用に対応)

2.1. ClientPlayerクラスの修正

前回までにClientPlayerクラスは作成していますが、以下のように修正し、ついでに前回はChatApp.cs内に記述していたので別ファイルとしてクラスを定義しました。

ClientPlayer.cs
using AppServer.MessagePackObjects;
using AppServer.VoiceChat;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace AppServer.Hubs
{
    public class ClientPlayer
    {
        /// <summary>
        /// アバターのオブジェクト
        /// </summary>
        public GameObject avatarObject;

        /// <summary>
        /// アバターの頭
        /// </summary>
        public GameObject headSolver;

        /// <summary>
        /// アバターの左手
        /// </summary>
        public GameObject leftHandSolver;

        /// <summary>
        /// アバターの右手
        /// </summary>
        public GameObject rightHandSolver;

        /// <summary>
        /// Player
        /// </summary>
        public Player player;

        ///<summary>
        /// キャッシュ用のVoiceDecoder
        /// </summary>
        public VoiceDecoder voiceDecoder;

        ///<summary>
        /// キャッシュ用のChatBoard
        /// </summary>
        public ChatBoard chatBoard;
    }
}

このクラスで、通信に使われるPlayerクラスとアバターのオブジェクトやコンポーネントをキャッシュする変数をまとめて管理ができるようになります。
GetComponentでコンポーネントを取得することはできますが、GetComponent自体が重い処理であり、VRではなるべく処理を軽くしたいところであるため、なるべくキャッシュを使うようにします。

これでアバター生成時にキャッシュを保持できれば良いのですが、前回までと同様にアバターはPrefab化したものを生成するようにしているため、直接Inspector上からアバターのオブジェクトについているコンポーネントを指定し保持することができません。
そのため、生成後にアタッチされているコンポーネントをGetComponentするようにします・・・が、前述の通りなるべくGetComponentはしたくないため、アバターに前もってキャッシュしたいものを設定できるコンポーネントをアタッチしておきます。

AvatarSettings.cs
using AppServer.VoiceChat;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;

public class AvatarSettings : MonoBehaviour
{
    public GameObject headSolver;
    public GameObject leftHandSolver;
    public GameObject rightHandSolver;
    public VoiceDecoder voiceDecoder;
    public TextMeshProUGUI billBoardName;
    public ChatBoard chatBoard;

}

・・・ただ、書いていて思いましたが、AvatarSettingsオブジェクトで保持されている値をClientPlayerオブジェクトに送り込むよりも、AvatarSettingsオブジェクトをそのまま保持した方が良い気がします。

2.2. アバターのVR対応

デスクトップアプリとは異なり、当然ですがアバターの頭や両手をHMDやOculus Touchの位置・回転に合わせる必要があります。
かなり冗長な気がしますが、以下のように記述しました。

ChatApp.cs
        /// <summary>
        /// 自分のアバターの設定
        /// </summary>
        private void MyAvatarSetting()
        {
            clientPlayer = new ClientPlayer();
            Player _player = new Player();
            _player.userName = userName;
            clientPlayer.player = _player;

            //自分のアバターのオブジェクトをスポーン
            OVRPlayer.transform.position = spawnPoint.transform.position;
            OVRPlayer.transform.rotation = spawnPoint.transform.rotation;
            GameObject _avatarObject = Instantiate(avatar, spawnPoint.transform.position, spawnPoint.transform.rotation);
            _avatarObject.transform.parent = OVRPlayer.transform;

            clientPlayer.avatarObject = _avatarObject;
            clientPlayer.avatarObject.name = clientPlayer.player.userName;

            var _avatarSettings = clientPlayer.avatarObject.GetComponent<AvatarSettings>();
            clientPlayer.headSolver = _avatarSettings.headSolver.gameObject;
            clientPlayer.leftHandSolver = _avatarSettings.leftHandSolver.gameObject;
            clientPlayer.rightHandSolver = _avatarSettings.rightHandSolver.gameObject;

            StartCoroutine("MySolverSetting");

            clientPlayer.voiceDecoder = _avatarSettings.voiceDecoder;
            clientPlayer.chatBoard = _avatarSettings.chatBoard;
        }

        ///<summary>
        /// Solverの設定
        /// </summary>
        private IEnumerator MySolverSetting()
        {
            //頭の位置の調整
            clientPlayer.headSolver.transform.parent = headSolver;
            Vector3 _headSolverPos = headSolver.transform.localPosition;
            _headSolverPos.x = 0f;
            _headSolverPos.y -= 0.75f;
            _headSolverPos.z -= 0.25f;
            headSolver.transform.localPosition = _headSolverPos;

            //左手の位置の調整
            clientPlayer.leftHandSolver.transform.parent = leftHandSolver;
            Vector3 _leftHandSolverPos = clientPlayer.leftHandSolver.transform.localPosition;
            _leftHandSolverPos.x = 0f;
            _leftHandSolverPos.y = 0f;
            _leftHandSolverPos.z = 0f;
            clientPlayer.leftHandSolver.transform.localPosition = _leftHandSolverPos;
            Vector3 _leftHandSolverRot = leftHandSolver.transform.localRotation.eulerAngles;
            _leftHandSolverRot.x = 0f;
            _leftHandSolverRot.y = 180f;
            _leftHandSolverRot.z = -90f;
            leftHandSolver.transform.localRotation = Quaternion.Euler(_leftHandSolverRot);

            //右手の位置の調整
            clientPlayer.rightHandSolver.transform.parent = rightHandSolver;
            Vector3 _rightHandSolverPos = clientPlayer.rightHandSolver.transform.localPosition;
            _rightHandSolverPos.x = 0f;
            _rightHandSolverPos.y = 0f;
            _rightHandSolverPos.z = 0f;
            clientPlayer.rightHandSolver.transform.localPosition = _rightHandSolverPos;
            Vector3 _rightHandSolverRot = rightHandSolver.transform.localRotation.eulerAngles;
            _rightHandSolverRot.x = 0f;
            _rightHandSolverRot.y = 0f;
            _rightHandSolverRot.z = -90f;
            rightHandSolver.transform.localRotation = Quaternion.Euler(_rightHandSolverRot);

            yield return null;
        }

2.3. 位置同期の修正

位置同期についても、既に前回まででアバター本体の位置同期の基盤はできているため、頭・両手の3つに対して同じように処理を書いてあげれば大丈夫です。ただ、もう少し工夫したいところではあります・・・。

ChatApp.cs
        /// <summary>
        /// アバター本体の補間と予測
        /// </summary>
        /// <param name="_clientPlayer">同期前の位置・回転が保持されている</param>
        /// <param name="_player">同期後の位置・回転が保持されている</param>
        /// <returns></returns>
        private IEnumerator AvatarMoveInterpolation(ClientPlayer _clientPlayer, Player _player)
        {

            //補間の開始座標
            Vector3 p1 = _clientPlayer.avatarObject.transform.position;
            
            //補間の終了座標
            Vector3 p2 = _player.avatarPosition;

            //補間の開始角度
            Quaternion r1 = _clientPlayer.avatarObject.transform.rotation;

            //補間の終了角度
            Quaternion r2 = _player.avatarRotation;

            float elapsedTime = 0f;
            while(elapsedTime < syncTimer)
            {
                elapsedTime += Time.deltaTime;
                _clientPlayer.avatarObject.transform.position = Vector3.Lerp(p1, p2, elapsedTime / interpolationPeriod);
                _clientPlayer.avatarObject.transform.rotation = Quaternion.Lerp(r1, r2, elapsedTime / interpolationPeriod);
                yield return null;
            }
            _clientPlayer.avatarObject.transform.position = Vector3.LerpUnclamped(p1, p2, elapsedTime / interpolationPeriod);

        }

        /// <summary>
        /// 頭の補間と予測
        /// </summary>
        /// <param name="_clientPlayer">同期前の位置・回転が保持されている</param>
        /// <param name="_player">同期後の位置・回転が保持されている</param>
        /// <returns></returns>
        private IEnumerator HeadMoveInterpolation(ClientPlayer _clientPlayer, Player _player)
        {

            //補間の開始座標
            Vector3 p1 = _clientPlayer.headSolver.transform.position;

            //補間の終了座標
            Vector3 p2 = _player.headPosition;

            //補間の開始角度
            Quaternion r1 = _clientPlayer.headSolver.transform.rotation;

            //補間の終了角度
            Quaternion r2 = _player.headRotation;

            float elapsedTime = 0f;
            while (elapsedTime < syncTimer)
            {
                elapsedTime += Time.deltaTime;
                _clientPlayer.headSolver.transform.position = Vector3.Lerp(p1, p2, elapsedTime / interpolationPeriod);
                _clientPlayer.headSolver.transform.rotation = Quaternion.Lerp(r1, r2, elapsedTime / interpolationPeriod);
                yield return null;
            }
            _clientPlayer.headSolver.transform.position = Vector3.LerpUnclamped(p1, p2, elapsedTime / interpolationPeriod);

        }

        /// <summary>
        /// 左手の補間と予測
        /// </summary>
        /// <param name="_clientPlayer">同期前の位置・回転が保持されている</param>
        /// <param name="_player">同期後の位置・回転が保持されている</param>
        /// <returns></returns>
        private IEnumerator LeftHandMoveInterpolation(ClientPlayer _clientPlayer, Player _player)
        {

            //補間の開始座標
            Vector3 p1 = _clientPlayer.leftHandSolver.transform.position;

            //補間の終了座標
            Vector3 p2 = _player.leftHandPosition;

            //補間の開始角度
            Quaternion r1 = _clientPlayer.leftHandSolver.transform.rotation;

            //補間の終了角度
            Quaternion r2 = _player.leftHandRotation;

            float elapsedTime = 0f;
            while (elapsedTime < syncTimer)
            {
                elapsedTime += Time.deltaTime;
                _clientPlayer.leftHandSolver.transform.position = Vector3.Lerp(p1, p2, elapsedTime / interpolationPeriod);
                _clientPlayer.leftHandSolver.transform.rotation = Quaternion.Lerp(r1, r2, elapsedTime / interpolationPeriod);
                yield return null;
            }
            _clientPlayer.leftHandSolver.transform.position = Vector3.LerpUnclamped(p1, p2, elapsedTime / interpolationPeriod);

        }

        /// <summary>
        /// 右手の補間と予測
        /// </summary>
        /// <param name="_clientPlayer">同期前の位置・回転が保持されている</param>
        /// <param name="_player">同期後の位置・回転が保持されている</param>
        /// <returns></returns>
        private IEnumerator RightHandMoveInterpolation(ClientPlayer _clientPlayer, Player _player)
        {

            //補間の開始座標
            Vector3 p1 = _clientPlayer.rightHandSolver.transform.position;

            //補間の終了座標
            Vector3 p2 = _player.rightHandPosition;

            //補間の開始角度
            Quaternion r1 = _clientPlayer.rightHandSolver.transform.rotation;

            //補間の終了角度
            Quaternion r2 = _player.rightHandRotation;

            float elapsedTime = 0f;
            while (elapsedTime < syncTimer)
            {
                elapsedTime += Time.deltaTime;
                _clientPlayer.rightHandSolver.transform.position = Vector3.Lerp(p1, p2, elapsedTime / interpolationPeriod);
                _clientPlayer.rightHandSolver.transform.rotation = Quaternion.Lerp(r1, r2, elapsedTime / interpolationPeriod);
                yield return null;
            }
            _clientPlayer.rightHandSolver.transform.position = Vector3.LerpUnclamped(p1, p2, elapsedTime / interpolationPeriod);

        }

また、今回から3点のトラッキング情報を追加で送るため、受け渡す部分の記述を修正します。

ChatApp.cs
            Observable.Interval(TimeSpan.FromSeconds(syncTimer)).TakeUntilDestroy(clientPlayer.avatarObject).Subscribe(async _ =>
            {
                if (isSpawn)
                {
                    await streamingClient.MoveAsync(
                        clientPlayer.avatarObject.transform.position,
                        clientPlayer.avatarObject.transform.rotation,
                        clientPlayer.headSolver.transform.position,
                        clientPlayer.headSolver.transform.rotation,
                        clientPlayer.leftHandSolver.transform.position,
                        clientPlayer.leftHandSolver.transform.rotation,
                        clientPlayer.rightHandSolver.transform.position,
                        clientPlayer.rightHandSolver.transform.rotation);
                }
            });
ChatApp.cs
        ///<summary>
        ///移動同期
        /// </summary>
        public void OnMove(Player player)
        {
            ClientPlayer _clientPlayer = GetPlayerByUUID(player.UUID);

            Observable.FromCoroutine(() => AvatarMoveInterpolation(_clientPlayer, player)).TakeUntilDestroy(_clientPlayer.avatarObject).Subscribe();
            Observable.FromCoroutine(() => HeadMoveInterpolation(_clientPlayer, player)).TakeUntilDestroy(_clientPlayer.avatarObject).Subscribe();
            Observable.FromCoroutine(() => LeftHandMoveInterpolation(_clientPlayer, player)).TakeUntilDestroy(_clientPlayer.avatarObject).Subscribe();
            Observable.FromCoroutine(() => RightHandMoveInterpolation(_clientPlayer, player)).TakeUntilDestroy(_clientPlayer.avatarObject).Subscribe();

        }

また、Playerクラスも併せて修正します。
正直なところ、アバター本体の位置同期は不要なような・・・?

Player.cs
using MessagePack;
using UnityEngine;

namespace AppServer.MessagePackObjects
{
    [MessagePackObject]
    public class Player
    {
        [Key(0)]
        public string userName { get; set; }

        [Key(1)]
        public Vector3 avatarPosition { get; set; }

        [Key(2)]
        public Quaternion avatarRotation { get; set; }

        [Key(3)]
        public string UUID { get; set; }

        [Key(4)]
        public Vector3 headPosition { get; set; }

        [Key(5)]
        public Quaternion headRotation { get; set; }

        [Key(6)]
        public Vector3 leftHandPosition { get; set; }

        [Key(7)]
        public Quaternion leftHandRotation { get; set; }

        [Key(8)]
        public Vector3 rightHandPosition { get; set; }

        [Key(9)]
        public Quaternion rightHandRotation { get; set; }

    }
}

最後に、クライアント側とサーバー側で共通しているインターフェイスの修正です。
流石に引数が多すぎるので、何とかしたいですね・・・。

IChatAppHub.cs
        ///<summary>
        ///移動通知
        /// </summary>
        Task MoveAsync(Vector3 avatarPosition, Quaternion avatarRotation, Vector3 headPosition, Quaternion headRotation, Vector3 leftHandPosition, Quaternion leftHandRotation, Vector3 rightHandPosition, Quaternion rightHandRotation);

2.4. ボイスチャット機能の追加

前回まではテキストチャットのみ実装していましたが、VRでのアバター間のコミュニケーションって、やっぱりボイスチャットが大半を占めていると思います。
Unityでのボイスチャットの実装は、VivoxやPhoton Voice、MUNなど多くの手段が考えられますが、今回は通信フレームワークとしてMagicOnionを使用しているので、自前で音声をエンコードし、MagicOnionでエンコード化された音声を受け取りデコードして再生、というようにしたいところです。
そこで、今回はUnityOpusを採用することとしました。
用意されているサンプルを参考にしながら、

  • VoiceRecorder(自分の声を録音)
  • VoiceEncoder(録音した声をエンコード)
  • VoiceDecoder(受け取った音声をデコードして再生)

の3つのクラスを定義しました。

VoiceRecorder.cs
using UnityEngine;
using System;
using UnityOpus;
using AppServer.Hubs;

namespace AppServer.VoiceChat
{
    public class VoiceRecorder : MonoBehaviour {
        public event Action<float[]> OnAudioReady;

        const int samplingFrequency = 24000;
        const int lengthSeconds = 1;

        AudioClip clip;
        int head = 0;
        float[] processBuffer = new float[512];
        float[] microphoneBuffer = new float[lengthSeconds * samplingFrequency];

        [SerializeField]
        private ChatApp chatApp;

        [SerializeField]
        private MicController micController;

        public float GetRMS() {
            float sum = 0.0f;
            foreach (var sample in processBuffer) {
                sum += sample * sample;
            }
            return Mathf.Sqrt(sum / processBuffer.Length);
        }

        void Start() {
            clip = Microphone.Start(null, true, lengthSeconds, samplingFrequency);
        }

        void Update() {
            if (chatApp.isSpawn && !micController.isMute)
            {
                var position = Microphone.GetPosition(null);
                if (position < 0 || head == position)
                {
                    return;
                }

                clip.GetData(microphoneBuffer, 0);
                while (GetDataLength(microphoneBuffer.Length, head, position) > processBuffer.Length)
                {
                    var remain = microphoneBuffer.Length - head;
                    if (remain < processBuffer.Length)
                    {
                        Array.Copy(microphoneBuffer, head, processBuffer, 0, remain);
                        Array.Copy(microphoneBuffer, 0, processBuffer, remain, processBuffer.Length - remain);
                    }
                    else
                    {
                        Array.Copy(microphoneBuffer, head, processBuffer, 0, processBuffer.Length);
                    }

                    OnAudioReady?.Invoke(processBuffer);

                    head += processBuffer.Length;
                    if (head > microphoneBuffer.Length)
                    {
                        head -= microphoneBuffer.Length;
                    }
                }
            }

        }

        static int GetDataLength(int bufferLength, int head, int tail) {
            if (head < tail) {
                return tail - head;
            } else {
                return bufferLength - head + tail;
            }
        }
    }
}

VoiceEncoder.cs
using System.Collections.Generic;
using UnityEngine;
using System;
using UnityOpus;
using AppServer.Hubs;

namespace AppServer.VoiceChat
{
    public class VoiceEncoder : MonoBehaviour {
        public event Action<byte[], int> OnEncoded;

        const int bitrate = 96000;
        const int frameSize = 120;
        const int outputBufferSize = frameSize * 4; // at least frameSize * sizeof(float)

        [SerializeField]
        private VoiceRecorder recorder;

        Encoder encoder;
        Queue<float> pcmQueue = new Queue<float>();
        readonly float[] frameBuffer = new float[frameSize];
        readonly byte[] outputBuffer = new byte[outputBufferSize];

        [SerializeField]
        private ChatApp chatApp;

        void OnEnable() {
            recorder.OnAudioReady += OnAudioReady;
            encoder = new Encoder(
                SamplingFrequency.Frequency_24000,
                NumChannels.Mono,
                OpusApplication.Audio) {
                Bitrate = bitrate,
                Complexity = 10,
                Signal = OpusSignal.Music
            };
        }

        void OnDisable() {
            recorder.OnAudioReady -= OnAudioReady;
            encoder.Dispose();
            encoder = null;
            pcmQueue.Clear();
        }

        void OnAudioReady(float[] data) {
            foreach (var sample in data) {
                pcmQueue.Enqueue(sample);
            }
            while (pcmQueue.Count > frameSize) {
                for (int i = 0; i < frameSize; i++) {
                    frameBuffer[i] = pcmQueue.Dequeue();
                }
                var encodedLength = encoder.Encode(frameBuffer, outputBuffer);
                //OnEncoded?.Invoke(outputBuffer, encodedLength);
                chatApp.SendVoiceMessage(outputBuffer, encodedLength);
            }
        }
    }
}

VoiceDecoder.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using UnityOpus;

namespace AppServer.VoiceChat
{
    public class VoiceDecoder : MonoBehaviour {
        public event Action<float[], int> OnDecoded;

        const NumChannels channels = NumChannels.Mono;

        Decoder decoder;
        readonly float[] pcmBuffer = new float[Decoder.maximumPacketDuration * (int)channels];

        [SerializeField]
        private AudioSource audioSource;

        int head = 0;
        float[] audioClipData;
        int audioClipLength = (int)SamplingFrequency.Frequency_24000;

        void OnEnable() {
            decoder = new Decoder(
                SamplingFrequency.Frequency_24000,
                NumChannels.Mono);

            audioSource.clip = AudioClip.Create("AudioStreamPlayer", audioClipLength, (int)channels, (int)SamplingFrequency.Frequency_24000, false);
            audioSource.loop = false;
        }

        void OnDisable() {
            audioSource.Stop();
            decoder.Dispose();
            decoder = null;
        }

        public void ReceiveBytes(byte[] encodedData, int length)
        {
            if (decoder != null)
            {
                var pcmLength = decoder.Decode(encodedData, length, pcmBuffer);

                if (audioClipData == null || audioClipData.Length != pcmLength)
                {
                    // assume that pcmLength will not change.
                    audioClipData = new float[pcmLength];
                }
                Array.Copy(pcmBuffer, audioClipData, pcmLength);
                audioSource.clip.SetData(audioClipData, head);
                head += pcmLength;
                if (!audioSource.isPlaying && head > audioClipLength / 2)
                {
                    audioSource.Play();
                }
                head %= audioClipLength;
            }
        }
    }
}

さらに、音声データをMagicOnionによって送受信できるようにMessagePackObjectとして定義しておきます。

VoiceMessage.cs
using MessagePack;

[MessagePackObject]
public class VoiceMessage
{
    [Key(0)]
    public string SenderUUID { get; set; }

    [Key(1)]
    public byte[] EncodedData { get; set; }

    [Key(2)]
    public int DataLength { get; set; }
}

そして、エンコーダ等をアプリ本体に繋げるために、ChatApp.cs内の記述を追加します。

ChatApp.cs
        ///<summary>
        /// ボイスを送る
        ///</summary>
        public async void SendVoiceMessage(byte[] data, int length)
        {
            if (isSpawn)
            {
                VoiceMessage voiceMessage = new VoiceMessage();
                voiceMessage.SenderUUID = clientPlayer.player.UUID;
                voiceMessage.EncodedData = data;
                voiceMessage.DataLength = length;
                await streamingClient.SendVoiceMessageAsync(voiceMessage);
            }
        }

        /// <summary>
        /// ボイスを受け取る
        /// </summary>
        /// <param name="voiceMessage"></param>
        public void OnSendVoiceMessage(VoiceMessage voiceMessage)
        {
            ClientPlayer _clientPlayer = GetPlayerByUUID(voiceMessage.SenderUUID);
            if (voiceMessage.EncodedData != null && voiceMessage.DataLength != 0 && _clientPlayer != null)
            {
                _clientPlayer.voiceDecoder.ReceiveBytes(voiceMessage.EncodedData, voiceMessage.DataLength);
            }
        }

また、ミュート切り替え機能も併せて設定します(Sceneの方の設定は省略します)。
ミュート切り替え機能は、VRChatのそれと同じような方式を採用しました。
なお、Oculus Touchでの入力検知はこちらを参考にしました。

MicController.cs
using AppServer.VoiceChat;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class MicController : MonoBehaviour
{

    [SerializeField]
    private VoiceEncoder voiceEncoder;

    public bool isMute;

    [SerializeField]
    private Sprite micON;

    [SerializeField]
    private Sprite micOFF;

    [SerializeField]
    private Texture micONIcon;

    [SerializeField]
    private Texture micOFFIcon;

    [SerializeField]
    private Material mat;

    [SerializeField]
    private AudioSource audioSource;

    [SerializeField]
    private AudioClip micSwitchClip;

    // Start is called before the first frame update
    void Start()
    {
        isMute = true;
        mat.SetTexture("_MainTex", micOFFIcon);
        voiceEncoder.enabled = false;
    }

    // Update is called once per frame
    void Update()
    {
        if(OVRInput.GetDown(OVRInput.Button.One, OVRInput.Controller.LTouch) || OVRInput.GetDown(OVRInput.Button.One, OVRInput.Controller.RTouch))
        {
            isMute = !isMute;
            voiceEncoder.enabled = !voiceEncoder.enabled;
            audioSource.PlayOneShot(micSwitchClip);
            if (isMute)
            {
                mat.SetTexture("_MainTex", micOFFIcon);
            }
            else
            {
                mat.SetTexture("_MainTex", micONIcon);
            }
        }
    }
}

最後に、クライアント側とサーバー側で共通しているインターフェイスにも加筆しておきます。

IChatAppHub.cs
        ///<summary>
        ///ボイスチャット用
        /// </summary>
        Task SendVoiceMessageAsync(VoiceMessage voiceMessage);
IChatAppHubReceiver.cs
        ///<summary>
        ///ボイスチャット用
        /// </summary>
        void OnSendVoiceMessage(VoiceMessage voiceMessage);

あとは、サーバー側のプログラムを修正することでボイスチャットが実現できます。

2.5. テキストチャット機能の修正

前回までにテキストチャット機能は実装できていますが、デスクトップ向けに画面上に表示させていました。VRではUI等は基本的にワールド固定となるため、そのように修正していきます。
Canvas自体の設定は省略させていただきますが、Canvasの設定を変えたところでVRでは常にプレイヤーは動き回るため、プレイヤーがどんな位置にいてもすぐにCanvasを目の前に出せるようにしたいです(或いは常に手に追従させても良いかと思います)。そのため、意図した挙動になるようにプログラムを加筆していきます。

ChatApp.cs
        void Update()
        {
            if (isSpawn)
            {
                if (OVRInput.GetDown(OVRInput.Button.Two, OVRInput.Controller.LTouch) || OVRInput.GetDown(OVRInput.Button.Two, OVRInput.Controller.RTouch))
                {
                    StartCoroutine("ReSettingChatCanvas");
                }
            }
        }

        /// <summary>
        /// チャット欄のCanvasの再設置
        /// </summary>
        private IEnumerator ReSettingChatCanvas()
        {
            Vector3 _pos = clientPlayer.headSolver.transform.position;
            Vector3 _newPos = _pos + clientPlayer.headSolver.transform.up;

            Vector3 _rot = clientPlayer.headSolver.transform.rotation.eulerAngles;
            Vector3 _newRot = new Vector3(0, _rot.y, 0);

            chatPage.transform.SetPositionAndRotation(_newPos, Quaternion.Euler(_newRot));
            chatPage.SetActive(!isChatPageOn);
            isChatPageOn = !isChatPageOn;

            yield return null;
        }

また、アバターから吹き出しが出るようにします。

ChatBoard.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

public class ChatBoard : MonoBehaviour
{
    [SerializeField]
    private TextMeshProUGUI chatBoardText;

    [SerializeField]
    private GameObject chatCanvas;

    // Start is called before the first frame update
    void Start()
    {
        chatCanvas.SetActive(false);
    }

    public IEnumerator ShowChatBoard(string text)
    {
        if(text.Length > 30)
        {
            text = text.Substring(0, 26);
            text += "...";
        }

        chatBoardText.text = text.Replace("\r", "").Replace("\n", "");
        chatCanvas.SetActive(true);
        Invoke("HideChatBoard", 5);

        yield return null;
    }

    public void HideChatBoard()
    {
        chatBoardText.text = "";
        chatCanvas.SetActive(false);
    }
}

サーバー側からテキストチャットを受け取ったタイミングで吹き出しを出してほしいので、ChatApp.cs内のOnSendMessage関数にも加筆します。

ChatApp.cs
        ///<summary>
        ///メッセージ通知
        /// </summary>
        public void OnSendMessage(Player player, string message)
        {
            ClientPlayer _clientPlayer = GetPlayerByUUID(player.UUID);
            _clientPlayer.chatBoard.StartCoroutine("ShowChatBoard", message);
            chatComment.text = $"{chatComment.text}{player.userName}:{message}\n";
        }

あとは、吹き出しのオブジェクト等をアバターに設定しておき、コンポーネントをアタッチ・設定することで吹き出しが出るようになります。

3. サーバー側のプログラムの修正

クライアント側は大方修正できたので、次はサーバー側のプログラムを修正していきます。とはいっても、クライアント側で追加された機能に合わせて修正するだけなので、そこまで多い修正ではありません。

ChatAppHub.cs
using MagicOnion.Server.Hubs;
using System.Threading.Tasks;
using AppServer.MessagePackObjects;
using UnityEngine;
using System;
using System.Linq;

namespace AppServer.Hubs
{
    public class ChatAppHub : StreamingHubBase<IChatAppHub, IChatAppHubReceiver>, IChatAppHub
    {
        ///<summary>
        ///ルーム
        /// </summary>
        private IGroup room;

        ///<summary>
        ///ユーザ名
        /// </summary>
        private string userName;

        ///<summary>
        ///通知先
        /// </summary>
        private Player _self;

        ///<summary>
        ///ストレージ
        /// </summary>
        IInMemoryStorage<Player> _strage;

        ///<summary>
        ///入室通知
        /// </summary>
        public async Task<JoinerInfo> JoinAsync(string roomName, string userName, Vector3 position, Quaternion rotation)
        {
            _self = new Player { userName = userName, avatarPosition = position, avatarRotation = rotation, UUID = Guid.NewGuid().ToString("N") };

            (room,_strage) = await Group.AddAsync(roomName,_self);

            this.userName = userName;
            BroadcastExceptSelf(room).OnJoin(_self);

            JoinerInfo joinerInfo = new JoinerInfo { players = _strage.AllValues.ToArray(), UUID = _self.UUID };

            return joinerInfo;
        }

        ///<summary>
        ///退室通知
        /// </summary>
        public async Task LeaveAsync()
        {
            await room.RemoveAsync(this.Context);
            Broadcast(room).OnLeave(_self);
        }

        ///<summary>
        ///メッセージ通知
        /// </summary>
        public async Task SendMessageAsync(string userName, string message)
        {
            Broadcast(room).OnSendMessage(_self, message);
            await Task.CompletedTask;
        }

        ///<summary>
        ///移動通知
        /// </summary>
        public async Task MoveAsync(Vector3 avatarPosition, Quaternion avatarRotation, Vector3 headPosition, Quaternion headRotation, Vector3 leftHandPosition, Quaternion leftHandRotation, Vector3 rightHandPosition, Quaternion rightHandRotation)
        {
            _self.avatarPosition = avatarPosition;
            _self.avatarRotation = avatarRotation;

            _self.headPosition = headPosition;
            _self.headRotation = headRotation;

            _self.leftHandPosition = leftHandPosition;
            _self.leftHandRotation = leftHandRotation;

            _self.rightHandPosition = rightHandPosition;
            _self.rightHandRotation = rightHandRotation;

            BroadcastExceptSelf(room).OnMove(_self);

            await Task.CompletedTask;
        }

        ///<summary>
        ///切断通知
        /// </summary>
        protected override ValueTask OnDisconnected()
        {
            BroadcastExceptSelf(room).OnLeave(_self);
            return CompletedTask;
        }

        ///<summary>
        ///ボイスチャット用
        /// </summary>
        public async Task SendVoiceMessageAsync(VoiceMessage voiceMessage)
        {
            BroadcastExceptSelf(room).OnSendVoiceMessage(voiceMessage);
            await Task.CompletedTask;
        }
    }
}

修正後は、前回と同様にAWSへデプロイします。

4. 動作確認

所々端折っているため、完全に同じような動作になるかは分かりませんが、実行するとこちらのようになるかと思います。
※デバッグプレイ時の様子なので、実際とは若干異なります。
c6gyu-51ppe.gif

5. まとめ

今回は、前回までデスクトップアプリだったものをOculus Quest2用のVRアプリに修正してみました。
VRChatとかclusterみたいな、メジャーなメタバース空間に近づいたかなとは思いつつも、まだまだ程遠いように感じます。
普段からVRChatに入り浸っている分、開発陣の凄さが身に染みて感じました。
また、今回は一人での開発だったため、VRでのデバッグプレイがとても大変でした・・・(ご協力いただいた方々本当にありがとうございました)。

そして今回の記事は、見返すとかなり長くなってしまったので、何回かに分けた方が良さそうだと感じました・・・。
かなり端折っている部分や不足事項も多いため、時間のある時に補足したいと思います。

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
7