HoloToolkitのサンプルSharingTestの中身を見る

投稿者: | 2017-04-25

HoloTookitにはライブラリだけでなく各種サンプルが揃っています。SharingTestシーンはSharingについての一番シンプルなサンプルで、SharingしているユーザのHoloLens上にCubeを重畳させるものです。アプリケーションとしては非常にシンプルですが、その実装にはSharingを理解するうえで必要なノウハウが詰まっていましたので、今回はこれをテーマに記事を書きたいと思います。

なお、使用したHoloToolkitのバージョンは1.5.6.0です。

SharingTestの流れ

SharingTestシーンをHoloLensへデプロイしアプリを起動すると、ユーザは何も操作をすることなくSharingが開始されます(成功すれば)。しかし裏側ではいくらかのステップを踏んだ後に位置などの情報の共有が行われます。

Step 1. サーバ(SharingService.exe)への接続

なにはともあれサーバへの接続を行わないと何も始まりません。サーバへの接続はSharing.prefabにアタッチされているSharingStageスクリプトから行われます。インスペクタ上のサーバのIPアドレスと接続ポートだけを指定しておけばとりあえず接続することは可能です。

Step 2. セッションへの接続

サーバへの接続が完了したら、次はセッションへ接続します。セッションとはSharingをしたいユーザ達をまとめておく箱のようなもので、ネトゲにおける鯖やルームのイメージです。

なお、接続先のセッションを識別するときに、IDとしてstring型のデータを使用します。サーバ内にアプリ側で指定したIDと一致するセッションが存在しない場合にクライアントから新しくセッションを作る、といったことも可能です。

SharingTestではSharing.prefabにアタッチされているAutoJoinSessionスクリプトにてセッションの接続が行われます。インスペクタから設定できるSession Nameプロパティで指定したセッションに接続を試み、セッションが存在しない場合は新規にセッションを作成します。

Step 3. アンカーのアップロード/ダウンロード

SharingTestをはじめ、Academy240やその他のサンプルでは、ユーザ間で共有するSharingの基準点としてWorldAnchorを使用します(WorldAnchorについては過去の記事等を参照ください)。WorldAnchorにはアタッチしたGameObjectのTransform、すなわち座標系についてのデータもふくまれているようですので、これをサーバにアップロード、またダウンロードすることによりユーザ間でSharingができるようになります。

なお、アンカーをアップロードする際に、セッション内にRoomという領域が作られます。これは名が表す通りSharingをする部屋を表しており、この中に空間についてのデータ、すなわちWorldAnchorが保存されます。

また、Roomの識別子もstringが使用されます。SharingTestではHologramCollectionにアタッチされているImportExportAnchorManagerスクリプトにてアンカーのアップロード/ダウンロードが行われており、インスペクタ上のRoom Nameプロパティを使ってRoomにinします。該当のRoomがまだ出来ていない場合は新しくRoomを作成しアンカーをアップロードします。

ここまでがSharingの準備段階です。

おさらいとしてここまでをオンライン格ゲーで例えると、ゲーム起動(サーバに接続)→ガチ勢、エンジョイ勢、初心者等のロビー選択(セッションに接続)→3on3、負け抜け等のルーム選択(ルームにアンカーをアップロード/ダウンロード)と言ったところでしょうか。むしろわかりづらくなっていたらすみません。

Step 4. CameraのPositionとRotationを共有

ここからがユーザに体験させたい部分となります。リモートユーザのHoloLens上にCubeを重畳表示してもらうため、アンカーを設置したポイントを基準としたCamera(HoloLens)のPositionとRotationをサーバ(SharingService.exe)を介して他のユーザにブロードキャストします。メッセージを受信したユーザは、受け取ったPositionとRotationが表すポイントにCubeを表示します。

位置などの情報のやり取りはHologramCollectionにアタッチされているRemoteHeadManagerスクリプトとCustomMessageスクリプトで行われます。前者で位置情報等の計算を行い、後者はデータの送受信を担当しています。

各Stepの実装

ここからは各Stepの実装を追うためコードの中身を見ていきます。しかしながらコードの一部分はアンマネージプラグインによるところもありますので、一から十まで把握したい場合はプラグインのコードも追いかける必要があります。本記事ではマネージコードだけを対象にします。

Step 1. SharingStaga.cs

サーバ(SharingService.exe)への接続はこのスクリプトで行います。といっても処理自体はほとんどがプラグイン側で行いますので、このスクリプトだけでは抽象的な処理の流れしかわかりません。また、サーバへの接続以外にも、サーバの自動検索、同期オブジェクトの設定等の機能も備えております。ここではサーバへの接続部分にだけ絞って解説します。

まずはアプリが立ち上がった時の処理です。

public class SharingStage : Singleton<SharingStage>
{
    // アプリインスタンス毎のユニークID
    public string AppInstanceUniqueId { get; private set; }
    // UnityEditorへのログを出力するクラス
    private ConsoleLogWriter logWriter;
    // サーバを自動で検索するかどうか、インスペクタから設定できる
    public bool AutoDiscoverServer
    
