Holographic Academy 230 平面推定について

投稿者: | 2017-03-08

前回の記事ではHolographic Academy 230のSpatialMappingについて、環境スキャンの部分をネタにしました。今回はその続きとして、スキャン結果から平面を推定する部分について書きたいと思います。

Academyでの該当箇所はChapter3になり、環境スキャンによって得られたメッシュから平面を推定します。その後のChapter4では取得した平面に3Dモデルを配置するサンプルが説明されています。

平面推定処理の概要

AcademyのコードではSpatialProccessingというオブジェクトが主体となって平面推定が行われています。

SpatialProccessingにアタッチされているスクリプトは以下の通りです。

PlaySpaceManager
平面推定処理の依頼、得られた平面にオブジェクトを配置するなど、アプリケーションのマネジメントを行う、本サンプル独自のコンポーネント
SurfaceMeshesToPlanes
HoloToolKitに用意されている、平面推定処理を管理するコンポーネント
RemoveSurfaceVertices
HoloToolKitに用意されている、推定した平面に内包される頂点群を削除するコンポーネント

メッシュを使って平面推定処理を実際に行うのはアンマネージコードのPlaneFinding.dllになりますので、SurfaceMeshesToPlanesは平面推定処理を管理するコンポーネント、と私はとらえています。SurfaceMeshesToPlanesはアンマネージコードにメッシュ情報を渡し、平面を取得する、および取得した平面をUnityで扱いやすいように加工するコンポーネントとなっております。

今回アンマネージ側の低レイヤ部分の調査は気力が追い付かなかったので、本記事ではマネージコード部分について調べてみた内容を記載します。

平面推定処理のコードを読む

前述したとおり、本サンプルではPlaySpaceManagerクラスがアプリのマネジメントの役割を担ってますので、まずはこのクラスのメンバ変数とStart()を見てみます。

public class PlaySpaceManager : Singleton<PlaySpaceManager>
{
    // スキャンにかける時間を指定するかどうかのフラグ
    public bool limitScanningByTime = true;
    // スキャン1回あたりにかける時間
    public float scanTime = 30.0f;
    // 環境スキャンの完了時に、スキャンしたメッシュに割り当てるマテリアル
    public Material defaultMaterial;
    // 平面推定の完了時に、平面以外のメッシュに割り当てるマテリアル
    public Material secondaryMaterial;
    // アプリに最低限必要な大地と水平な平面の数
    public uint minimumFloors = 1;
    // アプリに最低限必要な第Þと垂直な平面の数
    public uint minimumWalls = 1;

    /// 平面推定処理が実行中であることを表すフラグ
    private bool meshesProcessed = false;

    private void Start()
    {
        // 環境スキャンの結果得られたメッシュをレンダリングするときに使うマテリアルを指定
        SpatialMappingManager.Instance.SetSurfaceMaterial(defaultMaterial);
        // 平面推定が完了したタイミングで呼び出されるコールバック関数の設定
        SurfaceMeshesToPlanes.Instance.MakePlanesComplete += SurfaceMeshesToPlanes_MakePlanesComplete;
    }

平面推定が完了したタイミングでSurfaceMeshesToPlanesクラスからイベントが発火されるので、そのコールバック関数を設定しています。後述しますが、このコールバック関数にて、3Dモデルを、推定した平面上に配置します。

続いてPlaySpaceManager.Update()を見てみます。

    private void Update()
    {
        // 平面推定処理中でないことと、スキャン時間が指定されていることを確認
        if (!meshesProcessed && limitScanningByTime)
        {
            // スキャンを開始してから、指定した時間が経過しているかを確認
            if (limitScanningByTime && ((Time.time - SpatialMappingManager.Instance.StartTime) < scanTime))
            {
                // 設定したスキャン時間に達していない場合は本フレームでのUpdateを終了し、スキャンを継続する
            }
            else
            {
                //スキャンを開始してから、指定した時間を経過したら、スキャンを停止する
                if(SpatialMappingManager.Instance.IsObserverRunning())
                {
                    SpatialMappingManager.Instance.StopObserver();
                }
                // スキャンで取得した空間情報から平面を推定する
                // 平面推定処理はコルーチンを使い、かつバックグラウンドで実行されるので、推定処理中フラグを立ててUpdateは終了する
                CreatePlanes();
                meshesProcessed = true;
            }
        }
    }

~中略~

