HoloToolkitのSpatialMappingを理解する

投稿者: | 2017-05-19

HoloLensがこれまでのAR系デバイスと大きく異なる点は、現実のテーブルの上でバーチャルなボールが跳ね回るといった、デバイス自体が現実環境を認識し画面に映るバーチャルな情報に作用させることができる点かと思います。それを実現するのに大きく貢献しているのがデプスセンサーや環境認識カメラによる空間認識技術です。HoloLensは内部にスキャンした空間の3Dマップを保持できますが、この空間スキャン機能や取得した空間データはHoloToolkitを使うことにより、アプリケーションでも取り扱うことができるようになります。

実際、SpatialMapping.prefabをシーンに配置するだけで、スキャンした空間をメッシュ化して表示することができます。

同時にメッシュコライダーも作られるのでこれだけでも色々とできるとは思うのですが、中身を知っておくと使い方の幅が広がると思いますので、今回はSpatialMapping.prefabについての調査結果をまとめたいと思います。(対象バージョンはHoloToolkit-Unity 1.5.7.0ですが、ソースにほとんど差がないので1.5.6.0でも大丈夫だと思います。)

概要としくみ

このprefabを使用すると、空間スキャンを実施し一定範囲ごとにメッシュとGameObjectが生成され、シーンに配置されていきます。同時にアプリ側でもコレクションとしてデータが溜まっていくのでスクリプトで操作することができるようになります。

もう少し詳しく言うと、空間スキャナをAPIを通じて使用すると、その結果としてデバイス内部で保持している分轄された空間データと一意に紐づくID(SurfaceID)を取得することができます。このままだとスクリプト側で空間データそのものを操作することができないので、空間データをメッシュに加工するようAPIにお願いします。アプリ側でプレースホルダを用意し、IDとこのプレースホルダをいっしょにしてAPIに渡すとメッシュ生成が開始されます。その結果プレースホルダ内に生成されたメッシュがセットされて返ってくるので、この時点からアプリ側で空間データを操作することが可能となります。

SpatialMapping.prefabではSurfaceObjectという構造体で、ID、メッシュ、メッシュをシーン内に表示するGameObjectをワンセットにして管理しています。

続いて、SpatialMapping.prefabにアタッチされているスクリプトのクラス構造を見てみます。

SpatialMappingSourceクラスは、スキャンした空間データをUnity内で表現するためのデータソースとなるクラスです。内部にSurfaceObjectのコレクションを保持しています。

空間スキャナAPIであるSurfaceObserverの実体を持つSpatialMappingObserverはこのクラスを継承しています。すなわち、SpatialMappingObserverは、空間スキャナで取得した空間データをデータソースとするクラス、と言えます。空間スキャナで取得した空間データをSurfaceObjectに加工して保持し、それをデータソースとして利用するためのメソッドが用意されているクラスです。

また、ObjectSurfaceObserverクラスもSpatialMappingSourceを継承しています。ObjectSurfaceObserverはインスペクタで指定したメッシュをUnityエディタ上に表示するクラスで、他のデバイスで取得した空間データをPC上で確認してデバッグする、といったことに使えるクラスです。したがってObjectSurfaceObserverクラスは、アプリ内にハードコードされた空間データをデータソースとするクラス、と言えます。

このようにHoloToolkitを使って空間データを取り扱う場合は、SpatialMappingSourceを継承したクラスを使う、もしくは継承したクラスを独自に実装して使うとアプリ内でのデータの生成元に囚われず空間データを操作することができるようになるかと思います。

本記事では解説しませんがが、スキャンした空間データを整形したり特定の形状を抽出するといったSpatialUnderstandingでも、整形済み空間データを管理するためにSpatialMappingSourceを継承したクラスを使っています。

つづいてSpatialMappingManagerですが、こちらはSpatialMappingObserverを操作するためのクラスで、スキャンの開始/停止の制御やデータソースからのデータの取り出しなどを行うことができます。シーン上にレンダリングするデータソースを切り替えることができ、デフォルトではHoloLens実機で動かす場合はSpatialMappingObserver(空間スキャナによるデータソース)が、Unityエディタ上で動かす場合はObjectSurfaceObserver(ハードコードされたデータソース)が使用されます。

