simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

Unity:オンラインVRMチャットサンプルの作り方3

リアルタイム通信でオンラインチャット

f:id:simplestar_tech:20190526232641p:plain
最後まで読むとできるようになる絵

こちらの記事の続きです。
simplestar-tech.hatenablog.com

Unity 同士でリアルタイム通信するためのアセットはいくつかある様子ですが、全部試してられません。
C# を書く感覚でリアルタイム通信コード書けないかな?と思ってたところ、次の技術ブログ記事がタイムリーに刺さりました。
tech.cygames.co.jp

次の GitHub リポジトリで公開されていますね。

github.com

MagicOnion は Unity に導入する方法が難しいためサンプルが用意されています。
推奨される方法かはわかりませんが git clone して
MagicOnion\samples\ChatApp\ChatApp.Unity\Assets\Scripts
にあるフォルダのうち
MagicOnionとMessagePackをプロジェクトにコピーします。
また必要となる以下のパッケージ類も移動します。
MagicOnion\samples\ChatApp\ChatApp.Unity\Assets\Plugins\dll
(楽しすぎかな?)

コレを使えるようにするため、以下の通り .NET 4.x となるようにプロジェクト設定を更新しておきます。

f:id:simplestar_tech:20190526165519p:plain
Project Settings > Configuration > Api Compatibility Level

ServerShared を設計

サンプルを参考に以下の Shared クラスを定義してみました。

Requests.cs

using MessagePack;

namespace CubeWalk.Shared.MessagePackObjects
{
    [MessagePackObject]
    public struct JoinRequest
    {
        [Key(0)] public string RoomName { get; set; }
        [Key(1)] public string PlayerName { get; set; }
    }

    [MessagePackObject]
    public struct AnimFloatParamRequest
    {
        [Key(0)] public int NameHash { get; set; }
        [Key(1)] public float Value { get; set; }
    }

    [MessagePackObject]
    public struct AnimIntegerParamRequest
    {
        [Key(0)] public int NameHash { get; set; }
        [Key(1)] public int Value { get; set; }
    }

    [MessagePackObject]
    public struct AnimBoolParamRequest
    {
        [Key(0)] public int NameHash { get; set; }
        [Key(1)] public bool Value { get; set; }
    }
}

Responses.cs

using CubeWalk.Shared.Hubs;
using MessagePack;

namespace CubeWalk.Shared.MessagePackObjects
{
    [MessagePackObject]
    public struct MessageResponse
    {
        [Key(0)] public string PlayerName { get; set; }
        [Key(1)] public int UserUniqueId { get; set; }
        [Key(2)] public string Message { get; set; }
    }

    [MessagePackObject]
    public struct EventResponse
    {
        [Key(0)] public int UserUniqueId { get; set; }
        [Key(1)] public EventId EventId { get; set; }
    }

    [MessagePackObject]
    public struct Vector3Response
    {
        [Key(0)] public int UserUniqueId { get; set; }
        [Key(1)] public float X { get; set; }
        [Key(2)] public float Y { get; set; }
        [Key(3)] public float Z { get; set; }
    }

    [MessagePackObject]
    public struct QuaternionResponse
    {
        [Key(0)] public int UserUniqueId { get; set; }
        [Key(1)] public float X { get; set; }
        [Key(2)] public float Y { get; set; }
        [Key(3)] public float Z { get; set; }
        [Key(4)] public float W { get; set; }
    }

    [MessagePackObject]
    public struct ContentUrlResponse
    {
        [Key(0)] public int UserUniqueId { get; set; }
        [Key(1)] public string URL { get; set; }
        [Key(2)] public string ContentType { get; set; }
        [Key(3)] public string PlayerName { get;  set; }
    }
}

ICubeWalkHub.cs

using CubeWalk.Shared.MessagePackObjects;
using MagicOnion;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace CubeWalk.Shared.Hubs
{
    /// <summary>
    /// Client -> Server API (Streaming)
    /// </summary>
    public interface ICubeWalkHub : IStreamingHub<ICubeWalkHub, ICubeWalkHubReceiver>
    {
        Task<int> JoinAsync(JoinRequest request);
        Task LeaveAsync();
        Task SendMessageAsync(string message);
        Task SendEventAsync(EventId eventId);
        Task SendContentUrlAsync(string url, string type);
        Task SendPositionAsync(float x, float y, float z);
        Task SendRotationAsync(float x, float y, float z, float w);
        Task SendAnimFloatAsync(List<AnimFloatParamRequest> animParams);
        Task SendAnimIntegerAsync(List<AnimIntegerParamRequest> animParams);
        Task SendAnimBoolAsync(List<AnimBoolParamRequest> animParams);
        Task SendAnimTriggerAsync(List<AnimBoolParamRequest> animParams);
        Task SendAnimStateAsync(int shortNameHash);
    }

    public enum EventId
    {
        Default = 0,
        VRMLoaded,

        Max
    }
}