    protected override void Awake()
    {
        base.Awake();
        // アプリのインスタンス毎にユニークなIDを振る
        AppInstanceUniqueId = Guid.NewGuid().ToString();
        // ログ出力用のクラスをインスタンス化
        // プラグインが吐き出すログをUnity Editor上で確認できるようになる
        logWriter = new ConsoleLogWriter();
        logWriter.ShowDetailedLogs = ShowDetailedLogs;

        if (AutoDiscoverServer) {
            // 設定に応じて自動検索を行う
            AutoDiscoverInit();
        } else {
            // インスペクタで設定したIP/Portに基づいて接続する
            Connect();
        }
    }

自動検索機能は正直わからないところが多く、試しに使ってみても全く接続できませんでした。接続先サーバの情報は直接指定したほうが良いと思います。つづいてConnectメソッドを見ていきます。

private void Connect()
{
    // サーバへの接続に必要なコンフィグを設定する

    // ClientRole, AudioEndpointはまだよくわかっていない…。
    var config = new ClientConfig(ClientRole);
    config.SetIsAudioEndpoint(IsAudioEndpoint);
    config.SetLogWriter(logWriter);

    // インスペクタでconnectOnAwakeを設定した場合
    if (connectOnAwake)
    {
        // サーバIPとPortをコンフィグに設定
        config.SetServerAddress(ServerAddress);
        config.SetServerPort(ServerPort);
    }

    // コンフィグをもとに、サーバへの接続クライアントであるSharingManagerを取得
    Manager = SharingManager.Create(config);

    // サーバへのコネクションに接続完了時のイベントハンドラを設定する
    // コネクションの取得
    networkConnection = Manager.GetServerConnection();
    // コネクションにイベントハンドラを設定するにはアダプタ経由で行う
    networkConnectionAdapter = new NetworkConnectionAdapter();
    networkConnectionAdapter.ConnectedCallback += NetworkConnectionAdapter_ConnectedCallback;
    networkConnection.AddListener((byte)MessageID.StatusOnly, networkConnectionAdapter);
    
    // 同期オブジェクト機能を使うための設定、よくわかってない…。
    SyncStateListener = new SyncStateListener();
    Manager.RegisterSyncListener(SyncStateListener);
    Root = new SyncRoot(Manager.GetRootSyncObject());
    
    // セッショントラッカーとユーザトラッカーを取得(それぞれの詳細は後述)
    SessionsTracker = new ServerSessionsTracker(Manager.GetSessionManager());
    SessionUsersTracker = new SessionUsersTracker(SessionsTracker);
    
    // 自身のユーザ名を設定する
    using (var userName = new XString(DefaultUserName))
    {
    
    // デバイス名をユーザ名とする
    Manager.SetUserName(SystemInfo.deviceName);
    }
}

コンフィグを設定してサーバへの接続を試みます。サーバへの接続や通信に必要なコンポーネントはこのSharingManagerクラスに入っており、サーバと通信を行いたい場合にはSharingManagerからコネクションを取得して行います。

接続が完了するとイベントが発火され、コールバックとして設定したNetworkConnectionAdapter_ConnectedCallbackがコールされます。

private void NetworkConnectionAdapter_ConnectedCallback(NetworkConnection obj)
{
    SendConnectedNotification();
}

// SharingManagerがサーバへの接続が完了した際のイベント
public event EventHandler SharingManagerConnected;

private void SendConnectedNotification()
{
    // サーバに接続できているかはコネクション(NetworkConnectionクラス)から確認できる
    if (Manager.GetServerConnection().IsConnected())
    {
        // サーバへの接続完了イベントを発火
        EventHandler connectedEvent = SharingManagerConnected;
        if (connectedEvent != null)
        {
            connectedEvent(this, EventArgs.Empty);
        }
    }
    else
    {
        Log.Error(string.Format("Cannot connect to server {0}:{1}", ServerAddress, ServerPort.ToString()));
    }
}

サーバへの接続が完了したタイミングで各スクリプトに通知を行います。各スクリプトはこの通知を待ち受け、通知を得た時点からオンライン処理を始めるように実装する必要があります。

正直、私自身SharingStage(というかネイティブプラグイン部分)はわかってないところが多いのですが、上記の流れさえ把握しておけばある程度のSharingアプリは作れると思います。

Step 2. AutoJoinSession.cs

続いてセッションへの接続ですが、HoloToolkitではオートでセッションへの接続を行うヘルパースクリプトAutoJoinSessionを用意しています。Sharing.prefabにアタッチされているので、prefabを設置するだけでセッションへの接続までは自動で行われます。

セッションへ接続する時にはSharingStageにて取得したServerSessionTrackerクラスで行っており、このクラスにはサーバ内のセッションの検索や接続、セッションの新規作成といった機能を備えています。

シンプルなコードなので一気に説明します。

public class AutoJoinSession : MonoBehaviour
{
    // 接続先のセッション名
    public string SessionName = "Default";
    
    // セッショントラッカーはサーバ内のセッション情報を参照したり、セッションへの接続を行ったりするクラス
    private ServerSessionsTracker sessionsTracker;
    
    // セッションの新規作成を行っているかどうか
    private bool sessionCreationRequested;
    // 接続先セッションを変更したときは、変更前のセッション名を記録しておく
    private string previousSessionName;

    private void Start() {
        // SharingManagerからセッショントラッカーを取得する
        if (SharingStage.Instance != null && SharingStage.Instance.Manager != null) {
            sessionsTracker = SharingStage.Instance.SessionsTracker;
        }
    }

    private void Update() {
        // 初回接続時や対象セッションを変更した場合
        if (previousSessionName != SessionName)
        {
            sessionCreationRequested = false;
            previousSessionName = SessionName;
        }
        // SharingService.exe起動時にサーバ内で「Default」という名前でセッションが作られる
        // したがって左辺の後半部分の判定はtrueになる
        if (sessionsTracker != null && sessionsTracker.Sessions.Count > 0)
        {
            // GetCurrentSessionは現在接続しているセッションを取得するメソッド
            // 初回時はどのセッションにも接続していないのでnullが返ってくる
            Session currentSession = sessionsTracker.GetCurrentSession();
            // 初回接続などの理由でnullの場合
            if (currentSession == null ||
                // または接続先セッションとインスペクタで設定したセッション名が異なる場合
                currentSession.GetName().GetString() != SessionName ||
                // または接続先セッションのステータスがおかしい場合
                currentSession.GetMachineSessionState() == MachineSessionState.DISCONNECTED)
            {
                if (SharingStage.Instance.ShowDetailedLogs)
                {
                    Debug.LogFormat("AutoJoinSession: Session connected is {0} with {1} Sessions.", sessionsTracker.IsServerConnected.ToString(), sessionsTracker.Sessions.Count.ToString());
                    Debug.Log("AutoJoinSession: Looking for " + SessionName);
                }
                // セッション接続できていないフラグ
                bool sessionFound = false;
                // サーバ内のセッションを走査する
                for (int i = 0; i < sessionsTracker.Sessions.Count; ++i)
                {
                    Session session = sessionsTracker.Sessions[i];
                    // インスペクタで設定したセッション名と名前が一致した場合
                    if (session.GetName().GetString() == SessionName)
                    {
                        // そのセッションに接続する
                        sessionsTracker.JoinSession(session);
                        sessionFound = true;
                        break;
                    }
                }
                // 該当するセッションが見つからなかった場合
                if (sessionsTracker.IsServerConnected && !sessionFound && !sessionCreationRequested)
                {
                    if (SharingStage.Instance.ShowDetailedLogs)
                    {
                        Debug.Log("Didn't find session, making a new one");
                    }
                    // セッションを新しく作成する
                    if (sessionsTracker.CreateSession(new XString(SessionName)))
                    {
                        sessionCreationRequested = true;
                    }
                }
            }
        }
    }
}

やっていることは非常にシンプルで、サーバに指定した名前のセッションがあればそこに接続する、なければ新しくセッションを作る、というものです。

Step 3. ImportExportAnchorManager.cs

このサンプルの中でもっとも面白い部分です。アンカーをサーバ上のRoomにアップロード、またはRoomからダウンロードするステップですが、内部では様々な状態を経て処理が行われます。

まずはアプリ起動時にローカルにアンカーストアを用意し、サーバへ接続するのを待ちます。

public class ImportExportAnchorManager : Singleton&lt;ImportExportAnchorManager&gt; {
    // 進行状況を表す列挙体
    private enum ImportExportState {
        // Overall states
        Start
        Failed,
        Ready,
        RoomApiInitialized,
        AnchorEstablished,
        // AnchorStore states
        AnchorStore_Initializing,
        // Anchor creation values
        InitialAnchorRequired,
        CreatingInitialAnchor,
        ReadyToExportInitialAnchor,
        UploadingInitialAnchor,
        // Anchor values
        DataRequested,
        DataReady,
        Importing
    }
    // 現在のステータス、Startから始まる
    private ImportExportState currentState = ImportExportState.Start;
    
