首页 > 代码库 > Hololens官方教程精简版 - 08. Sharing holograms(共享全息影像)

Hololens官方教程精简版 - 08. Sharing holograms(共享全息影像)

前言

本篇集中学习全息影像“共享”的功能,以实现在同一房间的人,看到“同一个物体”。之所以打引号,是因为,每个人看到的并非同一个物体,只是空间位置等信息相同的同类物体而已。

要想实现这个效果,有以下几点需要注意:

  • 需开启设备的Spatial Perception功能(在Player Settings…面板的Publishing Settings > Capabilities中勾选)
  • 暂时只能两台以上真机测试,无法在Unity中测试(即便是Remoting连接Hololens也不行)
  • 设备在同一房间内(废话)

友情提醒:本章需在多台设备间折腾,把设备休眠时间设置得长一点,会方便很多。具体方法如下:
设备打开,浏览器访问设备IP,进入:Hololens Device PortalHome菜单下有个Sleep settings,最长设置30分钟。

要实现共享全息影像的效果,主要掌握以下技术点:

  • 使用Socket协议传递数据
  • 理解世界坐标系及空间锚点的使用(WorldAnchor及WorldAnchorStore)
  • Sharing组件的使用(锚点的上传和下载)

Chapter 1 - Unity Setup

  1. 请按照第一篇的教程,完成项目的创建。
  2. 新建文件夹:”Assets/_Scenes/Holograms 240/”
  3. 新建场景:”Assets/_Scenes/Holograms 240/Holograms 240.unity
  4. 打开场景,删除默认的Main Camera
  5. 将”Assets/HoloToolkit/Input/Prefabs/HololensCamera.prefab”添加到Hierarchy根级
  6. 将”Assets/HoloToolkit/Input/Prefabs/InputManager.prefab”添加到Hierarchy根级
  7. 将”Assets/HoloToolkit/Input/Prefabs/Cursor/DefaultCursor.prefab”添加到Hierarchy根级
  8. Hierarchy面板根级,添加一个Cube,设置如下:
    技术分享

本节完成!

Chapter 2 - 使用Socket协议传递数据

目标

使用HoloToolkit提供的Socket套件进行数据传输

实践

搭建Socket服务基础环境

首先要说明的是:HoloToolkit提供的Socket套件,使用的是RakNet,对其原理感兴趣的同学,可以去官网查看。

  1. 在下载的HoloToolkit-Unity开发包中,找到:”External\”文件夹,将其复制到项目目录下(与Assets文件夹同级目录)。如图:
    技术分享
  2. 点击Unity主菜单下的:HoloToolkit > Sharing Service > Launch Sharing Service,如图:
    技术分享
  3. 此时将会打开一个Socket服务端,如图所示,记录下IP,例如本例为:192.168.0.108
    技术分享
  4. Project面板中,找到:”Assets/HoloToolkit/Sharing/Prefabs/Sharing.prefab”,拖动到Hierarchy根级,并在其Inspector面板中找到Server Address属性,填写上面一步得到的IP地址。如图:
    技术分享
    此步相当于为APP增加了一个Socket客户端。

以上步骤完成后,可以点击Play按钮,并观察Socket服务端界面,看是否有设备加入到服务器。如图:
技术分享


创建Socket消息传输类

