Hololens 开发笔记(10)——World Anchor

Hololens 实现全息体验的一个特性就是场景保持。当用户离开场景或关闭应用时,场景中的全息图会被保存在所放置的位置,当用户回到场景或重新打开应用时,能够准确的还原之前场景内的全息内容。

World Anchor(空间锚)提供了一种能够将物体保留在特定位置和旋转状态上的方法,以此来保证全息对象的稳定性(即静止参考框架),也通过它来实现场景保持。

WorldAnchorStore 是实现空间锚特性的关键 API,为了能够真正保持一个全息对象,通常为根 GameObject 添加空间锚,同时对其子 GameObject 也附上具有相对位置偏移的空间锚组件。

一、相关 API

添加命名空间:

using UnityEngine.XR.WSA;
using UnityEngine.XR.WSA.Persistence;

(1)为物体添加空间锚

WorldAnchor anchor = gameObject.AddComponent<WorldAnchor>();

(2)销毁物体上的空间锚

当物体被添加空间锚后,该物体不能够再移动。

单纯的销毁空间锚,不需要移动物体:

Destroy(gameObject.GetComponent<WorldAnchor>());

需要移动物体,使用 DestroyImmediate 来销毁空间锚:

DestroyImmediate(gameObject.GetComponent<WorldAnchor>());

(3)移动已经添加空间锚的物体

之前说过物体被添加空间锚后无法移动,因此步骤如下:

  1. 销毁空间锚
  2. 移动物体
  3. 重新添加空间锚
DestroyImmediate(gameObject.GetComponent<WorldAnchor>());
gameObject.transform.position = new Vector3(0, 0, 2);
WorldAnchor anchor = gameObject.AddComponent<WorldAnchor>();

(4)读取已保存的所有空间锚

通过调用 WorldAnchorStore.GetAsync() 来加载所有保存的空间锚。

void Start () {
    WorldAnchorStore.GetAsync(AnchorStoreReady);
}

private void AnchorStoreReady(WorldAnchorStore store)
{
    // 读取所有已保存的空间锚
    WorldAnchorStore anchorStore = store;
    string[] ids = anchorStore.GetAllIds();
}

(5)保存空间锚

/**
 * 返回是否保存成功
 * @Param anchorName: 保存的锚点名
 * @Param anchor: 物体上的锚点组件
 */
bool saved = anchorStore.Save(anchorName, anchor);

(6)加载已保存的空间锚到物体上

/**
 * 当加载成功时返回锚点对象
 * @Param anchorName: 保存的锚点名
 * @Param gameObject: 被添加空间锚的目标对象
 */
WorldAnchor anchor = anchorStore.Load(anchorName, gameObject);

(7)删除已保存的空间锚

/**
 * 返回是否删除成功
 * @Param anchorName: 删除的锚点名
 */
bool deleted = anchorStore.Delete(anchorName);

(8)OnTrackingChanged 事件

当我们为物体添加空间锚的情况下,有些情况空间锚会被立即定位到,即:

WorldAnchor anchor = gameObject.AddComponent<WorldAnchor>();
// anchor.isLocated == true

但是有些情况下不会被立即定位到,我们可以为空间锚绑定 OnTrackingChanged 事件,当它定位成功后,再继续后面的逻辑。

anchor.OnTrackingChanged += Anchor_OnTrackingChanged;

例如,我们需要为物体添加空间锚,等到被定位后将其保存起来,那么代码大概如下:

void OnSelect() {
    WorldAnchor anchor = gameObject.AddComponent<WorldAnchor>();
    if(anchor.isLocated) {
        anchorStore.Save("测试锚点名", anchor);
    } else {
        anchor.OnTrackingChanged += Anchor_OnTrackingChanged;
    }
}

void Anchor_OnTrackingChanged(WorldAnchor self, bool located) {
    if(located) {
        anchorStore.Save("测试锚点名", self);
        // 取消事件监听
        self.OnTrackingChanged -= Anchor_OnTrackingChanged;
    }
}

二、示例程序

基于 Unity 2018 2.x 版本,使用 MRTK 2017.4.2.0 工具包

使用 MRTK 初始化一个 Hololens 应用:

  1. 删除默认相机,使用 HoloToolkit / Input / Prefabs / HoloLensCamera 替代
  2. 添加 HoloToolkit / Input / Prefabs / Cursor / CursorWithFeedback
  3. 添加 HoloToolkit / Input / Prefabs / InputManager,设置其 Simple Single Pointer Selector 的 Cursor 为上一步的 Cursor。

Hololens 开发笔记(10)——World Anchor

  1. 添加一个 Cube,它的 Position 为 (X:0, Y:0, Z:4), Scale 为 (X:0.25, Y:0.25, Z:0.25)

Hololens 开发笔记(10)——World Anchor

  1. 编写脚本 CubeCommand 并将其添加到 Cube 上。
using UnityEngine;
using HoloToolkit.Unity.InputModule;
using UnityEngine.XR.WSA;
using UnityEngine.XR.WSA.Persistence;
using System.Linq;

