HoloLensのSharing

(2017-03-25)

  • HoloToolkit-Unity v1.5.5.0

サーバー

SharingService.exeを ここ からとってきて実行する。開発に使っているHoloToolkitと同じリリースバージョンのものを使う。

> SharingService.exe -local
...
SharingService: Listening for session list connections on port 20602 of all network devices of the local machine.
SharingService: Local IP addresses are:
SharingService:         xxx.xxx.xxx.xxx
SharingService: Created Session "Default" with ID 0 on port 20601

今日のTokyo Hololens Meetup Vol.2の開発者セッションで、 ちょうどSharingの話があったのだけれど、残念ながら先着順で出遅れて聞けなかった。

Tweetを見る限りだとカスタマイズできず、スケーリングできないSharingService.exeは使わずに MagicOnionというのを自前で作ったらしい。

Tokyo Hololens MeetuUp Vol.2 Session5 #HoloLensJP #TMCN - Togetterまとめ

クライアント

Assets/HoloToolkit/Sharing/TestsのSceneで試してみる。

以下のcapabilitiesを設定し、

  • SpatialPerception
  • InternetClient

SharingのServer Addressを設定してビルド。ほかにはこんな設定がある。

  • Client Role
    • Primary: 直接セッションサーバーに接続し、セッションを管理する
    • Secondary: Primaryクライアントに接続して、セッション管理は任せる
  • Server Address
  • Port
  • Auto Discover Server
  • Session Name

起動して以下のようなエラーが出たらSharingService.exeがHoloToolkitのバージョンと合っていない。

List Server Handshake Failed: Invalid schema version.
Expected: 17, got 15
Please sync to latest XTools

接続と離脱のメッセージはこんな感じ。

SharingService: User UnknownUser at address xxx.xxx.xxx.xxx joined session Default
SharingService: User UnknownUser left session Default

2つ以上クライアントを立ち上げると、他のクライアントの、球からの相対的な頭の位置にCubeが映った。

他のクライアントの頭の位置にCubeがある

が、球の場所が空間に対して同期されない・・・。

原因を探るために、 TestsのSceneと同様に、SharingのPrefabにCustomMessage.csを、 適当なGameObjectにImportExportAnchorManager.csとRemoteHeadManager.csと 目印になるオブジェクトを追加し、 ImportExportAnchorManager.csにこんな感じのを追加してcurrentStateを表示してみた。

public GameObject statusText;
private void Update()
{
    statusText.GetComponent<TextMesh>().text = currentState.ToString();
    ...
}

結果、起動してからまだReady状態になっていなかったことが分かった。 少し待ってみるといろんな状態を経て、Ready状態になると、 目印のオブジェクトが物理的に同じところに移動し、頭の位置も正しいところに移動した。

Sharingしている状態


なにをやっているか見ていく。

まずは拾えるevent。

C#のdelegateとevent - sambaiz.net

SharingSessionTracker

  • public event EventHandler SessionJoined;

ユーザーがセッションに入ったとき。

public class SessionJoinedEventArgs : EventArgs
{
    public User joiningUser;
}
  • public event EventHandler SessionLeft;

セッションから出たとき。

public class SessionLeftEventArgs : EventArgs
{
    public long exitingUserId;
}

SharingStage

  • public event EventHandler SharingManagerConnected;

SharingManagerが接続されたとき。ArgsはEmpty。

connectedEvent(this, EventArgs.Empty);

これらのeventをsubscribeしているTestsの中のコード。

CustomMessages

データを送受信するところ。

初期化

SharingManagerが接続されたら初期化がはじまる。

void Start()
{
    SharingStage.Instance.SharingManagerConnected += SharingManagerConnected;
}

private void SharingManagerConnected(object sender, EventArgs e)
{
    InitializeMessageHandlers();
}

まず、ServerとのConnectionを取得し、Messageを受信したときのeventをsubscribeしている。

SharingStage sharingStage = SharingStage.Instance;
serverConnection = sharingStage.Manager.GetServerConnection();
connectionAdapter = new NetworkConnectionAdapter();
connectionAdapter.MessageReceivedCallback += OnMessageReceived;

それから自分自身のユーザーIDも保存してある。これはMessageを送るときに使う。

localUserID = SharingStage.Instance.Manager.GetLocalUser().GetID();

最後にMessageHandlersにnullを詰めて終わり。

for (byte index = (byte)TestMessageID.HeadTransform; index < (byte)TestMessageID.Max; index++)
{
    if (MessageHandlers.ContainsKey((TestMessageID)index) == false)
    {
        MessageHandlers.Add((TestMessageID)index, null);
    }

    serverConnection.AddListener(index, connectionAdapter);
}

後からこういう風にhandlerを設定している。

CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.HeadTransform] = this.UpdateHeadTransform;

受信

messageTypeに対応したhandlerに渡す。初期状態では全てnullなので何もしない。

void OnMessageReceived(NetworkConnection connection, NetworkInMessage msg)
{
    byte messageType = msg.ReadByte();
    MessageCallback messageHandler = MessageHandlers[(TestMessageID)messageType];
    if (messageHandler != null)
    {
        messageHandler(msg);
    }
}

送信

CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
CustomMessages.Instance.SendHeadTransform(headPosition, headRotation);

こんな感じにBroadcastしている。

public void SendHeadTransform(Vector3 position, Quaternion rotation)
{
    // If we are connected to a session, broadcast our head info
    if (serverConnection != null && serverConnection.IsConnected())
    {
        // Create an outgoing network message to contain all the info we want to send
        NetworkOutMessage msg = CreateMessage((byte)TestMessageID.HeadTransform);

        AppendTransform(msg, position, rotation);

        // Send the message as a broadcast, which will cause the server to forward it to all other users in the session.
        serverConnection.Broadcast(
            msg,
            MessagePriority.Immediate,
            MessageReliability.UnreliableSequenced,
            MessageChannel.Avatar);
    }
}

ImportExportAnchorManager

オブジェクトのpositionを物理的に固定するWorldAnchorを共有する。

currentStateは最初AnchorStore_Initializingで、 anchorStoreが取得できたらAnchorStore_Initializedになる。

private ImportExportState currentState = ImportExportState.Start;

// インスタンスがロードされたときに呼ばれる。コンストラクターの代わり
protected override void Awake()
{
    base.Awake();
    Debug.Log("Import Export Manager starting");
    // We need to get our local anchor store started up.
    currentState = ImportExportState.AnchorStore_Initializing;
    WorldAnchorStore.GetAsync(AnchorStoreReady);
}

private void AnchorStoreReady(WorldAnchorStore store)
{
    anchorStore = store;
    currentState = ImportExportState.AnchorStore_Initialized;
}

private void Start()
{
    SharingStage.Instance.SharingManagerConnected += SharingManagerConnected;
    SharingSessionTracker.Instance.SessionJoined += Instance_SessionJoined;
}

SharingManagerが接続されたら、RoomManagerのインスタンスを取得して、 Anchorのダウンロードとアップロードしたときのeventをsubscribeしている。

Uploaded時はcurrentStateReadyにし、 Downloaded時はrawAnchorDataに保存し、currentStateDataReadyにする。

private void SharingManagerConnected(object sender, EventArgs e)
{
    // Setup the room manager callbacks.
    roomManager = SharingStage.Instance.Manager.GetRoomManager();
    roomManagerCallbacks = new RoomManagerAdapter();

    roomManagerCallbacks.AnchorsDownloadedEvent += RoomManagerCallbacks_AnchorsDownloaded;
    roomManagerCallbacks.AnchorUploadedEvent += RoomManagerCallbacks_AnchorUploaded;
    roomManager.AddListener(roomManagerCallbacks);
}

private void RoomManagerCallbacks_AnchorUploaded(bool successful, XString failureReason)
{
    if (successful)
    {
        currentState = ImportExportState.Ready;
    }
    else
    {
        Debug.Log("Upload failed " + failureReason);
        currentState = ImportExportState.Failed;
    }
}

private byte[] rawAnchorData = null;

private void RoomManagerCallbacks_AnchorsDownloaded(bool successful, AnchorDownloadRequest request, XString failureReason)
{
    // If we downloaded anchor data successfully we should import the data.
    if (successful)
    {
        int datasize = request.GetDataSize();
        Debug.Log(datasize + " bytes ");
        rawAnchorData = new byte[datasize];

        request.GetData(rawAnchorData, datasize);
        currentState = ImportExportState.DataReady;
    }
    else
    {
        // If we failed, we can ask for the data again.
        Debug.Log("Anchor DL failed " + failureReason);
        MakeAnchorDataRequest();
    }
}

SessionJoin時には、sharingServiceReadyをtrueにする。

InitRoomApi()ではcurrentRoomにJoin(あるいは新しく作る)し、Roomを代入している。

currentStateは新しくRoomを作った場合InitialAnchorRequiredで、すでにあるRoomに入った場合RoomApiInitializedになる。

private bool sharingServiceReady = false;
private Room currentRoom;

private void Instance_SessionJoined(object sender, SharingSessionTracker.SessionJoinedEventArgs e)
{
    SharingSessionTracker.Instance.SessionJoined -= Instance_SessionJoined;

    // ほかの処理が落ち着くまで5秒待って実行する
    Invoke("MarkSharingServiceReady", 5);
}

