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

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