はじめに
Oculus 機器でソーシャル VR を色々試してみたら自分でも作りたくなったので、
Unity の勉強兼ねて最終的に HMD で位置音声を同期するシステムまで作ってみます。
そのための第一歩として、まずは gRPC でカメラの位置/回転を同期させるシステムを作ってみました。
ソースコードは Github 上にアップしておきました。
https://github.com/nuhs/Unity_gRPC_Sample
Go や Unity での gRPC のセットアップ方法は こちら をご参照くださいませ。
gRPC サーバ側の解説
部屋に参加するユーザ間同期を目的として room.proto と user.proto を用意しました。
room.proto には部屋を管理するための RPC 群が定義されています。
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
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 が同一フォルダに生成されます。
サーバ側は部屋の管理及び状態の同期を行います。実際のコードは ↓ になります。
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 を使用して部屋への参加等を実現するためのコードは ↓ になります。
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
-
記事を書いていて気づいたのですが User 内に直接 Transform や Rotation を定義するよりも、 Body や Head という message を作成して、その中に Transform や Rotation を宣言しておいたほうが後々良いかもしれません。。(手足の同期等も取るなら) ↩