simplestarの技術ブログ

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

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

数十MBある大きいVRM データのリアルタイム送受信

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

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

Magic Onion を Unity 間のリアルタイム通信に使いましたが、送受信するデータが 1MB より大きくなる場合は別の手段を構築するべきであることがわかります。

Protocol Buffers are not designed to handle large messages.

developers.google.com

VRMチャットの命ともいえるキャラクターデータは 8~40MB ほどありますので、前回の Magic Onion の実装を使って受け渡しはできません。(試したところ 3MB 以上でエラーを返した)

今回は VRM データを Unity 間で送受信し合うサンプルを示し、動作確認まで見ていきたいと思います。

Amazon S3 を Unity から利用する

具体的な解決策として Amazon Web Services の一つ Simple Storage Service(S3) を Unity から直接利用してデータの送受信を行います。
考えは単純で、VRM ファイルデータを暗号化して S3 に配置し、その時のダウンロードURLを相手のクライアントに送り、Unity クライアントが直接 S3 からダウンロードを行い、復号化して利用します。

さっそく S3 にファイルを配置する and S3 からファイルデータをダウンロードする実装を見てみましょう。
参考コードはこちら
docs.aws.amazon.com

その前に AWS を利用できるように dll 群を Unity プロジェクトの Assets 以下に配置します。

手順は以下の Qiita 記事の AWS SDK のインストール という項目の通りです。

qiita.com

今回は S3 も使うので、追加でこちらもダウンロードして Assets 以下に配置します。

AWSSDK.S3

ライブラリの準備が完了したら、以下の実装を行って Unity シーンに配置します。
内容はファイルパスを渡してS3の指定バケットにアップロードするとオブジェクトキーを取得する機能、S3指定バケットのオブジェクトキーを指定するとダウンロードしたbyte配列を受け取る機能、いずれも非同期です。
S3 のバケット名は世界でたった一つなので、もし後述の Cognito で認証いらずのチェックを入れていると、全世界公開ということになるので注意です。
AmazonS3Client.cs

using Amazon;
using Amazon.CognitoIdentity;
using Amazon.S3;
using Amazon.S3.Model;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;

namespace AWS
{
    public class AmazonS3Client : MonoBehaviour
    {
        #region Scene Components
        [SerializeField] AmazonCognitoSignInGUI signInGUI;
        #endregion

        void Start()
        {
            this.signInGUI.onSignInSuccess += OnSignIn;
        }

        void OnSignIn(CognitoAWSCredentials credentials)
        {
            this.identityId = credentials.GetIdentityId();
            this.s3Client = new Amazon.S3.AmazonS3Client(credentials, this.resourceRegion);
        }

        public async Task PutObjectFileAsync(string filePath, UnityAction<string> onSuccess)
        {
            await this.PutObjectFileAsync(this.identityId + "/" + Path.GetFileName(filePath), filePath, onSuccess);
        }

        public async Task GetObjectDataAsync(string keyName, UnityAction<byte[]> onSuccess)
        {
            try
            {
                var request = new GetObjectRequest
                {
                    BucketName = this.bucketName,
                    Key = keyName
                };
                using (var response = await this.s3Client.GetObjectAsync(request))
                {
                    using (var responseStream = response.ResponseStream)
                    {
                        long partSize = 5 * (long)Math.Pow(2, 12); // 5 MB
                        var tmpBuffer = new byte[partSize];
                        using (var ms = new MemoryStream())
                        {
                            while (true)
                            {
                                var read = responseStream.Read(tmpBuffer, 0, tmpBuffer.Length);

                                if (read > 0)
                                {
                                    ms.Write(tmpBuffer, 0, read);
                                }
                                else
                                {
                                    break;
                                }
                            }
                            onSuccess?.Invoke(ms.ToArray());
                        }
                    }
                }
            }
            catch (AmazonS3Exception e)
            {
                Debug.LogError($"Error encountered on S3 Get Object. Message:'{e.Message}'");
            }
            catch (Exception e)
            {
                Debug.LogError($"Unknown encountered on Read Object. Message:'{e.Message}'");
            }
        }

        async Task PutObjectFileAsync(string keyName, string filePath, UnityAction<string> onSuccess)
        {
            var uploadResponses = new List<UploadPartResponse>();

            var initiateRequest = new InitiateMultipartUploadRequest
            {
                BucketName = this.bucketName,
                Key = keyName
            };

            var initResponse = await this.s3Client.InitiateMultipartUploadAsync(initiateRequest);

            long contentLength = new FileInfo(filePath).Length;
            long partSize = 5 * (long)Math.Pow(2, 20); // 5 MB

            try
            {
                long filePosition = 0;
                for (int partNumber = 1; filePosition < contentLength; partNumber++)
                {
                    var uploadRequest = new UploadPartRequest
                    {
                        BucketName = this.bucketName,
                        Key = keyName,
                        UploadId = initResponse.UploadId,
                        PartNumber = partNumber,
                        PartSize = partSize,
                        FilePosition = filePosition,
                        FilePath = filePath
                    };
                    uploadResponses.Add(await this.s3Client.UploadPartAsync(uploadRequest));
                    filePosition += partSize;
                }

                var completeRequest = new CompleteMultipartUploadRequest
                {
                    BucketName = this.bucketName,
                    Key = keyName,
                    UploadId = initResponse.UploadId
                };
                completeRequest.AddPartETags(uploadResponses);
                var completeUploadResponse = await s3Client.CompleteMultipartUploadAsync(completeRequest);

                onSuccess?.Invoke(keyName);
            }
            catch (Exception exception)
            {
                Debug.LogError($"An Exception was thrown on S3 Upload : {exception.Message}");

                var abortMPURequest = new AbortMultipartUploadRequest
                {
                    BucketName = this.bucketName,
                    Key = keyName,
                    UploadId = initResponse.UploadId
                };
                await this.s3Client.AbortMultipartUploadAsync(abortMPURequest);
            }
        }

        RegionEndpoint resourceRegion = RegionEndpoint.APNortheast1;
        string bucketName = "your-bucket-name";
        string identityId = "invalid value.";
        IAmazonS3 s3Client = null;
    }
}

これを読んで気になる点を挙げるなら AmazonCognitoSignInGUI の実装ですね。
こちらも以下に示します。

AmazonCognitoSignInGUI.cs