    // アプリが使用するアンカーストア(ローカルの保存領域)
    private WorldAnchorStore anchorStore;
    // Sharingの基準となるGameObjectに取り付けるアンカー
    private WorldAnchor thisAnchor;
    // Roomに一人もユーザがいなくても維持するかどうか
    public bool KeepRoomAlive;

    protected override void Awake() {
        base.Awake();
        // ステータスを更新
        currentState = ImportExportState.AnchorStore_Initializing;
        // アンカーストアを用意、コールバックを設定
        WorldAnchorStore.GetAsync(AnchorStoreReady);
    }

    // アンカーストアの用意が完了したときのコールバック
    private void AnchorStoreReady(WorldAnchorStore store) {
        // アンカーストアを取得
        anchorStore = store;
        // KeepAliveフラグが立っていないなら、ストアをクリアする
        if (!KeepRoomAlive) {
            anchorStore.Clear();
        }
        // ステータスを更新
        currentState = ImportExportState.Ready;
    }

    private void Start() {
        // HologramCollectionにアンカーを配置する
        thisAnchor = GetComponent<WorldAnchor>() ?? gameObject.AddComponent<WorldAnchor>();
        // サーバへ接続が完了したらConnected()を実行する
        if (SharingStage.Instance.IsConnected) {
            // Start()時点でサーバへの接続が確立できていたら直ちに実行
            Connected();
        } else {
            // まだ接続ができていなかったら、イベント通知のタイミングで実行できるようコールバックを設定
            SharingStage.Instance.SharingManagerConnected += Connected;
        }
    }
    
    // Room関連のAPI
    private RoomManager roomManager;
    // Room APIへのアダプタ
    private RoomManagerAdapter roomManagerListener;
    // デバッグ用テキスト
    // アプリ起動時に目の前にいるSphere付近に表示される
    public TextMesh AnchorDebugText;
    private void Connected(object sender = null, EventArgs e = null) {
        // コールバック設定を外す
        SharingStage.Instance.SharingManagerConnected -= Connected;
        // 以下、ログ出力
        if (SharingStage.Instance.ShowDetailedLogs) {
            Debug.Log("Anchor Manager: Starting...");
        }
        if (AnchorDebugText != null) {
            AnchorDebugText.text += "\nConnected to Server";
        }
        // SharingManagerからRoom関連のAPIを実行するためのインスタンスを取得
        roomManager = SharingStage.Instance.Manager.GetRoomManager();
        roomManagerListener = new RoomManagerAdapter();
        // Room APIも直接は操作できないのでアダプター経由でコールバックを設定する
        roomManagerListener.AnchorsChangedEvent += RoomManagerCallbacks_AnchorsChanged;
        roomManagerListener.AnchorsDownloadedEvent += RoomManagerListener_AnchorsDownloaded;
        roomManagerListener.AnchorUploadedEvent += RoomManagerListener_AnchorUploaded;
        roomManager.AddListener(roomManagerListener);
        // セッションにユーザが接続したとき、切断したときのコールバックを設定する
        SharingStage.Instance.SessionsTracker.CurrentUserJoined += CurrentUserJoinedSession;
        SharingStage.Instance.SessionsTracker.CurrentUserLeft += CurrentUserLeftSession;
    }

    // Sharingが開始できるよう、セッションへの接続ができているかどうかのフラグ
    private bool sharingServiceReady;

