ARKit と LiDAR で 3 次元空間認識して SceneKit でリアルタイム描画

はじめに

こんにちは.R&D チームの久保です.LiDAR スキャナ搭載 iPad Pro をいじり始めて 1 年以上が経ちました.

ご存知の方も多いと思いますが,iOS/iPadOS 13.4 以降の ARKit/RealityKit では LiDAR スキャナによって 3 次元空間を認識してメッシュとして得ることが可能になりました.LiDAR スキャナ搭載デバイスもつい先日予約開始された最新モデルの iPad Pro を加えると 12.9-inch iPad Pro (第 4 世代及び第 5 世代), 11-inch iPad Pro (第 2 世代及び第 3 世代), iPhone 12 Pro, iPhone 12 Pro Max といった感じでずいぶん増えてきています.

今回の記事では LiDAR スキャナで取得したメッシュの情報を SceneKit で自由にリアルタイム描画する方法を完結したコード付きで説明しています.すでに Web に情報が転がっている情報も多分にあるとは思いますが,私が以前調査した時にはなかなか断片的な情報しかなかったので,備忘録がてらまとめてみました.どなたかの参考になれば幸いです.

(iOS/iPadOS 14.0 以降ではさらにフレーム単位で depth map や confidence map を取得する API が追加されたため,それを用いて密な点群を得ることができるようになりましたが,それについては今回は割愛します.)

デバッグオプションでメッシュの可視化

LiDAR スキャナで認識したメッシュを可視化ための最も簡単な方法だと RealityKit の ARViewdebugOptionsshowSceneUnderstanding を指定するというのがあるでしょう.

let view = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: true)
view.debugOptions = .showSceneUnderstanding

すると以下のように勝手に ARView 上にカメラ映像に重ねてワイヤーフレームが描画されるようになります.色は距離に応じたグラデーション (近いほど赤,遠いほど青) になっています.

f:id:optim-tech:20210425201929j:plain

ただし,これはあくまでデバッグ目的で用意されたものですので,色や線の太さなど見た目は一切変更することができません.

もっと自由にメッシュの可視化

色や見た目を好きに変更したいのであれば,メッシュの頂点や面 (face) の情報を取得して何らかのフレームワークを用いて描画する必要があります.以下のスレッドにもあるように iOS/iPadOS だと SceneKit を利用するのが手頃なので今回はそれを用います.

カメラ映像に重ねたいなら ARKit の ARSCNView という SceneKit の SCNView ベースでカメラ映像も表示されるビューを用いるのが手頃でしょう.

ARKit のビューは 3 種類ありますが,それぞれ長所と短所があります.

  • ARView
    • 現実空間上に RealityKit によるリッチな仮想オブジェクトの描画ができる
    • AR 以外にも使える (.nonAR) (VR など向け?)
    • 唯一 LiDAR スキャナで認識したメッシュを描画するデバッグオプションがある
    • マルチスレッド 1
    • モダンな ECS (entity-component system) エンジンを実装している 2
  • ARSCNView
    • SceneKit の SCNView ベースなので現実空間上に 3D コンテンツを描画して重ねるのに向いている
    • 自前で 3 次元描画したいなら ARView よりも扱いやすい
    • シングルスレッド 3
  • ARSKView
    • SpriteKit の SKView ベースなので現実空間上に 2D コンテンツを描画して重ねるのに向いている

ARMeshAnchor について

ARView に限らず RealityKit や ARKit で現実世界のトラッキングや 3 次元空間の認識などの AR 処理を行う際は必ず ARSession というオブジェクトを通して行います.ARView の場合は ARView.session がそれを持っていて,これは自動で生成及び初期設定してもらうことも,自前で設定したものを持たせることもできます.

そして ARSession では LiDAR スキャナで認識したメッシュを ARMeshAnchor というアンカーとして取得できます.突然「アンカー」という用語が出てきて混乱された方もいらっしゃるかもしれませんが,ARKit では現実の物体や仮想的な物体の位置や向きなどの情報を ARAnchor オブジェクトに持たせて記憶しており,基本的には一つの物体につき一つの ARAnchor が対応づいています.ARMeshAnchor もそんな ARAnchor を継承したサブクラスの一つであり,この ARAnchor のことをアンカーと呼んでいます.

ARMeshAnchor は実は 2m x 2m x 2m の立方体ごとに細切れにされており,LiDAR スキャナでスキャンした範囲が広がるに従って ARMeshAnchor の数は増えていきます.また,ARMeshAnchor のもつ頂点の座標情報はあくまでそのアンカーに固有の座標系での値になっているため,以下の図のようにワールド座標系の上にアンカー固有の座標系が複数存在することになります.アンカー固有の座標系からワールド座標系に座標変換するための変換行列ももちろんアンカーは持っています.

f:id:optim-tech:20210426105621j:plain

(座標軸の向きに関してはこの辺から)

