動かして学ぶARKit ~平面検出~

ARKitで行う平面検出について扱います。
2020.08.30

理由あってARKitを始めました。学ぶことが山積みですが、手元で動かして遊べてワクワクします。Simulatorでデバッグできないのがちょっと面倒で、ARKitのコードって何をやっているのか、どのようなクラスなのかちゃんと理解していなくてもコード片を組み合わせてそれっぽく動いてしまうので説明しながら動かしたコードを落ちついて読んでいきたいと思って記事を書くことにしました。

環境

  • Xcode 11
  • iOS13.6
  • iPhone 11

ARKitの平面検出について

現在のARKitは平面だけでなく垂直面の検出も可能になり、画像検出やバーチャルオブジェクトの配置、AR空間の永続化や共有、環境マッピングなど様々な機能が追加されています。その中で平面検出は極めて基本的な機能です。

SceneKit

ARでモデルを表示するのに使用します。ARKitで行った空間認識や平面検出の結果を表示します。3Dモデルを表示するフレームワークは他にローレベルなAPIを提供するMetalなどがあります。

ARSCNView

SceneKitのSCNViewのサブクラスです。ARKit + SceneKitのプロジェクトを新規作成するとIBOutlet attributeでコードに接続されています。

SCNViewにARの機能を追加したクラスです。

SceneとNode

一つの画面をSceneとして扱い、Scene中の3DモデルなどはScene Nodeとして扱われます。Scene NodeはScene Graphというツリーで管理されています。

3Dモデルなど、と表現したのは、本来SceneKitは3Dゲームを作るためのフレームワークで、Scene Nodeにはほかにもキャラクターやライトなども含まれるためです。

そしてScene Nodeは形状を表現するGeometryと位置と姿勢を表現するTransformのプロパティを持っています。後ほど紹介するコードで出てきますし、物体の配置に使用したりします。今回検出する平面もScene Nodeに追加されます。

特徴点

ARKitで平面検出を行う時の指標になります。後程説明しますが、ARSCNViewのdebugOptionsプロパティに渡すArrayに.showFeaturePointsを渡すと特徴点の表示が可能になります。

特徴点の少ない平面の検出は苦手ですが、特徴点を表示すると検出しやすいかどうかの指標になります。

コードを動かして読むARAnchor, ARPlaneAnchor**

実際のコードです。Xcodeの新規プロジェクト作成からAugmented Reality Appを選択してプロジェクトを作成します。initial view controllerはViewControllerなのでViewControllerのコードを以下のようにします。

import UIKit
import SceneKit
import ARKit

class ViewController: UIViewController, ARSCNViewDelegate {
    @IBOutlet var sceneView: ARSCNView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        sceneView.delegate = self
        sceneView.scene = SCNScene()
        sceneView.debugOptions = [.showFeaturePoints]
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = .horizontal
        sceneView.session.run(configuration)
    }

    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else { fatalError() }
        let planeNode = SCNNode()
        let geometry = SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
        geometry.materials.first?.diffuse.contents = UIColor.white.withAlphaComponent(0.5)
        planeNode.geometry = geometry
        planeNode.transform = SCNMatrix4MakeRotation(-Float.pi / 2.0, 1, 0, 0)
        node.addChildNode(planeNode)
    }
    
    func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else { fatalError() }
        guard let geometryPlaneNode = node.childNodes.first, let planeGeometry = geometryPlaneNode.geometry as? SCNPlane else { fatalError() }
        planeGeometry.width = CGFloat(planeAnchor.extent.x)
        planeGeometry.height = CGFloat(planeAnchor.extent.z)
        geometryPlaneNode.simdPosition = SIMD3(planeAnchor.center.x, 0, planeAnchor.center.z)
    }
}

ARKitは特徴点を検出してARを表示、そしてカメラに画像を表示するまでをセッションとして管理していて、sceneView.session.runからそれが窺えます。

viewDidLoad内のコードを読んでいきます。

sceneView.delegate = self
sceneView.scene = SCNScene()
sceneView.debugOptions = [.showFeaturePoints]
let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = .horizontal
sceneView.session.run(configuration)

ARWorldTrackingCOnfiguration()に値を設定することでsceneViewに平面検出を行うことを知らせています。

debugOptionsプロパティにはshowFeaturePointsを指定しています。これで特徴点がカメラ上に表示されます。

ARSCNViewのdelegateプロパティにselfを代入しているのは、ViewControllerをARSCNViewDelegateに準拠させて平面検出の各タイミングで任意の処理を行うためです。これにより平面が検出されたタイミングで描画したり3Dモデルを配置したりできます。

