Holographic Academy 230 Spatial Mappingについて

投稿者: | 2017-02-28

前回に引き続きHoloLensネタでの記事です。

今回はMixed Realityというコンセプトを実現するにあたって、とても重要な機能の1つである空間認識、マッピングについての内容です。いつも通りHolographic Academyのチュートリアルをなぞりつつソースを読みながら勉強しているのですが、メモ量がかなり大きくなってきたので何回かに分割してまとめていきたいと思います。

Academyの日本語解説を見たい、Spatial Mappingをとりあえず使ってみたい!という方は以下の記事がおすすめです。


本記事では周辺環境のスキャン(以下、環境スキャン)についてまとめていこうと思います。この環境スキャンはHoloToolKitを使えば簡単に実行することができますが、その実装はパフォーマンスを意識した構成であることのあり、初学者にとっては少々複雑で本質を掴みづらいと感じました。実体はUnity5.5より提供されたSurfaceObserverのラッパーですので、まずはこのUnity APIについて調べてみました。

Spatial Mappingとは

HoloLensは赤外線による深度センサーとSLAM機能を使って周辺環境の3Dマップを作ることができます。
/// イメージ図 ///
この3Dマップ上にオブジェクトを配置していくことにより、「MR」世界を作り出しています。この環境スキャンは常に実行することができ、3Dマップを随時アップデートすることが可能です。ですので、ある程度の環境の変化(ドアの開閉、荷物の移動など)を認識することができます。しかしHoloLensにとってこの環境スキャンはそこそこ負荷が高い処理になるので、連続で実行することは推奨されていません(2~3秒間隔くらいがよいみたいです)。したがって人の動きなど、素早く動くものの検知は難しいものとなっています。

スキャンした空間情報は一定の単位に分割してデバイス内に保存され、Unityではこの単位を「Surface」と呼んでいるようです。情報の実態はUnity側で隠蔽されていますが、実際には深度センサでスキャンした結果を点群として保存しているのだと思います。また、隣接するSurfaceは端と端が少し重なるように設置されるらしく、隙間ができないようになっているみたいです。なお、Surfaceは空間としての情報しか持っていない、すなわちそこにあるものが人間なのか、静物なのか、そういった意味情報は全く持っていません。

Unity APIでは環境スキャンの役割を担うのがSurfaceObserverです。Unityのマニュアルに環境スキャンを実行し取得した点群データから、メッシュを生成してレンダリングするサンプルコードがあるので、こちらを見てみました。

参考:Unity Manual – Spatial mapping concepts

サンプルコードの解析

まずは環境スキャンを実施するための準備を行う部分を見てみます。

public class SMSample : MonoBehaviour {
    // SurfaceObserverは空間スキャンを実行するクラス
    SurfaceObserver m_Observer;
  // スキャンした空間情報(Surface)を管理する辞書オブジェクト
    Dictionary<int, SurfaceEntry> m_Surface;
    // メッシュをレンダリングするときに使用するマテリアル
    public Material m_drawMat;
    // メッシュ生成プロセスが実行されているかどうかのフラグ
    bool m_WaitingForBake;
    // 最後に空間スキャンを実施したタイミング
    float m_lastUpdateTime;

