本文提供相关源码,请放心食用,详见网页侧边栏或底部,有疑问请评论或 Issue

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

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

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

一、相关 API

添加命名空间:

1
2
using UnityEngine.XR.WSA;
using UnityEngine.XR.WSA.Persistence;

(1)为物体添加空间锚

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

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

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

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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
void Start () {
WorldAnchorStore.GetAsync(AnchorStoreReady);
}

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

(5)保存空间锚

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

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

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

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

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

(8)OnTrackingChanged 事件

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

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

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

1
anchor.OnTrackingChanged += Anchor_OnTrackingChanged;

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}
}

二、示例程序

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

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

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

  1. 编写脚本 CubeCommand 并将其添加到 Cube 上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
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. 运行程序

初始位置位于靠近屋顶:

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

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

三、锚点共享

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

(1)锚点导出方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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)锚点导入方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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;
}
}
}
}