上一步中,我们利用HoloToolkit提供的Socket套件,搭建了基础数据传输环境(包含一个Socket服务端程序和一个Socket客户端连接组件),下面用一个移动Cube的例子来学习如何同步数据。

  1. 新建文件夹:”Assets/_Scenes/Holograms 240/Scripts/
  2. 新建脚本:”Assets/_Scenes/Holograms 240/Scripts/Cube240.cs”,附加给Cube,编写脚本如下:

    using HoloToolkit.Unity.InputModule;
    using UnityEngine;
    
    public class Cube240 : MonoBehaviour, IInputClickHandler {
    
        // 是否正在移动
        bool isMoving = false;
    
        // 单击Cube,切换是否移动
        public void OnInputClicked(InputEventData eventData)
        {
            isMoving = !isMoving;
        }
    
        // 如果Cube为移动状态,让其放置在镜头前2米位置
        void Update () {
            if (isMoving)
            {
                transform.position = Camera.main.transform.position + Camera.main.transform.forward * 2f;
            }
        }
    }

    脚本实现了Cube的移动和放置,可以测试一下效果。

  3. 下面,我们来实现两台设备传递Cube的位置。
  4. Hierarchy面板,创建根级空对象,命名为:”Controller
  5. 建立一个消息传递类。
    新建脚本:”Assets/_Scenes/Holograms 240/Scripts/CustomMessage240.cs,附加给Controller,编辑内容如下:

    using HoloToolkit.Sharing;
    using HoloToolkit.Unity;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class CustomMessages240 : Singleton<CustomMessages240>
    {
        // 代表当前的Socket连接
        NetworkConnection serverConnection;
    
        // 当前连接的事件监听器,这是一个典型的适配器模式,继承自NetworkConnectionListener
        NetworkConnectionAdapter connectionAdapter;
    
        // 自定义消息类型
        public enum CustomMessageID : byte
        {
            // 自己的消息从MessageID.UserMessageIDStart开始编号,避免与MessageID内置消息编号冲突
            // Cube位置消息
            CubePosition = MessageID.UserMessageIDStart,
            Max
        }
    
        // 消息处理代理
        public delegate void MessageCallback(NetworkInMessage msg);
    
        // 消息处理字典
        public Dictionary<CustomMessageID, MessageCallback> MessageHandlers { get; private set; }
    
        // 当前用户在Sorket服务器中的唯一编号(自动生成)
        public long LocalUserID { get; private set; }
    
        protected override void Awake()
        {
            base.Awake();
            // 初始化消息处理字典
            MessageHandlers = new Dictionary<CustomMessageID, MessageCallback>();
            for (byte index = (byte)MessageID.UserMessageIDStart; index < (byte)CustomMessageID.Max; index++)
            {
                if (!MessageHandlers.ContainsKey((CustomMessageID)index))
                {
                    MessageHandlers.Add((CustomMessageID)index, null);
                }
            }
        }
    
        void Start () {
            // SharingStage是Sharing组件对应的脚本,内部是对经典的Socket客户端的封装。
            SharingStage.Instance.SharingManagerConnected += Instance_SharingManagerConnected;
        }
    
        private void Instance_SharingManagerConnected(object sender, System.EventArgs e)
        {
            // 初始化消息处理器
            InitializeMessageHandlers();
        }
    
        // 初始化消息处理器
        private void InitializeMessageHandlers()
        {
            SharingStage sharingStage = SharingStage.Instance;
    
            if (sharingStage == null)
            {
                return;
            }
    
            // 获取当前Socket连接
            serverConnection = sharingStage.Manager.GetServerConnection();
            if (serverConnection == null)
            {
                return;
            }
    
            // 初始化消息监听
            connectionAdapter = new NetworkConnectionAdapter();
            connectionAdapter.MessageReceivedCallback += ConnectionAdapter_MessageReceivedCallback;
    
            // 获取当前用户在Socket服务器中生成的唯一编号
            LocalUserID = sharingStage.Manager.GetLocalUser().GetID();
    
            // 根据每个自定义消息,添加监听器
            for (byte index = (byte)MessageID.UserMessageIDStart; index < (byte)CustomMessageID.Max; index++)
            {
                serverConnection.AddListener(index, connectionAdapter);
            }
        }
    
        // 接收到服务器端消息的回调处理
        private void ConnectionAdapter_MessageReceivedCallback(NetworkConnection connection, NetworkInMessage msg)
        {
            byte messageType = msg.ReadByte();
            MessageCallback messageHandler = MessageHandlers[(CustomMessageID)messageType];
            if (messageHandler != null)
            {
                messageHandler(msg);
            }
        }
    
        protected override void OnDestroy()
        {
            if (serverConnection != null)
            {
                for (byte index = (byte)MessageID.UserMessageIDStart; index < (byte)CustomMessageID.Max; index++)
                {
                    serverConnection.RemoveListener(index, connectionAdapter);
                }
                connectionAdapter.MessageReceivedCallback -= ConnectionAdapter_MessageReceivedCallback;
            }
            base.OnDestroy();
        }
    
        // 创建一个Out消息(客户端传递给服务端)
        // 消息格式第一个必须为消息类型,其后再添加自己的数据
        // 我们在所有的消息一开始添加消息发送的用户编号
        private NetworkOutMessage CreateMessage(byte messageType)
        {
            NetworkOutMessage msg = serverConnection.CreateMessage(messageType);
            msg.Write(messageType);
            msg.Write(LocalUserID);
            return msg;
        }
    
        // 将Cube位置广播给其他用户
        public void SendCubePosition(Vector3 position)
        {
            if (serverConnection != null && serverConnection.IsConnected())
            {
                // 将Cube的位置写入消息
                NetworkOutMessage msg = CreateMessage((byte)CustomMessageID.CubePosition);
    
                msg.Write(position.x);
                msg.Write(position.y);
                msg.Write(position.z);
    
                // 将消息广播给其他人
                serverConnection.Broadcast(msg,
                    MessagePriority.Immediate, //立即发送
                    MessageReliability.ReliableOrdered, //可靠排序数据包
                    MessageChannel.Default); // 默认频道
            }
        }
    
        // 读取Cube的位置
        public static Vector3 ReadCubePosition(NetworkInMessage msg)
        {
            // 读取用户编号,但不使用
            msg.ReadInt64();
    
            // 依次读取XYZ,这个和发送Cube时,写入参数顺序是一致的
            return new Vector3(msg.ReadFloat(), msg.ReadFloat(), msg.ReadFloat());
        }
    }
  6. 修改Cube240.cs,内容如下:

    using HoloToolkit.Sharing;
    using HoloToolkit.Unity.InputModule;
    using UnityEngine;
    
    public class Cube240 : MonoBehaviour, IInputClickHandler {
    
        // 是否正在移动
        bool isMoving = false;
    
        // 消息传递类
        CustomMessages240 customMessage;
    
        private void Start()
        {
            customMessage = CustomMessages240.Instance;
    
            // 指定收到Cube位置消息后的处理方法
            customMessage.MessageHandlers[CustomMessages240.CustomMessageID.CubePosition] = OnCubePositionReceived;
        }
    
        private void OnCubePositionReceived(NetworkInMessage msg)
        {
            // 同步Cube位置
            if (!isMoving)
            {
                transform.position = CustomMessages240.ReadCubePosition(msg);
            }
        }
    
        // 单击Cube,切换是否移动
        public void OnInputClicked(InputEventData eventData)
        {
            isMoving = !isMoving;
            // 放置Cube后,发送Cube的位置消息给其他人
            if (!isMoving)
            {
                customMessage.SendCubePosition(transform.position);
            }
        }
    
        // 如果Cube为移动状态,让其放置在镜头前2米位置
        void Update () {
            if (isMoving)
            {
                transform.position = Camera.main.transform.position + Camera.main.transform.forward * 2f;
            }
        }
    }
  7. 发布到Hololens设备,启动,同时再点击UnityPlay按钮