MeshSaverとSimpleMeshSerializerはメッシュをバイナリにシリアライズしてファイルに保存するためのヘルパークラスです。

Unityインスペクタで設定できる内容

SpatialMappingObserver

ここではアプリが使用する空間の領域やメッシュの粒度を設定することができます。

デフォルトではExtents(10,10,10)、Origin(0,0,0)が指定されており、かなりの広範囲の空間をアプリで利用できるようになっています。この項目による空間領域の定義の設定イメージは以下の通りとなります。

また、空間領域の形状もObserve Volume Typeで指定することができます。Axis Aligned Boxが上記の図のような座標軸に平行な箱形状、Oriented Boxが傾きを指定可能な箱形状(傾きはOrientationプロパティで指定できます)、Sphereが球形状となります。

この設定の用途はおそらくパフォーマンスチューニングのためだと思われます。利用する空間領域が大きいとその分メッシュも肥大化していくため、デバイスに負荷がかかります。HoloLensはマシンスペック自体はそれほど高くありませんので、あまりに負荷が高いと発熱がひどくなったり、最悪フリーズしてしまいます。

したがってメッシュの粒度を調整するTriangle Per Cubic Meterと併せてパフォーマンスを調整できるよう用意されているのだと思います。

また、空間領域についてのパラメータはプロパティやメソッドを経由して動的に設定することが可能です。したがって自分を中心として半径1mの範囲に限定してメッシュを表示する、といったことも可能です(何に役立つかはわかりませんが)。

SpatialMappingManager

ここでの設定値は以下の通りです。

APIリファレンス

SpatialMappingSource

イベント

SurfaceAdded
デリゲート void (object sender, DataEventArgs<SurfaceObject> e)
説明 データソースに新たなSurfaceObjectが追加されたときに発生します。引数のe.Dataから追加されたSurfaceObjectを参照できます。
SurfaceUpdated
デリゲート void (object sender, DataEventArgs<SurfaceUpdate> e)
説明 データソース内のSurfaceObjectが更新されたときに発生します。引数のe.Data.Oldから更新前のSurfaceObjectを、e.Data.Newから更新後のSurfaceObjectを参照できます。
SurfaceRemoved
デリゲート void (object sender, DataEventArgs≤SurfaceObject> e)
説明 データソース内のSurfaceObjectが削除されたときに発生します。SpatialMappingManager.CleanupObserver()等がコールされたときに発生します。
RemovingAllSurfaces
デリゲート void (object sender, EventArgs e)
説明 データソース内の全てのSurfaceObjectが削除されたときに発生します。

プロパティ

public ReadOnlyCollection SurfaceObjects
説明 データソース内で管理されているSurfaceObjectのコレクションを取得します。取得したコレクションは読み取り専用です。設定はできません。

メソッド

public virtual List<MeshFilter> GetMeshFilters()
引数 なし
説明 データソース内で管理されているすべてのSurfaceObjectからMeshFilterだけを取得します。
public virtual List GetMeshRenderers()
引数 なし
説明 データソース内で管理されているすべてのSurfaceObjectからMeshRendererだけを取得します。
public void SaveSpatialMeshes(string fileName)
引数 ・string fileName – 保存先のファイル名
説明 データソース内のメッシュをファイルに保存します。ファイル形式は独自形式です。

SpatialMappingObserver

プロパティ

public ObserverStates ObserverState
説明 空間スキャナの稼働状況を取得します。ObserverStateはRunningとStoppedの2値を持つ列挙体です。設定はできません。
public ObserverVolumeTypes ObserverVolumeType
説明 アプリケーションが使用する空間領域の形状を取得、設定します。HoloToolkitでは、軸平行箱状、傾き付き箱形状、球形状を設定できますが、視錐台は設定できません。
public Vector3 Extents
説明 空間領域の範囲を取得、設定します。設定後は直ちにアプリケーションに反映されます。
public Vector3 Origin
説明 空間領域の中心座標を取得、設定します。座標系はワールド座標系です。
public Quaternion Orientation
説明 空間領域の傾きを取得、設定します。ObserverVolumeTypesがOrientedBox(傾き付き箱形状)のときのみ有効です。