using System;
using UnityEngine;
using Amazon.CognitoIdentityProvider;
using Amazon.Extensions.CognitoAuthentication;
using TMPro;
using UnityEngine.Events;
using UnityEngine.UI;
using CodeStage.AntiCheat.ObscuredTypes;
using Amazon.CognitoIdentity;

public class AmazonCognitoSignInGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] InputField inputFieldEmail;
    [SerializeField] InputField inputFieldPassword;
    [SerializeField] Button buttonSignIn;
    [SerializeField] TMP_Text textStatus;
    [SerializeField] GameObject panelSignUpIn;
    #endregion

    internal UnityAction<CognitoAWSCredentials> onSignInSuccess;

    void Start()
    {
        this.buttonSignIn.onClick.AddListener(this.OnSignIn);

        this.InitializeUi();
    }

    void InitializeUi()
    {
        var emailAddress = ObscuredPrefs.GetString(AmazonCognitoPlayerPrefs.EmailAddress);
        this.inputFieldEmail.text = emailAddress;

        if (0 == inputFieldEmail.text.Length)
        {
            this.textStatus.text = "Input Email, Password and Press SignUp Button.\r\nAlready have an account? Press SignIn Button.";
        }
        else
        {
            this.textStatus.text = "Input Email and Password.\r\nPress SignIn Button.";
        }
    }

    public void OnSignIn()
    {
        try
        {
            AuthenticateWithSrpAsync();
        }
        catch (Exception ex)
        {
            Debug.LogError(ex);
        }
    }

    public async void AuthenticateWithSrpAsync()
    {
        var provider = new AmazonCognitoIdentityProviderClient(null, AmazonCognitoIDs.CognitoPoolRegion);
        var userPool = new CognitoUserPool(
            AmazonCognitoIDs.UserPoolId,
            AmazonCognitoIDs.UserPoolAppClientId,
            provider
        );
        var user = new CognitoUser(
            this.inputFieldEmail.text,
            AmazonCognitoIDs.UserPoolAppClientId,
            userPool,
            provider
        );

        try
        {
            await user.StartWithSrpAuthAsync(
                new InitiateSrpAuthRequest { 
                    Password = this.inputFieldPassword.text
                }).ConfigureAwait(true);
            this.textStatus.text = "SignIn Success.";
            var credentials = new CognitoAWSCredentials(AmazonCognitoIDs.IdentityPoolId, AmazonCognitoIDs.CognitoPoolRegion);
            credentials.AddLogin($"cognito-idp.{AmazonCognitoIDs.CognitoPoolRegion.SystemName}.amazonaws.com/{AmazonCognitoIDs.UserPoolId}", user.SessionTokens.IdToken);
            ObscuredPrefs.SetString(AmazonCognitoPlayerPrefs.EmailAddress, this.inputFieldEmail.text);
            this.panelSignUpIn.SetActive(false);

            this.onSignInSuccess?.Invoke(credentials);
        }
        catch (Exception ex)
        {
            this.textStatus.text = ex.Message;
        }
    }
}

SignInに関する UI との接続は次の通り

f:id:simplestar_tech:20190601142721p:plain
Cognito Sign In の UI 接続

用意した UI の見た目は以下の通りです。

f:id:simplestar_tech:20190601142813p:plain
サインイン・アップの UI の見た目

そしてまたまた気になる要素が次の固定値ですね。

AmazonCognitoIDs.CognitoPoolRegion
AmazonCognitoIDs.UserPoolId
AmazonCognitoIDs.UserPoolAppClientId
AmazonCognitoIDs.IdentityPoolId

一応リージョンは東京(RegionEndpoint.APNortheast1)だと予測できますが、それ以外はアプリ固有のユニークな情報となっています。基本秘匿されるべき情報になるのでコード埋め込みというよりは、暗号化して外部ファイルとして管理するなど必要かもしれません。

対策なく公開すると Amazon S3 のファイルアップロードやダウンロードを全世界に公開することになるわけで、続いてアプリで認証されたユーザーからのみ操作できるようにします。

Amazon Cognito を使えば認証機構を用意することができます。具体的には上記の4つの値を作成できます。
手順は公式ドキュメントを参照します。次のステップの1,2まで行うと UserPoolId と UserPoolAppClientId が作成されます。公式ドキュメントですが、わかりやすかったです。(ここ書いた人好き)
docs.aws.amazon.com

続いて、次の手順を公式ドキュメントに沿って行います。「ID プールを設定するには」 1~10 のステップを踏むと IdentityPoolId が作成されます。(Unity のログインサンプルコードだけ無いのが惜しい!)
docs.aws.amazon.com

ということで、Unity のログインサンプルコードがこちら

            var credentials = new CognitoAWSCredentials(AmazonCognitoIDs.IdentityPoolId, AmazonCognitoIDs.CognitoPoolRegion);
            credentials.AddLogin($"cognito-idp.{AmazonCognitoIDs.CognitoPoolRegion.SystemName}.amazonaws.com/{AmazonCognitoIDs.UserPoolId}", user.SessionTokens.IdToken);

この credentials を各種 Amazon Client のコンストラクタに渡せば、認証されたユーザーとしてクライアントAPIを利用することができるようになります!

email アドレスとパスワードを秘匿通信して、Unity ユーザーを認証し、そのユーザーに与えられた権限の範囲で AWS のデータ編集を許可します。

お気づきになられたと思いますが、権限の範囲の設定の説明が抜けていますね。
「ID プールを設定するには」 1~10 のステップを踏んでいる途中で確認できる画面に次のものがあります。

f:id:simplestar_tech:20190601155252j:plain
IDプールの編集で権限範囲のロールとの接続を行っている様子

公式ドキュメント作成者にお願いしたいこととして、どこの設定で何と接続されているかを具体的に強調してほしい。。。
つまりは、ここで設定された Cognito_*_Auth_Role (*印は ID プール名)という名前の権限設定が認証されたユーザーに与えられることになります!

続いて IAM ("あいあむ"と読む)のサービスにて Cognito_*_Auth_Role の内容を確認して、S3 の Get, Put の操作を許可する権限を付与しましょう。
現在のままではサインインした後、AmazonS3Client 操作を行っても Status 400 Bad Request が返って来てしまいますので。。

IAM で認証されたユーザーに最低限必要な範囲の権限を付与する

