search
LoginSignup
2
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

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

はじめに

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 を宣言しておいたほうが後々良いかもしれません。。(手足の同期等も取るなら) 

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
What you can do with signing up
2
Help us understand the problem. What are the problem?