    // 自身がセッションに接続したときのコールバック、前述のフラグを立てる
    private void CurrentUserJoinedSession(Session session) {
        // ユーザがセッションに接続できていることを確認
        if (SharingStage.Instance.Manager.GetLocalUser().IsValid()) {
            // Sharingの準備が完了した
            sharingServiceReady = true;
        } else {
            Debug.LogWarning("Unable to get local user on session joined");
        }
    }

このスクリプトではUpdate()内にてステータスに応じてswitchで処理を分岐させています。

初期処理として各種設定、サーバへの接続が確認出来たら、次はRoomの設定を行います。

private void Update() {
    switch (currentState) {
        case ImportExportState.Ready:
            if (sharingServiceReady) {
                // このアプリが使用するRoomをセットアップする
                StartCoroutine(InitRoomApi());
            }
            break;
    
        ~中略~

    }
}

// 現在inしているRoomを表す
private Room currentRoom;
// Roomを新しく作るときに使うID
private long roomID = 8675309;
// inするRoom名
public string RoomName = "DefaultRoom";

// Roomのセットアップ
private IEnumerator InitRoomApi() {
    // Roomにinしていないことを確認する
    currentRoom = roomManager.GetCurrentRoom();

    while (currentRoom == null) {
        // セッション内のRoomの数が0ならば
        if (roomManager.GetRoomCount() == 0) {
            // Roomを新規に作成する権限を持っているか確認する
            if (ShouldLocalUserCreateRoom) {
                // 以下、ログ出力
                if (SharingStage.Instance.ShowDetailedLogs) {
                    Debug.Log("Anchor Manager: Creating room " + RoomName);
                }
                if (AnchorDebugText != null) {
                    AnchorDebugText.text += string.Format("\nCreating room " + RoomName);
                }
                // 権限を持っているのでRoomを新しく作る
                // インスペクタで設定したIDとKeepRoomAliveを指定して作る
                // KeepRoomAliveがtrueだと、Room内のユーザ数が0になってもセッションにRoomが残る
                currentRoom = roomManager.CreateRoom(new XString(RoomName), roomID, KeepRoomAlive);
            }
        } else {
            // すでにRoomが存在するならば、セッション内のRoomを走査する
            int roomCount = roomManager.GetRoomCount();
            for (int i = 0; i < roomCount; i++) {
                // インスペクタで指定した名前と一致するRoomがあれば、そこにinする
                Room room = roomManager.GetRoom(i);
                if (room.GetName().GetString().Equals(RoomName, StringComparison.OrdinalIgnoreCase)) {
                    // inする
                    currentRoom = room;
                    roomManager.JoinRoom(currentRoom);
                    // 以下、ログ出力
                    if (SharingStage.Instance.ShowDetailedLogs) {
                        Debug.Log("Anchor Manager: Joining room " + room.GetName().GetString());
                    }
                    if (AnchorDebugText != null) {
                        AnchorDebugText.text += string.Format("\nJoining room " + room.GetName().GetString());
                    }
                    break;
                }
            }
            // 指定した名前と一致するRoomが見つからなかったら
            if (currentRoom == null) {
                // 走査上、一番最初に見つかったRoomにinする
                currentRoom = roomManager.GetRoom(0);
                roomManager.JoinRoom(currentRoom);
                // インスタンス内で管理している名前は上書き
                RoomName = currentRoom.GetName().GetString();
            }
            // ステータスを更新
            currentState = ImportExportState.RoomApiInitialized;
        }
        // 1F待機
        yield return new WaitForEndOfFrame();
    }
    if (currentRoom.GetAnchorCount() == 0) {
        // inしたRoomにまだアンカーがアップロードされていない場合のステータス設定
        currentState = ImportExportState.InitialAnchorRequired;
    } else {
        // inしたRoomにすでにアンカーがアップロードされていた場合のステータス設定
        currentState = ImportExportState.RoomApiInitialized;
    }
    // 以下、ログ出力
    if (SharingStage.Instance.ShowDetailedLogs) {
        Debug.LogFormat("Anchor Manager: In room {0} with ID {1}",
            roomManager.GetCurrentRoom().GetName().GetString(),
            roomManager.GetCurrentRoom().GetID().ToString());
    }
    if (AnchorDebugText != null) {
        AnchorDebugText.text += string.Format("\nIn room {0} with ID {1}",
            roomManager.GetCurrentRoom().GetName().GetString(),
            roomManager.GetCurrentRoom().GetID().ToString());
    }
    yield return null;
}

セッションの時と同様、すでにRoomが存在すればそこにinし、存在しなければRoomを新規に作成します。inしたRoomにアンカーがすでにアップロードされているかどうかで、この後の処理が大きく変わります。

まずはアンカーが存在しないとき、アンカーのエクスポートとアップロードを行う部分を見ていきます。アンカーをエクスポートするには当然アンカーが現実空間に固定されてないといけませんので、まずはそれを待ちます。

private void Update() {
    switch (currentState) {

        ~省略~
        
        case ImportExportState.InitialAnchorRequired:
            // ステータスをアンカー準備状態に更新
            currentState = ImportExportState.CreatingInitialAnchor;
            // アンカーを現実空間に固定されるのを待つ
            CreateAnchorLocally();
            break;

        ~省略~

    }
}

private void CreateAnchorLocally() {
    if (thisAnchor.isLocated) {
        // アンカーが現実空間に固定されたらステータス更新
        currentState = ImportExportState.ReadyToExportInitialAnchor;
    } else {
        // アンカーが固定されたときのコールバックを設定
        thisAnchor.OnTrackingChanged += Anchor_OnTrackingChanged_InitialAnchor;
    }
}

private void Anchor_OnTrackingChanged_InitialAnchor(WorldAnchor self, bool located) {
    // アンカーが現実空間に固定されたかどうか
    if (located) {
        // 以下、ログ出力
        if (SharingStage.Instance.ShowDetailedLogs) {
            Debug.Log("Anchor Manager: Found anchor, ready to export");
        }
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nFound anchor, ready to export");
        }
        // アンカーのアップロードの準備が整った状態にステータスを更新
        currentState = ImportExportState.ReadyToExportInitialAnchor;
    } else {
        // アンカーの挙動がおかしい場合はステータスFailedへ
        Debug.LogError("Anchor Manager: Failed to locate local anchor!");
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nFailed to locate local anchor!");
        }
        currentState = ImportExportState.Failed;
    }
    // コールバック設定を外す
    self.OnTrackingChanged -= Anchor_OnTrackingChanged_InitialAnchor;
}

アンカーの固定が完了したらアンカーのエクスポートを行いますが、ここでいうエクスポートとはバイナリへのシリアライズを指しています。

private void Update() {
    switch (currentState) {

        ~省略~

        case ImportExportState.ReadyToExportInitialAnchor:
            // ステータスをアンカーアップロード中状態に更新
            currentState = ImportExportState.UploadingInitialAnchor;
            // アンカーをRoomにアップロードするため、まずはエクスポート(シリアライズ)処理を行う
            Export();
            break;
    }
}

// アンカーの識別子
private string exportingAnchorName;
// シリアライズしたアンカーを格納するバイナリ
private List<byte> exportingAnchorBytes = new List<byte>();

// アンカーのエクスポート(シリアライズ)
private void Export() {
    // アンカーの識別子としてGUIDを取得
    string guidString = Guid.NewGuid().ToString();
    exportingAnchorName = guidString;
    // ローカルのストアにアンカーを保存
    if (thisAnchor != null && anchorStore.Save(exportingAnchorName, thisAnchor)) {
        // 保存成功時
        if (SharingStage.Instance.ShowDetailedLogs) {
            Debug.Log("Anchor Manager: Exporting anchor " + exportingAnchorName);
        }
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nExporting anchor {0}", exportingAnchorName);
        }
        // WorldAnchorTransferBatchにアンカーのシリアライズを委譲
        sharedAnchorInterface = new WorldAnchorTransferBatch();
        sharedAnchorInterface.AddWorldAnchor(guidString, thisAnchor);
        // アンカーのシリアライズは非同期で行われるので、第三引数にシリアライズ完了時のコールバックを設定する
        // 第二引数にはシリアライズしたデータを書き込む処理をコールバックとして指定する
        WorldAnchorTransferBatch.ExportAsync(sharedAnchorInterface, WriteBuffer, ExportComplete);
    } else {
        // ローカルへのストアに失敗した場合は、アンカーの固定からやりなおす
        Debug.LogWarning("Anchor Manager: Failed to export anchor, trying again...");
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nFailed to export anchor, trying again...");
        }
        // ステータスを更新
        currentState = ImportExportState.InitialAnchorRequired;
    }
}

// シリアライズしたデータをリストに配置する
private void WriteBuffer(byte[] data) {
    exportingAnchorBytes.AddRange(data);
}

アンカーのシリアライズや後のデシリアライズはWorldAnchorTransferBatchクラスで行います。シリアライズ/デシリアライズは少し時間のかかる処理なので非同期で行われ、完了時にコールバック関数が呼び出されます。そのコールバック関数にシリアライズの実行結果の成否がセットされるので、その内容を見ながら処理を行います。