認証されたユーザーのためのロールに権限を付与しなければならないのはアプリを便利に使うために必要なことですが、ここでもし、フルアクセス権限を与えると
悪意あるユーザーがクライアントを改ざんした場合に、すべての情報が抜き取られ、データは破壊され、場合により利用料金が跳ね上がりかねないので、特に権限付与の行為は注意が必要です。

権限設定の具体的な手順を説明します。
AWS Console にログインして、IAM の先程確認した名前のロールを選択して「インラインポリシーの追加」ボタンを押します。
ビジュアルエディタのボタンを押すと
どのサービスの?と聞かれるところがあるので S3 となるように選択します。
S3 のどんな操作を許可するの?と聞かれるので、まずは PutObject だけを選択します。

まだ危険な設定です。書き込める範囲を制限するためリソースの指定の欄にて次の ARN の設定を追加します。

f:id:simplestar_tech:20190601161155j:plain
ユーザーIDのディレクトリ以下しか操作させない設定

解説すると、${cognito-identity.amazonaws.com:sub} に当たる文字列は上記サンプルコードで出てくる credentials.GetIdentityId() で得た値と一致するので、要するに認証したユーザーに紐づくディレクトリ以下だけしかファイルをアップロードできないように制限しています。
ここの設定で参考になる公式ドキュメントはこちら
docs.aws.amazon.com

ほかにも自宅IPアドレスからの操作のみ受け付けるように制限も加えます。(これで試験中はかなり安心してテストできる)

最後に追加するインラインポリシーの名前を "S3PutObjectPlayerVRM" などにセットして保存します。
同様にして S3GetObjectPlayerVRM といった名前で S3 の GetObject で特定のバケットの特定のディレクトリ以下のみからしかデータを受け取れないようにする制約を与えます。

ここまで制限すれば、もしすべてのアプリ固有IDがゲームコードから割れても
編集できるのはユーザーのディレクトリの下だけとなり
読み取れるのは特定のバケットディレクトリの下だけとなります。

あとは暗号化されたファイルが S3 に配置され、一定時間が経つと S3 上からファイルが消えるように設定しておけば
問題が起きた時、被害の範囲が予想でき、その後の対応も行いやすいのではないかと考えています。

Amazon S3バケット作成と暗号化設定は、新規作成の設定画面で説明不要なくらいわかりやすい操作なので、ここまで Cognito などの使い勝手がわかってきていれば
迷うことなくバケットを作れると思います。

ここまでのまとめ

Unity から S3 クライアントを使ってファイルのアップロード、ダウンロードを行う動作確認済みのサンプルコードを示し
Unity から Amazon Cognito によるサインイン操作を提供する動作確認済みサンプルコードを示し
サインインしたユーザーに、必要な権限を付与するための手順と、一般的な影響範囲の制限の例を示しました。

まだ説明してほしいところが以下の2つあります。

  • ユーザーアカウントの作成方法
  • 前回までのチャットサンプルへの VRM データやり取りの機能追加

順に説明していきます。

Unity でユーザーアカウントの作成機構を作る

サインインするための Email と Password を入力して、初回はサインアップボタンを押してもらうことにし、検証用コードがメールアドレスに届き
この検証コードを一定時間以内に回答するとアカウントの作成が行える Unity UI を作ります。

具体的には次のロジックを記述する
AmazonCognitoSignUpGUI.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using Amazon.CognitoIdentityProvider;
using Amazon.CognitoIdentityProvider.Model;
using TMPro;
using UnityEngine.UI;
using UnityEngine.Events;

public class AmazonCognitoSignUpGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] InputField inputFieldEmail;
    [SerializeField] InputField inputFieldPassword;
    [SerializeField] Button buttonSignup;
    [SerializeField] TMP_Text textStatus;
    #endregion

    internal UnityAction onSingUpSuccess;

    void Start()
    {
        this.buttonSignup.onClick.AddListener(this.OnSignup);
    }

    void OnSignup()
    {
        var client = new AmazonCognitoIdentityProviderClient(null, AmazonCognitoIDs.CognitoPoolRegion);
        var signupRequest = new SignUpRequest
        {
            ClientId = AmazonCognitoIDs.UserPoolAppClientId,
            Username = this.inputFieldEmail.text,
            Password = this.inputFieldPassword.text,
            UserAttributes = new List<AttributeType> {
                new AttributeType {
                    Name = "email",
                    Value = this.inputFieldEmail.text
                }
            }
        };

        try
        {
            var result = client.SignUp(signupRequest);
            this.textStatus.text = $"We have sent a code by email to {result.CodeDeliveryDetails.Destination}.\r\nEnter it above to confirm your account.";
            this.onSingUpSuccess?.Invoke();
        }
        catch (Exception ex)
        {
            this.textStatus.text = ex.Message;
        }
    }
}

UIつなぎ込みは以下の通り

f:id:simplestar_tech:20190601142639p:plain
Cognito Sign Up の UI 接続

ここまでの実装で有効な mail アドレスと password を記入して SignUp ボタンを押してもらうと、指定した mail アドレスに検証用コードが記入された確認メールが届きます(たぶんスパムフォルダ)

その検証用コードを記入してアカウントを作成するロジックが次の通り
AmazonCognitoConfirmGUI.cs

using System;
using UnityEngine;
using Amazon.CognitoIdentityProvider;
using Amazon.CognitoIdentityProvider.Model;
using UnityEngine.UI;
using TMPro;
using UnityEngine.Events;
using CodeStage.AntiCheat.ObscuredTypes;

public class AmazonCognitoConfirmGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] InputField inputFieldEmail;
    [SerializeField] InputField inputFieldVerificationCode;
    [SerializeField] Button buttonConfirmAccount;
    [SerializeField] TMP_Text textStatus;
    #endregion

    #region Scene Components
    [SerializeField] AmazonCognitoSignUpGUI cognitoSingUp;
    #endregion

    internal UnityAction onSingUpConformed;

    void Start()
    {
        this.buttonConfirmAccount.onClick.AddListener(this.OnConfirmAccount);
        this.cognitoSingUp.onSingUpSuccess += this.OnSignUp;
    }

    void OnSignUp()
    {
        this.inputFieldVerificationCode.gameObject.SetActive(true);
        this.buttonConfirmAccount.gameObject.SetActive(true);
    }

    public void OnConfirmAccount()
    {
        var client = new AmazonCognitoIdentityProviderClient(null, AmazonCognitoIDs.CognitoPoolRegion);
        var confirmSignUpRequest = new ConfirmSignUpRequest();

        confirmSignUpRequest.Username = this.inputFieldEmail.text;
        confirmSignUpRequest.ConfirmationCode = this.inputFieldVerificationCode.text;
        confirmSignUpRequest.ClientId = AmazonCognitoIDs.UserPoolAppClientId;

        try
        {
            var confirmSignUpResult = client.ConfirmSignUp(confirmSignUpRequest);
            this.textStatus.text = $"SignUp Confirmed {confirmSignUpResult.HttpStatusCode}.\r\nRecord Your Password and Press SignIn Button.";
            ObscuredPrefs.SetString(AmazonCognitoPlayerPrefs.EmailAddress, this.inputFieldEmail.text);

            this.inputFieldVerificationCode.gameObject.SetActive(false);
            this.buttonConfirmAccount.gameObject.SetActive(false);
            onSingUpConformed?.Invoke();
        }
        catch (Exception ex)
        {
            this.textStatus.text = ex.Message;
        }
    }
}

UI接続は次の通り

f:id:simplestar_tech:20190601165411p:plain
Confirm の UI 接続の様子

サインアップ成功のイベントでコード入力欄とコード確認ボタンが現れ、これを押すとサインインボタン押してという作業を促します。

そこから先は、記事前半で説明してきたので、気になる方は読み返してみてください。

前回までのチャットサンプルへの VRM データやり取りの機能追加

ここまでがユーザー認証とオンラインストレージ(S3)とのファイルデータのやりとりの汎用的な機能紹介でした。
ここから本題の VRM データのやりとりをオンラインチャットアプリに組み込みます。

前回の記事でコメントをこのように残していたと思います。

    string myVRMObjectKey = "dummy_AWS_S3_URL"; // 本来は null で初期化しておく

ということで、こう

    string myVRMObjectKey = null;

前回の記事でコメントをこのように残していたと思います。2

            // 本当は 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);

ということで、こう

            // S3 に VRM ファイルをアップロードしてダウンロード用 URL を送信
            var playerVRMPath = ObscuredPrefs.GetString(VRMPlayerPrefs.PlayerVRMPath);
            await this.amazonS3Client.PutObjectFileAsync(playerVRMPath, async objectKey =>
            {
                this.myVRMObjectKey = objectKey;
                await this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, MagicOnionClientGUI.VRMContentKey);
            });

前回の記事でコメントをこのように残していたと思います。3

                // var vrmData = AWS_S3.Instance.GetObjectData(response.URL); 本当は S3 などから URL でデータを落して来たい…
                // プレイヤー VRM データを読み込んでインスタンス化し、ユーザーユニークIDで辞書に追加
                var playerVRMPath = ObscuredPrefs.GetString(VRMPlayerPrefs.PlayerVRMPath);
                var vrmData = File.ReadAllBytes(playerVRMPath);
                var vrmRoot = this.vrmLoader.LoadVRMinMemory(vrmData, false);

ということで、こう

                // S3 から URL でデータを落して VRM キャラクターを生成
                await this.amazonS3Client.GetObjectDataAsync(response.URL, vrmData => {
                    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);
                    }
                });

動作確認したところ、このように、お互いの VRM キャラデータを交換してオンラインチャットできました。
www.youtube.com

次はテキスト内容によってリップ・シンクする部分を見ていきます。

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

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

チャット入力画面とチャットテキストの表示

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

プラス、バルーンはこんな動きをする予定

こちらの記事の続きです。
simplestar-tech.hatenablog.com
今回はチャット入力画面を作ってみましょう

UI を新たに作るので Canvas 以下を次のように配置

f:id:simplestar_tech:20190526082741p:plain
チャットテキスト入力欄

UI はこんな感じの見た目にします

f:id:simplestar_tech:20190526082845p:plain
チャットテキスト入力欄の見た目

この UI と接続するロジックを以下の通り記述します。
今回はコードの目的ごとにコメントを記入したよ。気になる処理は参考にどうぞ

using System.Collections.Generic;
using Invector.vCharacterController;
using Invector.vCharacterController.vActions;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;