ICubeWalkHubReceiver.cs

using CubeWalk.Shared.MessagePackObjects;
using System.Collections.Generic;

namespace CubeWalk.Shared.Hubs
{
    /// <summary>
    /// Server -> Client API
    /// </summary>
    public interface ICubeWalkHubReceiver
    {
        void OnJoin(int userUniqueId, string playerName);
        void OnLeave(int userUniqueId, string playerName);
        void OnSendMessage(MessageResponse response);
        void OnSendEvent(EventResponse response);
        void OnSendContentUrl(ContentUrlResponse response);
        void OnSendPosition(Vector3Response response);
        void OnSendRotation(QuaternionResponse response);
        void OnSendAnimFloat(int userUniqueId, List<AnimFloatParamRequest> animParams);
        void OnSendAnimInteger(int userUniqueId, List<AnimIntegerParamRequest> animParams);
        void OnSendAnimBool(int userUniqueId, List<AnimBoolParamRequest> animParams);
        void OnSendAnimTrigger(int userUniqueId, List<AnimBoolParamRequest> animParams);
        void OnSendAnimState(int userUniqueId, int shortNameHash);
    }
}

サーバープロジェクトの作成

Shared インタフェースが決まったら次はサーバープロジェクトの作成と実装です。

ここまでの Unity プロジェクトフォルダを CubeWalkC1 としましょう。
それと同列の階層に CubeWalkC1Server フォルダを作成し、その中に CubeWalkC1Server.csproj テキストファイルを作成します。
ServerShared フォルダパスはみなさんのお好みでどうぞ

内容は以下の通り
CubeWalkC1Server.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <LangVersion>7.3</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="..\CubeWalkC1\Assets\Project\Scripts\MagicOnion\ServerShared\**\*.cs" LinkBase="LinkFromUnity" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="MagicOnion.Hosting" Version="2.1.0" />
  </ItemGroup>

  <ItemGroup>
    <Folder Include="LinkFromUnity\" />
  </ItemGroup>

</Project>

.csproj と同じ階層のフォルダに次のエントリポイントファイルを作成します。
サンプルそのままですが localhost → 0.0.0.0 と書き換えています。

Program.cs

using Grpc.Core;
using MagicOnion.Hosting;
using MagicOnion.Server;
using Microsoft.Extensions.Hosting;
using System.Threading.Tasks;

namespace ChatApp.Server
{
    class Program
    {
        static async Task Main(string[] args)
        {
            GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger());

            await MagicOnionHost.CreateDefaultBuilder()
                .UseMagicOnion(
                    new MagicOnionOptions(isReturnExceptionStackTraceInErrorDetail: true),
                    new ServerPort("0.0.0.0", 12345, ServerCredentials.Insecure))
                .RunConsoleAsync();
        }
    }
}

サーバー側のインタフェース実装は次の通り
こちらも .csproj と同じ階層のフォルダに配置します。

CubeWalkHub.cs