public class CubeCommand : MonoBehaviour, IInputClickHandler {
    // 被保存的锚点名
    public string ObjectAnchorStoreName;

    WorldAnchorStore anchorStore;

    // 是否可被移动
    bool HasMove = false;
    
    void Start ()
    {
        WorldAnchorStore.GetAsync(AnchorStoreReady);
    }

    private void AnchorStoreReady(WorldAnchorStore store)
    {
        anchorStore = store;

        if (anchorStore.GetAllIds().Contains(ObjectAnchorStoreName))
        {
            anchorStore.Load(ObjectAnchorStoreName, gameObject);
        }
    }
    
    void Update ()
    {
        // 如果立方体可移动,更新其位置
        if (HasMove)
        {
            gameObject.transform.position = Camera.main.transform.position + Camera.main.transform.forward * 2;
        }
	}

    public void OnInputClicked(InputClickedEventData eventData)
    {
        if (anchorStore == null)
        {
            return;
        }

        if(HasMove)
        {
            // 当物体处于可移动,且再次被点击后
            WorldAnchor anchor = gameObject.AddComponent<WorldAnchor>();

            if (anchor.isLocated)
            {
                anchorStore.Save(ObjectAnchorStoreName, anchor);
            }
            else
            {
                anchor.OnTrackingChanged += Anchor_OnTrackingChanged;
            }
        }
        else
        {
            // 当物体处于不可移动,且再次被点击后
            WorldAnchor anchor = gameObject.GetComponent<WorldAnchor>();
            if(anchor != null)
            {
                DestroyImmediate(anchor);
            }

            if (anchorStore.GetAllIds().Contains(ObjectAnchorStoreName))
            {
                anchorStore.Delete(ObjectAnchorStoreName);
            }
        }

        HasMove = !HasMove;
    }

    void Anchor_OnTrackingChanged(WorldAnchor self, bool located)
    {
        if (located)
        {
            anchorStore.Save(ObjectAnchorStoreName, self);
            // 取消事件监听
            self.OnTrackingChanged -= Anchor_OnTrackingChanged;
        }
    }
}
  1. 运行程序

初始位置位于靠近屋顶:

Hololens 开发笔记(10)——World Anchor

通过点击事件,将其拖拽到地上:

Hololens 开发笔记(10)——World Anchor

关闭程序,重新打开后,物体仍然停留在地上。

三、锚点共享

锚点可以在多个设备间共享,来使得不同设备可以使用相同的空间位置,可以通过 WorldAnchorTransferBatch将锚点信息导出为byte数组,在另外一台设备中加载这个数组并重新还原出锚点信息。

(1)锚点导出方

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.WSA;
using UnityEngine.XR.WSA.Sharing;

public class ExportAnchorScript : MonoBehaviour {
    public string exportingAnchorName;
    private List<byte> exportingAnchorBytes = new List<byte>();

    void Start ()
    {
        WorldAnchorTransferBatch transferBatch = new WorldAnchorTransferBatch();
        transferBatch.AddWorldAnchor(exportingAnchorName, transform.GetComponent<WorldAnchor>());
        WorldAnchorTransferBatch.ExportAsync(transferBatch, OnExportDataAvailable, OnExportComplete);
	}

    private void OnExportDataAvailable(byte[] data)
    {
        exportingAnchorBytes.AddRange(data);
    }

    private void OnExportComplete(SerializationCompletionReason completionReason)
    {
        if (completionReason == SerializationCompletionReason.Succeeded)
        {
            Debug.Log("share anchor complete");
        }
        else
        {

        }
    }
}

(2)锚点导入方

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.WSA;
using UnityEngine.XR.WSA.Sharing;

public class ImportAnchorScript : MonoBehaviour {
    public string exportingAnchorName;
    // 导入的目标对象
    public GameObject targetObject;
    int retryCount = 5;

    private List<byte> exportingAnchorBytes = new List<byte>();
    
    void Start ()
    {
        WorldAnchorTransferBatch.ImportAsync(exportingAnchorBytes.ToArray(), OnImportComplete);
    }

    private void OnImportComplete(SerializationCompletionReason completionReason, WorldAnchorTransferBatch deserializedTransferBatch)
    {
        if (completionReason != SerializationCompletionReason.Succeeded)
        {
            Debug.Log("Failed to import: " + completionReason.ToString());
            if (retryCount > 0)
            {
                retryCount--;
                WorldAnchorTransferBatch.ImportAsync(exportingAnchorBytes.ToArray(), OnImportComplete);
            }
            return;
        }

        string[] ids = deserializedTransferBatch.GetAllIds();
        Debug.Log("load anchor count " + ids.Length);
        foreach (string id in ids)
        {
            Debug.Log("load anchor " + id);
            if (targetObject != null && id.Equals(exportingAnchorName))
            {
                Debug.Log("find anchor form share");
                if (targetObject.GetComponent<WorldAnchor>() == null)
                {
                    targetObject.AddComponent<WorldAnchor>();
                }

                deserializedTransferBatch.LockObject(id, targetObject);
                return;
            }
        }
    }
}

四、参考资料