/// <summary>
/// ゲーム内に InputField を出現させ、テキスト入力後に吹き出しGUIに配置
/// </summary>
public class TextChatGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] GameObject panelTextChat;
    [SerializeField] InputField inputFieldTextChat;
    [SerializeField] Transform panelTextChatBalloons;
    #endregion

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

    #region Assets
    [SerializeField] GameObject chatBalloonPrefab;
    #endregion

    UnityAction<string /*userUniqueId*/, string /*chatText*/> onEndEditMessage;

    void Start()
    {
        this.vrmLoader.onLoadVRM += this.OnLoadVRM;
        this.inputFieldTextChat.onEndEdit.AddListener(this.OnEndEditChatText);
        this.onEndEditMessage += this.OnSendMessage; // @debug 横着、オンライン通知イベントに OnSendMessage を接続予定
    }

    void Update()
    {
        // panel が非表示のときにキーボードの T を押すと panel が出現
        if (Input.GetKeyUp(KeyCode.T) && !this.panelTextChat.activeSelf)
        {
            var vInput = vrmCharacters.GetComponentInChildren<vThirdPersonInput>();
            if (null != vInput)
            {
                SwitchTextChatMode(vInput, showInputFieldTextChat : true);
            }
        }

        // 重なりが自然になるよう z 座標の小さい順にレンダリングするための処理
        if (this.panelTextChatBalloons.gameObject.activeSelf)
        {
            // 吹き出し UI の z 座標の大きい順に並べ替え
            var chatBalloons = new List<Transform>();
            foreach (var chatBalloon in this.panelTextChatBalloons)
            {
                chatBalloons.Add(chatBalloon as Transform);
            }
            chatBalloons.Sort((a, b) => b.localPosition.z > a.localPosition.z ? 1 : -1);
            // 並べ替えた順序でヒエラルキーの順序を変更
            int sibIndex = 0;
            foreach (var chatBallon in chatBalloons)
            {
                chatBallon.SetSiblingIndex(sibIndex++);
            }
        }
    }

    void OnLoadVRM(GameObject vrmRoot)
    {
        if (null == vrmRoot)
        {
            return;
        }

        // VRM インスタンスを指定 Transform 配下へ移動
        vrmRoot.transform.SetParent(this.vrmCharacters);
        // Balloon の 3D 位置用の空オブジェクトを作成し VRM インスタンス配下へ移動
        var textChatUIAnchor = new GameObject("TextChatUIAnchor", new System.Type[] { typeof(ChatBalloonLocater)});
        textChatUIAnchor.transform.SetParent(vrmRoot.transform);

        // メッシュデータから身長(m)を計測
        var heightMeasure = new MeshHeightMeasure();
        var vrmHeight = heightMeasure.GetMeshHeight(vrmRoot);
        // 身長 + オフセット上に来るように配置
        var heightOffset = 0.1f;
        textChatUIAnchor.transform.localPosition = new Vector3(0, vrmHeight + heightOffset, 0);
        // BalloonLocater が制御すべき Balloon を作成 + Locater に設定
        var chatBalloonLocater = textChatUIAnchor.GetComponent<ChatBalloonLocater>();
        var chatBalloon = Instantiate(this.chatBalloonPrefab, this.panelTextChatBalloons).GetComponent<RectTransform>();
        chatBalloonLocater.balloonRectTransform = chatBalloon.GetComponent<RectTransform>();

        // プレイヤー用の Balloon 名を 0 に設定
        chatBalloon.name = "0";
        this.playerTextChatBalloonName = chatBalloon.name;
        // 空文字列を初期テキストに設定してクリア
        UpdateBalloonText("", chatBalloon);
    }

    void OnEndEditChatText(string chatText)
    {
        var vInput = vrmCharacters.GetComponentInChildren<vThirdPersonInput>();
        if (null != vInput)
        {
            SwitchTextChatMode(vInput, showInputFieldTextChat: false);
        }
        this.onEndEditMessage?.Invoke(this.playerTextChatBalloonName, chatText);
    }

    void SwitchTextChatMode(vThirdPersonInput vInput, bool showInputFieldTextChat)
    {
        panelTextChat.SetActive(showInputFieldTextChat);

        if (showInputFieldTextChat)
        {
            // テキスト入力欄にキャレットを配置
            inputFieldTextChat.ActivateInputField();
        }
        else
        {
            // 非表示後にテキストをクリア
            inputFieldTextChat.text = "";
        }
        // プレイヤー入力のロック・ロック解除
        vInput.lockInput = showInputFieldTextChat;
        foreach (var vTriggerActions in triggerActions.GetComponentsInChildren<vTriggerGenericAction>())
        {
            vTriggerActions.actionInput.useInput = !vInput.lockInput;
        }
        // カーソル再表示とカーソルロック解除(なくてもチャットできる)
        // vInput.ShowCursor(vInput.lockInput);
        // vInput.LockCursor(vInput.lockInput);
    }

    void OnSendMessage(string userUniqueId, string chatText)
    {
        var chatBalloon = this.panelTextChatBalloons.Find($"{userUniqueId}")?.GetComponent<RectTransform>();
        UpdateBalloonText(chatText, chatBalloon);
    }

    static void UpdateBalloonText(string chatText, RectTransform chatBalloon)
    {
        if (null == chatBalloon)
        {
            return;
        }
        // 空文字列の時だけ非表示
        chatBalloon.GetComponent<Image>().enabled = 0 != chatText.Length;
        // 入力テキストが収まるよう表示幅を変更
        var chatBallonText = chatBalloon.GetComponentInChildren<Text>();
        chatBallonText.text = chatText;
        float margin = 20;
        chatBalloon.sizeDelta = new Vector2(chatBallonText.preferredWidth + margin, chatBalloon.sizeDelta.y);
    }

    string playerTextChatBalloonName = "";
}

インスペクタービューでは以下の通り UI と接続します

f:id:simplestar_tech:20190526083136p:plain
TextChatGUIのインスペクタービュー

prefab はなんの変哲もない Text です。

f:id:simplestar_tech:20190526083356p:plain
ChatBalloonPrefab
強いて言うならアンカーが左下という点
f:id:simplestar_tech:20190526083457p:plain
Anchorタイプ

Balloon の配置は以下のクラスに担当させています。(ロジックの作成の様子は過去記事で既出)

using UnityEngine;

/// <summary>
/// キャラの頭の上の吹き出し位置を毎フレーム更新
/// </summary>
public class ChatBalloonLocater : MonoBehaviour
{
    #region UI Connection
    internal RectTransform balloonRectTransform;
    #endregion

    #region Scene Components
    new Camera camera;
    #endregion

    void Start()
    {
        this.camera = Camera.main;
    }

    void Update()
    {
        if (null == this.balloonRectTransform || !this.balloonRectTransform.gameObject.activeSelf)
        {
            return;
        }

        // 遠くなるほど小さく
        var distance = Vector3.Distance(this.transform.position, this.camera.transform.position);
        this.balloonRectTransform.localScale = Vector3.one * Mathf.Clamp01(3 / distance);

        // スクリーン座標が画面外に出る時は、画面内に納まるようにクランプ
        Vector3 screenPos = this.camera.WorldToScreenPoint(this.transform.position);
        var scaleOffset = 5.0f;
        var marginX = this.balloonRectTransform.rect.width / 2 * this.balloonRectTransform.localScale.x + scaleOffset;
        var marginY = this.balloonRectTransform.rect.height / 2 * this.balloonRectTransform.localScale.y + scaleOffset;
        var x = Mathf.Clamp(screenPos.x, marginX, Screen.width - marginX);
        var y = Mathf.Clamp(screenPos.y, marginY, Screen.height - marginY);
        var z = screenPos.z;
        this.balloonRectTransform.rotation = Quaternion.identity;

        // 3Dアンカーのカメラの前後判定
        var dot = Vector3.Dot(this.camera.transform.forward, this.transform.position - this.camera.transform.position);
        var flag = Mathf.Sign(dot);

        // 3Dアンカーが背後にあるなら左右反転
        if (0 > flag)
        {
            x = Screen.width - x;
            y = Screen.height - y;
            this.balloonRectTransform.rotation = Quaternion.Euler(0, 180, 0);
        }
        // ここまでの計算を RectTransform に反映
        this.balloonRectTransform.position = new Vector3(x, y, z);
    }
}

コレを動かすと次の通り

次の記事では、オンライン通信部分を作ります。
simplestar-tech.hatenablog.com

Unity:VRMの身長を測る

まえがき