using CubeWalk.Shared.Hubs;
using CubeWalk.Shared.MessagePackObjects;
using MagicOnion.Server.Hubs;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace CubeWalk.Server
{
    /// <summary>
    /// 接続ごとにクラスインスタンスが作成されます
    /// </summary>
    public class CubeWalkHub : StreamingHubBase<ICubeWalkHub, ICubeWalkHubReceiver>, ICubeWalkHub
    {
        public async Task<int> JoinAsync(JoinRequest request)
        {
            this.room = await this.Group.AddAsync(request.RoomName);
            // ユニークなユーザーIDを作成
            this.userUniqueId = Convert.ToInt32(Guid.NewGuid().ToString("N").Substring(0, 8), 16);
            this.playerName = request.PlayerName;
            this.BroadcastExceptSelf(this.room).OnJoin(this.userUniqueId, this.playerName);
            await Task.CompletedTask;
            return this.userUniqueId;
        }

        public async Task LeaveAsync()
        {
            if (null != this.room)
            {
                await this.room.RemoveAsync(this.Context);
                this.BroadcastExceptSelf(this.room).OnLeave(this.userUniqueId, this.playerName);
                await Task.CompletedTask;
            }
        }

        public async Task SendMessageAsync(string message)
        {
            if (null != this.room)
            {
                this.Broadcast(this.room).OnSendMessage(new MessageResponse
                {
                    PlayerName = this.playerName,
                    UserUniqueId = this.userUniqueId,
                    Message = message
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendEventAsync(EventId eventId)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendEvent(new EventResponse
                {
                    UserUniqueId = this.userUniqueId,
                    EventId = eventId
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendContentUrlAsync(string url, string type)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendContentUrl(new ContentUrlResponse
                {
                    UserUniqueId = this.userUniqueId,
                    URL = url,
                    ContentType = type
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendPositionAsync(float x, float y, float z)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendPosition(new Vector3Response
                {
                    UserUniqueId = this.userUniqueId,
                    X = x, Y = y, Z = z
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendRotationAsync(float x, float y, float z, float w)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendRotation(new QuaternionResponse
                {
                    UserUniqueId = this.userUniqueId,
                    X = x, Y = y, Z = z, W = w
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimFloatAsync(List<AnimFloatParamRequest> animParams)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimFloat(this.userUniqueId, animParams);
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimIntegerAsync(List<AnimIntegerParamRequest> animParams)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimInteger(this.userUniqueId, animParams);
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimBoolAsync(List<AnimBoolParamRequest> animParams)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimBool(this.userUniqueId, animParams);
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimTriggerAsync(List<AnimBoolParamRequest> animParams)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimTrigger(this.userUniqueId, animParams);
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimStateAsync(int shortNameHash)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimState(this.userUniqueId, shortNameHash);
                await Task.CompletedTask;
            }
        }

        IGroup room = null;
        string playerName;
        int userUniqueId;
    }
}

.csproj を Visual Studio で開いて実行すれば確かに Server として機能してくれる筈です。(動作確認済み)

f:id:simplestar_tech:20190526173523p:plain
サーバープログラム実行時の様子

Unity クライアント用のコード生成

Magic Onion の次のサンプルフォルダにコード生成ツールが配置されています。(もう何でもサンプルに頼る形ですね…)
MagicOnion\samples\ChatApp\GeneratorTools

コマンドラインツールの一般的なコード生成コマンドは以下の通り(動作確認済み)

GeneratorTools/MagicOnionCodeGenerator/win-x64/moc.exe -i "CubeWalkC1Server\CubeWalkC1Server.csproj" -o "CubeWalkC1\Assets\Project\Scripts\MagicOnion\Generated\MagicOnion.Generated.cs"
GeneratorTools/MessagePackUniversalCodeGenerator/win-x64/mpc.exe -i "CubeWalkC1Server\CubeWalkC1Server.csproj" -o "CubeWalkC1\Assets\Project\Scripts\MagicOnion\Generated\MessagePack.Generated.cs"

これで Unity クライアント側にサーバーと通信するための下準備が整いました。

Unity クライアントコードを書く

先程作った Hub インタフェースを以下の通り実装します。

MagicOnionClientGUI.cs

using CubeWalk.Shared.Hubs;
using CubeWalk.Shared.MessagePackObjects;
using Grpc.Core;
using Invector.vCharacterController;
using Invector.vCharacterController.vActions;
using MagicOnion.Client;
using MessagePack.Resolvers;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;

public class MagicOnionClientGUI : MonoBehaviour, ICubeWalkHubReceiver
{
    #region UI Connection
    [SerializeField] GameObject panelJoinRoom;
    [SerializeField] Button buttonJointRoom;
    [SerializeField] InputField inputFieldPlayerName;
    [SerializeField] TMP_Text textStatus;
    #endregion

    #region Scene Components
    [SerializeField] TextChatGUI textChatGUI;
    [SerializeField] VRMLoader vrmLoader;
    [SerializeField] Transform triggerActions;
    #endregion

    internal UnityAction<int /*userUniqueId*/, VRMPlayerCache /*player*/> onJoin;
    internal UnityAction<int /*userUniqueId*/, VRMPlayerCache /*player*/> onLeave;
    internal UnityAction<int /*userUniqueId*/, string /*playerNaame*/, string /*chatText*/> onReceiveMessage;

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void RegisterResolvers()
    {
        CompositeResolver.RegisterAndSetAsDefault
        (
            MagicOnion.Resolvers.MagicOnionResolver.Instance,
            GeneratedResolver.Instance,
            BuiltinResolver.Instance,
            PrimitiveObjectResolver.Instance
        );
    }

    void Start()
    {
        this.textChatGUI.onEndEditMessage += this.SendChatText;
        this.buttonJointRoom.onClick.AddListener(this.OnButtonJoinRoom);
        this.InitializeMagicOnion();
    }

    bool WantsToQuit()
    {
        // アプリ終了前処理
        CleanUpMagicOnion();
        // アプリを終了しないフラグを返しておく
        return false;
    }

    void InitializeMagicOnion()
    {
        // サーバー情報を指定して接続
        this.channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
        this.streamingClient = StreamingHubClient.Connect<ICubeWalkHub, ICubeWalkHubReceiver>(this.channel, this);
        Application.wantsToQuit += WantsToQuit;
    }

    async void CleanUpMagicOnion()
    {
        // Join しているルームから Leave
        this.Leave();
        // MagicOnion のお片付け
        await this.streamingClient.DisposeAsync();
        await this.channel.ShutdownAsync();
        // アプリ終了
        Application.wantsToQuit -= WantsToQuit;
        Application.Quit();
    }

    public void OnTriggerEventJoin(GameObject vrmRoot)
    {
        // プレイヤー名を覚えていれば設定
        var playerName = PlayerPrefs.GetString(MagicOnionClientGUI.PlayerName);
        if (null != playerName)
        {
            this.inputFieldPlayerName.text = playerName;
        }

        // パネルを表示
        this.panelJoinRoom.SetActive(true);

        // テキスト入力欄にキャレットを配置
        this.inputFieldPlayerName.ActivateInputField();

        // プレイヤー入力のロック
        this.LockPlayerInput(vrmRoot, lockInput: true);
        // プレイヤーを記憶
        this.myVrmRoot = vrmRoot;
    }

    void LockPlayerInput(GameObject vrmRoot, bool lockInput)
    {
        var vInput = vrmRoot.GetComponentInChildren<vThirdPersonInput>();
        if (null != vInput)
        {
            // プレイヤー入力のロック・ロック解除
            vInput.lockInput = lockInput;
            foreach (var vTriggerActions in this.triggerActions.GetComponentsInChildren<vTriggerGenericAction>())
            {
                vTriggerActions.actionInput.useInput = !vInput.lockInput;
            }
            // カーソル再表示とカーソルロック解除
            vInput.ShowCursor(vInput.lockInput);
            vInput.LockCursor(vInput.lockInput);
        }
    }

    #region Client -> Server (Streaming)

    async void OnButtonJoinRoom()
    {
        if (this.isJoin)
            return;

        if (0 == this.inputFieldPlayerName.text.Length)
        {
            this.textStatus.text = "Input Player Name.";
            return;
        }

        try
        {
            // Room への Join リクエストを作成して Join
            var request = new JoinRequest { PlayerName = this.inputFieldPlayerName.text, RoomName = "RoomA" };
            this.myUserUniqueId = await this.streamingClient.JoinAsync(request);
            this.isJoin = true;
            PlayerPrefs.SetString(MagicOnionClientGUI.PlayerName, request.PlayerName);
            this.panelJoinRoom.SetActive(false);
            this.LockPlayerInput(this.myVrmRoot, lockInput: false);
        }
        catch (Exception ex)
        {
            textStatus.text = ex.Message;
        }

        if (this.isJoin)
        {
            // プレイヤー情報を作成
            var player = new VRMPlayerCache
            {
                playerName = this.inputFieldPlayerName.text,
                vrmRoot = this.myVrmRoot,
                animator = this.myVrmRoot.GetComponent<Animator>(),
                interpolation = null
            };
            // 辞書に追加
            if (!this.userUniqueIdToVRMPlayer.ContainsKey(this.myUserUniqueId))
            {
                this.userUniqueIdToVRMPlayer.Add(this.myUserUniqueId, player);
                this.onJoin?.Invoke(this.myUserUniqueId, player);
            }

            // Join を希望した VRM にアニメーション同期させるためのClientを渡す
            var animSync = this.myVrmRoot.GetComponent<AnimationSync>();
            animSync.streamingClient = this.streamingClient;

            // 本当は S3 にファイルをアップロードしてダウンロード用 URL を送信たい…
            //AWS_S3.Instance.FileUpload(playerVRMPath, objectKey =>
            //{
            //    this.myVRMObjectKey = objectKey;
            //    this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, VRMContentKey);
            //});
            await this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, MagicOnionClientGUI.VRMContentKey);
        }
    }

    async void Leave()
    {
        if (this.isJoin)
        {
            await this.streamingClient.LeaveAsync();
            this.isJoin = false;
        }
    }

    void SendChatText(string chatText)
    {
        if (!this.isJoin)
            return;
        this.streamingClient.SendMessageAsync(chatText);
    }

    #endregion


    #region Server -> Client (Streaming)

    public void OnJoin(int userUniqueId, string playerName)
    {
        // 後から参加してきたユーザーにモデルデータのキーを送信
        StartCoroutine(CoSendMyVRMObjectKey());
    }

    IEnumerator CoSendMyVRMObjectKey()
    {
        // 自身の VRM のキーが未定の場合は待機してから、有効なキーを参加してきたプレイヤーへ送信
        while (null == this.myVRMObjectKey)
        {
            yield return new WaitForSeconds(1.0f);
        }
        this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, MagicOnionClientGUI.VRMContentKey);
    }

    public void OnLeave(int userUniqueId, string playerName)
    {
        // Leave するユーザーの VRM インスタンスを削除して、辞書からも削除
        if (this.userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = this.userUniqueIdToVRMPlayer[userUniqueId];
            this.userUniqueIdToVRMPlayer.Remove(userUniqueId);
            this.onLeave?.Invoke(userUniqueId, player);
            Destroy(player.vrmRoot);
        }
    }

    public void OnSendMessage(MessageResponse response)
    {
        this.onReceiveMessage?.Invoke(response.UserUniqueId, response.PlayerName, response.Message);
    }

    public void OnSendEvent(EventResponse response)
    {
        switch (response.EventId)
        {
            // 外からVRLロード完了を受け取ったら、現在のプレイヤー VRM の位置を渡して初期配置してもらう
            case EventId.VRMLoaded:
                {
                    var position = this.myVrmRoot.transform.position;
                    this.streamingClient.SendPositionAsync(position.x, position.y, position.z);
                    var rotation = this.myVrmRoot.transform.rotation;
                    this.streamingClient.SendRotationAsync(rotation.x, rotation.y, rotation.z, rotation.w);
                }
                break;
            default:
                break;
        }
    }

    public void OnSendContentUrl(ContentUrlResponse response)
    {
        if (0 == string.Compare(MagicOnionClientGUI.VRMContentKey, response.ContentType))
        {
            if (!this.userUniqueIdToVRMPlayer.ContainsKey(response.UserUniqueId))
            {
                // var vrmData = AWS_S3.Instance.GetObjectData(response.URL); 本当は S3 などから URL でデータを落して来たい…
                // プレイヤー VRM データを読み込んでインスタンス化し、ユーザーユニークIDで辞書に追加
                var playerVRMPath = PlayerPrefs.GetString(MagicOnionClientGUI.PlayerVRMPath);
                var vrmData = File.ReadAllBytes(playerVRMPath);
                var vrmRoot = this.vrmLoader.LoadVRMinMemory(vrmData, false);
                var player = new VRMPlayerCache
                {
                    playerName = response.PlayerName,
                    vrmRoot = vrmRoot,
                    animator = vrmRoot.GetComponent<Animator>(),
                    interpolation = vrmRoot.AddComponent<TransformInterpolation>()
                };

                // 再チェックして辞書に追加
                if (!this.userUniqueIdToVRMPlayer.ContainsKey(response.UserUniqueId))
                {
                    this.userUniqueIdToVRMPlayer.Add(response.UserUniqueId, player);
                    this.onJoin?.Invoke(response.UserUniqueId, player);
                    this.streamingClient.SendEventAsync(EventId.VRMLoaded);
                }
                else
                {
                    Destroy(vrmRoot);
                }
            }
        }
    }

    public void OnSendPosition(Vector3Response response)
    {
        // 指定されたVRMの位置を補正
        if (userUniqueIdToVRMPlayer.ContainsKey(response.UserUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[response.UserUniqueId];
            if (null != player.interpolation)
            {
                player.interpolation.position = new Vector3(response.X, response.Y, response.Z);
            }
        }
    }

    public void OnSendRotation(QuaternionResponse response)
    {
        // 指定されたVRMの回転を補正
        if (userUniqueIdToVRMPlayer.ContainsKey(response.UserUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[response.UserUniqueId];
            if (null != player.interpolation)
            {
                player.interpolation.rotation = new Quaternion(response.X, response.Y, response.Z, response.W);
            }
        }
    }

    public void OnSendAnimFloat(int userUniqueId, List<AnimFloatParamRequest> animParams)
    {
        // 指定されたVRMのアニメーションパラメータを更新
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                foreach (var param in animParams)
                {
                    player.animator.SetFloat(param.NameHash, param.Value);
                }
            }
        }
    }

    public void OnSendAnimInteger(int userUniqueId, List<AnimIntegerParamRequest> animParams)
    {
        // 指定されたVRMのアニメーションパラメータを更新
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                foreach (var param in animParams)
                {
                    player.animator.SetInteger(param.NameHash, param.Value);
                }
            }
        }
    }

    public void OnSendAnimBool(int userUniqueId, List<AnimBoolParamRequest> animParams)
    {
        // 指定されたVRMのアニメーションパラメータを更新
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                foreach (var param in animParams)
                {
                    player.animator.SetBool(param.NameHash, param.Value);
                }
            }
        }
    }

    public void OnSendAnimTrigger(int userUniqueId, List<AnimBoolParamRequest> animParams)
    {
        // 指定されたVRMのアニメーションパラメータを更新
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                foreach (var param in animParams)
                {
                    player.animator.SetTrigger(param.NameHash);
                }
            }
        }
    }

    public void OnSendAnimState(int userUniqueId, int shortNameHash)
    {
        // 指定されたVRMのアニメーションステートの遷移
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                player.animator.CrossFadeInFixedTime(shortNameHash, 0.1f);
            }
        }
    }

    #endregion

    #region MagicOnion
    Channel channel;
    ICubeWalkHub streamingClient;
    #endregion

    Dictionary<int, VRMPlayerCache> userUniqueIdToVRMPlayer = new Dictionary<int, VRMPlayerCache>();
    bool isJoin = false;
    GameObject myVrmRoot = null;
    string myVRMObjectKey = "dummy_AWS_S3_URL"; // 本来は null で初期化しておく
    int myUserUniqueId = 0;

    const string PlayerName = "PlayerName";
    const string PlayerVRMPath = "PlayerVRMPath";
    const string VRMContentKey = "application/vrm";
}