当Hololens放置完Cube后,Play窗口中的Cube也会发生位置变化,反之亦然。


实时更新Cube的位置

我们只需做少量改动,就可以实现实时传递Cube的位置。

  1. 找到文件”CustomMessages240.cs”的SendCubePosition方法(大概在124行的位置),修改为:

    // 将Cube位置广播给其他用户
    public void SendCubePosition(Vector3 position, MessageReliability? reliability = MessageReliability.ReliableOrdered)
    {
        if (serverConnection != null && serverConnection.IsConnected())
        {
            // 将Cube的位置写入消息
            NetworkOutMessage msg = CreateMessage((byte)CustomMessageID.CubePosition);
    
            msg.Write(position.x);
            msg.Write(position.y);
            msg.Write(position.z);
    
            // 将消息广播给其他人
            serverConnection.Broadcast(msg,
                MessagePriority.Immediate, //立即发送
                reliability.Value, //可靠排序数据包
                MessageChannel.Default); // 默认频道
        }
    }
  2. 找到”Cube240.cs”文件的Update方法,修改为:

    // 如果Cube为移动状态,让其放置在镜头前2米位置
    void Update () {
        if (isMoving)
        {
            transform.position = Camera.main.transform.position + Camera.main.transform.forward * 2f;
            // 实时传递Cube位置
            customMessage.SendCubePosition(transform.position, MessageReliability.UnreliableSequenced);
        }
    }

再次测试,不论是移动还是放置Cube,两个客户端都可以实时看到Cube的位置变化。

大家注意到,在同步Cube实时移动时,使用了MessageReliability.UnreliableSequenced(不可靠序列数据包),而在同步Cube放置时,使用了默认的MessageReliability.ReliableOrdered(可靠排序数据包),是有原因的。两种情况对应了两种不同场景,一种是高频的数据同步,另外一种是低频的数据同步。不同场景对消息的可靠性、消息传递序列也有不同的要求。具体请看下面《关于消息传递方式》的说明。

说明

  • 关于消息结构
    这里要注意的是,组装消息时所使用的数据结构和解析消息时所使用的数据结构需要保持一致。
    比如,本例中,组装Cube消息后的数据结构如下:

    1. 消息类型,在CreateMessage(byte messageType)方法中的msg.Write(messageType);
    2. 用户编号,在CreateMessage(byte messageType)方法中的msg.Write(LocalUserID);
    3. Cube的X坐标,在SendCubePosition(Vector3 position)方法中的msg.Write(position.x);
    4. Cube的Y坐标,在SendCubePosition(Vector3 position)方法中的msg.Write(position.y);
    5. Cube的Z坐标,在SendCubePosition(Vector3 position)方法中的msg.Write(position.z);

    同样,在解析消息时,也应该按照上面的顺序进行,如下:

    1. 消息类型,在ConnectionAdapter_MessageReceivedCallback(NetworkConnection connection, NetworkInMessage msg)方法中的byte messageType = msg.ReadByte();
    2. 用户编号,在ReadCubePosition(NetworkInMessage msg)方法中的msg.ReadInt64();
    3. Cube的X坐标,在ReadCubePosition(NetworkInMessage msg)方法中的”return new Vector3(msg.ReadFloat(), msg.ReadFloat(), msg.ReadFloat());”
    4. Cube的Y坐标,在ReadCubePosition(NetworkInMessage msg)方法中的”return new Vector3(msg.ReadFloat(), msg.ReadFloat(), msg.ReadFloat());”
    5. Cube的Z坐标,在ReadCubePosition(NetworkInMessage msg)方法中的”return new Vector3(msg.ReadFloat(), msg.ReadFloat(), msg.ReadFloat());”
  • 关于消息传递方式

    • MessageReliability.Reliable
      可靠数据包:数据一定到达,但包可能乱序。适用于开关按钮等类似场景。
    • MessageReliability.ReliableOrdered
      可靠排序数据包:数据一定到达,且经过排序,但需要等待传输最慢的包。适用于聊天等类似场景。
    • MessageReliability.ReliableSequenced
      可靠序列数据包:数据一定到达,且经过排序,不等待慢包,旧包被抛弃。适用于低频有顺序要求的场景。比如:每2000ms更新物体的位置。
    • MessageReliability.Unreliable
      不可靠数据包:数据不一定到达,包也可能乱序。适用于语音通话等类似场景。
    • MessageReliability.UnreliableSequenced
      不可靠序列数据包:数据不一定到达,但经过排序,不等待慢包,旧包被抛弃。适用于高频有顺序要求的场景。比如:每100ms更新物体的位置。