メソッド

public void StartObserving()
引数 なし
説明 空間スキャンを開始します。開始後、データソース内にSurfaceObjectが蓄積されていきます。
public void StopObserving()
引数 なし
説明 空間スキャンを停止します。
public void CleanupObserver()
引数 なし
説明 空間スキャンを停止し、データソース内のSurfaceObjectをすべて削除します。
public bool SetObserverOrigin(Vector3 origin)
引数 ・Vector3 origin – 空間領域の中心座標
説明 空間領域の中心座標を設定します。座標系はワールド座標系です。

ObjectSurfaceObserver

メソッド

public void Load(GameObject roomModel)
引数 ・GameObject room – アプリ上に表示したいメッシュがアタッチされたGameObject
説明 引数に指定したGameObjectとそのメッシュをSurfaceObjectに加工して取り込み、そのGameObjectをアプリ上に表示します。

SpatialMappingManager

イベント

SourceChanged
デリゲート void (object sender, PropertyChangedEventArgsEx<SpatialMappingSource> e)
説明 このクラスが使用するデータソースが変更されたときに発生します。引数のe.OldValueから変更前のデータソースを、e.NewValueから変更後のデータソースを参照できます。

プロパティ

public float StartTime
説明 空間スキャナが起動した時間を取得します。得られる時間はアプリケーション起動からの時間(秒)です。
public SpatialMappingObserver SurfaceObserver
説明 このクラスが保持するSpatialMappingObserverのインスタンスを取得します。設定はできません。
public SpatialMappingSource Source
説明 このクラスで使用するデータソースを取得、設定します。設定した場合はデータソースに応じてメッシュをレンダリングしなおし、SourceChangedイベントを発生させます。
public int LayerMask
説明 データソース内のGameObjectに割り当てられたレイヤー番号を、ビットマスクできる形で取得します。
public Material SurfaceMaterial
説明 データソース内のメッシュに割り当てるマテリアルを取得、設定します。設定した場合は直ちに反映されます。
public bool DrawVisualMeshes
説明 データソース内のメッシュをレンダリング状態(ON/OFF)を取得、設定します。設定した場合は直ちに反映されます。
public bool CastShadows
説明 データソース内のメッシュの影の状態(ON/OFF)を取得、設定します。設定した場合は直ちに反映されます。

メソッド

public void SetSpatialMappingSource(SpatialMappingSource mappingSource)
引数 SpatialMappingSource mappingSource – 設定したいデータソース
説明 このクラスが使用するデータソースを設定します。
public void SetSurfaceMaterial(Material setSurfaceMaterial)
引数 Materia setSurfaceMaterial – メッシュに割り当てるマテリアル
説明 データソース内のメッシュに割り当てるマテリアルを設定します。
public bool IsObserverRunning()
引数 なし
説明 空間スキャナ(SpatialMappingObserver)が稼働しているかを確認します。trueでは稼働中、falseでは停止中となります。
public void StartObserver()
引数 なし
説明 空間スキャナ(SpatialMappingObserver)を起動します。
public void StopObserver()
引数 なし
説明 空間スキャナ(SpatialMappingObserver)を停止します。
public void CleanupObserver()
引数 なし
説明 空間スキャナ(SpatialMappingObserver)を停止し、データソース内で管理されているすべてのSurfaceObjectを削除します。
public List GetMeshes()
引数 なし
説明 データソース内で管理されているすべてのSurfaceObjectからMeshだけを取得します。
public List GetMeshFilters()
引数 なし
説明 データソース内で管理されているすべてのSurfaceObjectからMeshFilterだけを取得します。
public ReadOnlyCollection<SpatialMappingSource.SurfaceObject> GetSurfaceObjects()
引数 なし
説明 データソース内で管理されているすべてのSurfaceObjectを取得する。

