[Swift, ARKit]検出した平面を使って動画をマルチスクリーンで再生する


SwiftのARKitには平面を検出するという機能があります。
一般的な使い方としては検出した平面を色付けしたりタップして他の3Dモデルを置いたりといったものだと思いますが、今回はその平面をそのままテレビのように動画を再生するスクリーンにしてみました。

流れとしては
平面を検知してARPlaneAnchorを置く
↓
ARPlaneAnchorが置かれた時にSCNPlaneを作成する
↓
SCNPlaneに動画を貼り付けて再生する
↓
SCNPlaneをもとにSCNNodeを作成する
というものです。
自分でARPlaneやARBoxを作成して動画を貼り付けるものはいくつか見つかりましたが、平面を検出して動的にARPlaneを増やし続けるものは無かったのでやってみようと思います。

##検証環境

- Swift 5.3
- Xcode 12.0
- iOS 14.0.1

再生する動画は以下のものを使用しました。以前会社で作成したものです。
ちょっと長いので78秒からの失敗例を出した部分だけ切り取りました。
https://youtu.be/4xfmPCFUplU?t=78

##結果
ソースコードが長いので最初に結果から。
こういうものが作れます。
![result.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670074/2817448e-6ea7-a2fb-1990-1779c4a548b2.gif)
結果から言えば上手くいきました。
テーブルやイス、壁などを平面として検出してその度に動画を貼り付けています。
後述しますが大きさが条件を満たしていれば正方形にも縦長にも変えられるようです。
少し分かりづらいですが各動画が独立して再生されていることも確認できます。
**ちなみに動画なので音声も流れます。かつ各動画が独立しているので複数の音声が異なるタイミングで飛んできます。**
作った自分が言うのもなんですが洗脳色が強いです。

##ソースコード
全体のソースコードは以下の通りです。
大まかな説明はコメントアウトしています。

```swift:ViewController.swift
import ARKit

class ViewController: UIViewController, ARSCNViewDelegate {
    
    @IBOutlet weak var scnView: ARSCNView!
    
    var videoURL: URL!
    
    override var prefersStatusBarHidden: Bool { return true }
    override var prefersHomeIndicatorAutoHidden: Bool { return true }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.scnView.scene = SCNScene()
        self.scnView.delegate = self
        //再生する動画のURLを取得
        self.videoURL = Bundle.main.url(forResource: "pien", withExtension: "mp4")!
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        let configuration = ARWorldTrackingConfiguration()
        //平面検出を水平・垂直両方に設定
        configuration.planeDetection = [.vertical, .horizontal]
        configuration.isLightEstimationEnabled = true
        self.scnView.session.run(configuration)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        self.scnView.session.pause()
    }
    //動画を再生するためのスクリーン作成部分
    func createVideo(size: CGSize) -> SKScene {
        let skScene = SKScene(size: CGSize(width: 1280, height: 700))
        let player = AVPlayer(url: videoURL)
        //動画をリピート設定に
        NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime,
                                               object: player.currentItem, queue: nil) { (_) in
            player.seek(to: CMTime.zero)
            player.play()
        }
        
        let skNode = SKVideoNode(avPlayer: player)
        //動画の位置と向きを調整
        skNode.position = CGPoint(x: skScene.size.width / 2.0, y: skScene.size.height / 2.0)
        skNode.size = skScene.size
        skNode.yScale = -1.0
        skNode.play()
        skScene.addChild(skNode)
        
        return skScene
    }
    
    //平面を検出してARAnchorが置かれた時に発火するDelegate Method
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else {
            return
        }
        DispatchQueue.main.async {
            //動画を再生するスクリーンとなるPlaneを作成
            let plane = SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
            let planeNode = SCNNode(geometry: plane)
            //Planeの位置と向きを調整
            planeNode.position = SCNVector3(x: planeAnchor.center.x,
                                            y: planeAnchor.center.y,
                                            z: planeAnchor.center.z)
            planeNode.eulerAngles.x = -.pi / 2
            node.addChildNode(planeNode)
        }
    }
    
    //既に存在するARAnchorが更新された時に発火するDelegate Method
    func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor,
              let plane = node.childNodes.first?.geometry as? SCNPlane else {
            return
        }
        //Planeの大きさが20cm x 20cm以上かどうかで分岐　この条件は自由
        if plane.height > 0.2 && plane.width > 0.2 {
            //既にplaneに動画が貼り付けられている場合は処理を抜ける
            if plane.materials.first?.diffuse.contents is SKScene {
                return
            }
            let size = CGSize(width: plane.width, height: plane.height)
            let skScene = self.createVideo(size: size)
            let material = SCNMaterial()
            material.diffuse.contents = skScene
            plane.materials = [material]
        }else {
            plane.width = CGFloat(planeAnchor.extent.x)
            plane.height = CGFloat(planeAnchor.extent.z)
        }
    }
}
```

###個別解説

```swift
self.videoURL = Bundle.main.url(forResource: "pien", withExtension: "mp4")!

let player = AVPlayer(url: videoURL)
```

まず動画の読み込みについてですが、URLはviewDidLoad()で、AVPlayerはcreateVideo()というメソッドで取得しています。
AVPlayerをURLと同じviewDidLoad()で取得してメンバ変数として残しておくこともできますが、その場合は各スクリーンで再生される動画が全て同じものとなります。

```swift
let plane = SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
let planeNode = SCNNode(geometry: plane)
planeNode.position = SCNVector3(x: planeAnchor.center.x,
                                y: planeAnchor.center.y,
                                z: planeAnchor.center.z)
planeNode.eulerAngles.x = -.pi / 2
```

次にスクリーンとなるPlaneを作成する部分。
気をつけないといけないのはSCNPlaneの引数heightにARPlaneAnchorのextent.zを指定していることです。
ARPlaneAnchorは奥行きがy軸となっているので、引数にextent.yを指定すると表示されません。
また、作成したplaneNodeの向きeulerAngles.xもデフォルトだと検出した平面に対して垂直になっているので、平面の上にかぶせる形に修正が必要です。

```swift
if plane.height > 0.2 && plane.width > 0.2 {
    if plane.materials.first?.diffuse.contents is SKScene {
        return
    }
    let skScene = self.createVideo()
    let material = SCNMaterial()
    material.diffuse.contents = skScene
    plane.materials = [material]
}else {
    plane.width = CGFloat(planeAnchor.extent.x)
    plane.height = CGFloat(planeAnchor.extent.z)
}
```

最後に動画をSCNPlaneに貼り付ける部分です。
今回は条件式でplaneの高さと幅が一定以上の値かどうかをチェックしていますが、これは条件を設定しない場合、少しでもARPlaneAnchorが更新されるとその時点で動画を貼り付けてしまうためです。
一応動画を貼り付けた後でも高さや幅は更新できますが、その度に動画のサイズを変更して改めて貼り付けることになる上、動画も最初から再生されることになります。
高さや幅などの条件は自由に変えることができます。
条件式がTrueの場合に改めて条件式が出てきますが、これは既に動画を貼り付けている場合に再度貼り付けることを防ぐものです。

条件式がFalseの場合には更新されたARPlaneAnchorの大きさを既存のSCNPlaneに適用しています。
こちらもPlaneを作成した部分と同様、extent.yを指定すると表示されなくなるので注意してください。

##まとめ
結果は最初にお見せした通りです。平面を検出し、マルチスクリーンで動画を再生することができました。
今回再生した動画は1つだけですが、条件を変えることで複数種類の動画を各スクリーンで再生することができると思います。
ここまで読んでいただきありがとうございました。