Chapter 3 - 空间锚点的使用

目标

实现固化物体到空间,实现仿真的“共享”物体效果

实践

上一章节中,我们虽然实现了Cube的数据同步,但因为每台设备启动后的参考坐标系不同,导致看到的Cube仍然是独立与设备的(对不齐)。所以,要实现仿真的“共享”效果,肯定需要同步设备的世界坐标系。这一章节,我们将会结合空间扫描、空间锚点,来调整Cube的位置,以实现高仿真的“共享”效果。

准备工作:

  • 需开启设备的Spatial Perception功能(在Player Settings…面板的Publishing Settings > Capabilities中勾选)
  • 两台Hololens
  • 设备在同一房间内

原理:

  1. 两台设备在同一房间开启空间扫描,得到基本一致的世界坐标参考系
  2. 其中一台设备在世界坐标系中设置一个锚点(坐标),并绑定到APP中的一个物体上(一般为一个根节点(0, 0, 0)),所有物体作为这个根节点的子集。
  3. 这台设备开设房间(其实就是自己的世界坐标参考系,房间包含上面的锚点),并将锚点上传至服务器
  4. 其他设备加入房间,并下载房间中的锚点信息
  5. 将锚点信息绑定到自己APP的根节点上(0, 0, 0)
  6. 之后通过上文提到的Socket技术,传递子集中的各种数据(比如:LocalPosition等)