たとえば空間データのメッシュを加工したり、その他のデバイスに送信したりしたい場合はGetMeshes()を使ってMeshのコレクションを取得して処理するとよいと思います。

基本的にSpatialMapping.prefabを使う場合は、SpatialMappingManagerから関連クラスを含めすべての操作ができるので、SpatialMappingMangerのAPIを使うのがよいと思います。

実装解説

毎度おなじみ趣味全開の部分ですが、全部書いているとさすがに膨大なので、空間スキャンからデータソースへの蓄積の部分だけに絞りたいと思います。

まずはスキャナを動かし、スキャンを行う部分です。

// 空間スキャナの実体(Unity API)
private SurfaceObserver observer;

// スキャン間隔
[Tooltip("How long to wait (in sec) between Spatial Mapping updates.")]
public float TimeBetweenUpdates = 3.5f

private void Update() {
        
        ~中略~

        // メッシュ生成処理が走っておらず、設定したスキャン間隔だけ時間が経過していれば
        else if ((Time.unscaledTime - updateTime) >= TimeBetweenUpdates) {
            // スキャンを行う
            // 引数にスキャン完了時のコールバックを設定する
            observer.Update(SurfaceObserver_OnSurfaceChanged);
            // 前回スキャンタイミングを更新する
            updateTime = Time.unscaledTime;
        }
    }
}

過去の記事でも少し説明しましたが、スキャン自体はSurfaceObserver.Update()で行います。スキャンが完了したタイミングでUpdateの引数に渡したコールバック関数が呼び出されます。

/// <summary>
/// スキャン完了時のコールバック
/// </summary>
/// <param name="id">デバイス内部で管理されている空間データのID</param>
/// <param name="changeType">スキャン結果種別</param>
/// <param name="bounds">スキャンした空間のバウンディングボックス</param>
/// <param name="updateTime">スキャンが完了したタイミング</param>
private void SurfaceObserver_OnSurfaceChanged(SurfaceId id, SurfaceChange changeType, Bounds bounds, DateTime updateTime) {
    // スキャナが稼働中であることを確認
    if (ObserverState != ObserverStates.Running) {
        return;
    }
    switch (changeType) {
        // スキャンの結果、新しい空間データが見つかった、もしくは更新されたとき
        case SurfaceChange.Added:
        case SurfaceChange.Updated:
            // キューにSurfaceIDを追加
            surfaceWorkQueue.Enqueue(id);
            break;
        // スキャンの結果、空間データが消滅したとき
        case SurfaceChange.Removed:
            // SurfaceObjectをコレクションから除外する
            SurfaceObject? removedSurface = RemoveSurfaceIfFound(id.handle, destroyGameObject: false);
            if (removedSurface != null) {
                ReclaimSurface(removedSurface.Value);
            }
            break;
        // なにかしらのエラーが発生
        default:
            Debug.LogErrorFormat("Unexpected {0} value: {1}.", changeType.GetType(), changeType);
            break;
    }
}

概要でも説明した通り、空間スキャンが完了したタイミングでは、まだメッシュデータを取得することは出来ません。この後、別のAPIを呼び出してメッシュ生成を行う必要があります。なお、メッシュ生成は比較的重い処理になりますので。同時に実行されない様にIDをキューにいれて順次処理するようにしています。

また、空間スキャンは新しい空間データの取得以外にも、空間データの削除も行います。前項で説明した通り、SurfaceObserverではアプリケーションが使用する空間領域を定義することができます。この領域以外に位置する空間データはスキャンできたとしてもデバイス内部には破棄されますので、そういった場合に空間データの削除といった処理が発生します。

続いてメッシュ生成の部分です。

// メッシュ生成中のSurfaceObject
// メッシュのベイクは負荷がかかるので多重処理が発生しない様に制御するために使ったり、
// 生成完了後に参照するために使われる。
private SurfaceObject? outstandingMeshRequest = null;

