ARkit平面検出とSCNNodeオブジェクトの追加、理解した

https://dev.classmethod.jp/smartphone/ios11-arkit-plane-detection/

今更ですが、なんとなく触っていたARkitですが、ところどころ曖昧なところがあったので、1行づつ噛み砕いて理解しました。

ViewControllerへの記述

ViewControllerではARの空間の作成と、PlaneNodeの表示を行なっています。

    @IBOutlet var sceneView: ARSCNView!

まず1つだけ持っているメンバですが、StoryBordとOutlet接続されています。

StoryBordを見ると⇦のバーにはSceneViewとだけしか書いてないので、ただのSceneViewに見えますが、クリックして確認すると、SceneViewを継承したARSCNViewだということがわかります。

ライフサイクルを思い出そう

わかりやすいライフサイクルの画像があったので借りてきました。

今回のコードで使用されていたメソッドは

  • viewDidloat()
  • viewWillAppear()
  • viewWillDissapear()

この3つです。

ViewDidLoad()

override func viewDidLoad() {
        super.viewDidLoad()
        
        sceneView.delegate = self
        sceneView.scene = SCNScene()
    }

viewがロードされる前の状態であるviewDidLoadではARSCNViewDelegateの設定と、SCNSceneの作成を行なっています。

viewWillAppear

override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        let configuration = ARWorldTrackingConfiguration()
        // 平面の検出を有効化する
        configuration.planeDetection = .horizontal
        sceneView.session.run(configuration)
    }

viewが表示される直前のviewWillAppearではまずARWorldTrackingConfiguration()なるもののインスタンスを作成しています。

公式ドキュメンをにはこのように記述がありました。

A configuration that uses the back-facing camera, tracks a device’s orientation and position, and detects real-world surfaces, and known images or objects.

https://developer.apple.com/documentation/arkit/arworldtrackingconfiguration

カメラでARオブジェクトや画像を検出する。つまりARkitの一番大事な部分です。

そのインスタンスに平面の検出を有効化して、session.runでARの世界を開始しています。

viewWillDissapear

override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        sceneView.session.pause()
    }

viewが消滅する直前のviewWillDisappearでは、ARのセッションを止めてARの世界を終了させています。

肝心のARの処理

ARの処理ですがこのプログラムでは2つのメソッドを使っています。

追加の処理

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        DispatchQueue.main.async {
            if let planeAnchor = anchor as? ARPlaneAnchor {
                
                // 平面を表現するノードを追加する
                node.addChildNode((PlaneNode(anchor: planeAnchor) ))
            }
        }
    }

1つ目はノードを追加するrendererです。第二引数のラベルがdidAddとなっています。中身の処理から分かるように平面のノードを追加しています。

DispatchQueue.main.async {}

ここで何をやっているかというと、メインスレッドで処理を実行させています。

メインスレッドとは先ほど画像で示したライフサイクルのメソッドの一連の流れのことです。rendererメソッドは別のスレッドで処理が行われています。

iosにもandroidにも共通して言えることだと思うのですが、UIをいじるときはメインスレッドで行うというのが決まりです。メインスレッド以外からUIに触ろうとするとクラッシュします。

if let planeAnchor = anchor as? ARPlaneAnchor

この条件は引数でもらってくるanchorがARPlaneAnchorでキャストできた場合に処理を実行しています。

アップデートの処理

func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        DispatchQueue.main.async {
            if let planeAnchor = anchor as? ARPlaneAnchor, let planeNode = node.childNodes[0] as? PlaneNode {
                // ノードの位置及び形状を修正するp
                planeNode.update(anchor: planeAnchor)
            }
        }
    }

こちらのrendererは第二引数のラベルがdidUPdateになっています。

あとはUIを触ろうとしているのでメインスレッドで実行をし、as?演算子でキャストできるか判定してノードの向きや大きさをの修正を行なっています。

PlaneNodeクラス

ここまでは元の記事で詳しく説明してくれていたので分かると思うのですが、問題はこのPlaneNodeクラスです。

コード全文

import UIKit
import SceneKit
import ARKit

class PlaneNode: SCNNode {
    