// シリアライズ完了時のコールバック
// 引数にシリアライズ結果のステータスがセットされる
private void ExportComplete(SerializationCompletionReason status) {
    // シリアライズが成功しており、かつデータサイズが規定値(100KB)を満たしていることを確認
    if (status == SerializationCompletionReason.Succeeded 
               && exportingAnchorBytes.Count > MinTrustworthySerializedAnchorDataSize) {
        // 以下、ログ出力
        if (SharingStage.Instance.ShowDetailedLogs) {
            Debug.Log("Anchor Manager: Uploading anchor: " + exportingAnchorName);
        }
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nUploading anchor: " + exportingAnchorName);
        }
        // inしているRoomにアンカーをアップロードする
        roomManager.UploadAnchor(
            currentRoom,
            new XString(exportingAnchorName),
            exportingAnchorBytes.ToArray(),     // シリアライズしたbyteのリストをbyte[]に変換
            exportingAnchorBytes.Count);        // アップロードするデータのバイト数
    } else {
        // シリアライズが失敗した場合は、アンカーの固定からやり直す
        Debug.LogWarning("Anchor Manager: Failed to upload anchor, trying again...");
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nFailed to upload anchor, trying again...");
        }
        // ステータスを戻す
        currentState = ImportExportState.InitialAnchorRequired;
    }
}

シリアライズ処理はたまに失敗する(デシリアライズよりはましな気がする)ので、失敗時は再処理を行うようにしないとアプリとして成り立たなくなります。

アンカーのシリアライズが完了したら、Room APIのUploadAnchorメソッドを使ってアンカーをアップロードします。なお、アンカーのアップロードが完了すると、サーバへの接続完了時に設定したコールバックが呼び出されます。

private void Connected(object sender = null, EventArgs e = null) 
    ~省略~

    roomManagerListener.AnchorsChangedEvent += RoomManagerCallbacks_AnchorsChanged;
    roomManagerListener.AnchorsDownloadedEvent += RoomManagerListener_AnchorsDownloaded;
    roomManagerListener.AnchorUploadedEvent += RoomManagerListener_AnchorUploaded;
}

それではRoomManagerListener_AnchorUploadedメソッドの内容を見ていきます。

/// アンカーのアップロードが完了したときのコールバック
/// 第一引数に成否が、第二引数に失敗時の理由がセットされる
private void RoomManagerListener_AnchorUploaded(bool successful, XString failureReason) {
    if (successful) {
        // 成功時
        if (SharingStage.Instance.ShowDetailedLogs) {
            Debug.Log("Anchor Manager: Sucessfully uploaded anchor");
        }
        if (AnchorDebugText != null) {
            AnchorDebugText.text += "\nSucessfully uploaded anchor";
        }
        // ステータスを準備完了状態に変更する
        currentState = ImportExportState.AnchorEstablished;
    } else {
        // 失敗時はFailedステータスとする
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\n Upload failed " + failureReason);
        }
        Debug.LogError("Anchor Manager: Upload failed " + failureReason);
        currentState = ImportExportState.Failed;
    }
    // アンカーアップロード完了イベントを発火
    if (AnchorUploaded != null) {
        AnchorUploaded(successful);
    }
}

// アンカーアップロード完了時のイベント
// 引数にアップロードの成否がセットされる
public event Action<bool> AnchorUploaded;

アンカーのアップロードが無事完了すれば、アップロード側のSharingの準備は完了です。このタイミングでサンプルのようにイベントを発火するようにしておけば、Sharing開始のタイミングを掴んで何か処理させるということも可能です。

続いてアンカーをダウンロードする側を見ていきます。

private void Update() {
    switch (currentState) {

        ~中略~

        case ImportExportState.RoomApiInitialized:
            // Roomからアンカーをダウンロードする
            StartAnchorProcess();
            break;
    
            ~中略~

    }
}

private void StartAnchorProcess() {
    // inしているRoom内のアンカーの数を確認
    int anchorCount = currentRoom.GetAnchorCount();
    // 以下、ログ出力
    if (SharingStage.Instance.ShowDetailedLogs) {
        Debug.LogFormat("Anchor Manager: {0} anchors found.", anchorCount.ToString());
    }
    if (AnchorDebugText != null) {
        AnchorDebugText.text += string.Format("\n{0} anchors found.", anchorCount.ToString());
    }
    if (anchorCount > 0) {
        // Roomにアップロードされているアンカーの名前を取得
        XString storedAnchorString = currentRoom.GetAnchorName(0);
        string storedAnchorName = storedAnchorString.GetString();
        // ローカルに該当するアンカーが存在しないか確認する
        if (AttachToCachedAnchor(storedAnchorName) == false) {
            // 存在しなかった場合
            if (SharingStage.Instance.ShowDetailedLogs) {
                Debug.Log("Anchor Manager: Starting room anchor download of " + storedAnchorString);
            }
            if (AnchorDebugText != null) {
                AnchorDebugText.text += string.Format("\nStarting room anchor download of " + storedAnchorString);
            }
            // Roomからアンカーをダウンロードする
            MakeAnchorDataRequest();
        }
    }
}

// Roomに保存されているアンカーが、ローカルに保存されているかチェックする
private bool AttachToCachedAnchor(string anchorName) {
    // 以下、ログ出力
    if (SharingStage.Instance.ShowDetailedLogs) {
        Debug.LogFormat("Anchor Manager: Looking for cahced anchor {0}...", anchorName);
    }
    if (AnchorDebugText != null) {
        AnchorDebugText.text += string.Format("\nLooking for cahced anchor {0}...", anchorName);
    }
    // ローカルのアンカーストアから全てのアンカー名を取り出して走査する
    string[] ids = anchorStore.GetAllIds();
    for (int index = 0; index < ids.Length; index++) {
        // アップロードされたアンカーが、過去ローカルに保存されていた場合
        if (ids[index] == anchorName) {
            // 以下、ログ出力
            if (SharingStage.Instance.ShowDetailedLogs) {
                Debug.LogFormat("Anchor Manager: Attempting to load cached anchor {0}...", anchorName);
            }
            if (AnchorDebugText != null) {
                AnchorDebugText.text += string.Format("\nAttempting to load cached anchor {0}...", anchorName);
            }
            // アンカーをロードしてアタッチする
            WorldAnchor anchor = anchorStore.Load(ids[index], gameObject);
            if (anchor.isLocated) {
                // ロードした直後にアンカーの固定が完了した場合は
                // 直ちに準備完了メソッドをコール
                AnchorLoadComplete();
            } else {
                if (AnchorDebugText != null) {
                    AnchorDebugText.text += "\n" + anchorName;
                }
                // そうでない場合はコールバックを設定
                anchor.OnTrackingChanged += ImportExportAnchorManager_OnTrackingChanged_Attaching;
                // ステータスを準備完了状態に変更する(ここでやらなくてもいい気が…)
                currentState = ImportExportState.AnchorEstablished;
            }
            // アンカーがローカルに保存されていたのでtrueを返す
            return true;
        }
    }
    // アンカーがローカルのストアに存在しない場合はfalseを返す
    return false;
}