	// Use this for initialization
    void Start () {
        // SurfaceObserverインスタンスの生成
        m_Observer = new SurfaceObserver();
        // 環境スキャンのスキャン範囲の設定
        m_Observer.SetVolumeAsAxisAlignedBox(new Vector3(0.0f, 0.0f, 0.0f),
            new Vector3(SurfaceEntry.c_Extents, SurfaceEntry.c_Extents, SurfaceEntry.c_Extents));
        // 辞書オブジェクトの初期化
        m_Surface = new Dictionary<int, SurfaceEntry>();
        // フラグとスキャンタイミングを初期化
        m_WaitingForBake = false;
        // アップデートタイム初期化
        m_lastUpdateTime = 0.0f;
    }

ここでSurfaceEntryというクラスが登場していますが、こちらはサンプルコード内で定義されている、スキャンしたSurfaceと空間情報から生成したメッシュを保持するGameObjectを結び付けるためのヘルパークラスです。

/// <summary>
/// Surfaceと実際のゲームオブジェクトを結びつけるためのヘルパークラス
/// </summary>
class SurfaceEntry {
    // メッシュを保持するGameObject
    public GameObject m_Surface;
    // Surfaceに割り振られる固有のID
    public int m_id;
    // このSurfaceが最期にスキャンされたタイミング
    public DateTime m_UpdateTime;
    // メッシュの生成状況
    public BakedState m_BakeState;
    // スキャン範囲
    public const float c_Extents = 5.0f;
}

// メッシュの生成状況をあらわす列挙体
public enum BakedState {
    NeverBaked = 0,     // まだメッシュが生成されていないSurface
    Baked = 1,          // すでにメッシュが生成されているSurface
    UpdatePostBake = 2  // メッシュは生成されているが、Surfaceが更新され再度メッシュの生成が必要なSurface
}

ここで重要なのはint型のm_idです。SurfaceObserverによってスキャンされたSurfaceにはデバイス内で固有のID(以下、SurfaceID)が割り当てられます。このIDをキーとして内部で管理している空間情報(Surface)にアクセスし、メッシュ生成などの処理を実行することが可能となります。

続いてスキャン実施部分を見てます。本サンプルコードではスキャンを定期的に実行するため、MonoBehavior.Updateにてスキャンは行われています。