UI はこんな感じにしてます。(VRM選択画面とほとんど同じ要素タイプ)

f:id:simplestar_tech:20190526231800p:plain
JoinRoomPanel

これらの UI との接続は次の通り

f:id:simplestar_tech:20190526232132p:plain
UI との接続

前回の記事の実装とイベントで接続するように書き換えることで、以下のツィートのような結果になりました。
正しく、VRM 同士で通信してチャットできていますね。


オンライン化

ちょっとUnityユーザーを振り落としてしまうかもしれないけど、気にせず読んでほしい。

サーバーは .NET Core ランタイムで動きますので、これをコンテナとして使用できるように準備します。
オンライン化のため Amazon ECR というコンテナイメージを簡単に保存できるサービスを利用し
その保存したコンテナをサーバー管理せずに使用できる AWS Fargate を利用します。
公開 IP アドレスに自宅の PC から接続してオンラインでチャットができるまでの具体的な手順は Qiita 記事として一月ほど前に確認しておきました。
qiita.com

これでローカル通信テストをそのままオンライン化できます。

Tips

Project Settings で Visible In Background にチェック入れると、Build したゲーム非アクティブにしていても、通信内容を正しく受信できることがわかりました。

追記

AnimSync もここに置いておきます。
これがマスターピースかな