private void Update() {
    // スキャナが稼働しており、かつメッシュ生成処理が発生していないならば
    if ((ObserverState == ObserverStates.Running) && (outstandingMeshRequest == null)) {
        // キューにスキャン済みの空間データのIDがあれば、メッシュ生成を行う
        if (surfaceWorkQueue.Count > 0) {
            SurfaceId surfaceID = surfaceWorkQueue.Dequeue();
            // シーン上でのSurfaceObjectの名前
            string surfaceName = ("Surface-" + surfaceID.handle);
            SurfaceObject newSurface;
            WorldAnchor worldAnchor;

            // アプリ内にスペアがない場合
            if (spareSurfaceObject == null) {
                // 新しくSurfaceObjectを作る
                newSurface = CreateSurfaceObject(
                    mesh: null,
                    objectName: surfaceName,
                    parentObject: transform,
                    meshID: surfaceID.handle,
                    drawVisualMeshesOverride: false
                    );
                // アンカーを設置する
                worldAnchor = newSurface.Object.AddComponent<WorldAnchor>();
            }

            // スペアを使う場合
            else {
                newSurface = spareSurfaceObject.Value;
                // スペアのプレースホルダは空にしておく
                spareSurfaceObject = null;
                // SurfaceObjectの各プロパティを置き換えていく
                newSurface.Object.SetActive(true);  
                newSurface.Object.name = surfaceName;
                newSurface.ID = surfaceID.handle;
                newSurface.Renderer.enabled = false;
                worldAnchor = newSurface.Object.GetComponent<WorldAnchor>();
            }

            // Meshを生成するためのデータを作る
            // どちらかというとプレースホルダとしての意味合い
            var surfaceData = new SurfaceData(
                surfaceID,
                newSurface.Filter,      // この時点ではnull
                worldAnchor,
                newSurface.Collider,    // この時点ではnull
                TrianglesPerCubicMeter, // メッシュの粒度を指定
                _bakeCollider: true     // コライダーを作るかどうか
                );

            // メッシュ生成を開始(非同期処理)
            if (observer.RequestMeshAsync(surfaceData, SurfaceObserver_OnDataReady)) {
                // メッシュ生成中のSurfaceObjectとして保持しておく
                outstandingMeshRequest = newSurface;
            } else {
                // 何らかの理由でメッシュ生成リクエストが失敗
                Debug.LogErrorFormat("Mesh request for failed. Is {0} a valid Surface ID?", surfaceID.handle);
                Debug.Assert(outstandingMeshRequest == null);
                ReclaimSurface(newSurface);
            }
        }
    
        ~中略~

    }
}

新しい空間を発見、もしくは既存空間のアップデートを検知した場合は、SurfaceObjectを新しく用意します。SurfaceObjectはメンバにGameObjectを持っているため、新規に作成しようとするとGameObjectの生成が発生します。ただ、GameObjectの生成は比較的重い処理となるため、少しでも軽くなるようにSpatialMappinhでは1つだけ空っぽのスペア用のSurfaceObjectを持つことがあります。これは空間データの削除が発生したときに以下のように用意されます。

/// <summary>
/// 空間データがデバイス内部からデバイスから削除されたとき、
/// SurfaceObjectも削除する必要がある
/// ただ、GameObjectの新規作成は比較的負荷が高い処理なので、
/// 空間データが削除された場合でも、再利用のため空のSurfaceObjectをアプリ内では保持しておく
/// </summary>
private SurfaceObject? spareSurfaceObject = null;