    void Update () {
        // 環境スキャンは負荷が大きいので2秒に1回のコールにとどめている
	if(m_lastUpdateTime + 2.0f < Time.realtimeSinceStartup) {
            // HoloLensの現在位置を中心とし、5m*5m*5mの範囲でスキャンを行う
            Vector3 extents;
            extents.x = SurfaceEntry.c_Extents;
            extents.y = SurfaceEntry.c_Extents;
            extents.z = SurfaceEntry.c_Extents;
            // スキャン範囲の設定
            m_Observer.SetVolumeAsAxisAlignedBox(Camera.main.transform.position, extents);

            try {
                // スキャンの実施
                m_Observer.Update(SurfaceChangedHandler);
            } catch {
                Debug.Log("Observer update failed unexpectedly!");
            }
            // スキャンを実施したタイミングを更新する
            m_lastUpdateTime = Time.realtimeSinceStartup;
        }

SurfaceObserver.Updateメソッドの引数には、スキャンが完了したときに呼び出されるコールバック関数を指定します。コールバック関数はSurfaceObserver.SurfaceChangedDelegateというデリゲート型で、以下の引数を持つ関数です。

SurfaceId surfaceId
Surfaceに割り当てられる固有のID。SurfaceId構造体はint型の値を一つ持つだけ。
SurfaceChange changeType
Surfaceにどのような変化が生じたかを表す列挙体。Surfaceの新規追加、更新、削除のいずれかを表す。
Bounds bounds
Surfaceのバウンディングボックス。Surfaceは点群データなので、それらをすべて内包するバウンディングボックス。
DateTime updateTime
Surfaceが更新されたタイミング。

コールバック関数が呼び出された時点で上記の引数にスキャンした空間情報(Surface)についてのメタデータが設定されます。本サンプルコードではSurfaceの変化に応じて、スクリプト内で管理しているSurfaceの辞書を操作しています。

private void SurfaceChangedHandler(SurfaceId surfaceId, SurfaceChange changeType, Bounds bounds, DateTime updateTime) {
    SurfaceEntry entry;
    switch(changeType) {
        case SurfaceChange.Added:
        case SurfaceChange.Updated:
            if(m_Surface.TryGetValue(surfaceId.handle, out entry)) {
                // 対象のSurfaceが既知のものである場合
                if(entry.m_BakeState == BakedState.Baked) {
                    // 要メッシュ更新のステータスにする
                    entry.m_BakeState = BakedState.UpdatePostBake;
                    // Surfaceがupdateされた時間を更新する
                    entry.m_UpdateTime = updateTime;
                }
            // 新しいSurfaceを検出した場合
            } else {
                entry = new SurfaceEntry();
                // Surfaceのメッシュがまだ生成されていないことを表す
                entry.m_BakeState = BakedState.NeverBaked;
                // Surfaceが検出されたタイミングを記録する
                entry.m_UpdateTime = updateTime;
                // IDの設定
                entry.m_id = surfaceId.handle;
                // ゲームオブジェクトを生成し、メッシュ関連のコンポーネントを追加する
                entry.m_Surface = new GameObject(System.String.Format("Surface-{0}", surfaceId.handle));
                entry.m_Surface.AddComponent<MeshFilter>();
                entry.m_Surface.AddComponent<MeshCollider>();
                MeshRenderer mr = entry.m_Surface.AddComponent<MeshRenderer>();
                // 影は描画しない
                mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
                mr.receiveShadows = false;
                // 空間アンカーコンポーネントを追加する
                entry.m_Surface.AddComponent<WorldAnchor>();
                // メッシュをレンダリングするマテリアルを設定する
                entry.m_Surface.GetComponent<MeshRenderer>().sharedMaterial = m_drawMat;
                // 辞書に登録
                m_Surface[surfaceId.handle] = entry;
            }
            break;
        case SurfaceChange.Removed:
            // Surfaceを削除する場合
            if(m_Surface.TryGetValue(surfaceId.handle, out entry)) {
                // 辞書から除外する
                m_Surface.Remove(surfaceId.handle);
                // メッシュとゲームオブジェクトを削除する
                Mesh mesh = entry.m_Surface.GetComponent<MeshFilter>().mesh;
                if(mesh) {
                    Destroy(mesh);
                }
                Destroy(entry.m_Surface);
            }
            break;
    }
}

前述したとおり、本サンプルコードではSurfaceそのものではなく、SurfaceIdをキーとしたSurfaceEntryというヘルパークラスで管理しています。SurfaceIdの他に重要な値としてBakeStateというメッシュ生成状況を表す値があります。この値に応じてメッシュ生成をコントロールします。メッシュ生成はUpdateの後半部分で実行されます。

        if(!m_WaitingForBake) {
            // メッシュ生成の対象とするSurfaceEntry
            SurfaceEntry bestSurface = null;
            // 辞書を走査する
            foreach(KeyValuePair<int, SurfaceEntry> surface in m_Surface) {
                // イテレータが示すSurfaceEntryのメッシュ生成状況から、メッシュの生成、もしくは更新が必要であった場合
                if(surface.Value.m_BakeState != BakedState.Baked) {
                    if (bestSurface == null) {
                        // メッシュ生成対象が設定されていなければ、イテレータが示すSurfaceEntryを対象とする
                        bestSurface = surface.Value;
                    } else {
                        // 対象がすでにセットされている場合、
                        // ステータスがNeverBaked(まだメッシュが生成されていない)ものを優先する
                        if(surface.Value.m_BakeState < bestSurface.m_BakeState) {
                            bestSurface = surface.Value;
                        // ステータスが対象と同じであった場合、前回更新タイミングが古いSurfaceを優先する
                        } else if(surface.Value.m_UpdateTime < bestSurface.m_UpdateTime) {
                            bestSurface = surface.Value;
                        }
                    }
                }
            }

            // メッシュ生成対象のSurfaceが設定されているならば
            if (bestSurface != null) {
                // SurfaceDataを作成する
                SurfaceData sd;
                sd.id.handle = bestSurface.m_id;
                sd.outputMesh = bestSurface.m_Surface.GetComponent<MeshFilter>();
                sd.outputAnchor = bestSurface.m_Surface.GetComponent<WorldAnchor>();
                sd.outputCollider = bestSurface.m_Surface.GetComponent<MeshCollider>();
                sd.trianglesPerCubicMeter = 300.0f;
                sd.bakeCollider = false;

                try {
                    // メッシュを生成するにはRequestMeshAsyncを実行する
                    if(m_Observer.RequestMeshAsync(sd, SurfaceDataReadyHandler)) {
                        m_WaitingForBake = true;
                    } else {
                        Debug.Log(String.Format("Bake request for {0} failed. Is {0} a valid Surface ID?", bestSurface.m_id));
                    }
                } catch {
                    Debug.Log(System.String.Format("Bake for id {0} failed unexpectedly!", bestSurface.m_id));
                }
            }
        }

メッシュを生成するRequestMeshAsyncは空間情報(Surface)としてSurfaceData構造体を引数に渡す必要があります。SurfaceDataは生成したメッシュを格納する入れ物のような役割を果たす構造体です。この構造体はSurfaceIdをメンバとして持っており、RequestMeshAsyncの引数にSurfaceDataを渡すと、構造体にセットされているSurfaceIdの値に紐づくSurfaceからメッシュを生成し、SurfaceDataの他のメンバに生成したデータを設定します。サンプルコードのSurfaceDataの加工部分を見るとわかる通り、SurfaceEntryが保持するGameObjectにアタッチされているメッシュ関連のコンポーネント(MeshFilter等)の参照を設定しているので、RequestMeshAsyncの処理が完了するとこのGameObjectはSurfaceのメッシュデータを保持するオブジェクトとなります。SurfaceData構造体は以下のメンバを保持しており、output~というメンバ名にSurfaceObserverが生成したデータがセットされます。

bool bakeCollider
メッシュを生成する際に、MeshColliderも同時に生成するかどうかを表すフラグ
SurfaceId id
Surfaceに紐づく固有のID
WorldAnchor outputAnchor
Surfaceを実世界に固定するための空間アンカー
MeshCollider outputCollider
生成されたメッシュから作られるMeshCollider。bakeColliderをtrueにセットした場合は、このメンバにもMeshColliderオブジェクトを設定しなくてはいけない
MeshFilter outputMesh
空間情報(Surface)から生成されたメッシュ
trianglesPerCubicMeter
メッシュの解像度を表す。単位立方メートルあたりの三角形の数を設定する

また、SurfaceObserver.Updateと同様、RemoteMeshAsyncの第二引数に設定したコールバック関数がメッシュ生成が完了したタイミングで呼び出されます。このコールバック関数はSurfaceObserver.SurfaceDataReadyDelegateという型で以下の引数をもつ関数です。

SurfaceData bakeData
生成したメッシュなどがセットされたSurfaceData
bool outputWritten
メッシュ生成プロセスの実行結果。成功していればtrueがセットされる
float elapsedBakeTimeSeconds
メッシュを生成するのにかかった時間

RequestMeshAsyncメソッドはメッシュの生成がスタートすると戻り値としてtrueを返します。サンプルコードではメッシュ生成プロセスが実行中であることを示すフラグをこのタイミングで立てています。メッシュの生成も負荷がかかる処理なので、処理が並列に複数走らないように制御する必要があります(サンプルコードではMonoBehavior.Update後半部分のif(!m_WaitingForBake)で制御している)。

また、引数にはメッシュ関連のデータが設定されたSurfaceDataがありますので、ここからメッシュを取得してもいいかもしれませんね。サンプルコードではこのコールバック関数でメッシュのレンダリングを有効化しています。

private void SurfaceDataReadyHandler(SurfaceData bakedData, bool outputWritten, float elapsedBakeTimeSeconds) {
    // ベイク待ちフラグは完了する
    m_WaitingForBake = false;
    SurfaceEntry entry;

    // 辞書から該当するSurfaceEntryを取得する
    if(m_Surface.TryGetValue(bakedData.id.handle, out entry)) {
        // ベイクステータスを更新
        entry.m_BakeState = BakedState.Baked;
        // レンダリングを有効化する
        MeshRenderer renderer = entry.m_Surface.GetComponent<MeshRenderer>();
        renderer.sharedMaterial = this.m_drawMat;
        renderer.enabled = true;
    } else {
        Debug.Log(System.String.Format("Paranoia: Couldn't find surface {0} after a bake !", bakedData.id.handle));
    }
}

サンプルコードは以上となります。

HoloToolKitではどうなっている?

HoloToolKitを使って環境スキャンを実行するには、SpatialMappingプレファブをシーンに追加するだけでよいです。このSpatialMappingプレファブにはSpatialMappingManagerとSpatialMappingObserverというスクリプトがアタッチされています。SpatilaMappingObserverが環境スキャンを実施し、SpatialMappingManagerがそれを制御するマネージャクラスという構成になっています。SpatialMappingObserverは上記サンプルコードと比較すると少し複雑ですが、やっていること自体はおおきく違いはありません。パフォーマンスへの影響を最小限にするため、メッシュを保持するGameObjectの生成、破棄をできるだけ行わなくてもいいように工夫されています。

まとめ

Holographic AcademyのSpatialMappingを実施したときに気になった、環境スキャンのコードを調べてみました。次は生成したメッシュから平面を推定する部分を見てみたいと思いますが、どうやらこの処理はネイティブのプラグインで処理しているようです。プラグインのソースもgithubで公開されていますが、それも調べだすと結構な時間がかかりそうなので、使い方を把握するレベルに留めておきたいと思います。