using CubeWalk.Shared.Hubs;
using CubeWalk.Shared.MessagePackObjects;
using System.Collections.Generic;
using UnityEngine;

internal class AnimationSync : MonoBehaviour
{
    Animator animator;
    int frameCount;
    float prevTime;

    List<AnimFloatParamRequest> floatParams = new List<AnimFloatParamRequest>();
    List<AnimIntegerParamRequest> intParams = new List<AnimIntegerParamRequest>();
    List<AnimBoolParamRequest> boolParams = new List<AnimBoolParamRequest>();

    internal ICubeWalkHub streamingClient;

    AnimatorSnapShot lastSnapShot = new AnimatorSnapShot {
        Position = Vector3.zero,
        Rotation = Quaternion.identity,
        InputHorizontal = new AnimFloatParam { nameHash = -1691455067, value = 0 },
        InputVertical = new AnimFloatParam { nameHash = -2095045246, value = 0 },
        InputMagnitude = new AnimFloatParam { nameHash = -811395533, value = 0 },
        TurnOnSpotDirection = new AnimFloatParam{ nameHash = -957318344, value = 0 },
        ActionState = new AnimIntParam { nameHash = -821380803 , value = 0 },
        isDead = new AnimBoolParam { nameHash = 1276664872, value = false },
        IsGrounded = new AnimBoolParam { nameHash = 507951781, value = false },
        IsCrouching = new AnimBoolParam { nameHash = -1154928320, value = false },
        IsStrafing = new AnimBoolParam { nameHash = 417244036, value = false },
        GroundDistance = new AnimFloatParam { nameHash = -1263328476, value = 0 },
        VerticalVelocity = new AnimFloatParam { nameHash = -1380605809, value = 0 },
        MoveSet_ID = new AnimFloatParam { nameHash = -1327825759, value = 0 },
        IdleRandom = new AnimIntParam { nameHash = 2117418050, value = 0 },
        RandomAttack = new AnimIntParam { nameHash = -429213197 ,value = 0 },
        BaseLayerState = new AnimationState { shortNameHash = 0 },
    };

