【ARKit】今日からはじめる AR プログラミング Part.3「平面を検出してプレーンを置く」

# この投稿は何？
iOS アプリケーションのための拡張現実フレームワーク _ARKit_ を使って、以下の機能を実装します。

- 現実空間にある水平で平らな面（サーフェス）を検出する
- 検出した平面に仮想ノード（四角いプレーン）を置く

## ARKit のキホン
ARKit は、iPhone が認識した現実空間に、仮想的なオブジェクトを投影します。
なお、以前に投稿した記事「[【ARKit】拡張現実テンプレートを再現する](https://qiita.com/chino_tweet/items/7f7b305ddfdc551134b7)」で、ARKitを使用するまでの準備を解説しています。
この投稿では、前述した2つの機能にフォーカスします。

## 開発環境
この投稿の内容は、以下の環境で開発しました。
また、ARアプリケーションの動作を確認するには、iPhoneSE 以降の実機が必要です。
予め、Xcode と iPhone が正しく接続できることを確認してください。

- macOS10.14.2 がインストールされた Macbook Pro
- Xcode10.1
- iOS12.1 がインストールされた iPhoneX

#手順
## プロジェクトを作成する

1. _Create a new Xcode project_ を選択します。
2. _Product Name_ は、「Find Flat Surface」としておきます。
3. _Augmented Reality App Template_ を選択します。

## 現実空間を追跡する
ARKit は、iPhone に搭載されたカメラとモーションセンサーを使って、現実空間をトラッキングします。
現実空間のトラッキングは、セッションとして実行されます。
アプリが起動されて画面に表示されたタイミングでセッションを実行し、アプリがバックグラウンドになり画面から非表示になったらセッションを停止します。

また、ARKit で発生したイベント（「平面を検出」したり「検出した情報を更新」したとき）の処理を _ViewController_ クラス側で扱えるように、デリゲートしています。

[平面を検出!!]--------------------->[プレーンを置く]
__ARKit__ ---------[デリゲート]-------> __ViewController__
      
````ruby:ViewController
class ViewController: UIViewController, ARSCNViewDelegate {

    @IBOutlet var sceneView: ARSCNView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        sceneView.delegate = self        
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        let configuration = ARWorldTrackingConfiguration()

        sceneView.session.run(configuration)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        sceneView.session.pause()
    }
    
}
````

## 平面を検出する
### コンフィギュレーションを設定する
ARセッションで、どんな機能を有効にするかを指定する _ARWorldTrackingConfiguration_ オブジェクトのインスタンス _configuration_ を生成しています。
このインスタンスの _planeDetection_ プロパティで、「水平な平面」を検出することを指定します。

````ruby:ViewController
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]

        sceneView.session.run(configuration)
    }
````

## プレーンを配置する
### アンカーとは
ARKit は、カメラ画像内で平面を検出すると、_ARPlaneAnchor_ オブジェクトというアンカーを生成します。
アンカーは、その平面の寸法や位置の情報を持っています。

### プレーンノードを作る
シーン（3Dの仮想空間）にあるすべての物体は _Node_ として管理されます。
シーンは、_SceneGraph_ と呼ばれるツリー構造になっており、すべてのノードは１つの親ノード（ルート）の子ノードとして追加されます。
なお、ノードは主に以下の情報があります。

- ジオメトリ（形状）
- ポジション（座標）

今回は、_SCNPlane_ 型オブジェクトを使って、板状のジオメトリを持つノードを生成します。
この板状ノードのサイズは _ARKit_ が検出したアンカー情報に基づいています。
また、ポシジョンは3軸（x, y, z）の3D座標系で指定されます。
アンカー情報の y 軸は高さ方向の数値なので、水平な板状ノードの寸法は x, z軸の数値に基づきます。

なお、生成した板状ノードは、デフォルト状態で垂直に立っている状態です。
x軸方向に -90 度回転することで、検出した平面に置いたようにします。