// ローカルに保存されていたアンカーが現実空間に固定できたときのコールバック
private void ImportExportAnchorManager_OnTrackingChanged_Attaching(WorldAnchor self, bool located) {
    if (located) {
        // 固定がうまくできた
        AnchorLoadComplete();
    } else {
        Debug.LogWarning("Anchor Manager: Failed to find local anchor from cache.");
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nFailed to find local anchor from cache.");
        }
        // 固定がうまくできなかった場合は、Roomからダウンロードする
        // 同じ名前で違う部屋でアンカーを保存している場合に起こりうる
        MakeAnchorDataRequest();
    }
    self.OnTrackingChanged -= ImportExportAnchorManager_OnTrackingChanged_Attaching;
}

private void AnchorLoadComplete() {
    // アンカーのロードが完了したのでイベントを発火
    if (AnchorLoaded != null) {
        AnchorLoaded();
    }
    // ステータスを準備完了状態に変更する
    currentState = ImportExportState.AnchorEstablished;
}

ダウンロードするとはいえ、アプリを再起動したなどの理由でアンカーがローカルに保存されている可能性もあるので、まずはローカルのアンカーストアを確認し、アップロードされているアンカーと同じ名前のものがないか確認します。アンカーが見つかった場合はそれを使用してSharingの準備完了とし、見つからなかった場合および見つかったとしてもうまく固定化できなかった場合にダウンロードを行います。

private void MakeAnchorDataRequest() {
    // アンカーのダウンロードリクエストを投げる
    if (roomManager.DownloadAnchor(currentRoom, currentRoom.GetAnchorName(0))) {
        // ステータスアンカーダウンロード中状態に変更する
        currentState = ImportExportState.DataRequested;
    } else {
        // リクエストが失敗した場合
        Debug.LogError("Anchor Manager: Couldn't make the download request.");
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nCouldn't make the download request.");
        }
        // ステータスをFailed状態に変更する
        currentState = ImportExportState.Failed;
    }
}

/// アンカーのダウンロードが完了したときのコールバック
/// 第一引数にダウンロードの成否が、第二引数にダウンロードしたデータが、第三引数に失敗時の理由がセットされる
private void RoomManagerListener_AnchorsDownloaded(bool successful, AnchorDownloadRequest request, XString failureReason) {
    if (successful) {
        // 成功した場合
        // データのバイト数を取得
        int datasize = request.GetDataSize();
        if (SharingStage.Instance.ShowDetailedLogs) {
            Debug.LogFormat("Anchor Manager: Anchor size: {0} bytes.", datasize.ToString());
        }
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nAnchor size: {0} bytes.", datasize.ToString());
        }
        // バイナリバッファを確保
        rawAnchorData = new byte[datasize];
        // バッファにデータを移す
        request.GetData(rawAnchorData, datasize);
        // ステータスを変更する
        currentState = ImportExportState.DataReady;
    } else {
        // 失敗した場合
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nAnchor DL failed " + failureReason);
        }
        // 再度ダウンロードを試みる
        Debug.LogWarning("Anchor Manager: Anchor DL failed " + failureReason);
        MakeAnchorDataRequest();
    }
}

アンカーのダウンロードはアップロードの時と同様にRoom APIを使用します。ダウンロードが完了するとRoomManagerからAnchorsDownloadedEventが発火されますので、それをコールバックで処理します。その結果、ダウンロードに失敗していたら、再度ダウンロードを試みます。

アンカーをダウンロードしデータをバッファに移し終えたら、続いてアンカーをでデシリアライズしていきます。デシリアライズは前述したとおり、WorldAnchorTransferBatchクラスにて行います。

private void Update() {
    switch (currentState) {
        
        ~省略~

        case ImportExportState.DataReady:
            // アンカーのダウンロードが完了したので、ステータスをインポート中状態に変更する
            currentState = ImportExportState.Importing;
            // アンカーをデシリアライズしてインポートする
            WorldAnchorTransferBatch.ImportAsync(rawAnchorData, ImportComplete);
            break;

        ~省略~

    }
}

// アンカーをデシリアライズが完了したときのコールバック
private void ImportComplete(SerializationCompletionReason status, WorldAnchorTransferBatch anchorBatch) {
    if (status == SerializationCompletionReason.Succeeded) {
        if (anchorBatch.GetAllIds().Length > 0) {
            // デシリアライズしたアンカーの名前を取得
            string first = anchorBatch.GetAllIds()[0];
            // 以下、ログ出力
            if (SharingStage.Instance.ShowDetailedLogs) {
                Debug.Log("Anchor Manager: Sucessfully imported anchor " + first);
            }
            if (AnchorDebugText != null) {
                AnchorDebugText.text += string.Format("\nSucessfully imported anchor " + first);
            }
            // アンカーをGameObjectに取り付け現実空間に固定し、ローカルのストアに保存する
            WorldAnchor anchor = anchorBatch.LockObject(first, gameObject);
            anchorStore.Save(first, anchor);
        }
        // アンカーのインポートが完了したのでステータスを変更する
        AnchorLoadComplete();
    } else {
        // インポートに失敗した場合(よく失敗する)
        Debug.LogError("Anchor Manager: Import failed");
        if (AnchorDebugText != null) {
            AnchorDebugText.text += string.Format("\nImport failed");
        }
        // ステータスを戻し、再度インポートを試みる
        currentState = ImportExportState.DataReady;
    }
}

無事アンカーのインポートが完了すれば、ダウンロード側の準備も完了です。

しかしながら過去の記事でも書いたように、アンカーのダウンロードがかなりの曲者で結構な確率で失敗します(その考察についてはこちら)。過去の記事にて別の手法でSharingを実現する方法も書いていますのでご参照ください。

お手軽にSharingの接続成功率と精度をあげる
Vuforiaを使ってSharingの接続成功率をあげる

Step 4. RemoteHeadManager.csとCustomMessage.cs

続いて、互いのHoloLensの位置や向きを共有する部分です。前節に書いた通り、CustomMessage.csがサーバへの通信を行い、RemoteHeadManager.csが送信するデータの作成および受信データの反映を担当しています。まずはCustomMessageのメンバと初期化処理を見ていきます。