ここでややこしいのが,ARView を提供する RealityKit にも AnchorEntity クラスや HasAnchoring プロトコルというものがあるという点です.ここの図にあるように,RealityKit は ARKit にさらにもう一層加えるようなイメージのものです.ARKit だけでも現実世界に仮想的な物体を配置するような AR 処理は行えるのですが,RealityKit によってさらにリッチな仮想的な物体を追加で配置することができます.この「追加で配置できる物体」を表現するのがここの図にあるように Anchor entity や Entity となるわけです.この RealityKit の世界におけるアンカーと ARKit の世界におけるアンカーは混同しないように気をつけましょう.

閑話休題,ここで一旦 ARMeshAnchor のもつ情報をまとめると,主なところだと以下のようになります.

  • geometry: ARMeshGeometry
    • vertices: ARGeometrySource
      • 頂点の座標 (アンカー固有の座標系上での値)
      • 一つの ARMeshAnchor は 2m x 2m x 2m の範囲しか含まないことに注意
    • faces: ARGeometryElement
      • 面 (face) の頂点情報
      • 3 つの頂点からなる三角形 (face) によってメッシュが表現されており,その各三角形の頂点番号が保持されている
    • normals: ARGeometrySource
      • 面 (face) の法線
    • classification: ARMeshClassification
      • 面のクラス分類結果 (床なのか天井なのか窓なのかなど)
  • transform: simd_float4x4
    • アンカー固有の座標系からワールド座標系へ変換するための変換行列 (4x4)
  • identifier: UUID
    • アンカーを識別するための UUID

この ARGeometrySourceARGeometryElement がそのまま SceneKit で使えれば良いですが,そのままでは使えないので後で述べるように変換する必要があります.幸い MTLBuffer のレベルでは互換性があるので,そこまで大変ではないです.

ちなみに vertices から特定のインデックスの頂点の XYZ 座標を抽出しようと思うとこんなコードになります.今回は使用しませんが,知っておくと頂点の扱いがずいぶん楽になります.

extension ARMeshGeometry {
    func vertex(at index: Int) -> SIMD3<Float> {
        self.vertices.buffer
            .contents()
            .advanced(
                by: self.vertices.offset + (self.vertices.stride * index)
            )
            .assumingMemoryBound(to: SIMD3<Float>.self)
            .pointee
    }
}

ARMeshAnchor の取得方法

次にこの ARMeshAnchor を得るための方法を解説します.一度に全部取得するのであれば

のどちらかでいけますが,今回やりたいのはあくまでリアルタイムに描画することなので最後にまとめて取得するタイプのこれらは使えません.

代わりに,ARSessionDelegate

を使用します.ここでお気づきかもしれませんが,ある一つの ARMeshAnchor は一度作られたら終わりというわけではなく,内容が更新されていきます.また,削除されることもあります.したがって,これらを考慮して描画していけるようにする必要があります.

コードで表すとこんな感じです.

let arSCNView = ARSCNView(frame: .zero)
arSCNView.session.delegate = hoge  // hoge は Hoge のインスタンス
extension Hoge: ARSessionDelegate {
    func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
        for anchor in anchors.compactMap({ $0 as? ARMeshAnchor }) {
            // anchor を描画
        }
    }

    func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
        for anchor in anchors.compactMap({ $0 as? ARMeshAnchor }) {
            // すでに描画済みの anchor を更新
        }
    }

    func session(_ session: ARSession, didRemove anchors: [ARAnchor]) {
        for anchor in anchors.compactMap({ $0 as? ARMeshAnchor }) {
            // すでに描画済みの anchor の描画をやめる
        }
    }
}

ARMeshAnchor を ARSCNView で表示

ここまでで ARMeshAnchor をリアルタイムに取得する方法と,その中身について理解が進んだかと思います.ここでようやくこれを使って SceneKit で描画させていきます.

まずは ARMeshAnchor.geometry (ARMeshGeometry) の頂点情報である vertices (ARGeometrySource) や面の情報である faces (ARGeometryElement) を SceneKit で扱える SCNGeometrySourceSCNGeometryElement 及びそれらから構成される SCNGeometry の形に変換する必要があります.

extension SCNGeometry {
    convenience init(from meshAnchor: ARMeshGeometry) {
        let meshGeometry = meshAnchor.geometry

        // Vertices source
        let vertices = meshGeometry.vertices
        let verticesSource = SCNGeometrySource(
            buffer: vertices.buffer,
            vertexFormat: vertices.format,
            semantic: .vertex,
            vertexCount: vertices.count,
            dataOffset: vertices.offset,
            dataStride: vertices.stride
        )

        // Indices element
        let faces = meshGeometry.faces
        let facesElement = SCNGeometryElement(
            data: Data(
                bytesNoCopy: faces.buffer.contents(),
                count: faces.buffer.length,
                deallocator: .none
            ),
            primitiveType: .triangles,
            primitiveCount: faces.count,
            bytesPerIndex: faces.bytesPerIndex
        )

        self.init(
            sources: [verticesSource],
            elements: [facesElement]
        )
    }
}

これを使えば,func session(ARSession, didAdd: [ARAnchor]) が呼ばれた時はこのように SceneKit で ARSCNView に描画させることができるようになりますね.

