ARKitを使って結婚式の賑やかしアプリを作った

# はじめに
自分の結婚式に来てくれる人に、少しでも楽しんでもらおうとARKitを使ってiPhoneアプリを作りました。これまでの思い出の写真やムービーが見れるようにしておき、結婚式後の小規模なディナーパーティーで使ってもらいました。

<blockquote class="twitter-tweet" data-lang="en"><p lang="en" dir="ltr">ar application for my wedding reception. <a href="https://t.co/GIs8HELnA9">pic.twitter.com/GIs8HELnA9</a></p>&mdash; ykshr (@ykshr2) <a href="https://twitter.com/ykshr2/status/1044577230364401664?ref_src=twsrc%5Etfw">September 25, 2018</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 機能
主な機能は2つです。

* テーブルや地面から写真を表示する
平面を認識して、写真入りのシャボン玉が出ます。シャボン玉はランダムな場所に動いていき、しばらくすると消えます。
<img src="https://github.com/ykshr/AR-PhotoViewer/blob/master/images/bubble.gif?raw=true">

* スケッチブックにムービーを表示する
スケッチブックの中のイラストを認識して、ムービーが表示されます。
<img src="https://github.com/ykshr/AR-PhotoViewer/blob/master/images/sketchbook.gif?raw=true">


# 表示コンテンツ
表示される写真やムービーは、画面右上（右下）の名前が書かれたボタンをタップすることで切り替わるようにしました。実際と上記のデモムービーでは次のような形で分けました。

|      |  Everyone (AR_0)   |  それぞれの名前 (AR_1,2,3…)  |
|:-----|:------------|:---------------|
| 実際 | 結婚式の様子 | その人との写真・ムービー |
| デモ | 色々な芸能人 |   阿部寛の写真・ムービー（Abe）  |

また、直前までコンテンツの入れ替え（結婚式の写真を入れるなど）に対応したかったため、コンテンツはマイアルバムで管理しました。マイアルバムの名前を`AR_※`にし、※の部分をソースコード内のIDと対応付けています。

<img src="https://qiita-image-store.s3.amazonaws.com/0/129013/970ccd8a-0e41-a926-8b5a-d626df935dc7.png" width="200px">    <img src="https://qiita-image-store.s3.amazonaws.com/0/129013/242ec135-f7c4-9d56-38c7-59a830359bb1.png" width="200px">

