HoloToolKitを使ってWorld Anchorを管理する

投稿者: | 2017-04-05

WorldAnchorとはアプリ上で表示した3Dモデル等のオブジェクトを実空間に固定するための技術です。WorldAnchorの概念については公式リファレンスを参照するとよいかと思います。

【Spatial Anchor】

また、littlewingさん(@keshin_sky)による日本語訳記事も併せて読むと理解しやすいと思います。

WorldAnchorはUnityやWindows SDKにてAPIが公開されています。今回はHoloToolkitを使ってWorldAnchorを取り扱う方法を調べた結果を記録として記事にします。

Unityが提供するWorld Anchor関連のAPI

UnityではWorld Anchorに関連するAPIとしてWorldAnchorとWorldAnchorStoreを用意しています。

WorldAnchor

3Dモデルを実空間に固定するアンカーそのものをです。使い方は非常にシンプルで、固定したい3DモデルにWorldAnchorをアタッチするだけです。

gameobject.AddComponent<WorldAnchor>();

WorldAncbhorが追加されたオブジェクトは位置(position)を変更することができなくなります(Unity EditorのPlayモードで確認してみるとわかりやすいです)。WorldAnchorで固定した3Dモデルを動かしたい場合は、いったんWorldAnchorを外してあげる必要があります。WorldAnchorの外し方もいたってシンプルでデストローイしてあげるだけです。

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

アンカー処理の内部実装がどうなっているのかわからないのですが、WorldAnchorは実空間への固定ができている/できていないの2種類の状態を持ち得るようです。この状態はbool型のインスタンスプロパティisLocatedから参照できます。固定ができていないときにはisLocatedの値がfalseとなり、固定ができているときはtrueとなります。

なお、isLocatedの値が変わるとWorldAnchorのインスタンスからOnTrackingChangedイベントが発火するのですが、このイベントをハンドリングする場合はすこし注意が必要です。というのも、WorldAnchorによる3Dモデルの実空間への固定処理は、コンポーネントをアタッチしてやればすぐに完了するわけではなく多少の時間を必要とするようです。しかしながら、コンポーネントを追加すると、間をおかず固定が完了することもあるみたいです。その場合はイベントが発火しないので、固定が完了したタイミングで何か処理をしたい場合(設置したWorldAnchorをデバイスに保存する処理を行うことが多いと思います)は、以下のスクリプトように2つのケースを想定しておく必要があります。

private void AddAnchor() {
    var anchor = gameobject.AddComponent<WorldAnchor>();
    
    // WorldAnchorの固定が完了した場合に処理を行わせたい
    if(anchor.isLocated){
        // 固定処理が瞬時に完了した場合は直接ハンドラ用メソッドをコールする
        TrackingChangedAnchor(anchor, anchor.isLocated);
    } else {
        // コンポーネントをアタッチしたときには固定できていない場合はイベント経由でハンドラをコールする
        anchor.OnTrackingChanged += TrackingChangedHandler;
    }
}

/// <summary>
/// アンカーの固定状態が変化したときのハンドラ
/// </summary>
/// <param name="self">状態が変わったWorldAnchor、イベントの発火元</param>
/// <param name="located">実空間へ固定できているかどうか</param>
private void TrackingChangedHandler(WorldAnchor self, bool located) {
    // 状態が変わったときにさせたい処理
}

なお、興味本位でアンカーが瞬時に配置されるケースがどの程度あるか実機で試してみましたが、5回中0回でした…。

WorldAnchorStore

WorldAnchorStoreはWorld Anchorについての情報をデバイスに永続的に保管するためのAPIです。たとえばWorld Anchorをデバイスに保存しておけば、アプリを終了する、またはデバイスの電源を切ったとしても、再度アプリを立ち上げたときに、アプリ終了時と同じ場所にWorld Anchorで固定した3Dモデルが表示されるようになります。

WorldAnchorStoreを使うためには以下のようにちょっとした事前準備が必要です。

private WorldAnchorStor anchorStore;

private void Awake() {
    // WorldAnchorStoreのインスタンスの取得を試みる
    WorldAnchorStore.GetAsync(WorldAnchorStoreReady);
}