具体实施

  1. Cube拖放到Controller上,作为子集
  2. Project面板中,找到”Assets/HoloToolkit/ShatialMapping/Prefabs/SpatialMapping.prefab”,拖放到Hierarchy根级
  3. 新建脚本ImportExportAnchorManager240.cs,并附加给Controller,内容如下:

    using HoloToolkit.Sharing;
    using HoloToolkit.Unity;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.VR.WSA.Persistence;
    using UnityEngine.VR.WSA.Sharing;
    using System;
    using UnityEngine.VR.WSA;
    using HoloToolkit.Unity.SpatialMapping;
    
    public class ImportExportAnchorManager240 : Singleton<ImportExportAnchorManager240> {
    
        /// <summary>
        /// 建立共享坐标系过程中的各种状态
        /// </summary>
        private enum ImportExportState
        {
            // 整体状态
            /// <summary>
            /// 开始
            /// </summary>
            Start,
            /// <summary>
            /// 已完成
            /// </summary>
            Ready,
            /// <summary>
            /// 失败
            /// </summary>
            Failed,
            // 本地锚点存储器状态
            /// <summary>
            /// 本地锚点存储器正在初始化
            /// </summary>
            AnchorStore_Initializing,
            /// <summary>
            /// 本地锚点存储器已初始化完成(在状态机中)
            /// </summary>
            AnchorStore_Initialized,
            /// <summary>
            /// 房间API已初始化完成(在状态机中)
            /// </summary>
            RoomApiInitialized,
            // Anchor creation values
            /// <summary>
            /// 需要初始锚点(在状态机中)
            /// </summary>
            InitialAnchorRequired,
            /// <summary>
            /// 正在创建初始锚点
            /// </summary>
            CreatingInitialAnchor,
            /// <summary>
            /// 准备导出初始锚点(在状态机中)
            /// </summary>
            ReadyToExportInitialAnchor,
            /// <summary>
            /// 正在上传初始锚点
            /// </summary>
            UploadingInitialAnchor,
            // Anchor values
            /// <summary>
            /// 已请求数据
            /// </summary>
            DataRequested,
            /// <summary>
            /// 数据已准备(在状态机中)
            /// </summary>
            DataReady,
            /// <summary>
            /// 导入中
            /// </summary>
            Importing
        }
    
        /// <summary>
        /// 当前状态
        /// </summary>
        private ImportExportState currentState = ImportExportState.Start;
    
        /// <summary>
        /// 上次状态,用来测试的,代码在Update中
        /// </summary>
        private ImportExportState lastState = ImportExportState.Start;
    
        /// <summary>
        /// 当前状态名
        /// </summary>
        public string StateName
        {
            get
            {
                return currentState.ToString();
            }
        }
    
        /// <summary>
        /// 共享坐标系是否已经建立完成
        /// </summary>
        public bool AnchorEstablished
        {
            get
            {
                return currentState == ImportExportState.Ready;
            }
        }
    
        /// <summary>
        /// 序列化坐标锚点并进行设备间的传输
        /// </summary>
        private WorldAnchorTransferBatch sharedAnchorInterface;
    
        /// <summary>
        /// 下载的原始锚点数据
        /// </summary>
        private byte[] rawAnchorData = http://www.mamicode.com/null;
    
        /// <summary>
        /// 本地锚点存储器
        /// </summary>
        private WorldAnchorStore anchorStore = null;
    
        /// <summary>
        /// 保存我们正在导出的锚点名称
        /// </summary>
        public string ExportingAnchorName = "anchor-1234567890";
    
        /// <summary>
        /// 正在导出的锚点数据
        /// </summary>
        private List<byte> exportingAnchorBytes = new List<byte>();
    
        /// <summary>
        /// 共享服务是否已经准备好,这个是上传和下载锚点数据的前提条件
        /// </summary>
        private bool sharingServiceReady = false;
    
        /// <summary>
        /// 共享服务中的房间管理器
        /// </summary>
        private RoomManager roomManager;
    
        /// <summary>
        /// 当前房间(锚点将会保存在房间中)
        /// </summary>
        private Room currentRoom;
    
        /// <summary>
        /// 有时我们会发现一些很小很小的锚点数据,这些往往没法使用,所以我们设置一个最小的可信任大小值
        /// </summary>
        private const uint minTrustworthySerializedAnchorDataSize = 100000;
    
        /// <summary>
        /// 房间编号
        /// </summary>
        private const long roomID = 8675309;
    
        /// <summary>
        /// 房间管理器的各种事件监听
        /// </summary>
        private RoomManagerAdapter roomManagerCallbacks;
    
        protected override void Awake()
        {
            base.Awake();
            // 开始初始化本地锚点存储器
            currentState = ImportExportState.AnchorStore_Initializing;
            WorldAnchorStore.GetAsync(AnchorStoreReady);
        }
    
        /// <summary>
        /// 本地锚点存储器已准备好
        /// </summary>
        /// <param name="store">本地锚点存储器</param>
        private void AnchorStoreReady(WorldAnchorStore store)
        {
            Debug.Log("本地锚点存储器(WorldAnchorStore)已准备好 - AnchorStoreReady(WorldAnchorStore store)");
    
            anchorStore = store;
            currentState = ImportExportState.AnchorStore_Initialized;
        }
    
        private void Start()
        {
            bool isObserverRunning = SpatialMappingManager.Instance.IsObserverRunning();
            Debug.Log("空间扫描状态:" + isObserverRunning);
            if (!isObserverRunning)
            {
                SpatialMappingManager.Instance.StartObserver();
            }
    
            // 共享管理器是否已经连接
            SharingStage.Instance.SharingManagerConnected += Instance_SharingManagerConnected;
    
            // 是否加入到当前会话中(此事件在共享管理器连接之后才会触发)
            SharingSessionTracker.Instance.SessionJoined += Instance_SessionJoined;
        }
    
        #region 共享管理器连接成功后的一系列处理
    
        // 共享管理器连接事件
        private void Instance_SharingManagerConnected(object sender, EventArgs e)
        {
            Debug.Log("共享管理器连接成功 - Instance_SharingManagerConnected(object sender, EventArgs e)");
    
            // 从共享管理器中获取房间管理器
            roomManager = SharingStage.Instance.Manager.GetRoomManager();
    
            // 房间管理器的事件监听
            roomManagerCallbacks = new RoomManagerAdapter();
    
            // 房间中锚点下载完成事件
            roomManagerCallbacks.AnchorsDownloadedEvent += RoomManagerCallbacks_AnchorsDownloadedEvent;
            // 房间中锚点上传完成事件
            roomManagerCallbacks.AnchorUploadedEvent += RoomManagerCallbacks_AnchorUploadedEvent;
    
            // 为房间管理器添加上面的事件监听
            roomManager.AddListener(roomManagerCallbacks);
        }
    
        // 房间中锚点上传完成事件
        private void RoomManagerCallbacks_AnchorUploadedEvent(bool successful, XString failureReason)
        {
            if (successful)
            {
                Debug.Log("房间锚点上传完成 - RoomManagerCallbacks_AnchorUploadedEvent(bool successful, XString failureReason)");
    
                // 房间锚点上传成功后,空间坐标共享机制建立完成
                currentState = ImportExportState.Ready;
            }
            else
            {
                Debug.Log("房间锚点上传失败 - RoomManagerCallbacks_AnchorUploadedEvent(bool successful, XString failureReason)");
    
                // 房间锚点上传失败
                Debug.Log("Anchor Upload Failed!" + failureReason);
                currentState = ImportExportState.Failed;
            }
        }
    
        // 房间中锚点下载完成事件
        private void RoomManagerCallbacks_AnchorsDownloadedEvent(bool successful, AnchorDownloadRequest request, XString failureReason)
        {
            if (successful)
            {
                Debug.Log("房间锚点下载完成 - RoomManagerCallbacks_AnchorsDownloadedEvent(bool successful, AnchorDownloadRequest request, XString failureReason)");
    
                // 房间锚点下载完成
                // 获取锚点数据长度
                int datasize = request.GetDataSize();
                // 将下载的锚点数据缓存到数组中
                rawAnchorData = http://www.mamicode.com/new byte[datasize];
    
                request.GetData(rawAnchorData, datasize);
    
                // 保存完锚点数据,可以开始准备传输数据
                currentState = ImportExportState.DataReady;
            }
            else
            {
                Debug.Log("锚点下载失败!" + failureReason + " - RoomManagerCallbacks_AnchorsDownloadedEvent(bool successful, AnchorDownloadRequest request, XString failureReason)");
    
                // 锚点下载失败,重新开始请求锚点数据
                MakeAnchorDataRequest();
            }
        }
    
        /// <summary>
        /// 请求锚点数据
        /// </summary>
        private void MakeAnchorDataRequest()
        {
            if (roomManager.DownloadAnchor(currentRoom, new XString(ExportingAnchorName)))
            {
                // 下载锚点完成
                currentState = ImportExportState.DataRequested;
            }
            else
            {
                currentState = ImportExportState.Failed;
            }
        }
    
        #endregion
    
        #region 成功加入当前会话后的一系列处理
    
        // 加入当前会话完成
        private void Instance_SessionJoined(object sender, SharingSessionTracker.SessionJoinedEventArgs e)
        {
            SharingSessionTracker.Instance.SessionJoined -= Instance_SessionJoined;
    
            // 稍等一下,将共享服务状态设置为正常,即可以开始同步锚点了
            Invoke("MarkSharingServiceReady", 5);
        }
    
        /// <summary>
        /// 将共享服务状态设置为正常
        /// </summary>
        private void MarkSharingServiceReady()
        {
            sharingServiceReady = true;
    
    
    #if UNITY_EDITOR || UNITY_STANDALONE
    
            InitRoomApi();
    
    #endif
    
        }
    
        /// <summary>
        /// 初始化房间,直到加入到房间中(Update中会持续调用)
        /// </summary>
        private void InitRoomApi()
        {
            int roomCount = roomManager.GetRoomCount();
            if (roomCount == 0)
            {
                Debug.Log("未找到房间 - InitRoomApi()");
    
                // 如果当前会话中,没有获取到任何房间
                if (LocalUserHasLowestUserId())
                {
                    // 如果当前用户编号最小,则创建房间
                    currentRoom = roomManager.CreateRoom(new XString("DefaultRoom"), roomID, false);
                    // 房间创建好,准备加载本地的初始锚点,供其他人共享
                    currentState = ImportExportState.InitialAnchorRequired;
    
                    Debug.Log("我是房主,创建房间完成 - InitRoomApi()");
                }
            }
            else
            {
                for (int i = 0; i < roomCount; i++)
                {
                    // 获取第一个房间为当前房间
                    currentRoom = roomManager.GetRoom(i);
                    if (currentRoom.GetID() == roomID)
                    {
                        // 加入当前房间
                        roomManager.JoinRoom(currentRoom);
                        // TODO: 加入房间,房间API初始化完成,准备同步初始锚点
                        currentState = ImportExportState.RoomApiInitialized;
    
                        Debug.Log("找到房间并加入! - InitRoomApi()");
    
                        return;
                    }
                }
            }
        }
    
        /// <summary>
        /// 判断当前用户编号是不是所有用户中最小的
        /// </summary>
        /// <returns></returns>
        private bool LocalUserHasLowestUserId()
        {
            for (int i = 0; i < SharingSessionTracker.Instance.UserIds.Count; i++)
            {
                if (SharingSessionTracker.Instance.UserIds[i] < CustomMessages240.Instance.LocalUserID)
                {
                    return false;
                }
            }
    
            return true;
        }
    
        #endregion
    
        // Update中处理各种状态(简单状态机)
        private void Update()
        {
            if (currentState != lastState)
            {
                Debug.Log("状态变化:" + lastState.ToString() + " > " + currentState.ToString());
                lastState = currentState;
            }
    
            switch (currentState)
            {
                case ImportExportState.AnchorStore_Initialized:
                    // 本地锚点存储器初始化完成
                    // 如果成功加入当前会话,则开始加载房间
                    if (sharingServiceReady)
                    {
                        InitRoomApi();
                    }
                    break;
                case ImportExportState.RoomApiInitialized:
                    // 房间已加载完成,开始加载锚点信息
                    StartAnchorProcess();
                    break;
                case ImportExportState.DataReady:
                    // 锚点数据下载完成后,开始导入锚点数据
                    currentState = ImportExportState.Importing;
                    WorldAnchorTransferBatch.ImportAsync(rawAnchorData, ImportComplete);
                    break;
                case ImportExportState.InitialAnchorRequired:
                    // 房主房间创建完成后,需要创建初始锚点共享给他人
                    currentState = ImportExportState.CreatingInitialAnchor;
                    // 创建本地锚点
                    CreateAnchorLocally();
                    break;
                case ImportExportState.ReadyToExportInitialAnchor:
                    // 准备导出初始锚点
                    currentState = ImportExportState.UploadingInitialAnchor;
                    // 执行导出
                    Export();
                    break;
            }
        }
    
        /// <summary>
        /// 房主将本地锚点共享给其他人
        /// </summary>
        private void Export()
        {
            // 获取锚点,这个组件会在CreateAnchorLocally()中自动添加
            WorldAnchor anchor = GetComponent<WorldAnchor>();
    
            if (anchor == null)
            {
                return;
            }
    
            // 本地保存该锚点
            if (anchorStore.Save(ExportingAnchorName, anchor))
            {
                // 将锚点导出
                sharedAnchorInterface = new WorldAnchorTransferBatch();
                sharedAnchorInterface.AddWorldAnchor(ExportingAnchorName, anchor);
                WorldAnchorTransferBatch.ExportAsync(sharedAnchorInterface, WriteBuffer, ExportComplete);
            }
            else
            {
                currentState = ImportExportState.InitialAnchorRequired;
            }
        }
    
        /// <summary>
        /// 房主导出锚点成功
        /// </summary>
        /// <param name="completionReason"></param>
        private void ExportComplete(SerializationCompletionReason completionReason)
        {
            if (completionReason == SerializationCompletionReason.Succeeded && exportingAnchorBytes.Count > minTrustworthySerializedAnchorDataSize)
            {
                // 将锚点数据上传至当前房间中
                roomManager.UploadAnchor(
                    currentRoom,
                    new XString(ExportingAnchorName),
                    exportingAnchorBytes.ToArray(),
                    exportingAnchorBytes.Count);
            }
            else
            {
                currentState = ImportExportState.InitialAnchorRequired;
            }
        }
    
        private void WriteBuffer(byte[] data)
        {
            exportingAnchorBytes.AddRange(data);
        }
    
        /// <summary>
        /// 房主在本地创建一个新的锚点
        /// </summary>
        private void CreateAnchorLocally()
        {
            Debug.Log("开始创建本地锚点");
    
            // 添加世界锚点组件
            WorldAnchor anchor = GetComponent<WorldAnchor>();
            if (anchor == null)
            {
                anchor = gameObject.AddComponent<WorldAnchor>();
            }
    
            if (anchor.isLocated)
            {
                // 房主自己定位好本地锚点后,准备导出给其他人
                currentState = ImportExportState.ReadyToExportInitialAnchor;
            } 
            else
            {
                anchor.OnTrackingChanged += WorldAnchorForExport_OnTrackingChanged;
            }
        }
    
        private void WorldAnchorForExport_OnTrackingChanged(WorldAnchor self, bool located)
        {
            if (located)
            {
                // 房主自己定位好本地锚点后,准备导出给其他人
                currentState = ImportExportState.ReadyToExportInitialAnchor;
            }
            else
            {
                // 房主自己的锚点定位失败,则同步总体失败
                currentState = ImportExportState.Failed;
            }
    
            self.OnTrackingChanged -= WorldAnchorForExport_OnTrackingChanged;
        }
    
        /// <summary>
        /// 锚点数据下载完成后,开始导入锚点数据
        /// </summary>
        /// <param name="completionReason"></param>
        /// <param name="deserializedTransferBatch"></param>
        private void ImportComplete(SerializationCompletionReason completionReason, WorldAnchorTransferBatch deserializedTransferBatch)
        {
            if (completionReason == SerializationCompletionReason.Succeeded && deserializedTransferBatch.GetAllIds().Length > 0)
            {
                // 成功导入锚点
                // 获取第一个锚点名称
                bool hasAnchorName = false;
                string[] anchorNames = deserializedTransferBatch.GetAllIds();
                foreach (var an in anchorNames)
                {
                    if (an == ExportingAnchorName)
                    {
                        hasAnchorName = true;
                        break;
                    }
                }
    
                if (!hasAnchorName)
                {
                    currentState = ImportExportState.DataReady;
                    return;
                }
    
                // 保存锚点到本地
                WorldAnchor anchor = deserializedTransferBatch.LockObject(ExportingAnchorName, gameObject);
                if (anchor.isLocated)
                {
                    if(anchorStore.Save(ExportingAnchorName, anchor))
                    {
                        currentState = ImportExportState.Ready;
                    }
                    else
                    {
                        currentState = ImportExportState.DataReady;
                    }
    
                }
                else
                {
                    anchor.OnTrackingChanged += WorldAnchorForImport_OnTrackingChanged;
                }
            } 
            else
            {
                // 未成功导入,则设置为DataReady,准备在下一帧再次导入,直到导入完成
                currentState = ImportExportState.DataReady;
            }
        }
    
        private void WorldAnchorForImport_OnTrackingChanged(WorldAnchor self, bool located)
        {
            if (located)
            {
                WorldAnchor anchor = GetComponent<WorldAnchor>();
                if (anchorStore.Save(ExportingAnchorName, anchor))
                {
                    currentState = ImportExportState.Ready;
                }
                else
                {
                    currentState = ImportExportState.DataReady;
                }
            }
            else
            {
                currentState = ImportExportState.Failed;
            }
    
            self.OnTrackingChanged -= WorldAnchorForImport_OnTrackingChanged;
        }
    
        /// <summary>
        /// 加载锚点信息
        /// </summary>
        private void StartAnchorProcess()
        {
            Debug.Log("正在获取房间锚点…… - StartAnchorProcess()");
    
            // 检查当前房间有无锚点
            int anchorCount = currentRoom.GetAnchorCount();
    
            if (anchorCount > 0)
            {
                bool isRoomAnchorExists = false;
    
                for (int i = 0; i < anchorCount; i++)
                {
                    string roomAnchor = currentRoom.GetAnchorName(i).GetString();
                    if (roomAnchor == ExportingAnchorName)
                    {
                        isRoomAnchorExists = true;
                        break;
                    }
                }
    
                if (isRoomAnchorExists)
                {
                    Debug.Log("获取房间锚点成功!开始下载锚点");
                    // 获取房间锚点信息成功后,开始下载锚点数据
                    MakeAnchorDataRequest();
                }
            }
        }
    
        protected override void OnDestroy()
        {
            if (SharingStage.Instance != null)
            {
                SharingStage.Instance.SharingManagerConnected -= Instance_SharingManagerConnected;
            }
    
            if (roomManagerCallbacks != null)
            {
                roomManagerCallbacks.AnchorsDownloadedEvent -= RoomManagerCallbacks_AnchorsDownloadedEvent;
                roomManagerCallbacks.AnchorUploadedEvent -= RoomManagerCallbacks_AnchorUploadedEvent;
    
                if (roomManager != null)
                {
                    roomManager.RemoveListener(roomManagerCallbacks);
                }
            }
    
            base.OnDestroy();
        }
    }

    代码有点多,但理解起来并不困难,核心就是一个维护一个简单状态机,我写好了注释,然后还画了张状态图帮助大家理解,如下:
    技术分享

  4. 因为Cube已经作为Controller的子集,我们将之前传递Position改为传递LocalPosition,修改Cube240.cs内容如下:

    using HoloToolkit.Sharing;
    using HoloToolkit.Unity.InputModule;
    using UnityEngine;
    
    public class Cube240 : MonoBehaviour, IInputClickHandler {
    
        // 是否正在移动
        bool isMoving = false;
    
        // 消息传递类
        CustomMessages240 customMessage;
    
        private void Start()
        {
            customMessage = CustomMessages240.Instance;
    
            // 指定收到Cube位置变化消息后的处理方法
            customMessage.MessageHandlers[CustomMessages240.CustomMessageID.CubePosition] = OnCubePositionReceived;
        }
    
        private void OnCubePositionReceived(NetworkInMessage msg)
        {
            // 同步Cube位置
            if (!isMoving)
            {
                transform.localPosition = CustomMessages240.ReadCubePosition(msg);
            }
        }
    
        // 单击Cube,切换是否移动
        public void OnInputClicked(InputEventData eventData)
        {
            isMoving = !isMoving;
            // 放置Cube后,发送Cube的位置消息给其他人
            if (!isMoving)
            {
                customMessage.SendCubePosition(transform.localPosition);
            }
        }
    
        // 如果Cube为移动状态,让其放置在镜头前2米位置
        void Update () {
            if (isMoving)
            {
                transform.position = Camera.main.transform.position + Camera.main.transform.forward * 2f;
                customMessage.SendCubePosition(transform.localPosition, MessageReliability.UnreliableSequenced);
            }
        }
    }