private void MarkSharingServiceReady()
{
    sharingServiceReady = true;

#if UNITY_EDITOR || UNITY_STANDALONE
    InitRoomApi();
#endif
}

private void InitRoomApi()
{
    if (roomManager.GetRoomCount() == 0)
    {
        if (LocalUserHasLowestUserId())
        {
            Debug.Log("Creating room ");            
            currentRoom = roomManager.CreateRoom(new XString("DefaultRoom"), roomID, false);
            currentState = ImportExportState.InitialAnchorRequired;
        }
    }
    else
    {
        Debug.Log("Joining room ");
        currentRoom = roomManager.GetRoom(0);
        roomManager.JoinRoom(currentRoom);
        currentState = ImportExportState.RoomApiInitialized;
    }
}

Update。ここでcurrentStateを見ている。ここまでのcurrentStateをまとめると、

  • AnchorStore_Initializing: 初期状態
  • AnchorStore_Initialized: AnchorStore取得完了
  • InitialAnchorRequired: 新しくRoomを作った(のでWorldAnchorを生成する)
  • RoomApiInitialized: すでにあるRoomに入った
  • Ready: Upload完了
  • DataReady: Download完了

こんな感じ。

private void Update()
{
    switch (currentState)
    {
        case ImportExportState.AnchorStore_Initialized:
            if (sharingServiceReady)
            {
                InitRoomApi();
            }
            break;
        case ImportExportState.RoomApiInitialized:
            StartAnchorProcess();
            break;
        case ImportExportState.DataReady:
            // DataReady is set when the anchor download completes.
            currentState = ImportExportState.Importing;
            WorldAnchorTransferBatch.ImportAsync(rawAnchorData, ImportComplete);
            break;
        case ImportExportState.InitialAnchorRequired:
            currentState = ImportExportState.CreatingInitialAnchor;
            CreateAnchorLocally();
            break;
        case ImportExportState.ReadyToExportInitialAnchor:
            // We've created an anchor locally and it is ready to export.
            currentState = ImportExportState.UploadingInitialAnchor;
            Export();
            break;
    }
}

Roomを新しく作ったならWorldAnchorを作成する必要がある。 isLocatedがtrueになったら OnTrackingChanged_InitialAnchorにし、AnchorをUploadする。

private void CreateAnchorLocally()
{
    WorldAnchor anchor = GetComponent<WorldAnchor>();
    if (anchor == null)
    {
        anchor = gameObject.AddComponent<WorldAnchor>();
    }

    if (anchor.isLocated)
    {
        currentState = ImportExportState.ReadyToExportInitialAnchor;
    }
    else
    {
        anchor.OnTrackingChanged += Anchor_OnTrackingChanged_InitialAnchor;
    }
}

private void Anchor_OnTrackingChanged_InitialAnchor(WorldAnchor self, bool located)
{
    if (located)
    {
        Debug.Log("Found anchor, ready to export");
        currentState = ImportExportState.ReadyToExportInitialAnchor;
    }
    else
    {
        Debug.Log("Failed to locate local anchor (super bad!)");
        currentState = ImportExportState.Failed;
    }

    self.OnTrackingChanged -= Anchor_OnTrackingChanged_InitialAnchor;
}

anchorStoreに保存して、SerializeしてUploadする。

private void Export()
{
    WorldAnchor anchor = GetComponent<WorldAnchor>();

    string guidString = Guid.NewGuid().ToString();
    exportingAnchorName = guidString;

    // Save the anchor to our local anchor store.
    if (anchorStore.Save(exportingAnchorName, anchor))
    {
        sharedAnchorInterface = new WorldAnchorTransferBatch();
        sharedAnchorInterface.AddWorldAnchor(guidString, anchor);
        WorldAnchorTransferBatch.ExportAsync(sharedAnchorInterface, WriteBuffer, ExportComplete);
    }
    else
    {
        Debug.Log("This anchor didn't work, trying again");
        currentState = ImportExportState.InitialAnchorRequired;
    }
}

public void ExportComplete(SerializationCompletionReason status)
{
    if (status == SerializationCompletionReason.Succeeded && exportingAnchorBytes.Count > minTrustworthySerializedAnchorDataSize)
    {
        Debug.Log("Uploading anchor: " + exportingAnchorName);
        roomManager.UploadAnchor(
            currentRoom,
            new XString(exportingAnchorName),
            exportingAnchorBytes.ToArray(),
            exportingAnchorBytes.Count);
    }
    else
    {
        Debug.Log("This anchor didn't work, trying again");
        currentState = ImportExportState.InitialAnchorRequired;
    }
}

もしすでにあるRoomにJoinしている(RoomApiInitialized)なら、Anchorをダウンロードし始め、DataRequestedになる。 ダウンロードしたらDataReadyになって、AnchorデータをImportする。

