gRPC で実現する Unity と Go 使ってカメラの位置/回転を同期させる方法

# はじめに

Oculus 機器でソーシャル VR を色々試してみたら自分でも作りたくなったので、
Unity の勉強兼ねて最終的に HMD で位置音声を同期するシステムまで作ってみます。

そのための第一歩として、まずは gRPC でカメラの位置/回転を同期させるシステムを作ってみました。

↓ のようなシステムになります。![qiita.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86070/6b0e94ba-4448-380b-2133-32e5f9f0cedb.gif)

ソースコードは Github 上にアップしておきました。
https://github.com/nuhs/Unity_gRPC_Sample

Go や Unity での gRPC のセットアップ方法は [こちら](https://qiita.com/nuhs/items/fdcad59bd9a0ebf6a792) をご参照くださいませ。

# gRPC サーバ側の解説
部屋に参加するユーザ間同期を目的として room.proto と user.proto を用意しました。

room.proto には部屋を管理するための RPC 群が定義されています。

```proto:Unity_gRPC_Sample/golang_server/nikaera.com/gRPC_sample/world/room.proto
syntax = "proto3";

package world;

// 同一ディレクトリに存在する user.proto 内の定義を
// room.proto 内で使用するために import する
import "user.proto";

service Room {
  // 部屋に入室するために使用する RPC
  // 部屋の ID を指定するとユーザ ID が発行される
  rpc Join (JoinRequest) returns (JoinResponse) {}

  // 部屋の情報を同期するために使用する RPC
  // Join で発行されたユーザ ID とユーザの情報を部屋に送信すると同時に
  // 部屋に参加しているユーザリスト及びその情報が取得出来る
  rpc Sync (SyncRequest) returns (SyncResponse) {}

  // 部屋から退出するために使用する RPC
  // Sync が返却するユーザリストから指定したユーザを削除する
  rpc Leave (LeaveRequest) returns (LeaveResponse) {}
}

message JoinRequest {
  string room_id = 1;
}
message JoinResponse {
  string user_id = 1;
}

message SyncRequest {
  string room_id = 1;
  User user = 2;
}
message SyncResponse {
  // repeated 修飾子を付けると繰り返し参照可能な
  // 配列のようなフィールドとして扱えるようになる
  repeated User users = 1;
}

message LeaveRequest {
  string room_id = 1;
  string user_id = 2;
}
message LeaveResponse {}
```

user.proto にはユーザの状態を表した message が定義されています。[^1]

```proto:Unity_gRPC_Sample/golang_server/nikaera.com/gRPC_sample/world/user.proto
syntax = "proto3";

package world;

// ユーザ(カメラ)の位置情報
message Transform_ {
    float x = 1;
    float y = 2;
    float z = 3;
}

// ユーザ(カメラ)の回転情報
message Rotation_ {
    float eulerX = 1;
    float eulerY = 2;
    float eulerZ = 3;
}

// ユーザの情報
message User {
  // room.proto 内の Join RPC を使用した際に返却される user_id が設定される
  string user_id = 1;
  Transform_ transform = 2;
  Rotation_ rotation = 3;
}
```

上記実装を Go で行うため、`protoc` コマンドで Go のファイルを生成します。
proto ファイルを配置したフォルダ内で下記コマンドを実行します。

```shell
protoc -I . --go_out=plugins=grpc:. room.proto user.proto
```

正常に実行されると room.pb.go と user.pb.go が同一フォルダに生成されます。

サーバ側は部屋の管理及び状態の同期を行います。実際のコードは ↓ になります。

```go:Unity_gRPC_Sample/golang_server/nikaera.com/gRPC_sample/server/main.go
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net"

    // user_id を発行するために使用するライブラリ
    // 未インストールの場合は go get で取得しておく
	"github.com/google/uuid"
	"google.golang.org/grpc"

    // protoc で出力した *.pb.go を使用する
	pb "nikaera.com/gRPC_sample/world"
)

const (
	port = ":50051"
)

type server struct {
    // 部屋情報を扱う連想配列
    // key には部屋の ID を value にはユーザの ID を格納する
	rooms map[string][]string

    // ユーザ情報を扱う連想配列
    // key にはユーザの ID を value にはユーザ情報を格納する
	users map[string]*pb.User
}

// ルームにユーザが参加しているかどうかを判定するための関数
func (s *server) isExistUser(roomID string, userID string) bool {
	userIDs, _ := s.rooms[roomID]

	for _, v := range userIDs {
		if v == userID {
			return true
		}
	}

	return false
}

// ルームに参加しているユーザリストを取得するための関数
func (s *server) roomUsers(roomID string) []*pb.User {
	var _users []*pb.User
	for _, uid := range s.rooms[roomID] {
		_users = append(_users, s.users[uid])
	}

	return _users
}

// 指定した値が配列の何番目かを取得するための関数
// (指定ユーザが部屋に参加しているユーザリストの中の何番目かを取得するために使用)
func indexOfArray(a []string, v string) int {
	for i, _v := range a {
		if _v == v {
			return i
		}
	}

	return -1
}

// 指定した index の値を配列内から削除するための関数
// (指定ユーザを部屋のユーザリストから削除するために使用)
func unset(s []string, i int) []string {
	if i >= len(s) {
		return s
	}
	return append(s[:i], s[i+1:]...)
}

// ユーザが部屋に参加するときに実行される
func (s *server) Join(ctx context.Context, req *pb.JoinRequest) (*pb.JoinResponse, error) {
    // リクエスト内容から部屋の ID を取得
    // 今回のサンプルでは部屋の ID には "world" のみ使用可能
	roomID := req.GetRoomId()

    // 発行するユーザの ID を生成する
	userUUID, _ := uuid.NewRandom()
	userID := userUUID.String()

    // 指定した部屋が存在していなければ、エラーを発生させる
	_, isExistRoom := s.rooms[roomID]
	if !isExistRoom {
		s := fmt.Sprintf("%s's room not found\n", roomID)
		return nil, errors.New(s)
	}
    // 生成したユーザの ID を部屋のユーザリストに追加する
	s.rooms[roomID] = append(s.rooms[roomID], userID)
    // 生成したユーザの ID に紐づくデータを初期化して users に設定する
	s.users[userID] = &pb.User{
		UserId:    userID,
		Transform: &pb.Transform_{},
		Rotation:  &pb.Rotation_{},
	}

    // 生成したユーザの ID を返却する
	return &pb.JoinResponse{
		UserId: userID,
	}, nil
}

// ユーザが自身のデータ及び部屋に参加しているユーザのデータを同期/取得するときに実行される
func (s *server) Sync(ctx context.Context, req *pb.SyncRequest) (*pb.SyncResponse, error) {
	roomID := req.GetRoomId()
	userID := req.GetUser().GetUserId()

    // 部屋が存在しないか、ユーザが部屋に参加していなかった場合、エラーを発生させる
	_, isExistRoom := s.rooms[roomID]
	if !isExistRoom {
		s := fmt.Sprintf("%s's room not found\n", roomID)
		return nil, errors.New(s)
	}
	if !s.isExistUser(roomID, userID) {
		s := fmt.Sprintf("%s's user not found\n", userID)
		return nil, errors.New(s)
	}

    // 部屋全体に通知するためのユーザ(カメラ)自身の位置情報と回転情報を更新する。
	transform := req.GetUser().GetTransform()
	s.users[userID].Transform.X = transform.GetX()
	s.users[userID].Transform.Y = transform.GetY()
	s.users[userID].Transform.Z = transform.GetZ()

	rotation := req.GetUser().GetRotation()
	s.users[userID].Rotation.EulerX = rotation.GetEulerX()
	s.users[userID].Rotation.EulerY = rotation.GetEulerY()
	s.users[userID].Rotation.EulerZ = rotation.GetEulerZ()

    // 最新の部屋内のユーザリスト情報を取得する
	return &pb.SyncResponse{
		Users: s.roomUsers(roomID),
	}, nil
}

// ユーザが部屋から退出する際に実行される
func (s *server) Leave(ctx context.Context, req *pb.LeaveRequest) (*pb.LeaveResponse, error) {
	roomID := req.GetRoomId()
	userID := req.GetUserId()

    // 部屋が存在しないか、ユーザが部屋に参加していなかった場合、エラーを発生させる
	_, isExistRoom := s.rooms[roomID]
	if !isExistRoom {
		s := fmt.Sprintf("%s's room not found\n", roomID)
		return nil, errors.New(s)
	}
	if !s.isExistUser(roomID, userID) {
		s := fmt.Sprintf("%s's user not found\n", userID)
		return nil, errors.New(s)
	}

    // ユーザの情報を部屋からもユーザリストからも削除する
	index := indexOfArray(s.rooms[roomID], userID)
	s.rooms[roomID] = unset(s.rooms[roomID], index)

	return &pb.LeaveResponse{}, nil
}

func main() {
	lis, err := net.Listen("tcp", port)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	s := grpc.NewServer()
    // gRPC サーバの登録を行う
    // 部屋の ID として "world" だけ初期設定に入れておく
    // "world" という部屋にだけユーザは参加可能になる
	pb.RegisterRoomServer(s, &server{
		rooms: map[string][]string{"world": {}},
		users: map[string]*pb.User{},
	})

    // gRPC サーバの起動を行う
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}
```

上記を手っ取り早く動かすには [Github 上のフォルダ](https://github.com/nuhs/Unity_gRPC_Sample/tree/master/golang_server) を GOPATH に設定されているフォルダへコピーしてから、`go run $GOPATH/nikaera.com/gRPC_Sample/server/main.go` を実行することで gRPC サーバが起動します。

# gRPC クライアント側の説明

まずは proto ファイルから C# のコードを protoc コマンドで生成します。
proto ファイルを配置したフォルダ内で下記コマンドを実行します。

```
protoc -I ./ --csharp_out=./ --grpc_out=./ --plugin=protoc-gen-grpc=/usr/local/bin/grpc_csharp_plugin room.proto user.proto
```

正常に実行されれば RoomGrpc.cs, Room.cs, User.cs ファイルが同一フォルダ内に生成されるはずです。RoomGrpc.cs には rpc 関連の内容が出力されていて、Room.cs, User.cs には message 関連の内容が出力されています。

生成されたファイルは Unity プロジェクト内にコピーしておきます。
gRPC を使用して部屋への参加等を実現するためのコードは ↓ になります。

```csharp:Unity_gRPC_Sample/unity_client/gRPC_sample/Assets/Scripts/RoomScript.cs
using UnityEngine;
using System.Linq;
using System.Threading.Tasks;
using System.Threading;
using Grpc.Core;
using World;
using System.Collections.Generic;

public class RoomScript : MonoBehaviour
{
    // 参加したい部屋の ID (現在は "world" のみ有効)
    [SerializeField]
    string roomID;

    // Main Camera をアタッチする
    [SerializeField]
    Transform cameraTransform;

    // 他ユーザを表す GameObject(アバター) を設定する
    [SerializeField]
    GameObject dummyPrefab;

    // gRPC サーバの Join 関数を実行して発行されるユーザの ID を保持する変数
    string userID;

    // gRPC のチャネル及びクライアントを管理するための変数群
    Channel channel;
    World.Room.RoomClient client;

    // 部屋に参加している自分以外のユーザの GameObject (アバター) を管理するための変数
    Dictionary<string, GameObject> userGameObjects = new Dictionary<string, GameObject>();

    void OnEnable()
    {
        // gRPC サーバの TCP リスナー先を指定して、
        // RoomClient クライアントとして接続を行う
        channel = new Channel("localhost:50051", ChannelCredentials.Insecure); 
        client = new World.Room.RoomClient(channel);

        // gRPC サーバの Join を実行し、指定した部屋からユーザの ID を発行してもらう
        JoinResponse response = client.Join(new JoinRequest
        {
            RoomId = this.roomID
        });

        // 発行されたユーザの ID は変数で保持しておく
        this.userID = response.UserId;
    }

    void OnDisable()
    {
        // 参加している部屋から退出する。
        // 退出する際は部屋の ID とユーザ の ID を引数に渡して Leave 関数を実行する
        client.Leave(new LeaveRequest
        {
            UserId = this.userID,
            RoomId = this.roomID,
        });

        // gRPC のチャネルを切る
        channel.ShutdownAsync().Wait();
    }

    // Update is called once per frame
    void Update()
    {
        // 別スレッドでの実行結果をメインスレッドで実行するため
        // メインスレッドのコンテキストを変数として保持しておく
        var context = SynchronizationContext.Current;
        Vector3 position = cameraTransform.position;
        Vector3 rotation = cameraTransform.eulerAngles;

        // 非同期で gRPC サーバの Sync 関数を実行して、
        // gRPC サーバ側で保持している自身の位置/回転情報を更新する。
        Task.Run(() =>
        {
            var response = client.Sync(new SyncRequest
            {
                RoomId = this.roomID,
                User = new User
                {
                    UserId = this.userID,
                    Transform = new Transform_
                    {
                        X = position.x,
                        Y = position.y,
                        Z = position.z,
                    },
                    Rotation = new Rotation_
                    {
                        EulerX = rotation.x,
                        EulerY = rotation.y,
                        EulerZ = rotation.z,
                    }
                },
            });

            // 戻り値は部屋に参加している他ユーザの位置/回転情報が含まれたリスト
            var enumrator = response.Users.GetEnumerator();

            // 処理の実行を MainThread に戻す
            // MainThread では他ユーザの様子(位置/回転情報)を
            // Unity シーンに反映させるための処理を行う
            context.Post(__ =>
        {
            SyncRoom(enumrator);
        }, null);
        });
    }

    private void SyncRoom(IEnumerator<User> enumrator)
    {
        // 部屋から退出済みのユーザの ID を保持しておくための変数
        var exceptUserIDList = new List<string>(userGameObjects.Keys);

        // gRPC サーバの Sync 関数の実行結果をループで参照する
        while (enumrator.MoveNext())
        {
            var user = enumrator.Current;

            // ユーザリストには自分自身も含まれているため、
            // 自分以外のユーザであった場合にその情報を画面に反映させる
            if (this.userID != user.UserId)
            {
                // 既にユーザの GameObject (アバター)が存在していれば、
                // その GameObject (アバター) を取得する
                // 存在していなければ dummyPrefab で指定した内容で、
                // 該当ユーザの GameObject (アバター) を生成する
                GameObject userGameObject;
                if (userGameObjects.ContainsKey(user.UserId))
                {
                    userGameObject = userGameObjects[user.UserId];
                }
                else
                {
                    userGameObject = Instantiate(dummyPrefab, Vector3.zero, Quaternion.identity, this.transform.root);
                    userGameObjects[user.UserId] = userGameObject;
                }

                // GameObject (アバター) にユーザの位置/回転情報を反映させる。
                userGameObject.transform.position = new Vector3(
                  x: user.Transform.X, y: user.Transform.Y, z: user.Transform.Z
                );
                userGameObject.transform.eulerAngles = new Vector3(
                  x: user.Rotation.EulerX, y: user.Rotation.EulerY, z: user.Rotation.EulerZ
                );

                exceptUserIDList.Remove(user.UserId);
            }
        }

        // Sync レスポンスに含まれていないユーザの ID(退出済みのユーザの ID) が
        // 存在していたら該当ユーザの GameObject(アバター) を削除する
        foreach (var userId in exceptUserIDList)
        {
            Destroy(userGameObjects[userId]);
            userGameObjects.Remove(userId);
        }
    }
}

```

上記スクリプトを Unity プロジェクトに追加して、`SerializeField` を適切に設定すれば、
記事冒頭の gif のように Run を行えるはずです。
プロジェクトを Unity で複数開いて Run すると、その度にシーン上にアバターが出現するはずです。

# おわりに

gRPC 関連のソースコードは自動で生成出来るため、
実装部分にのみ注力することが出来ました。
そのため、実際に書いたサーバ及びクライアントコードは 300行程度で済みました。

次回は本記事の内容を HMD で動作させられるようにする予定です。

[MagicOnion](https://github.com/Cysharp/MagicOnion) でもカメラの位置/回転の同期にトライしてみました。(2019/06/19)
https://qiita.com/nuhs/items/3d2a9d6015b13840097f

[^1]: 記事を書いていて気づいたのですが User 内に直接 Transform や Rotation を定義するよりも、 Body や Head という message を作成して、その中に Transform や Rotation を宣言しておいたほうが後々良いかもしれません。。（手足の同期等も取るなら）

# 参考リンク

https://github.com/google/uuid
https://qiita.com/oubakiou/items/0d9ace7a9767cbacca19
https://qiita.com/CyLomw/items/9aa4551bd6bb9c0818b6
https://qiita.com/usk81/items/5ff7bfe27f702d77e909
https://qiita.com/toRisouP/items/a2c1bb1b0c4f73366bc6