/// <summary>
/// WorldAnchorStore.GetAsync完了時のコールバック
/// </summary>
/// <param name="anchorStore">取得できたWorldAnchorStoreのインスタンス</param>
private void WorldAnchorStoreReady(WorldAnchorStore anchorStore) {
    this.anchorStore = anchorStore;
}

GetAsyncメソッドはWorldAnchorStoreのインスタンスを取得するためのメソッドです。ただし、このメソッドをコールした瞬間にインスタンスが取得できるわけではなく、インスタンス準備のために少し時間が必要です。そこでインスタンスが準備できたときに呼び出されるコールバックをGetAsyncに渡します。GetAsyncメソッドの引数は、戻り値がvoid型でWorldAnchorStoreの引数を持つデリゲートです。コールバック時に、この引数に準備ができたWorldAnchorStoreのインスタンスがセットされるので、ここからインスタンスを取得できます。

なお、WorldAnchorStoreにはアプリ内のWorldAnchorを管理するためのメソッドが用意されています。

Save
その名の通り、WorldAnchorStoreにWorldAnchorを保管するメソッドです。WorldAnchorStoreでは識別子としてのstringとWorldAnchorをペアにして内部に保存します。

bool successful = anchorStore.Save("anchor01", sampleAnchor);

なお、識別子は一意であることが求められるので、stringの識別子が内部で重複するとWorldAnchorの保存が失敗し、falseが返ってきます。実用する場合はGUID等を使用して識別子が重複しない様にしてあげる必要があります。

Load
保存されているWorldAnchorをロードする関数です。引数に指定した識別子をもとに検索を行い、見つかった場合はWorldAnchorを返します。

WorldAnchor anchor = anchorStore.Load("anchor01", gameobject);

ロードが成功した場合は、第二引数に指定したGameObjectにロードしたWorldAnchorがアタッチされます。もし既にWorldAnchorがアタッチされていた場合は上書きされます。ロードが失敗した場合はnullが返され、GameObjectには何の変化もありません。

Delete
指定した引数に紐づくWorldAnchorをWorldAnchorStoreから削除します。

bool successful = anchorStore.Delete("anchor01");

削除が成功した場合はtrueが返されます。指定した識別子がWorldAnchorStore内に存在しない場合はfalseが返されます。

Clear
WorldAnchorStore内のWorldAnchorをクリアします。List.Clear()と同じかんじ。

anchorStore.Clear();
GetAllIds
WorldAnchorStoreに保管されているすべてのWorldAnchorの識別子を取得します。

string[] ids = new string[anchorStore.anchorCount];
ids = anchorStore.GetAllIds();

また、オーバーロードされたメソッドとして、int GetAllIds(string[] ids)も存在します。

string[] ids = new string[anchorStore.anchorCount];N
int count = anchorStore.GetAllIds(ids);

こちらは引数にstring配列を渡すと、配列に識別子が格納され、格納した識別子の数が帰ってきます。引数の配列の大きさが保管されているWorldAnchorStoreの総数に満たない場合は、詰められるだけ配列に詰め、詰めた総数が帰ってきます。いまいち使いどころがわかりません。

HoloToolKitが提供するWorldAnchorManager

HoloToolKitではHoloToolkit/Utilities/にWorldAnchorManagerという、WorldAnchorの管理を容易にするためのスクリプトが用意されています。使い方はいたってシンプルで、適当に用意したGameObjectにWorldAnchorManagerをアタッチするだけで準備は完了です。

その後はWorldAnchorManagerのAttachAnchorメソッドを呼び出すだけでWorldAnchorのアタッチとストアへの保存ができます。WorldAnchorを外したい場合はRemoveAnchorメソッドを呼び出せば、ストアからの削除も同時に行えます。

// WorldAnchorManagerはシングルトン
// WorldAnchorをアタッチしたいGameObjectと識別子を指定する
WorldAnchorManager.Instance.AttachAnchor(gameobject, "anchor01")

// Anchorを外したいGameObjectを指定する
WorldAnchorManager.Instance.RemoveAnchor(gameobject);

また、WorldAnchorManagerはWorldAnchorの取り付け、取り外しを簡易にするだけでなく、パフォーマンス面についても配慮された設計になっています。WorldAnchorを使用して永続的に位置を保存するようなアプリの場合、アプリの起動時にWorldAnchorをロードする処理を実装することになると思います。このとき内部に保存されているWorldAnchorが大量にある場合、ロード処理で性能が落ちてしまう、最悪の場合アプリが落ちてしまうことも考えられます。そこでWorldAnchorManagerでは処理対象のWorldAnchorが見つかった場合は即時処理を始めず、いったんキューに入れた後にフレーム毎にキューからWorldAnchorを取り出して処理を行うようにしています。