    private void CreatePlanes()
    {
        // 平面推定処理はSurfaceToPlanesに委譲する
        SurfaceMeshesToPlanes surfaceToPlanes = SurfaceMeshesToPlanes.Instance;
        if (surfaceToPlanes != null && surfaceToPlanes.enabled)
        {
            surfaceToPlanes.MakePlanes();
        }
    }

環境スキャンについては、前回の記事で少し説明したHoloToolKitのSpatialMappingプレファブをそのまま使っていればシーンの開始時にスキャンが始まりますので、スクリプト側でスキャン開始を制御する必要はありません。本サンプルではあらかじめ決めておいた時間の間にスキャンを行い、その後に平面推定を行うといった流れになっています。

コードからも見てわかる通り、平面推定処理はHoloToolKitのSurfaceMeshesToPlanesにて管理されています。マネージャクラス同様、このクラスのメンバ変数とStart()から見てみます。

namespace Academy.HoloToolkit.Unity
{
    public class SurfaceMeshesToPlanes : Singleton<SurfaceMeshesToPlanes>
    {
        // 推定の結果、得られた平面を管理するGameObjectのリスト
        public List<GameObject> ActivePlanes;
        // 平面をシーン上で表現するのに使うGameObjectのプレファブ
        public GameObject SurfacePlanePrefab;
        // 平面かどうかを判定するのに使用する、面積の閾値
        public float MinArea = 0.025f;

        // アプリ上でレンダリングする平面タイプを指定する
        [HideInInspector]
        public PlaneTypes drawPlanesMask =
            (PlaneTypes.Wall | PlaneTypes.Floor | PlaneTypes.Ceiling | PlaneTypes.Table);

        // アプリでは使用しない、推定完了後に破棄する平面タイプを指定する
        [HideInInspector]
        public PlaneTypes destroyPlanesMask = PlaneTypes.Unknown;

        // 床のy座標
        // HoloLens(カメラ)よりも低い位置で見つかった平面のうち、もっとも面積が大きい平面のy座標がセットされる
        public float FloorYPosition { get; private set; }

        // 天井のy座標
        // 床と同様に、HoloLensよりも高い位置で見つかった平面のうち、もっとも面積が大きい平面のy座標がセットされる
        public float CeilingYPosition { get; private set; }

        // 平面推定が完了したタイミングで発行されるイベントのデリゲート
        public delegate void EventHandler(object source, EventArgs args);
        // 平面推定が完了したタイミングで発火するevent
        public event EventHandler MakePlanesComplete;

        // 平面GameObjectを管理するための親GameObject
        private GameObject planesParent;

        // よくわからない…。-y方向に補正するための閾値?
        private float snapToGravityThreshold = 5.0f;

        // 平面推定処理が実行中かどうかをあらわすフラグ
        private bool makingPlanes = false;

        // アプリが維持すべきFPS
        private static readonly float FrameTime = .008f;    // 120FPS維持する

