ARKit+SceneKit で 地面崩落

カメラのキャプチャをその場所に置く仮想オブジェクトのテクスチャにする＆ポータルの組み合わせを試してみた。題材として地面を崩落させてみる。

完成イメージ
![demo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/2528f5a5-f6cc-cfe8-166f-f2c177c8bb34.png)  ![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/e944432c-cfc7-b1f9-9345-49df318ea433.gif)
このように仮想オブジェクトにカメラのキャプチャ画像を貼ることができれば、地面を階段状にしたり、壁を開き扉にしたり等の演出も可能。

## 地面の崩壊のさせ方

①地面に埋め込む箱状の３Dモデル（以下、「グリッド」）を準備。
　グリッドの上部には小さな箱（以下、「セル」）を１０ｘ１０で敷き詰めておく。
　セルが崩れてグリッドの底に溜まるようにする。
②ARKitの平面認識を有効にした状態で、画面タップのタイミングで画面中央でヒットテストをする
③ヒットテストで得られた平面のtransformを取得し、グリッドに設定。これでヒットした位置・姿勢にグリッドが合う。
④カメラ画像をキャプチャする
⑤個々のセル表面の頂点（四隅のワールド座標）を画面の座標（正規化デバイス座標）に変換
⑥④をセルのテクスチャとして使うので⑤の正規化デバイス座標をuv座標に変換
⑦個々のセルのテクスチャ座標に⑥を設定
⑧１０ｘ１０のセルが中心から崩れ落ちるように、中心のセルのノードから外側に向けてSCNPhysicsBodyを設定