# ソースコード 
[Github - ykshr/AR-PhotoViewer](https://github.com/ykshr/AR-PhotoViewer)にあげています。
※ 無理やりなんとか動くレベルのソースコードなので、あまり参考にはならないと思います。

### シャボン玉
写真入りシャボン玉は`SCNSphere`の中に、`UIImage`を貼り付けた`SCNPlane`を入れることで作っています。`SCNAction.move`でランダムな位置に移動させた後、`removeFromParentNode`で削除しています。

```swift
func createBubble(image: UIImage) -> SCNNode {
    let plane = SCNPlane(width: 0.1, height: 0.1)
    plane.firstMaterial?.isDoubleSided = true
    plane.firstMaterial?.diffuse.contents = image
    let planeNode = SCNNode(geometry: plane)
    let prx = Float(arc4random_uniform(360)) * (Float.pi / 180)
    let pry = Float(arc4random_uniform(360)) * (Float.pi / 180)
    planeNode.eulerAngles = SCNVector3(prx, pry, 0)

    let sphere = SCNSphere(radius: 0.1)
    sphere.firstMaterial?.diffuse.contents = #imageLiteral(resourceName: "ar-bubbleText")
    sphere.firstMaterial?.isDoubleSided = true
    sphere.firstMaterial?.writesToDepthBuffer = false
    sphere.firstMaterial?.blendMode = .screen
    //sphere.firstMaterial?.lightingModel = .physicallyBased
    //sphere.firstMaterial?.metalness.contents = 1.0
    //sphere.firstMaterial?.roughness.contents = 0
    let sphereNode = SCNNode(geometry: sphere)
    sphereNode.opacity = 0.1
    let srx = Float(arc4random_uniform(360)) * (Float.pi / 180)
    let sry = Float(arc4random_uniform(360)) * (Float.pi / 180)
    sphereNode.eulerAngles = SCNVector3(srx, sry, 0)

    let bubbleNode = SCNNode()
    bubbleNode.addChildNode(planeNode)
    bubbleNode.addChildNode(sphereNode)
    
    let box = SCNBox(width: 0.2, height: 0.2, length: 0.2, chamferRadius: 0)
    box.firstMaterial?.diffuse.contents = UIColor.white.withAlphaComponent(0.0)
    let bubbleBoxNode = SCNNode(geometry: box)
    bubbleBoxNode.addChildNode(bubbleNode)

    // ボックスの外に出す
    let firstAction = SCNAction.move(by: SCNVector3(0, 0.4, 0), duration: 2)
    firstAction.timingMode = .easeOut
    bubbleNode.runAction(firstAction)
    // フワフワさせる
    let secondAction = SCNAction.move(by: SCNVector3(randomNumbers(firstNum: -1.5, secondNum: 1.5 ),randomNumbers(firstNum: 0, secondNum: 3), randomNumbers(firstNum: -1.5, secondNum: 1.5 )), duration: TimeInterval(randomNumbers(firstNum: 10, secondNum: 30)))
    secondAction.timingMode = .easeOut
    bubbleNode.runAction(secondAction, completionHandler: {
        bubbleNode.runAction(SCNAction.fadeOut(duration: 0), completionHandler: {
            DispatchQueue.main.async {
                self.playSoftImpact()
            }
            bubbleNode.removeFromParentNode()
            bubbleBoxNode.removeFromParentNode()
        })
    })
    
    return bubbleBoxNode
}
```

シャボン玉の生成は、一定時間間隔でランダムなディスプレイ上の点を取得し、その点に平面アンカーが存在していた場合、シャボン玉を生成するようにしています。ここは`Timer`ではなく、もっといい方法があるかもしれません。

```swift
Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) {Void in
    let p = CGPoint(x: Int(arc4random() % 1000), y: Int(arc4random() % 1000))
    let hitTestResult = self.sceneView.hitTest(p, types: .existingPlaneUsingExtent)
    if !hitTestResult.isEmpty {
        if let hitResult = hitTestResult.first {
            let bubbleBoxNode = self.createBubble(image: self.imgAssets[Int(arc4random_uniform(UInt32(self.imgAssets.count)))])

            bubbleBoxNode.position = SCNVector3(hitResult.worldTransform.columns.3.x, hitResult.worldTransform.columns.3.y - 0.2, hitResult.worldTransform.columns.3.z)

            scene.rootNode.addChildNode(bubbleBoxNode)
        }
    }
}
```

### スケッチブック
スケッチブックへの動画の表示は、`SKScene`使って`AVPlayer`を`SCNPlane`に貼り付けています。`ARImageAnchor`が追加されたタイミング（didAdd）ではなく、更新されたタイミング（didUpdate）で生成しているのですが、これは、画面上からスケッチブックがなくなってもなかなか削除イベント（didRemove）が呼ばれなかったためです。

```swift
if let imageAnchor = anchor as? ARImageAnchor {
    // 平面ジオメトリのサイズを更新
    if (node.childNodes.count == 0) {
        // AVPlayerを生成する
        let avPlayer = AVPlayer(url: self.avUrls[Int(arc4random_uniform(UInt32(self.avUrls.count)))])
        avPlayer.actionAtItemEnd = AVPlayerActionAtItemEnd.none;
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(ViewController.didPlayToEnd),
                                               name: NSNotification.Name("AVPlayerItemDidPlayToEndTimeNotification"),
                                               object: avPlayer.currentItem)
        
        let skScene = SKScene(size: CGSize(width: 1000, height: 1000))
        let skNode = SKVideoNode(avPlayer: avPlayer)
        skNode.position = CGPoint(x: skScene.size.width / 2.0, y: skScene.size.height / 2.0)
        skNode.size = skScene.size
        skNode.yScale = -1.0
        skScene.addChild(skNode)
        
        // 平面ジオメトリを作成
        let geometry = SCNPlane(width: CGFloat(imageAnchor.referenceImage.physicalSize.width), height: CGFloat(imageAnchor.referenceImage.physicalSize.height))
        // geometry.materials.first?.diffuse.contents = UIColor.darkGray.withAlphaComponent(0.5)
        geometry.materials.first?.diffuse.contents = skScene
        
        let planeNode = SCNNode(geometry: geometry)
        planeNode.eulerAngles.x = -.pi / 2
        planeNode.runAction(
            SCNAction.sequence([
                SCNAction.fadeIn(duration: 3),
                SCNAction.wait(duration: 15),
                SCNAction.fadeOut(duration: 3)]),
            completionHandler: {
                planeNode.removeFromParentNode()
        })

        node.addChildNode(planeNode)
        
        skNode.play()

    } else {
        for childNode in node.childNodes {
            guard let plane = childNode.geometry as? SCNPlane else { continue }
            plane.width = CGFloat(imageAnchor.referenceImage.physicalSize.width)
            plane.height = CGFloat(imageAnchor.referenceImage.physicalSize.height)
            break
        }
    }
}
```

### コンテンツの読み込み
`PHAssetCollection`を使ってマイアルバムより写真や動画を読み込んでいます。写真はシャボン玉、動画はスケッチブックでの表示に使っています。

```swift
func updateImgAssets(personNo: Int) -> Void {
    let fetchOptions = PHFetchOptions()
    fetchOptions.predicate = NSPredicate(format: "title = %@", "AR_" + String(personNo))
    let collections: PHFetchResult = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: fetchOptions)
    for i in 0 ..< collections.count {
        let collection = collections.object(at: i)
        let assetCollection: PHAssetCollection = collection as! PHAssetCollection
        let arAssets = PHAsset.fetchAssets(in: assetCollection, options: nil)
        
        self.imgAssets = [UIImage]()
        self.avUrls = [URL]()
        arAssets.enumerateObjects { (asset, index, stop) -> Void in
            let filename: String = asset.value(forKey: "filename") as! String
            let isPhoto: Bool = asset.value(forKey: "isPhoto") as! Bool
            let isVideo: Bool = asset.value(forKey: "isVideo") as! Bool
            
            // Photo
            if (isPhoto) {
                PHImageManager.default().requestImage(for: asset,
                                                      targetSize: CGSize(width: 400, height: 400),
                                                      contentMode: .aspectFill,
                                                      options: nil) {
                                                        (image, info) -> Void in self.imgAssets.append(image!)
                }
                
            // Video
            } else if (isVideo) {
                PHImageManager.default().requestAVAsset(forVideo: asset,
                                                        options: nil) {
                                                            (avurlAsset, audioMix, info) -> Void in
                                                            let avurlasset = avurlAsset as! AVURLAsset
                                                            self.avUrls.append(avurlasset.url)
                }
            }
        }
    }
}
```


# その他
* ARKitは[公式ドキュメント](https://developer.apple.com/documentation/arkit)が分かりやすかったです。
* iOS 12だと、[Environment Texturing](https://developer.apple.com/documentation/arkit/adding_realistic_reflections_to_an_ar_experience?changes=l_4_1_3_5)が使えるのでもっときれいなシャボン玉ができるかもしれないです。
* 横画面への対応忘れていました。縦画面ロックの状態で使ってもらいました。