残りはARSCNViewDelegateのoptionalなメソッドです。renderer(_:didAddfor:)は新しいアンカーに対応するノードがシーンに追加されたときに呼び出されます。アンカーはARAnchor、ARPlaneAnchorのことで、Sceneにオブジェクトを配置するために使用する実世界の位置と方向を持ったオブジェクトになります。ARAnchorのサブクラスであるARPlaneAnchorは平面を表現しているものです。

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
  guard let planeAnchor = anchor as? ARPlaneAnchor else { fatalError() }
  let planeNode = SCNNode()
  let geometry = SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
  geometry.materials.first?.diffuse.contents = UIColor.black.withAlphaComponent(0.5)
  planeNode.geometry = geometry
  planeNode.transform = SCNMatrix4MakeRotation(-Float.pi / 2.0, 1, 0, 0)
  node.addChildNode(planeNode)
}
    
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
  guard let planeAnchor = anchor as? ARPlaneAnchor else { fatalError() }
  guard let geometryPlaneNode = node.childNodes.first, let planeGeometry = geometryPlaneNode.geometry as? SCNPlane else { fatalError() }
  planeGeometry.width = CGFloat(planeAnchor.extent.x)
  planeGeometry.height = CGFloat(planeAnchor.extent.z)
  geometryPlaneNode.simdPosition = SIMD3(planeAnchor.center.x, 0, planeAnchor.center.z)
}

ARWorldTrackingConfigurationのインスタンスのplaneDetectionに平面検出を差すhorizontalを指定しているのでrenderer(_:didAddfor:)で取得できるanchorはARPlaneAnchorのはずです。そのため、optional bindingでARPlaneAnchorのインスタンスを取り出しています。

その後新規にノードを作成しています。これは検出した場所に3DモデルをScene Nodeを表示するためで、必要なGemoetryとTransformを設定しています。

planeNode.transform = SCNMatrix4MakeRotation(-Float.pi / 2.0, 1, 0, 0)`が初見だと何をやっているのかわからなかったです。

これによりx軸を中心に-90度の回転をさせる行列を示しています。この行列をtransformに与えると、この式ではNodeが回転します。

回転させるのはSceneKitの座標系(z軸が上)、ARKitの座標系(y軸が上)に射影するためです。

この回転行列と座標系については以下の記事がわかりやすかったです。

renderer(_:didUpdateFor: に記述しているのは、検出された水平面の情報が更新された時に平面の3Dモデルのサイズを合わせて更新するための処理です。

extentプロパティから取り出せる平面の幅と奥行を利用しているのがわかると思います。

検出した平面に文字列を表示する

renderer(_:didAdd:for:)で検出したARPlaneAnchorを使って位置情報などを決めて平面のNodeにaddChildNode(_:)します。

    // 平面を検出した時に呼ばれる
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {

        // 平面ジオメトリ
        guard let planeAnchor = anchor as? ARPlaneAnchor else { return }

        let planeGeometry = ARSCNPlaneGeometry(device: sceneView.device!)!
        planeGeometry.update(from: planeAnchor.geometry)

        let planeColor = UIColor.white.withAlphaComponent(0.5)
        planeGeometry.materials.first?.diffuse.contents = planeColor
        let planeNode = SCNNode(geometry: planeGeometry)
        let text = SCNText(string: "Hello world!", extrusionDepth: 0.0)
        text.font = UIFont.boldSystemFont(ofSize: CGFloat(planeAnchor.extent.x / 10.0))
        text.materials.first?.diffuse.contents = UIColor.green
        let textNode = SCNNode(geometry: text)
        let (min, max) =  (textNode.boundingBox)
        textNode.pivot = SCNMatrix4MakeTranslation((max.x - min.x) / 2 + min.x, (max.y - min.y) / 2 + min.y, 0)
        textNode.transform = SCNMatrix4MakeRotation(-Float.pi / 2.0, 1, 0, 0)
        textNode.position = SCNVector3(planeAnchor.center)
        planeNode.addChildNode(textNode)
        DispatchQueue.main.async {
            node.addChildNode(planeNode)
        }
    }

検出した平面を都度更新していますが、その場合テキストの位置調整も必要になります。

guard let plainGeometry = childNode.geometry as? ARSCNPlaneGeometry else { break }
plainGeometry.update(from: planeAnchor.geometry)
guard let textNode = childNode.childNodes.first,let text = textNode.geometry as? SCNText else { break }
let size = CGFloat(min(planeAnchor.extent.x, planeAnchor.extent.z) / 10.0)
text.font = UIFont.boldSystemFont(ofSize: size)
let (min, max) = (textNode.boundingBox)
let textBoundsWidth = (max.x - min.x)
let textBoundsheight = (max.y - min.y)
textNode.pivot = SCNMatrix4MakeTranslation(textBoundsWidth/2 + min.x, textBoundsheight/2 + min.y, 0)
textNode.position = SCNVector3(planeAnchor.center)

コードはGitHubに置きました。

まとめ

AR、まだまだ学びたてですが、普段のアプリケーションで使わない知識を必要として楽しいです。片手間でやっていた数学を本格的に学び直す良いきっかけになりそうなのも嬉しいポイントです。まだ作りたいものがイメージできないのでアウトプット駆動で学習を進めていく予定です。

記事宙やコードで誤りがあった際にはご指摘ください。

参考にした記事・書籍へのリンク