extension Hoge: ARSessionDelegate {
    func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
        for anchor in anchors.compactMap({ $0 as? ARMeshAnchor }) {
            // anchor を SCNGeometry に変換
            let scnGeometry = SCNGeometry(from: anchor.geometry)

            // 見た目を指定
            let defaultMaterial = SCNMaterial()
            defaultMaterial.fillMode = .lines  // ワイヤーフレームのように線で描画
            defaultMaterial.diffuse.contents = UIColor.white  // 線の色は白
            scnGeometry.materials = [defaultMaterial]

            // SCNGeometry から SCNNode 作成
            let node = SCNNode(geometry: scnGeometry)
            node.simdTransform = anchor.transform

            // SCNNode を描画 (arSCNView は ARSCNView のインスタンス)
            arSCNView.scene.rootNode.addChildNode(node)
        }
    }

    ...
}

では更新や削除の時にはどうしましょう?一つの案としては,アンカーが持つ UUID をキーとして DictionarySCNNode を記憶させておくというのが良さげなので今回はそれを使用します.SCNNode はクラスなので参照になっているため,SCNNodegeometrysimdTransform を書き換えればそれが SCNView に伝わります.また,削除するときも removeFromParentNode メソッドで SCNView から削除することが可能です.コードでは以下のようになるでしょう.

var knownAnchors = [UUID: SCNNode]()
extension Hoge: ARSessionDelegate {
    func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
        for anchor in anchors.compactMap({ $0 as? ARMeshAnchor }) {
            // anchor を SCNGeometry に変換
            let scnGeometry = SCNGeometry(from: anchor.geometry)

            // 見た目を指定
            let defaultMaterial = SCNMaterial()
            defaultMaterial.fillMode = .lines  // ワイヤーフレームのように線で描画
            defaultMaterial.diffuse.contents = UIColor.white  // 線の色は白
            scnGeometry.materials = [defaultMaterial]

            // SCNGeometry から SCNNode 作成
            let node = SCNNode(geometry: scnGeometry)
            node.simdTransform = anchor.transform

            // SCNNode を描画 (arSCNView は ARSCNView のインスタンス)
            arSCNView.scene.rootNode.addChildNode(node)

            // knownAnchors に記憶させる
            knownAnchors[anchor.identifier] = node
        }
    }

    func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
        for anchor in anchors.compactMap({ $0 as? ARMeshAnchor }) {
            // knownAnchors から UUID が一致するものを探す
            if let node = knownAnchors[anchor.identifier] {
                // 再度 SCNGeometry を作り直す
                let scnGeometry = SCNGeometry(from: anchor.geometry)

                // 見た目を指定
                let defaultMaterial = SCNMaterial()
                defaultMaterial.fillMode = .lines  // ワイヤーフレームのように線で描画
                defaultMaterial.diffuse.contents = UIColor.white  // 線の色は白
                scnGeometry.materials = [defaultMaterial]

                // 描画中の SCNNode を更新
                node.geometry = scnGeometry
                node.simdTransform = anchor.transform
            }
        }
    }

    func session(_ session: ARSession, didRemove anchors: [ARAnchor]) {
        for anchor in anchors.compactMap({ $0 as? ARMeshAnchor }) {
            // knownAnchors から UUID が一致するものを探す
            if let node = knownAnchors[anchor.identifier] {
                // ARSCNView 上から SCNNode を消す
                node.removeFromParentNode()
                // knownAnchors からも削除
                knownAnchors.removeValue(forKey: anchor.identifier)
            }
        }
    }
}

メッシュの見た目を変える

ここまででメッシュを ARSCNView で描画することはできるようになりました.今度は当初の目的であった見た目の変更をやってみましょう.

といっても簡単で,例えば色を変えたいなら func session(_ session: ARSession, didAdd anchors: [ARAnchor])func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) の中で

defaultMaterial.diffuse.contents = UIColor.white  // 線の色は白

のように指定していた色を変えるだけです.SCNMaterial に造詣の深い人であればもっと複雑なマテリアルにすることもできるでしょう.(もちろんワイヤーフレームでなく面を貼るなども)

ただし,線の太さを変えるのは鬼門です.SceneKit のバックエンドが OpenGL ES だった頃は glLineWidth を呼ぶだけで変えられたようなのですが,Metal に変わってからはそのような API が無いようです.また,バックエンドの API を変更することも可能ですが,OpenGL ES が非推奨になってしまったことによりその方法ももはや取れないようです.

したがって,次に考えたのは SCNCylinder (円柱) や SCNTube を線の代わりに描画することで太さをコントロールしようというものです.ですが流石に計算が面倒なので,まさにこの目的のために作られたライブラリ (Swift package もあり) の SCNLine というものを用いて実装してみました.ですが結果はパフォーマンス的にとてもリアルタイム描画に耐えられるようなものではありませんでした.この辺はどうするのが良いのでしょうね...

最後に

完結したコードは GitHub に公開しています.説明で理解できない部分があればこちらをみて理解を深めてみてください.SwiftUI で書いています.

capture

OPTiM では必要とあらば機械学習,Web アプリケーション,iOS,Android,Windows (に限らず) あらゆる領域に手を出す探究心や向上心のあるエンジニアを募集しています.