/// <summary>
/// SurfaceObjectをクリーンアップする
/// 状態に応じてスペアを確保する
/// </summary>
/// <param name="availableSurface">対象となるSufaceObject</param>
private void ReclaimSurface(SurfaceObject availableSurface) {
    if (spareSurfaceObject == null) {
        // スペア用のSurfaceObjectがないときは、GameObjectは削除せずにクリーンアップする
        CleanUpSurface(availableSurface, destroyGameObject: false);
        // 名前の変更と非アクティブ化
        availableSurface.Object.name = "Unused Surface";
        availableSurface.Object.SetActive(false);
        // スペアとして設定
        spareSurfaceObject = availableSurface;
    } else {
        // スペアが存在するときはクリーンアップと同時にシーンから削除
        CleanUpSurface(availableSurface);
    }
}

メッシュ生成のAPIを呼び出すためにはSurfaceDataというデータ構造を用意する必要はあります。コメントにも書いてある通り、このデータはプレースホルダ的な要素が強く、APIにてIDに紐づく空間データのメッシュが生成されたあと、この構造体のメンバであるMeshFilterとMeshColliderにメッシュとコライダーがセットされてコールバックにて取得できます。コードからわかる通り、このMeshFIlterとMeshColliderはSurfaceObject(outstandingMeshRequest)に紐づいているので、メッシュ生成完了後はこのSurfaceObjectをデータソースとしてコレクションに加えてやれば、アプリからメッシュ化された空間データを取り扱えるようになります。

/// <summary>
/// メッシュ生成が完了したときのコールバック
/// </summary>
/// <param name="cookedData">生成されたメッシュが格納されているSurfaceData</param>
/// <param name="outputWritten">trueがセットされていればメッシュ生成が成功している</param>
/// <param name="elapsedCookTimeSeconds">メッシュ生成にかかった時間</param>
private void SurfaceObserver_OnDataReady(SurfaceData cookedData, bool outputWritten, float elapsedCookTimeSeconds) {

    if (outstandingMeshRequest == null) {
        Debug.LogErrorFormat("Got OnDataReady for surface {0} while no request was outstanding.",
            cookedData.id.handle
            );
        return;
    }

    // リクエスト時に渡したSurfaceDataのIDが、完了時のものと一致しないとき
    if (!IsMatchingSurface(outstandingMeshRequest.Value, cookedData)) {
        Debug.LogErrorFormat("Got mismatched OnDataReady for surface {0} while request for surface {1} was outstanding.",
            cookedData.id.handle,
            outstandingMeshRequest.Value.ID
            );
        ReclaimSurface(outstandingMeshRequest.Value);
        outstandingMeshRequest = null;
        return;
    }

    // メッシュ生成中にスキャナが停止したとき
    if (ObserverState != ObserverStates.Running) {
        Debug.LogFormat("Got OnDataReady for surface {0}, but observer was no longer running.",
            cookedData.id.handle
            );
        ReclaimSurface(outstandingMeshRequest.Value);
        outstandingMeshRequest = null;
        return;
    }

    // メッシュ生成に失敗したとき
    if (!outputWritten) {
        ReclaimSurface(outstandingMeshRequest.Value);
        outstandingMeshRequest = null;
        return;
    }

    Debug.Assert(outstandingMeshRequest.Value.Object.activeSelf);
    // メッシュのレンダリングフラグを設定
    outstandingMeshRequest.Value.Renderer.enabled = SpatialMappingManager.Instance.DrawVisualMeshes;
    // メッシュ生成が完了したSurfaceObjectを内部のリストに追加
    SurfaceObject? replacedSurface = UpdateOrAddSurfaceObject(outstandingMeshRequest.Value, destroyGameObjectIfReplaced: false);
    outstandingMeshRequest = null;
    if (replacedSurface != null) {
        ReclaimSurface(replacedSurface.Value);
    }
}

メッシュ生成プロセスが完了した後は、各種整合性チェックを行い、パスしたらデータソースに追加します。

まとめ

HoloLensアプリケーションで空間認識技術を取り扱う際に役に立つ、HoloToolkit内のSpatialMapping.prefabについてまとめました。

公式のAPIリファレンスがあるといいのですが、現状HoloToolkitのドキュメントが揃っているとは言い難いのでソースを読むしかないのが辛いところです。ただ、勉強にはすごくなるので十分な実力がつくまでは続けていこうと思います。