以下、ポイントになるところを記述。
## ①地面に埋め込む箱状の３Dモデル（以下、「グリッド」）を準備。
　＜ノード配置のイメージ＞
![IMG_0006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/28045ace-df5a-c12c-b098-266888b1f4cc.png)
上の図はノードの配置のイメージ。実際にはこの箱は特殊で、箱を外側から見ると透明なので背景（カメラ画像）がそのまま見えて、箱を内側から見ると箱の内側がきちんと見えるというもの（こうした表現は「どこでもドア」とか「ポータル」と呼ばれているらしい）。
作り方はこちらの記事[「SceneKit の SCNNode のレンダリングオーダーでどこでもドア的表現をする」](https://appleengine.hatenablog.com/entry/2017/11/28/175040)の方法をそのまま利用。

## 「⑤個々のセル表面の頂点（四隅のワールド座標）を画面の座標（正規化デバイス座標）に変換」＋「⑥④をセルのテクスチャとして使うので⑤の正規化デバイス座標をuv座標に変換」

キャプチャしたカメラ画像をどうやってテクスチャとして使うか、について。
![IMG_0010.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/83e60168-5ec9-b706-18ba-a8c67da7c8eb.png)

各セルのテクスチャには上図のようにキャプチャした画像をそのまま設定する。すべてのセルで同一の画像を利用し、セル毎に異なるのはセル表面の四隅の頂点のuv座標（赤い点の部分）。
画像上のセルの頂点座標を取得する方法は3Dモデルの通常のレンダリングと同じやりかた。セルの各頂点のワールド座標→ビュー変換→プロジェクション変換　を行う。

```swift
// モデル変換行列（セル表面ノードをワールド座標系）
let modelTransform = cellFaceNode.simdWorldTransform
// ビュー変換行列。カメラ視点の逆行列（すべてのワールド座標の頂点をカメラを原点とした位置に移動）
let viewTransform = cameraNode.simdTransform.inverse
// プロジェクション変換行列
let projectionTransform = simd_float4x4(camera.projectionTransform(withViewportSize: self.scnView.bounds.size))
// MVP行列
let mvpTransform = projectionTransform * viewTransform * modelTransform
 
// プロジェクション座標変換
var position = matrix_multiply(mvpTransform, SIMD4<Float>(vertex.x, vertex.y, vertex.z, 1.0))
```

が！、これをそのまま利用することはできない。
なんか合わないな・・・と調べていたら次の記事を発見（助かりました）。

・[ARKitのARFaceGeometryで得た頂点座標を2D座標に変換する](https://ringsbell.blog.fc2.com/blog-entry-1277.html)
・[WebGL2入門 3D知識編](https://sbfl.net/blog/2016/09/05/webgl2-tutorial-3d-knowledge/)

プロジェクション座標変換結果を -1.0~0.0 の**「正規化デバイス座標」**に変換する必要があり次のように、モデル・ビュー・プロジェクションの変換をした結果の w 成分で x, y を割る。

```swift
// -1.0~1.0 の値に正規化。wで割ることで「正規化デバイス座標」にする
position = position / position.w
```
これで-1.0~0.0 の座標系になったので、次のようにすればテクスチャの座標として使える。

```swift
// uv座標に変換
let texcordX = CGFloat(position.x + 1.0) / 2.0
let texcordY = CGFloat(-position.y + 1.0) / 2.0
texcoords.append(CGPoint(x: texcordX, y: texcordY))
```

## ⑦個々のセルのテクスチャ座標に⑥を設定

セルのノードの各頂点のuv座標を任意に設定する必要があるのでMetalで書くか。。。と思ったが、こちらの記事 [SceneKitでカスタムジオメトリの作り方＋おまけ](https://qiita.com/takabosoft/items/13114d5da7180a9b2ab0) を参考にさせてもらうことでSceneKitだけで簡単に実現できました。

```swift
// セル表面のジオメトリを生成
let texcoordSource = SCNGeometrySource(textureCoordinates: texcoords)
let cellFaceGeometry = SCNGeometry(sources: [vertex, texcoordSource], elements: [element])
let cellFaceMaterial = SCNMaterial()
cellFaceMaterial.diffuse.contents = captureImage
cellFaceGeometry.materials = [cellFaceMaterial]
cellFaceNode.geometry = cellFaceGeometry    // ジオメトリを差し替える
```

`SCNGeometry`でカスタムジオメトリを作成することで、テクスチャの座標を自由に指定可能。
今回はアプリ起動時にセルのノードを生成済みなので、画面タップ時にそのセルのノードのジオメトリを取得して、テクスチャ座標だけ差し替えてカスタムジオメトリとしている。

## Tips

①デバッグオプション
今回は、透明なノードがあったり、セルが敷き詰められていたりと、ノードの存在・区切りが分かりにくかった。こんな時、次のデバックオプションを設定すると、ノードの境界に白線が表示されるようになり分かりやすく便利。

```swift
// SCNSceneRendererに指定できるデバックオプション
scnView.debugOptions = [.showBoundingBoxes]
```

②ノードが小さいと物理判定がうまくいかない
最初、グリッドサイズを小さく10cmとして作り始めたが、セルが落下する際にどうしても底のノードと衝突判定がされず、底をすり抜ける現象を回避できなかった。
SceneKitにはこうしたすり抜け対策として`SCNPhysicsBody` に `continuousCollisionDetectionThreshold`というプロパティがあり、衝突するノードのサイズを指定しておくと、すり抜けないように計算してくれるらしいが、うまく動作しなかった。[Appleのドキュメント](https://developer.apple.com/documentation/scenekit/scnphysicsbody/3013743-continuouscollisiondetectionthre?language=objc)には「
Continuous collision detection has a performance cost and works only for spherical physics shapes, but provides more accurate results.」とあるため、今回のようにBoxシェイプではダメだったのかも。対策としてグリッドの底や側面をBoxシェイプにして厚みを持たせている（これでもグリッドサイズを小さくするとすり抜けるんだけどね）。

## ソースコード全体

```swift:ViewController.siwft
import ARKit
import SceneKit

class ViewController: UIViewController {

    @IBOutlet weak var scnView: ARSCNView!
    private let device = MTLCreateSystemDefaultDevice()!
    private let gridSize = 10                               // グリッド分割数
    private let gridLength: Float = 0.8                     // グリッドのサイズ[m]
    private let wallThickness: Float = 0.1                  // グリッドの側面・底の厚み
    private lazy var cellSize = gridLength / Float(gridSize) // セルのサイズ
    private let gridRootNode = SCNNode()                    // 地面に埋める直方体
    private let gridCellParentNode = SCNNode()              // 地表に並べるセルのルート
    // セル表面の頂点座標
    private lazy var vertices = [
        SCNVector3(-cellSize/2, 0.0, -cellSize/2), // 左奥
        SCNVector3( cellSize/2, 0.0, -cellSize/2), // 右奥
        SCNVector3(-cellSize/2, 0.0, cellSize/2), // 左手前
        SCNVector3( cellSize/2, 0.0, cellSize/2), // 右手前
    ]
    // セル表面の頂点インデックス
    private let indices: [Int32] = [
        0, 2, 1,
        1, 2, 3
    ]
    private var time = 0                // 描画カウンター
    private var isTouching = false      // タッチ検知中
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // 地面に埋めるグリッド状の箱のセットアップ
        setupGridBox()
        // AR Session 開始
        self.scnView.delegate = self
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        self.scnView.session.run(configuration, options: [.removeExistingAnchors, .resetTracking])
    }
}
    
extension ViewController: ARSCNViewDelegate {
    //
    // アンカーが追加された
    //
    func renderer(_: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else { return }

        // 平面ジオメトリノードを追加
        guard let geometory = ARSCNPlaneGeometry(device: self.device) else { return }
        geometory.update(from: planeAnchor.geometry)
        let planeNode = SCNNode(geometry: geometory)
        planeNode.isHidden = true
        DispatchQueue.main.async {
            node.addChildNode(planeNode)
        }
    }
    //
    // アンカーが更新された
    //
    func renderer(_: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
        
        DispatchQueue.main.async {
            for childNode in node.childNodes {
                // 平面ジオメトリを更新
                guard let planeGeometry = childNode.geometry as? ARSCNPlaneGeometry else { continue }
                planeGeometry.update(from: planeAnchor.geometry)
                break
            }
        }
    }
    //
    // フレームごとに呼び出される
    //
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime _: TimeInterval) {
        
        if isTouching {
            // 画面がタッチされた
            isTouching = false
            DispatchQueue.main.async {
                // グリッド表示済みならスキップ
                guard self.gridRootNode.isHidden else { return }
                // カメラ画像をキャプチャしてここのセル表面にテクスチャとしてはる
                self.setupCellFaceTexture()
                // グリッド表示
                self.gridRootNode.isHidden = false
            }
        }
        
        DispatchQueue.main.async {
            // グリッドが非表示なら何もしない
            guard !self.gridRootNode.isHidden else { return }
            // 地面崩落
            self.hourakuAnimation()
        }
    }
}

extension ViewController {
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let _ = touches.first else { return }
        isTouching = true
    }

    private func setupCellFaceTexture() {
        // 画面中央でヒットテスト
        let bounds = self.scnView.bounds
        let screenCenter =  CGPoint(x: bounds.midX, y: bounds.midY)
        let results = self.scnView.hitTest(screenCenter, types: [.existingPlaneUsingGeometry])
        guard let existingPlaneUsingGeometryResult = results.first(where: { $0.type == .existingPlaneUsingGeometry }),
              let _ = existingPlaneUsingGeometryResult.anchor as? ARPlaneAnchor else {
            // 画面中央に平面がないので何もしない
            return
        }
        // カメラ画像をキャプチャ。連続キャプチャしないのでお手軽にsnapshot()を利用
        let captureImage = self.scnView.snapshot()
        // カメラ取得
        guard let cameraNode = self.scnView.pointOfView,
              let camera = cameraNode.camera else { return }
        // ヒットした場所のtransformをグリッドのtransformに転記
        self.gridRootNode.simdTransform = existingPlaneUsingGeometryResult.worldTransform
        for cellNode in self.gridCellParentNode.childNodes {
            guard let cellFaceNode = cellNode.childNodes.first(where: {$0.name == "face"}) else { continue }
            guard let vertex = cellFaceNode.geometry?.sources.first(where: {$0.semantic == .vertex}) else { continue }
            guard let element = cellFaceNode.geometry?.elements.first else { continue }
            
            // モデル変換行列（セル表面ノードをワールド座標系）
            let modelTransform = cellFaceNode.simdWorldTransform
            // ビュー変換行列。カメラ視点の逆行列（すべてのワールド座標の頂点をカメラを原点とした位置に移動）
            let viewTransform = cameraNode.simdTransform.inverse
            // プロジェクション変換行列
            let projectionTransform = simd_float4x4(camera.projectionTransform(withViewportSize: self.scnView.bounds.size))
            // MVP行列
            let mvpTransform = projectionTransform * viewTransform * modelTransform
            // セルの各頂点座標をスクリーン座標に変換し、それをUV座標に変換
            var texcoords: [CGPoint] = []
            for vertex in self.vertices {
                // プロジェクション座標変換
                var position = matrix_multiply(mvpTransform, SIMD4<Float>(vertex.x, vertex.y, vertex.z, 1.0))
                // -1.0~1.0 の値に正規化。wで割ることで「正規化デバイス座標」にする
                position = position / position.w
                // uv座標に変換
                let texcordX = CGFloat(position.x + 1.0) / 2.0
                let texcordY = CGFloat(-position.y + 1.0) / 2.0
                texcoords.append(CGPoint(x: texcordX, y: texcordY))
            }
            // セル表面のジオメトリを生成
            let texcoordSource = SCNGeometrySource(textureCoordinates: texcoords)
            let cellFaceGeometry = SCNGeometry(sources: [vertex, texcoordSource], elements: [element])
            let cellFaceMaterial = SCNMaterial()
            cellFaceMaterial.diffuse.contents = captureImage
            cellFaceGeometry.materials = [cellFaceMaterial]
            cellFaceNode.geometry = cellFaceGeometry    // ジオメトリを差し替える
        }
    }
    
    private func hourakuAnimation() {
        // 一定時間で崩落は止める
        guard self.time < 150 else { return }
        self.time += 1
        
        let time = Float(self.time)
        let gridSize = Float(self.gridSize)
        // 中心から円を広げるように崩落させる
        let x = sin(Float.pi * 2 * time/30.0) * time / 150
        let y = cos(Float.pi * 2 * time/30.0) * time / 150
        let ygrid = Int((y + 1.0) / 2 * gridSize * gridSize) / self.gridSize * self.gridSize
        let xgrid = Int((x + 1.0) / 2 * gridSize) + ygrid
        guard 0 <= xgrid, xgrid < self.gridCellParentNode.childNodes.count else { return }
        let node = self.gridCellParentNode.childNodes[xgrid]
        // すでにphysicsBodyを設定済みのノードはスキップ
        guard node.physicsBody == nil else { return }
        // 物理判定のサイズをセルのサイズに合わせる
        let bodyLength = CGFloat(self.cellSize) * 1.0
        let box = SCNBox(width: bodyLength, height: bodyLength, length: bodyLength, chamferRadius: 0.0)
        let boxShape = SCNPhysicsShape(geometry: box, options: nil)
        let boxBody = SCNPhysicsBody(type: .dynamic, shape: boxShape)
        boxBody.continuousCollisionDetectionThreshold = 0.001 // 設定しているが効いている感じがしない
        // TODO: セルの原点は中央上だが、PhysicsBodyに設定しているジオメトリの原点は中央になっているため、セル半分座標がずれている。
        // PhysicsBodyジオメトリをカスタムするか、セルの原点を中央に持っていくか、になるが、どちらも面倒。機会があれば直す。
        node.physicsBody = boxBody
    }
    
    private func setupGridBox() {
        gridRootNode.isHidden = true
        //
        // セルノードの生成
        //
        // 各ノードの位置は必ず指定すること。中心座標だからといって初期化しないとY座標が期待通りにならない現象に遭遇した。
        gridCellParentNode.simdPosition = SIMD3<Float>(x: 0.0, y: 0.0, z: 0.0)
        gridRootNode.addChildNode(gridCellParentNode)
        gridRootNode.simdPosition = SIMD3<Float>(x: 0.0, y: 0.0, z: 0.0)
        self.scnView.scene.rootNode.addChildNode(gridRootNode)
        // gridSize x gridSize のセルノードを作成
        let cellFaceGeometry = makeCellFaceGeometry()
        let cellBoxGeometry = makeCellBoxGeometry()
        let cellLeftBackPos = -(gridLength / 2) + cellSize / 2
        for y in 0 ..< gridSize {
            for x in 0 ..< gridSize {
                // 各セルノード
                let cellNode = SCNNode()
                cellNode.simdPosition = SIMD3<Float>(x: cellLeftBackPos + (cellSize * Float(x)), y: 0, z: cellLeftBackPos + (cellSize * Float(y)))
                gridCellParentNode.addChildNode(cellNode)
                // セル表面の平面ノード生成
                let cellFaceNode = SCNNode(geometry: cellFaceGeometry)
                cellFaceNode.name = "face"
                // 各セルを敷き詰めるように座標を決める
                cellFaceNode.simdPosition = SIMD3<Float>(x: 0.0, y: 0.0, z: 0.0)
                cellNode.addChildNode(cellFaceNode)
                // 直方体のセルノードを生成
                let cellBoxNode = SCNNode(geometry: cellBoxGeometry)
                cellBoxNode.simdPosition = SIMD3<Float>(x: 0.0, y: -cellSize/2*1.001, z: 0.0)
                cellNode.addChildNode(cellBoxNode)
            }
        }
        //
        // グリッド側面ノード
        //
        let sideOuterBox = makeGridSideOuterGeometry()
        let sideInnerPlane = makeGridSideInnerGeometry()
        // Gridの側面ノード生成
        for i in 0..<4 {
            let x = sin(Float.pi / 2 * Float(i)) * gridLength / 2.0
            let z = cos(Float.pi / 2 * Float(i)) * gridLength / 2.0
            // 側面（外側）
            let outerNode = SCNNode(geometry: sideOuterBox)
            let nodePos = ((gridLength + wallThickness) / 2.0) / (gridLength / 2.0) * 1.001
            outerNode.simdPosition = SIMD3<Float>(x: x * nodePos, y: -gridLength/2.0, z: z * nodePos)
            outerNode.simdRotation = SIMD4<Float>(x: 0.0, y: 1.0, z: 0.0, w: -Float.pi / 2 * Float(i))
            // 側面に物理的な壁を作る
            outerNode.physicsBody = SCNPhysicsBody.static()
            // 外側の壁は（ほぼ）透明にしていて、レンダリング順序を-1にして他のノードよりも先に描画する（Zバッファに先に書き込む）。
            // これにより（ほぼ）透明の後ろに描画されるはずのノードは、描画されず、結果的に背景が見える。
            outerNode.renderingOrder = -1
            gridRootNode.addChildNode(outerNode)
            
            // 側面（内側）
            let innerNode = SCNNode(geometry: sideInnerPlane)
            innerNode.simdPosition = SIMD3<Float>(x: x, y: -gridLength/2.0, z: z)
            innerNode.simdRotation = SIMD4<Float>(x: 0.0, y: 1.0, z: 0.0, w: -Float.pi / 2 * Float(i))
            gridRootNode.addChildNode(innerNode)
        }
        //
        // グリッドの底ノード
        //
        let bottomBox = makeGridButtomGeometry()
        let bottomNode = SCNNode(geometry: bottomBox)
        bottomNode.simdPosition = SIMD3<Float>(x: 0.0, y: -gridLength+Float(wallThickness), z: 0.0)
        bottomNode.simdRotation = SIMD4<Float>(x: 1.0, y: 0.0, z: 0.0, w: -Float.pi / 2)
        // 底に物理的な壁を作る
        bottomNode.physicsBody = SCNPhysicsBody.static()
        gridRootNode.addChildNode(bottomNode)
    }
    
    private func makeCellFaceGeometry() -> SCNGeometry {
        // セルの表面のジオメトリ。このジオメトリに画面キャプチャした画像をテクスチャとして貼る
        let cellFaceVertices = SCNGeometrySource(vertices: vertices)
        let cellFaceIndices = SCNGeometryElement(indices: indices, primitiveType: .triangles)
        let cellFaceGeometry = SCNGeometry(sources: [cellFaceVertices], elements: [cellFaceIndices])
        let cellFaceMaterial = SCNMaterial()
        cellFaceMaterial.diffuse.contents = UIColor.clear
        cellFaceGeometry.materials = [cellFaceMaterial]
        return cellFaceGeometry
    }
    
    private func makeCellBoxGeometry() -> SCNGeometry {
        // セルの小さな箱部分。
        let cellBox = SCNBox(width: CGFloat(cellSize), height: CGFloat(cellSize), length: CGFloat(cellSize), chamferRadius: 0.0)
        let cellBoxMaterial = SCNMaterial()
        cellBoxMaterial.diffuse.contents = UIColor.darkGray
        cellBox.materials = [cellBoxMaterial]
        return cellBox
    }
    
    private func makeGridSideOuterGeometry() -> SCNGeometry {
        // Gridの側面（外側）のジオメトリ。セルが落下した時に側面を突き抜けないように厚い壁にしておく。
        let sideOuterBox = SCNBox(width: CGFloat(gridLength) * 1.001, height: CGFloat(gridLength), length: CGFloat(wallThickness), chamferRadius: 0)
        let sideOuterMaterial = SCNMaterial()
        sideOuterMaterial.transparency = 0.001
        sideOuterMaterial.diffuse.contents = UIColor.white
        sideOuterMaterial.isDoubleSided = true
        sideOuterBox.materials = [sideOuterMaterial]
        return sideOuterBox
    }
    
    private func makeGridSideInnerGeometry() -> SCNGeometry {
        // Gridの側面（内側）のジオメトリ
        let sideInnerPlane = SCNPlane(width: CGFloat(gridLength), height: CGFloat(gridLength))
        let sideInnerMaterial = SCNMaterial()
        sideInnerMaterial.diffuse.contents = UIColor.gray
        sideInnerMaterial.isDoubleSided = true
        sideInnerPlane.materials = [sideInnerMaterial]
        return sideInnerPlane
    }
    
    private func makeGridButtomGeometry() -> SCNGeometry {
        // Gridの底のジオメトリ。セルが落下した時に側面を突き抜けないように厚い壁にしておく。
        let bottomBox = SCNBox(width: CGFloat(gridLength), height: CGFloat(gridLength), length: CGFloat(wallThickness), chamferRadius: 0)
        let bottomMaterial = SCNMaterial()
        bottomMaterial.diffuse.contents = UIColor.black
        bottomBox.materials = [bottomMaterial]
        return bottomBox
    }
}
```