    fileprivate override init() {
        super.init()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    init(anchor: ARPlaneAnchor) {
        super.init()
        
        geometry = SCNPlane(width: CGFloat(anchor.extent.x), height: CGFloat(anchor.extent.z))
        let planeMaterial = SCNMaterial()
        planeMaterial.diffuse.contents = UIColor.white.withAlphaComponent(0.5)
        geometry?.materials = [planeMaterial]
        SCNVector3Make(anchor.center.x, 0, anchor.center.z)
        //transform = SCNMatrix4MakeRotation(-Float.pi / 2, 1, 0, 0)
    }
    
    func update(anchor: ARPlaneAnchor) {
        (geometry as! SCNPlane).width = CGFloat(anchor.extent.x)
        (geometry as! SCNPlane).height = CGFloat(anchor.extent.z)
        position = SCNVector3Make(anchor.center.x, 0, anchor.center.z)
    }
}

イニシャライザ

init(anchor: ARPlaneAnchor) {
        super.init()
        
        geometry = SCNPlane(width: CGFloat(anchor.extent.x), height: CGFloat(anchor.extent.z))
        let planeMaterial = SCNMaterial()
        planeMaterial.diffuse.contents = UIColor.white.withAlphaComponent(0.5)
        geometry?.materials = [planeMaterial]
        SCNVector3Make(anchor.center.x, 0, anchor.center.z)
        transform = SCNMatrix4MakeRotation(-Float.pi / 2, 1, 0, 0)
    }

イニシャライザの中ですごくいろんなことをやってます。

謎のメンバ変数

第一に疑問に思ってしまうことはgeometoryが一体何者なのかです、メンバとして宣言していないのに一体なぜ使えるのか謎でした。

jumptoDefinisionで宣言元に飛んで見ると、geometoryはSCNNodeクラスのメンバー変数でopenというアクセス修飾子がついていることがわかりました。

openが何者なのかわからなかったので調べて見ると詳しく解説をしてくれている記事を発見しました。

https://dev.classmethod.jp/smartphone/iphone/swift3_scoped_access_level/

要約するとすごく緩ゆる〜い修飾子だということがわかりました。継承していればそのまま使える便利な修飾子です。

色の設定

let planeMaterial = SCNMaterial()
        planeMaterial.diffuse.contents = UIColor.white.withAlphaComponent(0.5)
        geometry?.materials = [planeMaterial]

次に設定しているのが色の設定です。マテリアルを作成して、geometoryに半透明の色をセットしています。

位置のベクトル(使われてない)

SCNVector3Make(anchor.center.x, 0, anchor.center.z)

次にここ、ここが本当に謎だったのですがSCNVector3Makeを公式ドキュメントで調べて見ると。こんな記載がありました。

Returns a new three-component vector created from individual component values.

https://developer.apple.com/documentation/scenekit/1409705-scnvector3make

つまりベクトルの値が帰ってくるようですね。戻り値受け取ってないしこれ何やってるんだろうと悩んだんですが、結局使ってないようでした。

使ってない理由としては、一番最初に取得できる値はあまり正確ではないからだそうです。よーく記事を読み返して見るとそれっぽい記述がありました。

最初に検出した時点では、その平面情報(位置や大きさなど)は、あまり正確ではありません。

変換行列

transform = SCNMatrix4MakeRotation(-Float.pi / 2, 1, 0, 0)

そして最後の1行のこれ、これは何をやっているかというと回転行列を使ってノードの角度を変化させています。この行をコメントアウトするとノードが縦に鏡みたいな形で、地面と平行になってくれていないのでこの処理を入れているそうです。

ARにおいての計算の話はARKitのための3D数学下記のURLが参考になりました。

updateメソッド

func update(anchor: ARPlaneAnchor) {
        (geometry as! SCNPlane).width = CGFloat(anchor.extent.x)
        (geometry as! SCNPlane).height = CGFloat(anchor.extent.z)
        position = SCNVector3Make(anchor.center.x, 0, anchor.center.z)
    }

rendereのアップデートする方で使われていたメソッドですね、キャストしてるので不思議な感じもしますが、結局やってることはもらってきたanchorの横幅と奥行きをそれぞれセットしてpositionにセットしています。

positioinもSCNNodeのopenプロパティなのでそのまま使えています。