ここからは完全に趣味の範疇になるのですが、WorldAnchorManagerの実装も見てみました。

public class WorldAnchorManager : Singleton<WorldAnchorManager>
{
    // キューに格納するWorldAnchorの情報をパッケージした構造体
    private struct AnchorAttachmentInfo
    {
        // WorldAnchorの取り付け/取り外しの対象となるGameObject
        public GameObject GameObjectToAnchor { get; set; }
        // WorldAnchorStoreに保存するときの識別子
        public string AnchorName { get; set; }
        // オペレーション種別(以下の列挙体参照)
        public AnchorOperation Operation { get; set; }
    }
    // オペレーション種別
    private enum AnchorOperation
    {
        Create,   // 取り付け
        Delete    // 取り外し
    }
    // 処理対象のキュー
    private Queue<AnchorAttachmentInfo> anchorOperations = new Queue<AnchorAttachmentInfo>();
    // WorldAnchorStoreへのプロパティ
    public WorldAnchorStore AnchorStore { get; private set; }

    // WorldAnchorStoreのインスタンスを取得
    protected override void Awake()
    {
        base.Awake();
        AnchorStore = null;
        WorldAnchorStore.GetAsync(AnchorStoreReady);
    }

    private void AnchorStoreReady(WorldAnchorStore anchorStore)
    {
        AnchorStore = anchorStore;
    }

WorldAnchorの取り付け、取り外しを同じキューに格納するために、処理種別を判別するための列挙体と処理対象と内容をパッケージングした構造体を用意しています。

続いてWorldAnchorの取り付け取り外しを行うpublicメソッドですが、呼び出し時は構造体をキューに格納するだけです。

public void AttachAnchor(GameObject gameObjectToAnchor, string anchorName)
{
    // null check
    if (gameObjectToAnchor == null)
    {
        Debug.LogError("Must pass in a valid gameObject");
        return;
    }
    if (string.IsNullOrEmpty(anchorName))
    {
        Debug.LogError("Must supply an AnchorName.");
        return;
    }
    // キューに処理内容を格納する
    anchorOperations.Enqueue(
        new AnchorAttachmentInfo
        {
            GameObjectToAnchor = gameObjectToAnchor,    // WorldAnchorを取り付けるGameObject
            AnchorName = anchorName,                    // WorldAnchorの名前
            Operation = AnchorOperation.Create          // オペレーション種別
        }
    );
}

public void RemoveAnchor(GameObject gameObjectToUnanchor)
{
    // null check
    if (gameObjectToUnanchor == null)
    {
        Debug.LogError("Invalid GameObject");
        return;
    }
    
    if (AnchorStore == null)
    {
        Debug.LogError("remove anchor called before anchor store is ready.");
        return;
    }
    // キューに処理内容を格納する
    anchorOperations.Enqueue(
        new AnchorAttachmentInfo
        {
            GameObjectToAnchor = gameObjectToUnanchor,
            AnchorName = string.Empty,
            Operation = AnchorOperation.Delete  // オペレーション種別が違うだけ
        });
}

実際の処理はUpdate()にて、1F毎にキューからデータを取り出して処理していきます。まずはオペレーション種別がCreate、すなわちAttachAnchor()を呼び出した後どうなるのかを見ていきます。

private void Update()
{
    // キューにデータが入っているならば
    if (AnchorStore != null && anchorOperations.Count > 0)
    {
        // 取り出して処理する
        DoAnchorOperation(anchorOperations.Dequeue());
    }
}

// キューから取り出したデータを処理する
private void DoAnchorOperation(AnchorAttachmentInfo anchorAttachmentInfo)
{
    switch (anchorAttachmentInfo.Operation)
    {
        // 処理種別がCreateだった場合
        case AnchorOperation.Create:
            string anchorName = anchorAttachmentInfo.AnchorName;
            GameObject gameObjectToAnchor = anchorAttachmentInfo.GameObjectToAnchor;
            if (gameObjectToAnchor == null)
            {
                Debug.LogError("GameObject must have been destroyed before we got a chance to anchor it.");
                break;
            }
            // ローカルのストアに対象のWorldAnchorが保存されていないか確認する
            // 保存されている場合は、指定したgameobjectにロードしたWorldAnchorがアタッチされる
            WorldAnchor savedAnchor = AnchorStore.Load(anchorName, gameObjectToAnchor);
            if (savedAnchor == null)
            {
                // 保存されていない場合は新しく生成する
                Debug.LogWarning(gameObjectToAnchor.name + " : World anchor could not be loaded for this game object. Creating a new anchor.");
                CreateAnchor(gameObjectToAnchor, anchorName);
            }
            else
            {   
                // 保存されている場
                savedAnchor.name = anchorName;
                Debug.Log(gameObjectToAnchor.name + " : World anchor loaded from anchor store and updated for this game object.");
            }
            break;
            
            ~中略~

// WorldAnchorの新規作成
private void CreateAnchor(GameObject gameObjectToAnchor, string anchorName)
{
    // 対象のGameObjectにWorldAnchorをアタッチする
    var anchor = gameObjectToAnchor.AddComponent<WorldAnchor>();
    anchor.name = anchorName;
    
    // WorldAnchorの固定が完了したタイミングでWorldAnchorをストアに保存する
    if (anchor.isLocated)
    {
        SaveAnchor(anchor);
    }
    else
    {
        // イベントハンドラにてSaveAnchor()をコールする
        anchor.OnTrackingChanged += Anchor_OnTrackingChanged;
    }
}

// WorldAnchorをストアに保存する
private void SaveAnchor(WorldAnchor anchor)
{
    if (AnchorStore.Save(anchor.name, anchor))
    {
        Debug.Log(gameObject.name + " : World anchor saved successfully.");
    }
    else
    {
        Debug.LogError(gameObject.name + " : World anchor save failed.");
    }
}

続いて、RemoveAnchor()を呼び出した後の流れです。こちらはシンプルにストアからWorldAnchorを削除した後にGameObjectからデストローイしてるだけです。

private void DoAnchorOperation(AnchorAttachmentInfo anchorAttachmentInfo)
{
    switch (anchorAttachmentInfo.Operation)
    {
        case AnchorOperation.Create:
            ~中略~

        case AnchorOperation.Delete:
            if (AnchorStore == null)
            {
                Debug.LogError("Remove anchor called before anchor store is ready.");
                break;
            }
            GameObject gameObjectToUnanchor = anchorAttachmentInfo.GameObjectToAnchor;
            var anchor = gameObjectToUnanchor.GetComponent<WorldAnchor>();
            if (anchor != null)
            {   
                // WorldAnchorをストアから削除する
                AnchorStore.Delete(anchor.name);
                // WorldAcnchorをGameObjectから取り外す
                DestroyImmediate(anchor);
            }
            else
            {
                Debug.LogError("Cannot get anchor while deleting");
            }
            break;
    }
}

WorldAnchorManagerの中身については以上です。

WorldAnchorManagerを使用した場合は、WorldAnchorの生成と同時にストアへ必ず保存されます。したがって、ほぼ永続的に3Dモデルが実空間に固定されます。もしアプリの再起動時はいったんストアに保存されているWorldAnchorをクリアしたい場合は、WorldAnchorStoreをGetAsyncした後のコールバックでClear()を呼び出すようにちょっと改造するといいと思います。

public bool KeepAliveAnchor;

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

    if(KeepAliveAnchor) {
        AnchorStore.Clear();
    }
}

インスペクタで永続的に保持するかどうか選択できるようにしました。

まとめ

HoloToolkitにてWorldAnchorを簡単に使うWorldAnchorManagerについて調べました。パフォーマンスを考慮してキューを使用して逐次処理を行うというテクニックは覚えておいたほうがよさそうですね。

利用例についてはHoloToolkitのサンプルにあるTapToPlaceがシンプルでわかりやすいと思います。このサンプルでは3DモデルをAirTapするとWorldAnchorが外れて3Dモデルを動かすことができるようになり、再度AirTapするとWorldAnchorが取り付けられ固定されるものとなっています。

参考

https://developer.microsoft.com/ja-jp/windows/mixed-reality/world_anchor_in_unity