public class CustomMessages : Singleton<CustomMessages> {
    // 受信データの種類を判別するため、メッセージIDを設定する
    // デフォルトのIDはMessageID列挙体として用意されており
    // カスタムIDは以下のように定義する
    public enum TestMessageID : byte {
        HeadTransform = MessageID.UserMessageIDStart,   // HoloLensの位置と向きについての通信
        // 他のタイプを作りたい場合は、ここにIDを追加していく            
        Max                                             // 終端
    }

    // 受信データをさばくメソッドのデリゲート
    // NetworkInMessageが受信データの実体
    public delegate void MessageCallback(NetworkInMessage msg);
    // メッセージIDごとにハンドラを管理するため、メッセージIDをキーとする辞書を用意
    private Dictionary<TestMessageID, MessageCallback> messageHandlers = new Dictionary<TestMessageID, MessageCallback>();
    public Dictionary<TestMessageID, MessageCallback> MessageHandlers {
        get {
            return messageHandlers;
        }
    }

    // サーバへのコネクションとそのアダプタ
    private NetworkConnectionAdapter connectionAdapter;
    private NetworkConnection serverConnection;
    
    private void Start()  {
        // サーバへの接続完了を待ち、Connectedメソッドをコール
        if (SharingStage.Instance.IsConnected) {
            Connected();
        } else {
            SharingStage.Instance.SharingManagerConnected += Connected;
        }
    }
    
    private void Connected(object sender = null, EventArgs e = null) {
        SharingStage.Instance.SharingManagerConnected -= Connected;
        InitializeMessageHandlers();
    }

    // コネクション確立後の初期化処理
    private void InitializeMessageHandlers() {
        // SharingManagerを取得
        SharingStage sharingStage = SharingStage.Instance;
        // invalid
        if (sharingStage == null) {
            Debug.Log("Cannot Initialize CustomMessages. No SharingStage instance found.");
            return;
        }
        // コネクション取得
        serverConnection = sharingStage.Manager.GetServerConnection();
        // invalid
        if (serverConnection == null) {
            Debug.Log("Cannot initialize CustomMessages. Cannot get a server connection.");
            return;
        }
        // コネクションアダプタに、サーバからデータを受信したときのハンドラをセット
        connectionAdapter = new NetworkConnectionAdapter();
        connectionAdapter.MessageReceivedCallback += OnMessageReceived;
        // 自身のユーザIDを取得
        LocalUserID = SharingStage.Instance.Manager.GetLocalUser().GetID();
        
        // カスタムメッセージIDの数だけkey-valueを作成し、辞書にセット
        // value(受信データを実際にさばくメソッド)は後から追加するのでnullにしておく
        for (byte index = (byte)TestMessageID.HeadTransform; index < (byte)TestMessageID.Max; index++) {
            if (MessageHandlers.ContainsKey((TestMessageID)index) == false) {
                MessageHandlers.Add((TestMessageID)index, null);
            }
            // コネクションに受信対象となるメッセージIDを指定
            serverConnection.AddListener(index, connectionAdapter);
        }
    }
    
    // 自身のユーザID
    public long LocalUserID {
        get; set;
    }

サーバからの受信データは一度CustomMessageで受け取り、メッセージIDによって振り分けを行います。本サンプルでは受信データを実際に処理するのはRemoteHeadManagerとなり、受信データ処理用メソッドは次のように設定されます。

public class RemoteHeadManager : Singleton<RemoteHeadManager> {
    private void Start() {
        // UpdateHeadTransformがMessageCallbackデリゲート型のメソッド
        CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.HeadTransform] 
          = UpdateHeadTransform;

        ~省略~

サーバからデータを受信したときは、CustomMessageのInitializeMessageHandlersメソッドでコールバックとして設定したOnMessageReceivedにて、処理先の振り分けを行います。

/// CutsomMessage.cs

// サーバからメッセージを受信したときのハンドラ
private void OnMessageReceived(NetworkConnection connection, NetworkInMessage msg) {
    // 受信データの1バイト目にメッセージIDが設定されているので読み取る
    byte messageType = msg.ReadByte();
    // メッセージIDに紐づくメソッドを辞書から取り出して実行
    MessageCallback messageHandler = MessageHandlers[(TestMessageID)messageType];
    if (messageHandler != null) {
        messageHandler(msg);
    }
}

続いてデータの送信部を見ていきたいのですが、その前にRemoteHeadManagerの初期化処理部分を確認しておきます。

public class RemoteHeadManager : Singleton<RemoteHeadManager> {
    // リモートユーザを表す内部クラス
    public class RemoteHeadInfo {
        public long UserID;             // ユーザID 
        public GameObject HeadObject;   // CubeアバターのGameObject
    }

    // リモートユーザの情報を管理するための辞書、ユーザIDをキーとする
    private Dictionary<long, RemoteHeadInfo> remoteHeads = new Dictionary<long, RemoteHeadInfo>();
    
    private void Start() {
        // メッセージを受信したときのハンドラを設定
        CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.HeadTransform] = UpdateHeadTransform;
        // SharingManagerがサーバに接続するまで待つ
        if (SharingStage.Instance.IsConnected) {
            Connected();
        } else {
            SharingStage.Instance.SharingManagerConnected += Connected;
        }
    }