VRM を動的ロードして、そんな VRM キャラクターとオンラインでコミュニケーションを取るロビーを制作中
キャラクターの頭のパーツを隠さずに、吹き出し位置を調整したいなと思ったとき

VRM の身長データの取得方法がわからなかったので調べてみて
わからなかったので、作り方を考えて、実際取得できることまで確認しました

動いたコードがこちら
全頂点座標の中から最も高い値をイベント引数で返すようにしてみました。

using UnityEngine;
using UnityEngine.Events;

public class VRMHeightMeasure : MonoBehaviour
{
    #region Scene Components
    [SerializeField] VRMLoaderGUI vrmLoaderGUI;
    #endregion

    internal UnityAction<GameObject /*vrmRoot*/, float /*isUserPlayerFlag*/> OnMeasureVRMHeight;

    void Start()
    {
        this.vrmLoaderGUI.OnLoadVRM += OnLoadVRM;
    }

    private void OnLoadVRM(GameObject vrmRoot, bool isPlayer)
    {
        float vrmHeight = 0;
        foreach (var meshRenderer in vrmRoot.GetComponentsInChildren<SkinnedMeshRenderer>())
        {
            if (null == meshRenderer.sharedMesh)
            {
                continue;
            }
            var meshHeight = meshRenderer.transform.position.y;
            foreach (var vertex in meshRenderer.sharedMesh.vertices)
            {
                if (vrmHeight < meshHeight + vertex.y)
                {
                    vrmHeight = meshHeight + vertex.y;
                }
            }
        }
        OnMeasureVRMHeight?.Invoke(vrmRoot, vrmHeight);
    }
}

Unity:スクリーンスペースのUIを奥行き順に並べ替える

まえがき

Unity の uGUI の描画順は、Hierarchy View の上下関係で決まります。
developer.aiming-inc.com

UIの順序を 3D の奥行きで毎フレーム並べ替えたい
それができないと、手前のキャラクターの吹き出しなどが、奥のキャラクターの吹き出しに隠れてしまいます。

解決できるコードはこちら

var chatBalloons = new List<Transform>();
foreach (var chatBalloon in this.panelTextChatBalloons.transform)
{
    chatBalloons.Add(chatBalloon as Transform);
}
chatBalloons.Sort((a, b) => b.localPosition.z > a.localPosition.z ? 1 : -1);
int sibIndex = 0;
foreach (var chatBallon in chatBalloons)
{
    chatBallon.SetSiblingIndex(sibIndex++);
}

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

VRM 選択画面と動的ロードの実装詳細

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

Unity 2019.1.3f1 で新規プロジェクトを作成
ゲームを起動すると次の UI が表示されるように UI 要素をセットします。

f:id:simplestar_tech:20190519182834p:plain
ファイル選択UI

VRM を動的にロードして TPS カメラワークで TPS キャラクターとして動かしてみます。

依存するアセットは
assetstore.unity.com
assetstore.unity.com

github.com

依存するパッケージは
Cinemachine
TMPro(日本語使えるように準備したもの)

以下のロジックを組みます。

using Cinemachine;
using Invector.vCharacterController;
using Invector.vCharacterController.vActions;
using SimpleFileBrowser;
using System;
using System.IO;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using VRM;

public class VRMPlayerModelLoader : MonoBehaviour
{
    #region UI Connection
    [SerializeField] Button buttonBrowse;
    [SerializeField] Button buttonLoadVRM;
    [SerializeField] InputField inputFieldVRMPath;
    [SerializeField] TMP_Text textStatus;
    [SerializeField] GameObject panelSelectLoadVRM;
    #endregion

    #region Scene Components
    [SerializeField] Transform playerLookAtTarget;
    [SerializeField] CinemachineFreeLook cinemachineFreeLook;
    #endregion

    #region Assets
    [SerializeField] RuntimeAnimatorController playerAnimController;
    [SerializeField] Shader vrmShader;
    #endregion

    internal UnityAction<GameObject> OnLoadPlayerVRM;

    private void Start()
    {
        this.InitializeUi();
    }

    #region VRM