本节完成!

将代码发布到两台设备上,进行测试!注意,本例中一开始并未同步Cube位置,需要某台设备移动Cube后才能看见效果。

完成后,Hierarchy结构类似:
技术分享

说明

学习官方教程的过程中,有很大几率会遇到多台设备物体虽然能够正常显示Cube,也能正常移动Cube,但各设备的Cube并没有重叠。移动Cube时,可能另一台设备的Cube是往不同的方向移动。要问为什么?因为官方代码中充满了下面这种代码:

roomManager.GetRoom(0)
currentRoom.GetAnchorName(0)
wat.GetAllIds()[0]

入坑的同学现在应该已经明白了,当出现多个房间或者多个锚点时,设备总是拿第一个。造成设备间同步的并不一定是同一个房间或者锚点。

小结

这一节太伤元气,小结就写成问答形式了。

  1. 问: 为什么真机扫描不出来空间模型?
    答: 请检查是否开启Spatial Perception功能
    请检查是否放置了SpatialMapping.prefab,并打开Auto Start Observer
  2. 问: 出现”SpatialAnchorTransferManager denied access to WorldAnchor serialization”的提示
    答: 首先,请不要在Unity里测试(不管是不是Remoting),否则这个现象肯定会出现
    其次,看第1点。
  3. 问: 只能用多台真机进行测试吗?
    答: 目前是这样,主要是因为World Anchor
  4. 问: 设备中共享的物体,并没有重叠?
    答: 好吧,请注意代码逻辑,这要求设备扫描了同一空间,加入了同一房间,共享了同一锚点,锚点附加同一物体(要求真高!)。
  5. 问: SpatialMapping好卡啊
    答: 暂时先关闭Draw Visual Meshes
  6. 问: 设备测试的时候,设备休眠时间太短,在哪里可以设置得长一点?
    答: 设备打开,浏览器访问设备IP,进入:Hololens Device PortalHome菜单下有个Sleep settings,最长设置30分钟。

参考文档
官方教程Holograms 240:https://developer.microsoft.com/EN-US/WINDOWS/HOLOGRAPHIC/holograms_240


VR/AR/MR技术交流QQ群:594299228
技术分享

<script type="text/javascript"> $(function () { $(‘pre.prettyprint code‘).each(function () { var lines = $(this).text().split(‘\n‘).length; var $numbering = $(‘
    ‘).addClass(‘pre-numbering‘).hide(); $(this).addClass(‘has-numbering‘).parent().append($numbering); for (i = 1; i <= lines; i++) { $numbering.append($(‘
  • ‘).text(i)); }; $numbering.fadeIn(1700); }); }); </script>

    Hololens官方教程精简版 - 08. Sharing holograms(共享全息影像)