LoginSignup
6
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-06-02

はじめに

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

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

↓ のようなシステムになります。qiita.gif

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

Go や Unity での gRPC のセットアップ方法は こちら をご参照くださいませ。

gRPC サーバ側の解説

部屋に参加するユーザ間同期を目的として room.proto と user.proto を用意しました。

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

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

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 ファイルを配置したフォルダ内で下記コマンドを実行します。

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

正常に実行されると room.pb.go と user.pb.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 上のフォルダ を 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 を使用して部屋への参加等を実現するためのコードは ↓ になります。

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 でもカメラの位置/回転の同期にトライしてみました。(2019/06/19)
https://qiita.com/nuhs/items/3d2a9d6015b13840097f

参考リンク

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


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

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2