はじめに
こんにちは.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 の ARView
の debugOptions
に showSceneUnderstanding
を指定するというのがあるでしょう.
let view = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: true) view.debugOptions = .showSceneUnderstanding
すると以下のように勝手に ARView
上にカメラ映像に重ねてワイヤーフレームが描画されるようになります.色は距離に応じたグラデーション (近いほど赤,遠いほど青) になっています.
ただし,これはあくまでデバッグ目的で用意されたものですので,色や線の太さなど見た目は一切変更することができません.
もっと自由にメッシュの可視化
色や見た目を好きに変更したいのであれば,メッシュの頂点や面 (face) の情報を取得して何らかのフレームワークを用いて描画する必要があります.以下のスレッドにもあるように iOS/iPadOS だと SceneKit を利用するのが手頃なので今回はそれを用います.
カメラ映像に重ねたいなら ARKit の ARSCNView
という SceneKit の SCNView
ベースでカメラ映像も表示されるビューを用いるのが手頃でしょう.
ARKit のビューは 3 種類ありますが,それぞれ長所と短所があります.
ARView
ARSCNView
- SceneKit の
SCNView
ベースなので現実空間上に 3D コンテンツを描画して重ねるのに向いている - 自前で 3 次元描画したいなら
ARView
よりも扱いやすい - シングルスレッド 3
- SceneKit の
ARSKView
- SpriteKit の
SKView
ベースなので現実空間上に 2D コンテンツを描画して重ねるのに向いている
- SpriteKit の
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
のもつ頂点の座標情報はあくまでそのアンカーに固有の座標系での値になっているため,以下の図のようにワールド座標系の上にアンカー固有の座標系が複数存在することになります.アンカー固有の座標系からワールド座標系に座標変換するための変換行列ももちろんアンカーは持っています.
(座標軸の向きに関してはこの辺から)
ここでややこしいのが,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
この ARGeometrySource
や ARGeometryElement
がそのまま 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
を得るための方法を解説します.一度に全部取得するのであれば
ARSession.getCurrentWorldMap
で取得したARWorldMap
のanchors
からas?
でARMeshAnchor
にダウンキャストできるものを見つけるARSession.currentFrame
のanchors
からas?
でARMeshAnchor
にダウンキャストできるものを見つける
のどちらかでいけますが,今回やりたいのはあくまでリアルタイムに描画することなので最後にまとめて取得するタイプのこれらは使えません.
代わりに,ARSessionDelegate
の
func session(ARSession, didAdd: [ARAnchor])
- 追加された
ARAnchor
(及びそのサブクラス) を受け取れる
- 追加された
func session(ARSession, didUpdate: [ARAnchor])
- 更新された
ARAnchor
(及びそのサブクラス) を受け取れる
- 更新された
func session(ARSession, didRemove: [ARAnchor])
- 削除された
ARAnchor
(及びそのサブクラス) を受け取れる
- 削除された
を使用します.ここでお気づきかもしれませんが,ある一つの 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 で扱える SCNGeometrySource
や SCNGeometryElement
及びそれらから構成される 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 をキーとして Dictionary
に SCNNode
を記憶させておくというのが良さげなので今回はそれを使用します.SCNNode
はクラスなので参照になっているため,SCNNode
の geometry
や simdTransform
を書き換えればそれが 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 で書いています.
OPTiM では必要とあらば機械学習,Web アプリケーション,iOS,Android,Windows (に限らず) あらゆる領域に手を出す探究心や向上心のあるエンジニアを募集しています.