    public void OnButtonBrowse()
    {
        FileBrowser.SetFilters(false, new FileBrowser.Filter("VRM", ".vrm"));
        FileBrowser.SetDefaultFilter(".vrm");
        FileBrowser.SetExcludedExtensions(".lnk", ".tmp", ".zip", ".rar", ".exe");
        FileBrowser.AddQuickLink("Downloads", @"C:\Users\" + Environment.UserName + @"\Downloads");
        FileBrowser.ShowLoadDialog(path => this.inputFieldVRMPath.text = path, null, false, @"C:\Users\" + Environment.UserName + @"\Downloads");
    }

    public void OnButtonLoadVRM()
    {
        if (0 == this.inputFieldVRMPath.text.Length)
        {
            textStatus.text = "Input VRM Path.";
            return;
        }

        var vrmFilePath = this.inputFieldVRMPath.text;
        if (!File.Exists(vrmFilePath))
        {
            textStatus.text = "VRM Path File is Not Found.";
            return;
        }

        var vrmData = File.ReadAllBytes(vrmFilePath);
        var vrmRoot = LoadVRMinMemory(vrmData);

        if (null == vrmRoot)
        {
            textStatus.text = "Invalid VRM File, VRM Instantiation is Failed.";
            return;
        }
        PlayerPrefs.SetString(PlayerVRMPath, vrmFilePath);
        this.panelSelectLoadVRM.SetActive(false);
    }

    private void InitializeUi()
    {
        this.buttonBrowse.onClick.AddListener(OnButtonBrowse);
        this.buttonLoadVRM.onClick.AddListener(OnButtonLoadVRM);
        var playerVRMPath = PlayerPrefs.GetString(PlayerVRMPath);
        if (File.Exists(playerVRMPath))
        {
            this.inputFieldVRMPath.text = playerVRMPath;
        }
    }

    public GameObject LoadVRMinMemory(byte[] vrmData, bool isUserPlayerFlag = true)
    {
        var context = new VRMImporterContext();
        context.ParseGlb(vrmData);
        context.Load();
        if (null != context.Root && isUserPlayerFlag)
        {
            int layerMask = LayerMask.NameToLayer(LayerMask_Player);
            context.Root.SetLayerRecursively(layerMask);
            context.Root.SetTagRecursively(Tag_Player);
        }
        context.ShowMeshes();
        context.EnableUpdateWhenOffscreen();
        context.ShowMeshes();
        if (null != context.Root)
        {
            SetModel(context.Root, isUserPlayerFlag);
        }
        return context.Root;
    }

    void SetModel(GameObject vrmRoot, bool isUserPlayerFlag)
    {
        if (null != this.vrmShader)
        {
            foreach (var renderer in vrmRoot.GetComponentsInChildren<Renderer>())
            {
                foreach (var material in renderer.sharedMaterials)
                {
                    var tex = material.mainTexture;
                    material.shader = this.vrmShader;
                    material.mainTexture = tex;
                }
            }
        }
        var lookAt = vrmRoot.GetComponent<VRMLookAtHead>();
        if (lookAt)
        {
            vrmRoot.AddComponent<Blinker>();
            lookAt.Target = this.playerLookAtTarget;
            lookAt.UpdateType = UpdateType.LateUpdate; // after HumanPoseTransfer's setPose
        }

        if (isUserPlayerFlag)
        {
            var target = vrmRoot.transform.FindHeightRecursively(1.0f);
            cinemachineFreeLook.Follow = vrmRoot.transform;
            cinemachineFreeLook.LookAt = target;

            // Invector が動的コンポーネント追加を考慮していないので、必要と思われる公開パラメータをここで初期化
            var vInput = vrmRoot.AddComponent<vThirdPersonInput>();
            vInput.OnLateUpdate = new UnityEngine.Events.UnityEvent();
            vInput.jumpInput = new GenericInput("Space", "A", "A");
            vInput.rollInput = new GenericInput("Q", "Y", "Y");
            vInput.crouchInput = new GenericInput("C", "X", "X");

            var vController = vrmRoot.AddComponent<vThirdPersonController>();
            vController.strafeSpeed = new vThirdPersonMotor.vMovementSpeed();
            vController.freeSpeed = new vThirdPersonMotor.vMovementSpeed();
            vController.OnCrouch = new UnityEvent();
            vController.OnStandUp = new UnityEvent();
            vController.OnJump = new UnityEvent();
            vController.OnStartSprinting = new UnityEvent();
            vController.OnFinishSprinting = new UnityEvent();
            vController.OnFinishSprintingByStamina = new UnityEvent();
            vController.OnStaminaEnd = new UnityEvent();

            var vAction = vrmRoot.AddComponent<vGenericAction>();
            vAction.OnStartAction = new UnityEvent();
            vAction.OnEndAction = new UnityEvent();
        }

        var animator = vrmRoot.GetComponent<Animator>();
        if (animator && !animator.runtimeAnimatorController)
        {
            animator.runtimeAnimatorController = playerAnimController;
        }
        if (isUserPlayerFlag)
        {
            var rb = vrmRoot.AddComponent<Rigidbody>();
            rb.constraints = RigidbodyConstraints.FreezeRotation;
            rb.collisionDetectionMode = CollisionDetectionMode.Continuous;

            var coll = vrmRoot.AddComponent<CapsuleCollider>();
            coll.center = new Vector3(0, 0.8f, 0);
            coll.radius = 0.25f;
            coll.height = coll.center.y * 2;

            var animSync = vrmRoot.AddComponent<AnimationSync>();           
            OnLoadPlayerVRM?.Invoke(vrmRoot);
        }
    }
    
    #endregion

    const string PlayerName = "PlayerName";
    const string PlayerVRMPath = "PlayerVRMPath";
    const string Tag_Player = "Player";
    const string LayerMask_Player = "Player";
}

シーンに配置したら、次のように UI 要素やアセットと結びつけます。

f:id:simplestar_tech:20190519190230p:plain
VRMモデルローダースクリプトのインスペクタ

Cinemachine の Free Look の Radius の設定は次の通り

f:id:simplestar_tech:20190519190514p:plain
Free Look 半径の設定

追記:おっと拡張メソッド使っていますね。
以下のような拡張メソッドを実装しておきました。

TransformExtensions.cs

using UnityEngine;

public static class TransformExtensions
{
    public static Transform FindRecursively(
        this Transform self,
        string n
    )
    {
        var child = self.Find(n);
        if (null == child)
        {
            foreach (Transform c in self.transform)
            {
                child = FindRecursively(c, n);
                if (null != child)
                {
                    break;
                }
            }
        }
        return child;
    }

    public static Transform FindHeightRecursively(
        this Transform self,
        float height
    )
    {
        if (height < self.localPosition.y)
        {
            return self;
        }
        foreach (Transform c in self.transform)
        {
            var child = FindHeightRecursively(c, height - self.localPosition.y);
            if (null != child)
            {
                return child;
            }
        }
        return null;
    }
}

GameObjectExtensions.cs

using UnityEngine;

public static class GameObjectExtensions
{
    public static void SetLayerRecursively(
        this GameObject self,
        int layer
    )
    {
        self.layer = layer;

        foreach (Transform n in self.transform)
        {
            SetLayerRecursively(n.gameObject, layer);
        }
    }

    public static void SetTagRecursively(
        this GameObject self,
        string tag
    )
    {
        self.tag = tag;

        foreach (Transform n in self.transform)
        {
            SetTagRecursively(n.gameObject, tag);
        }
    }
}

Load VRM ボタンを押せば、次の通りマウスで TPS カメラ操作しつつ、WSAD キーで TPS キャラクター操作しつつ E キーでアクションできるようになっています。
カメラ操作は Free Look のディフォルトの入力を好みで反転させました。

次の記事ではチャット入力画面を作ってみましょう。
simplestar-tech.hatenablog.com

Unity:形態素解析してしゃべるVRMのサンプル

■前書き
文字列を受け取って、読みに変換し
あいうえおの母音に直した後、これを順に処理しながら
VRM の、ブレンドシェイプに繋げます。

小ゴールを切りながら、VRM キャラがしゃべる様子を確認できればゴールです。

■読みの抽出

公式ページ
MeCab: オリジナル辞書/コーパスからのパラメータ推定
より

ipadic の例です. 素性列として

品詞
品詞細分類1
品詞細分類2
品詞細分類3
活用型
活用形
基本形
読み
発音
が定義されています.

ということは、こうですね。

using NMeCab;
using UnityEngine;
using UnityEngine.UI;

public class NMeCabTest : MonoBehaviour
{
    public Text text;

    void Start()
    {
        string result = "";
        string sentence = "Unityで形態素解析";

        MeCabParam param = new MeCabParam();
        param.DicDir = $"{Application.streamingAssetsPath}/NMeCab/dic/ipadic";

        var t = MeCabTagger.Create(param);
        MeCabNode node = t.ParseToNode(sentence);
        while (node != null)
        {
            if (node.CharType > 0)
            {
                var splits = node.Feature.Split(',');
                var yomi = splits.Length < 8 ? node.Surface : splits[7];
                result += yomi + " ";
            }
            node = node.Next;
        }
        this.text.text = result;
    }
}

バッチリ動きました。

f:id:simplestar_tech:20190512180141p:plain
Unityで形態素解析

■あいうえお ニュートラル の 6 つの値に直す

ケイタイソ→エイアイオ
に直す変換です。

Japanese Katakana Unicode Chart
sites.psu.edu

ここによれば 12449 ~ 12539 の int の値を見れば、対応表が作れそうですね。

                var c = yomi.ToCharArray();
                int[] arr = new int[c.Length];
                for (int i = 0; i < c.Length; i++)
                {
                    arr[i] = (int)c[i];
                    result += arr[i].ToString();
                }

確かに、表と同じ値が確認できました。
マップを作る

12483 ッ
12484 ツ
12485 ヅ

のところだけ規則性から外れてしまう

12490 ナ
な行だけは、濁点がない

12495 ハ
は行は ハ、バ、パ の三文字単位で特殊

12510 ま行もまた、一つ

12516 ヤ
ユヨ はこれまでの5 文字連続から外れて、三文字

12521 ラ
ラ行もまた 一つ

12526 ワ
以降はもう、特殊文字として、一つ一つ、アイウエオ割り当ててあげましょう。

   int YomiToAIUEO(int yomi)
    {
        if (12449 <= yomi && 12539 > yomi)
        {
            if (12484 >= yomi)
            {
                return ((yomi - 12449) / 2) % 5;
            }
            else if (12485 == yomi)
            {
                return 2;
            }
            else if (12490 > yomi)
            {
                return ((yomi - 12486) / 2) % 5 + 3;
            }
            else if (12495 > yomi)
            {
                return (yomi - 12490) % 5;
            }
            else if (12510 > yomi)
            {
                return ((yomi - 12495) / 3) % 5;
            }
            else if (12515 > yomi)
            {
                return (yomi - 12490) % 5;
            }
            else if (12521 > yomi)
            {
                return ((yomi - 12515) / 2) * 2;
            }
            else if (12526 > yomi)
            {
                return (yomi - 12521) % 5;
            }
            else
            {
                switch (yomi)
                {
                    case 12526:
                    case 12527:
                        return 0;
                    case 12528:
                    case 12529:
                        return 3;
                    case 12530:
                        return 4;
                    case 12532:
                        return 2;
                    case 12533:
                    case 12534:
                    case 12535:
                        return 0;
                    case 12536:
                    case 12537:
                        return 3;
                    case 12538:
                        return 4;
                    default:
                        break;
                }
            }
        }
        return 6;
    }

    string DebugYomi(int aiueo)
    {
        switch (aiueo)
        {
            case 0:
                return "あ";
            case 1:
                return "い";
            case 2:
                return "う";
            case 3:
                return "え";
            case 4:
                return "お";
            default:
                break;
        }
        return "ん";
    }

ローマ字を処理してほしいところです。

}
        else if (65 <= yomi && 123 > yomi)
        {
            switch (yomi)
            {
                case 65:
                case 97:
                    return 0;
                case 73:
                case 105:
                    return 1;
                case 85:
                case 117:
                case 87:
                case 119:
                    return 2;
                case 69:
                case 101:
                    return 3;
                case 79:
                case 111:
                    return 4;
                case 78:
                case 110:
                    return 6;
                default:
                    return 7;
            }
        }
        return 7;

正しく動きました。

VRMの口を動かす

あいうえお の番号をキューに詰めて、毎秒取り出してきて、これに同期してブレンドシェイプを指定して動かせるか試します。

このコードでひとまず口は動かせました。(ブレンド値を滑らかにつなぐなどの調整が必要だけど、今回はそこまでやらない)

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using VRM;

public class LipSyncTest : MonoBehaviour
{
    [SerializeField] GameObject vrmRoot;
    [SerializeField] Text text;
    [SerializeField] NMeCabTest nMeCabTest;
    [SerializeField] float wordTime = 0.3f;