private void StartAnchorProcess()
{
    // First, are there any anchors in this room?
    int anchorCount = currentRoom.GetAnchorCount();

    // If there are anchors, we should attach to the first one.
    if (anchorCount > 0)
    {
        // Extract the name of the anchor.
        XString storedAnchorString = currentRoom.GetAnchorName(0);
        string storedAnchorName = storedAnchorString.GetString();

        // Attempt to attach to the anchor in our local anchor store.
        if (AttachToCachedAnchor(storedAnchorName) == false)
        {
            MakeAnchorDataRequest();
        }
    }
}

private void MakeAnchorDataRequest()
{
    if (roomManager.DownloadAnchor(currentRoom, currentRoom.GetAnchorName(0)))
    {
        currentState = ImportExportState.DataRequested;
    }
    else
    {
        Debug.Log("Couldn't make the download request.");
        currentState = ImportExportState.Failed;
    }
}

Import完了したらanchorStoreに保存し、Readyにする。

private void ImportComplete(SerializationCompletionReason status, WorldAnchorTransferBatch wat)
{
    if (status == SerializationCompletionReason.Succeeded && wat.GetAllIds().Length > 0)
    {
        Debug.Log("Import complete");

        string first = wat.GetAllIds()[0];
        Debug.Log("Anchor name: " + first);

        WorldAnchor anchor = wat.LockObject(first, gameObject);
        anchorStore.Save(first, anchor);
        currentState = ImportExportState.Ready;
    }
    else
    {
        Debug.Log("Import fail");
        currentState = ImportExportState.DataReady;
    }
}

RemoteHeadManager

他のユーザーの頭の位置にオブジェクトを表示させる。

受信時のhandlerを設定し、eventをsubscribeする。

void Start()
{
    CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.HeadTransform] = this.UpdateHeadTransform;

    SharingSessionTracker.Instance.SessionJoined += Instance_SessionJoined;
    SharingSessionTracker.Instance.SessionLeft += Instance_SessionLeft;
}

joinしたのが自分自身じゃないかチェック。

private void Instance_SessionJoined(object sender, SharingSessionTracker.SessionJoinedEventArgs e)
{
    if (e.joiningUser.GetID() != SharingStage.Instance.Manager.GetLocalUser().GetID())
    {
        GetRemoteHeadInfo(e.joiningUser.GetID());
    }
}

remoteHeadsになければ、HeadObjectを作成し、追加する。

public RemoteHeadInfo GetRemoteHeadInfo(long userID)
{
    RemoteHeadInfo headInfo;

    // Get the head info if its already in the list, otherwise add it
    if (!this.remoteHeads.TryGetValue(userID, out headInfo))
    {
        headInfo = new RemoteHeadInfo();
        headInfo.UserID = userID;
        headInfo.HeadObject = CreateRemoteHead();

        this.remoteHeads.Add(userID, headInfo);
    }

    return headInfo;
}

Sessionから離れたときはオブジェクトを削除し、remoteHeadsから取り除く。

private void Instance_SessionLeft(object sender, SharingSessionTracker.SessionLeftEventArgs e)
{
    if (e.exitingUserId != SharingStage.Instance.Manager.GetLocalUser().GetID())
    {
        RemoveRemoteHead(this.remoteHeads[e.exitingUserId].HeadObject);
        this.remoteHeads.Remove(e.exitingUserId);
    }
}

void RemoveRemoteHead(GameObject remoteHeadObject)
{
    DestroyImmediate(remoteHeadObject);
}

受信時のhandlerではMessageからpositionとquarternionを取得し、オブジェクトの位置を動かしている。

void UpdateHeadTransform(NetworkInMessage msg)
{
    // Parse the message
    long userID = msg.ReadInt64();

    Vector3 headPos = CustomMessages.Instance.ReadVector3(msg);

    Quaternion headRot = CustomMessages.Instance.ReadQuaternion(msg);

    RemoteHeadInfo headInfo = GetRemoteHeadInfo(userID);
    headInfo.HeadObject.transform.localPosition = headPos;
    headInfo.HeadObject.transform.localRotation = headRot;
}

自分の頭の位置はUpdate()で送信している。

void Update()
{
    // Grab the current head transform and broadcast it to all the other users in the session
    Transform headTransform = Camera.main.transform;

    // Transform the head position and rotation from world space into local space
    Vector3 headPosition = this.transform.InverseTransformPoint(headTransform.position);
    Quaternion headRotation = Quaternion.Inverse(this.transform.rotation) * headTransform.rotation;

    CustomMessages.Instance.SendHeadTransform(headPosition, headRotation);
}

ライセンス

MIT License

Copyright (c) 2016 Microsoft Corporation

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.