        private void Start()
        {
            // フラグを初期化
            makingPlanes = false;
            // 推定した平面を保持するGameObjectのリストを初期化
            ActivePlanes = new List<GameObject>();
            // 平面の親GameObjectを作成
            planesParent = new GameObject("SurfacePlanes");
            planesParent.transform.position = Vector3.zero;
            planesParent.transform.rotation = Quaternion.identity;
        }

PlaneTypesは平面の種別を表す列挙体で、HoloToolKitにて定義されています。

[Flags]
public enum PlaneTypes
{
    Wall = 0x1,
    Floor = 0x2,
    Ceiling = 0x4,
    Table = 0x8,
    Unknown = 0x10
}

HoloToolKitでは推定した平面を、壁(Wall)、床(Floor)、天井(Ceiling)、テーブル(Table)、UnKnownの5種類に分類します。床ないし天井は大地と並行な平面で、床と天井の間に存在する平面がテーブル、大地と垂直な平面は壁として分類されます。それ以外の、たとえば平行でも垂直でもない斜めに傾いているような平面はUnKnownに分類されるのですが、HoloToolKitではUnKnownな平面は破棄されてしまうので、アプリでそういった平面を取り扱いたい場合はSurfaceMeshesToPlane.destroyPlanesMaskの値を変更してやる必要があります。

それではマネージャから呼び出されるSurfaceMeshesToPlanes.MakePlanes()と後続するコルーチンの内容を見ていきましょう。

// SurfaceObserverが生成したメッシュをもとに平面を推定する
public void MakePlanes()
{
    // 平面推定は比較的重い処理なので、多重で処理しないようにする
    if (!makingPlanes)
    {
        // 平面推定処理実行中フラグを立てる
        makingPlanes = true;
        // 平面推定処理自体はコルーチンで動かす
        StartCoroutine(MakePlanesRoutine());
    }
}

private IEnumerator MakePlanesRoutine()
 {
     // 過去に推定した平面がシーン内に残っているならば、一度全てクリアする
     for (int index = 0; index < ActivePlanes.Count; index++)
     {
         Destroy(ActivePlanes[index]);
     }
     // いったん1フレーム待機する
     yield return null;
     // 平面推定を開始した時刻を記録しておく
     // この値を使って推定中も指定したFPSを維持するように努める
     float start = Time.realtimeSinceStartup;
     // 推定の結果得られた平面GameObjectを管理するリストをクリアする
     ActivePlanes.Clear();

     // MeshFilterをMeshData構造体に変換する必要があり、変換後のオブジェクトを管理するリストを用意する
     List<PlaneFinding.MeshData> meshData = new List<PlaneFinding.MeshData>();
     // スキャン結果あkらSurfaceのMeshFilterをすべて取得し、一つずつMeshDataに変換する
     List<MeshFilter> filters = SpatialMappingManager.Instance.GetMeshFilters();
     for (int index = 0; index < filters.Count; index++)
     {
         MeshFilter filter = filters[index];
         if (filter != null && filter.sharedMesh != null)
         {
             // メッシュの頂点法線を再計算し、MeshDataに変換、そしてリストに格納する
             filter.mesh.RecalculateNormals();
             meshData.Add(new PlaneFinding.MeshData(filter));
         }
         // 設定したFPSを維持するため、1F分の時間が迫っていたら一度待機し、後続の処理は次フレームで行う
         if ((Time.realtimeSinceStartup - start) > FrameTime)
         {
             yield return null;
             // FPS保持用の時刻の更新
             start = Time.realtimeSinceStartup;
         }
     }

     // いったん1フレーム待機する
     yield return null;

平面推定の処理は重い処理となるので、メインスレッドとは異なるスレッドで実行されます。平面推定の材料となる情報はもちろん環境スキャンによって得られたメッシュ情報なのですが、UnityのMeshFilterはスレッドセーフではないので、そのままではバックグラウンドで実行されるスレッドで取り扱うことができません。そこでPlaneFindingクラスにて定義されているMeshData構造体にMeshFilterを変換する必要があります。

public class PlaneFinding
{
    public struct MeshData
    {
        public Matrix4x4 Transform; // モデル変換行列
        public Vector3[] Verts;     // メッシュの頂点群
        public Vector3[] Normals;   // メッシュの頂点法線
        public Int32[] Indices;     // メッシュを構成するポリゴン群

        public MeshData(MeshFilter meshFilter)
        {
            Transform = meshFilter.transform.localToWorldMatrix;
            Verts = meshFilter.sharedMesh.vertices;
            Normals = meshFilter.sharedMesh.normals;
            Indices = meshFilter.sharedMesh.triangles;
        }
    }

SurfaceMeshesToPlaens.MakePlanesRoutine()に戻り、続きを見てみます。

    // 平面推定を実行し、平面を取得する
    BoundedPlane[] planes = PlaneFinding.FindPlanes(meshData, snapToGravityThreshold, MinArea);

    // いったん1フレーム待機する
    yield return null;

再三書いているとおり、平面推定処理のコア部分はアンマネージコードで行います。しかしながらアンマネージコードとマネージコード間では特定の型以外は直接データの受け渡しができないので、別途型を用意してあげる必要があります(詳しく知りたい方はマーシャリング等で調べてみてください)。ここでは平面情報をやり取りするため、BoundedPlane構造体を用意しています。

なお、引数のsnapToGravityThresholdがどのような値なのかはよくわかりませんでした。ただ、0.0f以外を指定してやると、壁平面の上下の辺がx-z平面と平行になるように補正される(うまく表現できない…)ので、自然な壁に見えるように補正するためのなんらかの閾値なのだと思います。

namespace Academy.HoloToolkit.Unity
{
    // 推定した平面のバウンディングボックスを表す構造体
    [StructLayout(LayoutKind.Sequential)]
    public struct OrientedBoundingBox
    {
        public Vector3 Center;
        public Vector3 Extents;
        public Quaternion Rotation;
    };

    // 推定した平面を表す構造体
    [StructLayout(LayoutKind.Sequential)]
    public struct BoundedPlane
    {
        public Plane Plane;                 // 平面を表すPlane
        public OrientedBoundingBox Bounds;  // 平面のバウンディングボックス
        public float Area;                  // 平面の面積

        public BoundedPlane(Transform xform)
        {
            Plane = new Plane(xform.forward, xform.position);
            Bounds = new OrientedBoundingBox()
            {
                Center = xform.position,
                Extents = xform.localScale / 2,
                Rotation = xform.rotation
            };
            Area = Bounds.Extents.x * Bounds.Extents.y;
        }
    };

アンマネージコードに平面推定処理を委譲する部分は以下の通りです。例外が吐かれることなく処理が完了すると、検出できた平面の配列としてBoundedPlane[]が返されます。


    public class PlaneFinding
    {
        public static BoundedPlane[] FindPlanes(List<MeshData> meshes, float snapToGravityThreshold = 0.0f, float minArea = 0.0f)
        {
            StartPlaneFinding();

            try
            {
                int planeCount;
                IntPtr planesPtr;
                IntPtr pinnedMeshData = PinMeshDataForMarshalling(meshes);
                DLLImports.FindPlanes(meshes.Count, pinnedMeshData, minArea, snapToGravityThreshold, out planeCount, out planesPtr);
                return MarshalBoundedPlanesFromIntPtr(planesPtr, planeCount);
            }
            finally
            {
                FinishPlaneFinding();
            }
        }

これでメッシュから平面が取得できたので、Unity上で取り扱いやすくするためデータを加工していきます。SurfaceMeshesToPlanes.MakePlanes()の続きです。

    // 床平面と天井平面を規定する
    float maxFloorArea = 0.0f;      // 最も広い床平面の面積
    float maxCeilingArea = 0.0f;    // 最も広い天井平面の面積
    FloorYPosition = 0.0f;          // 床のY座標
    CeilingYPosition = 0.0f;        // 天井のY座標
    // 平面(Plane)の法線ベクトルは単位ベクトルなので、法線ベクトルのy成分の絶対値が1に近いほど大地と平行になる
    // 床と平行かどうかを判定するための閾値を設定
    float upNormalThreshold = 0.9f;
    // 平面GameObjectのプレファブにアタッチされているSurfacePlaneコンポーネントにも閾値が設定できるので、
    // 設定されていればそちらを優先する
    if (SurfacePlanePrefab != null && SurfacePlanePrefab.GetComponent<SurfacePlane>() != null)
    {
        upNormalThreshold = SurfacePlanePrefab.GetComponent<SurfacePlane>().UpNormalThreshold;
    }

    // 取得した平面を走査する
    for (int i = 0; i < planes.Length; i++)
    {
        BoundedPlane boundedPlane = planes[i];
        // 平面がHoloLensよりも低いところに位置し、平面が大地と平行ならば
        if (boundedPlane.Bounds.Center.y < 0 && boundedPlane.Plane.normal.y >= upNormalThreshold)
        {
            // 取得した平面のうち、最も面積が広いものを床とみなす
            maxFloorArea = Mathf.Max(maxFloorArea, boundedPlane.Area);
            if (maxFloorArea == boundedPlane.Area)
            {
                FloorYPosition = boundedPlane.Bounds.Center.y;
            }
        }
        // 平面がHoloLensよりも高いところに位置し、平面が大地と平行ならば
        else if (boundedPlane.Bounds.Center.y > 0 && boundedPlane.Plane.normal.y <= -(upNormalThreshold))
        {
            // 取得した平面のうち、最も面積が広いものを天井とみなす
            maxCeilingArea = Mathf.Max(maxCeilingArea, boundedPlane.Area);
            if (maxCeilingArea == boundedPlane.Area)
            {
                CeilingYPosition = boundedPlane.Bounds.Center.y;
            }
        }
    }

取得した平面を前述したタイプに分類するため、まずは判定の基準値となる、床と天井のそれぞれのY座標を決定しています。続いて取得した平面をタイプを判定してGameObjectにアタッチしてシーン上にレンダリングしていきます。

    for (int index = 0; index < planes.Length; index++)
    {
        GameObject destPlane;
        BoundedPlane boundedPlane = planes[index];

        // インスペクタ上でSurfacePlaneコンポーネントがアタッチされたプレファブが設定されていれば、
        // プレファブをもとに平面を保持するGameObjectを生成する
        if (SurfacePlanePrefab != null && SurfacePlanePrefab.GetComponent<SurfacePlane>() != null)
        {
            destPlane = Instantiate(SurfacePlanePrefab);
        }
        else
        {
            // プレファブが設定されていなければ、CubeにSurfacePlaneをアタッチしたGameObjectを生成する
            destPlane = GameObject.CreatePrimitive(PrimitiveType.Cube);
            destPlane.AddComponent<SurfacePlane>();
            destPlane.GetComponent<Renderer>().shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
        }
        // 生成したGameObjectに、インスペクタ上で指定したGameObjectを親に設定
        destPlane.transform.parent = planesParent.transform;
        // SurfacePlaneコンポーネントを取得
        SurfacePlane surfacePlane = destPlane.GetComponent<SurfacePlane>();

        // SurfacePlaneに推定した平面(BoundedPlane)を設定すると、平面種別が分類される
        surfacePlane.Plane = boundedPlane;
        // drawPlaneMaskの設定値に応じて、平面をレンダリングする
        SetPlaneVisibility(surfacePlane);
        // destroyPlanesMaskの設定値に応じて、必要のない平面はシーンから削除する
        if ((destroyPlanesMask & surfacePlane.PlaneType) == surfacePlane.PlaneType)
        {
            DestroyImmediate(destPlane);
        }
        else
        {
            // 破棄されなかった平面は、レイヤー番号を設定し、リストに加える
            destPlane.layer = SpatialMappingManager.Instance.PhysicsLayer;
            ActivePlanes.Add(destPlane);
        }

        // FPSを維持するため、適宜後続の処理は次フレームで実行する
        if ((Time.realtimeSinceStartup - start) > FrameTime)
        {
            yield return null;
            start = Time.realtimeSinceStartup;
        }
    }

平面種別の判定は、平面情報を保持するGameObjectにアタッチされたSurfacePlaneコンポーネントにて行われます。SurfacePlaneはHoloToolKitに用意されているコンポーネントで、推定によって得られた平面情報と、シーン上に表示するGameObjectを結び付ける役割を担っています。SurfacePlaneはBoundedPlane型のプロパティを持っており、このプロパティに平面情報をセットすると、Transformの設定、平面タイプの判定が行われます。

namespace Academy.HoloToolkit.Unity
{
    /// 平面種別を表す列挙体
    [Flags]
    public enum PlaneTypes
    {
        Wall = 0x1,
        Floor = 0x2,
        Ceiling = 0x4,
        Table = 0x8,
        Unknown = 0x10
    }

    public class SurfacePlane : MonoBehaviour
    {
        // 平面の厚み
        public float PlaneThickness = 0.01f;
        // 大地に平行かどうかを判定する閾値
        public float UpNormalThreshold = 0.9f;
        // 床かどうかを判定するときに使用する補正値
        public float FloorBuffer = 0.1f;
        // 天井かどうかを判定するときに使用する補正値
        public float CeilingBuffer = 0.1f;

        // 各種平面をレンダリングするときに用用のマテリアル
        public Material WallMaterial;
        public Material FloorMaterial;
        public Material CeilingMaterial;
        public Material TableMaterial;
        public Material UnknownMaterial;

        // 平面種別を表すプロパティ
        public PlaneTypes PlaneType = PlaneTypes.Unknown;

        // 平面推定プロセスによって取得された平面オブジェクト
        private BoundedPlane plane = new BoundedPlane();
        public BoundedPlane Plane
        {
            get
            {
                return plane;
            }
            set
            {
                plane = value;
                // 平面情報がセットされると、その平面GameObjectのtransform、平面種別が決定される
                UpdateSurfacePlane();
            }
        }
        // 平面の法線
        public Vector3 SurfaceNormal { get; private set; }

        private void UpdateSurfacePlane()
        {
            SetPlaneGeometry();         // Transformのセット
            SetPlaneType();             // 平面種別のセット
            SetPlaneMaterialByType();   // レンダリング用マテリアルのセット
        }

        private void SetPlaneGeometry()
        {
            // 位置と回転は推定によって得られたBoundedPlaneのバウンディングボックスに従う
            gameObject.transform.position = plane.Bounds.Center;
            gameObject.transform.rotation = plane.Bounds.Rotation;
            // スケールもバウンディングボックスに従うが、厚みはインスペクタで指定した値を使う
            Vector3 extents = plane.Bounds.Extents * 2;
            gameObject.transform.localScale = new Vector3(extents.x, extents.y, PlaneThickness);
        }

        private void SetPlaneType()
        {
            // 平面の法線ベクトルを取得する
            SurfaceNormal = plane.Plane.normal;
            // SurfaceMeshesToPlanesにて決定した床と天井のY座標を取得する
            float floorYPosition = SurfaceMeshesToPlanes.Instance.FloorYPosition;
            float ceilingYPosition = SurfaceMeshesToPlanes.Instance.CeilingYPosition;
       
            // 床や天井を規定したときと同様、平面の法線ベクトルのy成分が1に近ければ近いほど、大地に平行な平面となる
            // 法線ベクトルが大地にほぼ垂直で、かつ上方向を向いていれば、床かテーブル
            if (SurfaceNormal.y >= UpNormalThreshold)
            {
                // 平面のy座標が床のy座標+補正値よりも大きければテーブルに分類する、そうでなければ床
                PlaneType = PlaneTypes.Floor;
                if (gameObject.transform.position.y > (floorYPosition + FloorBuffer))
                {
                    PlaneType = PlaneTypes.Table;
                }
            }
            // 法線ベクトルが大地にほぼ垂直で、かつ下方向を向いていたら、天井かテーブル
            else if (SurfaceNormal.y <= -(UpNormalThreshold))
            {
                // 平面のy座業が天井のy座標-補正値よりも小さければテーブルに分類する、そうでなければ天井
                PlaneType = PlaneTypes.Ceiling;
                if (gameObject.transform.position.y < (ceilingYPosition - CeilingBuffer))
                {
                    PlaneType = PlaneTypes.Table;
                }
            }
            // 法線ベクトルのy成分が0に近ければ、平面は大地と垂直なので壁に分類する
            else if (Mathf.Abs(SurfaceNormal.y) <= (1 - UpNormalThreshold))
            {
                PlaneType = PlaneTypes.Wall;
            }
            else
            {
                // どれにも該当しなければUnKnowとする
                PlaneType = PlaneTypes.Unknown;
            }
        }

これで平面推定の処理は終わりです。HoloToolKitでは平面推定が完了したタイミングでイベントが発火されるようになっています。

    // SurfaceMeshesToPlanes.MakePlanes()の続き
    // 推定完了のイベントハンドラが設定されていればイベントを発火する
    EventHandler handler = MakePlanesComplete;
    if (handler != null)
    {
        handler(this, EventArgs.Empty);
    }
    // 推定中フラグを降ろす
    makingPlanes = false;
}

一番最初に書いた、PlaySpaceManagerのStart()を思い出してください、SurfaceMeshesToPlanesのイベントにハンドラを設定しています。

        // 平面推定が完了したタイミングで呼び出されるコールバック関数の設定
        SurfaceMeshesToPlanes.Instance.MakePlanesComplete += SurfaceMeshesToPlanes_MakePlanesComplete;

このイベントハンドラの中身を見てみましょう。

// 平面推定が完了したらコールされるイベントハンドラ
private void SurfaceMeshesToPlanes_MakePlanesComplete(object source, System.EventArgs args)
{
    // 床やテーブルなど、大地に平行な平面を表すGameObjectのリスト
    List<GameObject> horizontal = new List<GameObject>();
    // 壁など、大地に垂直な平面を表すGameObjectのリスト
    List<GameObject> vertical = new List<GameObject>();
    // GetActivePlanesは、平面推定によって得られた平面から、引数に指定した種別の平面のリストを取得するメソッド
    // 床とテーブルを取得する
    horizontal = SurfaceMeshesToPlanes.Instance.GetActivePlanes(PlaneTypes.Table | PlaneTypes.Floor);
    // 壁を取得する
    vertical = SurfaceMeshesToPlanes.Instance.GetActivePlanes(PlaneTypes.Wall);
    // 床と壁がアプリで最低限確保できたか確認する
    if (horizontal.Count >= minimumFloors && vertical.Count >= minimumWalls)
    {
        // パフォーマンス確保のため、推定した平面に内包される頂点を削除する
        RemoveVertices(SurfaceMeshesToPlanes.Instance.ActivePlanes);
        // 平面以外のメッシュのマテリアルを変更する
        SpatialMappingManager.Instance.SetSurfaceMaterial(secondaryMaterial);
        // 3Dオブジェクトを生成し、平面上に配置する
        SpaceCollectionManager.Instance.GenerateItemsInWorld(horizontal, vertical);
    }
    else
    {
        // 指定した数の平面が見つからなかった場合は再度環境スキャンと平面推定を行う
        SpatialMappingManager.Instance.StartObserver();
        meshesProcessed = false;
    }
}

アプリによってどのような平面がいくつ必要になるのかは変わってくるかと思います。SurfaceMeshesToPlanesには指定した平面タイプだけを抽出するメソッドが用意されているので、上記コードのように欲しいだけの平面が確保できたかを確認し、足りなければ再度環境スキャンと平面推定を行うのがよいでしょう。Flagmentsなどでも、アプリ起動時に床や壁等のスキャンが行われていますが、似たようなロジックで行っているのではないでしょうか。

まとめ

SpatialMappingの調査の続きとして、平面推定の処理について調べてみました。次は推定した平面上へのオブジェクト配置を調べてみようと思いますが、そこでは空間アンカーが重要となりそうです。HoloToolKitにも空間アンカーを管理するWorldAnchorManagerコンポーネントや、ユーザの入力に応じて空間アンカーをアタッチ/デタッチしつつ3Dモデルの位置を移動できるTapToPlaceコンポーネントといったものが用意されているので、それらの使い方を調べてみようと思います。