    VRMBlendShapeProxy proxy;
    BlendShapeKey[] aiueoKey = new BlendShapeKey[5];
    Queue<int> aiueoQueue = new Queue<int>();
    float prevTime = 0;

    float fade = 0;

    // Start is called before the first frame update
    void Start()
    {
        this.proxy = vrmRoot.GetComponent<VRMBlendShapeProxy>();
        for (int aiueo = 0; aiueo < this.aiueoKey.Length; aiueo++)
        {
            this.aiueoKey[aiueo] = new BlendShapeKey
            {
                Preset = BlendShapePreset.A + aiueo
            };
        }
    }

    // Update is called once per frame
    void Update()
    {
        float time = Time.realtimeSinceStartup - this.prevTime;
        int sayWord = -1;
        if (time >= this.wordTime)
        {
            this.prevTime = Time.realtimeSinceStartup;

            if (0 < this.aiueoQueue.Count)
            {
                sayWord = this.aiueoQueue.Dequeue();
            }
        }
        
        if (Input.GetKey(KeyCode.A))
        {
            sayWord = 0;            
        }
        if (Input.GetKey(KeyCode.I))
        {
            sayWord = 1;
        }
        if (Input.GetKey(KeyCode.U))
        {
            sayWord = 2;
        }
        if (Input.GetKey(KeyCode.E))
        {
            sayWord = 3;
        }
        if (Input.GetKey(KeyCode.O))
        {
            sayWord = 4;
        }
        for (int aiueo = 0; aiueo < this.aiueoKey.Length; aiueo++)
        {
            float lastKey = this.proxy.GetValue(aiueoKey[aiueo]);
            this.proxy.AccumulateValue(aiueoKey[aiueo], sayWord == aiueo ? 1 : lastKey * 0.95f);
        }
        this.proxy.Apply();
    }

    public void OnSayClick()
    {
        var sentence = text.text;
        nMeCabTest.ReturnAiueo(sentence, ref aiueoQueue);
    }
}

できた。