````ruby:ViewController.swift
    func createFloor(from anchor: ARPlaneAnchor) -> SCNNode{
        let anchorWidth  = CGFloat(anchor.extent.x)
        let anchorHeight = CGFloat(anchor.extent.z)
        
        let planeGeometry = SCNPlane(width: anchorWidth, height: anchorHeight)
        planeGeometry.firstMaterial?.diffuse.contents = UIColor.green
        
        let planeNode = SCNNode(geometry: planeGeometry)
        planeNode.eulerAngles.x = -Float.pi/2
        planeNode.opacity = 0.25
        
        return planeNode
    }
````

## 平面を検出したときのデリゲート処理
検出されたアンカー情報から、板状ノードを生成して、ルートノードに追加します。

````ruby:ViewController.swift
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else { return  }

        let floor = createFloor(from: planeAnchor)
        node.addChildNode(floor)
    }
````

## 平面情報が更新されたときのデリゲート処理
ARKit はセッション実行中に絶え間なく現実空間を追跡し、その情報を更新し続けることで、高品質な体験をユーザに提供します。
すでに検出されている平面のサイズが変化するなどアンカー情報が更新されたとき、板状ノードのポジション・ジオメトリも変更します。

````ruby:ViewController.swift
    func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor,
                   let planeNode = node.childNodes.first,
                   let planeNodeGeometry = planeNode.geometry as? SCNPlane
            else { return }

        let updatedPosition = SCNVector3(planeAnchor.center.x, 0, planeAnchor.center.z)
        planeNode.position = updatedPosition

        planeNodeGeometry.width  = CGFloat(planeAnchor.extent.x)
        planeNodeGeometry.height = CGFloat(planeAnchor.extent.z)
    }
````

## 完成したコード
完成したコードは以下のようになります。

````ruby:ViewController
import UIKit
import SceneKit
import ARKit

class ViewController: UIViewController, ARSCNViewDelegate {

    @IBOutlet var sceneView: ARSCNView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        sceneView.delegate = self        
        sceneView.debugOptions = [.showWorldOrigin, .showFeaturePoints]        
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]

        sceneView.session.run(configuration)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        sceneView.session.pause()
    }
        
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else { return  }

        let floor = createFloor(from: planeAnchor)
        node.addChildNode(floor)
    }
    
    func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor,
                   let planeNode = node.childNodes.first,
                   let planeNodeGeometry = planeNode.geometry as? SCNPlane
            else { return }

        let updatedPosition = SCNVector3(planeAnchor.center.x, 0, planeAnchor.center.z)
        planeNode.position = updatedPosition

        planeNodeGeometry.width  = CGFloat(planeAnchor.extent.x)
        planeNodeGeometry.height = CGFloat(planeAnchor.extent.z)
    }

    func createFloor(from anchor: ARPlaneAnchor) -> SCNNode{
        let anchorWidth  = CGFloat(anchor.extent.x)
        let anchorHeight = CGFloat(anchor.extent.z)
        
        let planeGeometry = SCNPlane(width: anchorWidth, height: anchorHeight)
        planeGeometry.firstMaterial?.diffuse.contents = UIColor.green
        
        let planeNode = SCNNode(geometry: planeGeometry)
        planeNode.eulerAngles.x = -Float.pi/2
        planeNode.opacity = 0.25
        
        return planeNode
    }

}
````

## ビルド
実機の iPhone を接続して実行します。
アプリが起動すると、ARKit が水平な面を検出して、その位置に半透明なグリーンのノードを表示します。
また、カメラを動かしながら移動すると、トラッキング情報が更新されて、ノードの位置と形状が変化します。

# まとめ
「ARKit が現実空間をトラッキング → 平面を検出 → その情報から仮想オブジェクトを配置」する仕組みが理解できました。
随所に _SceneKit_ の概念が見られますが、ARプログラミングには欠かせないようです。
合わせて、学習していく必要性を感じました。