    private void Connected(object sender = null, EventArgs e = null) {
        SharingStage.Instance.SharingManagerConnected -= Connected;
       // ユーザがセッションに接続したときのハンドラ
        SharingStage.Instance.SessionUsersTracker.UserJoined += UserJoinedSession;
        // ユーザがセッションから離脱したときのハンドラ
        SharingStage.Instance.SessionUsersTracker.UserLeft += UserLeftSession;
    }

本サンプルでのリモートユーザはすべてRemoteHeadManagerクラス内で管理されます。そのための内部クラスと管理用の辞書を持っていますが、こちらはユーザがセッションに出入りしたタイミングで辞書が更新されます。このとき使用するのSessionUsersTrackerクラスで、これはセッション内のユーザについての情報を参照したり、ユーザがセッションに出入りしたタイミングでイベントを発火する機能を持っています。

/// RemoteHeadManager.cs

// ユーザがセッションに接続したときのハンドラ
private void UserJoinedSession(User user) {
    // ローカルユーザでなければ
    if (user.GetID() != SharingStage.Instance.Manager.GetLocalUser().GetID()) {
        // リモートユーザオブジェクトを新規作成
        GetRemoteHeadInfo(user.GetID());
    }
}

// 指定したユーザIDの情報を取得する
// 指定したIDが辞書内になければ、リモートユーザオブジェクトを新規に作成する
public RemoteHeadInfo GetRemoteHeadInfo(long userId) {
    RemoteHeadInfo headInfo;
    // リモートユーザの情報取得をIDをキーに試みる、ないなら新規作成
    if (!remoteHeads.TryGetValue(userId, out headInfo)) {
        headInfo = new RemoteHeadInfo();
        headInfo.UserID = userId;                   // ID設定
        headInfo.HeadObject = CreateRemoteHead();   // GameObjectの設定、位置はまだ設定していない
        remoteHeads.Add(userId, headInfo);          // 辞書に追加
    }
    return headInfo;
}

// リモートユーザの位置、向き表示用のGameObjectを新しく作る
private GameObject CreateRemoteHead() {
    // Cubeを作成
    GameObject newHeadObj = GameObject.CreatePrimitive(PrimitiveType.Cube);
    // ParentはHologramCollection
    newHeadObj.transform.parent = gameObject.transform;
    // ローカルスケールは固定(0.2^3 m^3)
    newHeadObj.transform.localScale = Vector3.one * 0.2f;
    return newHeadObj;
}

// ユーザがセッションから離脱したときのハンドラ
private void UserLeftSession(User user) {
    // 引数からユーザIDを取得
    int userId = user.GetID();
    // IDが自分のものでないことを確認
    if (userId != SharingStage.Instance.Manager.GetLocalUser().GetID()) {
        RemoveRemoteHead(remoteHeads[userId].HeadObject);
        remoteHeads.Remove(userId);
    }
}

続いてHoloLensの位置、向きを送信する部分を見ていきます。データはRemoteHeadMangerクラスのUpdateメソッドで作られ、1Fごとにサーバへ送信されます。

/// RemoteHeadManager.cs

private void Update() {
    // CameraのTransformがHoloLensの位置と向きを表す
    Transform headTransform = Camera.main.transform;
    // 取得した位置と向きを、基準点(HologramCollection)のローカル座標系に変換する
    Vector3 headPosition = transform.InverseTransformPoint(headTransform.position);
    Quaternion headRotation = Quaternion.Inverse(transform.rotation) * headTransform.rotation;
    // 位置と向きをリモートユーザに送信する
    CustomMessages.Instance.SendHeadTransform(headPosition, headRotation);
}

実際のサーバへのデータ送信はCustomMessageクラスで行われます。

/// CustomMessage.cs

// RemoteHeadManagerから依頼を受けて、位置と向きをサーバに送信する
public void SendHeadTransform(Vector3 position, Quaternion rotation) {
    // セッションへのコネクションは張れていることを確認する
    if (serverConnection != null && serverConnection.IsConnected())
    {
        // メッセージIDを指定して、メッセージを生成する
        NetworkOutMessage msg = CreateMessage((byte)TestMessageID.HeadTransform);
        // メッセージにTransformデータを追加する
        AppendTransform(msg, position, rotation);
        // メッセージをブロードキャストするようサーバに依頼する
        serverConnection.Broadcast(
            msg,
            MessagePriority.Immediate,
            MessageReliability.UnreliableSequenced,
            MessageChannel.Avatar);
    }
}

// サーバに送信するデータ(メッセージ)を生成する
private NetworkOutMessage CreateMessage(byte messageType) {
    NetworkOutMessage msg = serverConnection.CreateMessage(messageType);
    // メッセージタイプとローカルユーザIDをメッセージに書き込む
    msg.Write(messageType);
    msg.Write(LocalUserID);
    return msg;
}

private void AppendTransform(NetworkOutMessage msg, Vector3 position, Quaternion rotation) {
    AppendVector3(msg, position);
    AppendQuaternion(msg, rotation);
}

private void AppendVector3(NetworkOutMessage msg, Vector3 vector) {
    // メッセージに位置情報をセットする 
    msg.Write(vector.x);
    msg.Write(vector.y);
    msg.Write(vector.z);
}

private void AppendQuaternion(NetworkOutMessage msg, Quaternion rotation) {
    // メッセージに向きの情報をセットする
    msg.Write(rotation.x);
    msg.Write(rotation.y);
    msg.Write(rotation.z);
    msg.Write(rotation.w);
}

NetworkOutMessageがサーバに送信するデータを表しており、これはNetworkConnectionのCreateMessageメソッドから取得することができます。NetworkOutMessageクラスには各種データをメッセージに書き込むメソッドが用意されています。データの作成が完了したらNetworkConnectionのメソッドを使ってサーバに送信します。本サンプルではBroadcastメソッドを使って全ユーザに送信していますが、他にも特定のユーザだけにデータを送信するといったことも可能です。

最後にデータの受信部分です。データの受信については前述したとおり、CustomMessageクラスのコールバックメソッドOnMessageReceivedによって振り分けられ、RemoteHeadManagerクラスのUpdateHeadTransformメソッドにて受信したデータをアプリに反映します。

private void UpdateHeadTransform(NetworkInMessage msg) {
    // 送信元のユーザIDを取得
    long userID = msg.ReadInt64();
    // 位置と向きの情報を取得
    Vector3 headPos = CustomMessages.Instance.ReadVector3(msg);
    Quaternion headRot = CustomMessages.Instance.ReadQuaternion(msg);
    // 取得したユーザIDからリモートユーザの情報を取得
    RemoteHeadInfo headInfo = GetRemoteHeadInfo(userID);
    // Cubeアバターは基準点(HorogramCollection)の配下にいるので、ローカル座標系に受信データをセットする
    headInfo.HeadObject.transform.localPosition = headPos;
    headInfo.HeadObject.transform.localRotation = headRot;
}

引数のNetworkInMessageが受信データを表します。データのパース用のメソッドはCustomMessageクラス経由で行っていますが、NetworkOutMessageクラスと同様にNetworkInMessageクラスに読み取りメソッドが定義されています。

public Vector3 ReadVector3(NetworkInMessage msg) {
    return new Vector3(msg.ReadFloat(), msg.ReadFloat(), msg.ReadFloat());
}

public Quaternion ReadQuaternion(NetworkInMessage msg) {
    return new Quaternion(msg.ReadFloat(), msg.ReadFloat(), msg.ReadFloat(), msg.ReadFloat());
}

コードの解説は以上です。

まとめ

かなり長くなってしまいましたが、HoloToolkitのSharingについてのサンプルであるSharingTestシーンの内容を解説しました。Sharingの調査をしようとなるとAcademy 240から始める方が多いかと思いますが、初めの一歩としてはシンプルなこちらの方が学習に適していると思います。

Sharingについての記事が続きましたが、そろそろ他のネタの記事を書きたいと思っています。