    void Start()
    {
        this.animator = GetComponent<Animator>();
    }

    void Update()
    {
        float time = Time.realtimeSinceStartup - prevTime;
        if (time >= 0.16f)
        {
            prevTime = Time.realtimeSinceStartup;

            #region Position Rotation
            if (0.01f < Vector3.Distance(lastSnapShot.Position, animator.transform.position))
            {
                //Debug.Log($"bodyPosition = {this.animator.transform.position}");
                lastSnapShot.Position = animator.transform.position;
                streamingClient?.SendPositionAsync(lastSnapShot.Position.x, lastSnapShot.Position.y, lastSnapShot.Position.z);
            }

            if (lastSnapShot.Rotation != animator.transform.rotation)
            {
                //Debug.Log($"rootRotation = {this.animator.transform.rotation}");
                lastSnapShot.Rotation = animator.transform.rotation;
                streamingClient?.SendRotationAsync(lastSnapShot.Rotation.x, lastSnapShot.Rotation.y, lastSnapShot.Rotation.z, lastSnapShot.Rotation.w);
            }
            #endregion

            #region Animator Float Parameter
            var InputHorizontal = animator.GetFloat(lastSnapShot.InputHorizontal.nameHash);
            if (0.001f < Mathf.Abs(lastSnapShot.InputHorizontal.value - InputHorizontal))
            {
                // Debug.Log($"InputHorizontal = {InputHorizontal}");
                lastSnapShot.InputHorizontal.value = InputHorizontal;
                floatParams.Add(new AnimFloatParamRequest { NameHash = lastSnapShot.InputHorizontal.nameHash, Value = lastSnapShot.InputHorizontal.value });
            }

            var InputVertical = animator.GetFloat(lastSnapShot.InputVertical.nameHash);
            if (0.001f < Mathf.Abs(lastSnapShot.InputVertical.value - InputVertical))
            {
                // Debug.Log($"InputVertical = {InputVertical}");
                lastSnapShot.InputVertical.value = InputVertical;
                floatParams.Add(new AnimFloatParamRequest { NameHash = lastSnapShot.InputVertical.nameHash, Value = lastSnapShot.InputVertical.value });
            }

            var InputMagnitude = animator.GetFloat(lastSnapShot.InputMagnitude.nameHash);
            if (0.001f < Mathf.Abs(lastSnapShot.InputMagnitude.value - InputMagnitude))
            {
                // Debug.Log($"InputMagnitude = {InputMagnitude}");
                lastSnapShot.InputMagnitude.value = InputMagnitude;
                floatParams.Add(new AnimFloatParamRequest { NameHash = lastSnapShot.InputMagnitude.nameHash, Value = lastSnapShot.InputMagnitude.value });
            }

            var TurnOnSpotDirection = animator.GetFloat(lastSnapShot.TurnOnSpotDirection.nameHash);
            if (0.001f < Mathf.Abs(lastSnapShot.TurnOnSpotDirection.value - TurnOnSpotDirection))
            {
                // Debug.Log($"TurnOnSpotDirection = {TurnOnSpotDirection}");
                lastSnapShot.TurnOnSpotDirection.value = TurnOnSpotDirection;
                floatParams.Add(new AnimFloatParamRequest { NameHash = lastSnapShot.TurnOnSpotDirection.nameHash, Value = lastSnapShot.TurnOnSpotDirection.value });
            }

            var GroundDistance = animator.GetFloat(lastSnapShot.GroundDistance.nameHash);
            if (0.001f < Mathf.Abs(lastSnapShot.GroundDistance.value - GroundDistance))
            {
                // Debug.Log($"GroundDistance = {GroundDistance}");
                lastSnapShot.GroundDistance.value = GroundDistance;
                floatParams.Add(new AnimFloatParamRequest { NameHash = lastSnapShot.GroundDistance.nameHash, Value = lastSnapShot.GroundDistance.value });
            }

            var VerticalVelocity = animator.GetFloat(lastSnapShot.VerticalVelocity.nameHash);
            if (0.001f < Mathf.Abs(lastSnapShot.VerticalVelocity.value - VerticalVelocity))
            {
                // Debug.Log($"VerticalVelocity = {VerticalVelocity}");
                lastSnapShot.VerticalVelocity.value = VerticalVelocity;
                floatParams.Add(new AnimFloatParamRequest { NameHash = lastSnapShot.VerticalVelocity.nameHash, Value = lastSnapShot.VerticalVelocity.value });
            }

            var MoveSet_ID = animator.GetFloat(lastSnapShot.MoveSet_ID.nameHash);
            if (0.001f < Mathf.Abs(lastSnapShot.MoveSet_ID.value - MoveSet_ID))
            {
                // Debug.Log($"MoveSet_ID = {MoveSet_ID}");
                lastSnapShot.MoveSet_ID.value = MoveSet_ID;
                floatParams.Add(new AnimFloatParamRequest { NameHash = lastSnapShot.MoveSet_ID.nameHash, Value = lastSnapShot.MoveSet_ID.value });
            }

            if (0 < floatParams.Count)
            {
                streamingClient?.SendAnimFloatAsync(floatParams);
                floatParams.Clear();
            }

            #endregion            
        }

        #region Animator Int Bool Parameter
        var ActionState = animator.GetInteger(lastSnapShot.ActionState.nameHash);
        if (lastSnapShot.ActionState.value != ActionState)
        {
            // Debug.Log($"ActionState = {ActionState}");
            lastSnapShot.ActionState.value = ActionState;
            intParams.Add(new AnimIntegerParamRequest { NameHash = lastSnapShot.ActionState.nameHash, Value = lastSnapShot.ActionState.value });
        }

        var isDead = animator.GetBool(lastSnapShot.isDead.nameHash);
        if (lastSnapShot.isDead.value != isDead)
        {
            // Debug.Log($"isDead = {isDead}");
            lastSnapShot.isDead.value = isDead;
            boolParams.Add(new AnimBoolParamRequest { NameHash = lastSnapShot.isDead.nameHash, Value = lastSnapShot.isDead.value });
        }

        var IsGrounded = animator.GetBool(lastSnapShot.IsGrounded.nameHash);
        if (lastSnapShot.IsGrounded.value != IsGrounded)
        {
            // Debug.Log($"IsGrounded = {IsGrounded}");
            lastSnapShot.IsGrounded.value = IsGrounded;
            boolParams.Add(new AnimBoolParamRequest { NameHash = lastSnapShot.IsGrounded.nameHash, Value = lastSnapShot.IsGrounded.value });
        }

        var IsCrouching = animator.GetBool(lastSnapShot.IsCrouching.nameHash);
        if (lastSnapShot.IsCrouching.value != IsCrouching)
        {
            // Debug.Log($"IsCrouching = {IsCrouching}");
            lastSnapShot.IsCrouching.value = IsCrouching;
            boolParams.Add(new AnimBoolParamRequest { NameHash = lastSnapShot.IsCrouching.nameHash, Value = lastSnapShot.IsCrouching.value });
        }

        var IsStrafing = animator.GetBool(lastSnapShot.IsStrafing.nameHash);
        if (lastSnapShot.IsStrafing.value != IsStrafing)
        {
            // Debug.Log($"IsStrafing = {IsStrafing}");
            lastSnapShot.IsStrafing.value = IsStrafing;
            boolParams.Add(new AnimBoolParamRequest { NameHash = lastSnapShot.IsStrafing.nameHash, Value = lastSnapShot.IsStrafing.value });
        }

        var IdleRandom = animator.GetInteger(lastSnapShot.IdleRandom.nameHash);
        if (lastSnapShot.IdleRandom.value != IdleRandom)
        {
            // Debug.Log($"IdleRandom = {IdleRandom}");
            lastSnapShot.IdleRandom.value = IdleRandom;
            intParams.Add(new AnimIntegerParamRequest { NameHash = lastSnapShot.IdleRandom.nameHash, Value = lastSnapShot.IdleRandom.value });
        }

        var RandomAttack = animator.GetInteger(lastSnapShot.RandomAttack.nameHash);
        if (lastSnapShot.RandomAttack.value != RandomAttack)
        {
            // Debug.Log($"RandomAttack = {RandomAttack}");
            lastSnapShot.RandomAttack.value = RandomAttack;
            intParams.Add(new AnimIntegerParamRequest { NameHash = lastSnapShot.RandomAttack.nameHash, Value = lastSnapShot.RandomAttack.value });
        }

        if (0 < intParams.Count)
        {
            streamingClient?.SendAnimIntegerAsync(intParams);
            intParams.Clear();
        }

        if (0 < boolParams.Count)
        {
            streamingClient?.SendAnimBoolAsync(boolParams);
            boolParams.Clear();
        }

        #endregion

        #region AnimationState
        var state = animator.GetCurrentAnimatorStateInfo(0);
        if (lastSnapShot.BaseLayerState.shortNameHash != state.shortNameHash)
        {
            // Debug.Log($"BaseLayerState = {state.shortNameHash}");
            lastSnapShot.BaseLayerState.shortNameHash = state.shortNameHash;

            streamingClient?.SendAnimStateAsync(lastSnapShot.BaseLayerState.shortNameHash);
        }
        #endregion
    }

    struct AnimatorSnapShot
    {
        public Vector3 Position;
        public Quaternion Rotation;
        public AnimFloatParam InputHorizontal;
        public AnimFloatParam InputVertical;
        public AnimFloatParam InputMagnitude;
        public AnimFloatParam TurnOnSpotDirection;
        public AnimIntParam ActionState;
        public AnimBoolParam isDead;
        public AnimBoolParam IsGrounded;
        public AnimBoolParam IsCrouching;
        public AnimBoolParam IsStrafing;
        public AnimFloatParam GroundDistance;
        public AnimFloatParam VerticalVelocity;
        public AnimFloatParam MoveSet_ID;
        public AnimIntParam IdleRandom;
        public AnimIntParam RandomAttack;
        public AnimationState BaseLayerState;
    }

    public struct AnimFloatParam
    {
        public int nameHash;
        public float value;
    }

    public struct AnimIntParam
    {
        public int nameHash;
        public int value;
    }

    public struct AnimBoolParam
    {
        public int nameHash;
        public bool value;
    }

    struct AnimationState
    {
        public int shortNameHash;
    }
}

続きはこちら
simplestar-tech.hatenablog.com