ARKit+SceneKitで任意のタイミングにモデルを出し入れする方法

この記事は[ARKit Advent Calendar 2017](https://qiita.com/advent-calendar/2017/arkit)の12日目の記事として書かれました。

今回解説していることを用いたアプリについての解説を[Life is Tech ! メンター Advent Calendar 2017](https://qiita.com/advent-calendar/2017/lit-mentor)の12日目の記事として公開しているのでよければあわせてお読みください。

---

## はじめに

ARKitが発表されて以来、世の中には様々なチュートリアルが出てきていてますます期待も高まっていますね。

ですが、現在公開されている記事のサンプルでは現実の物の距離を測ったり、平面を検知してそこにモデルを置くなど「現実との融合」という観点で見れば興味深い例は多いものの、もっと基本的な部分での活用方法が書かれていないなという印象を受けています。

それが**様々な種類のオブジェクトを任意のタイミングで置いたりしまったりする方法**です。

これはもちろん、UITableViewなどで別にchildNodeのリストを用意しておいてタップされたものを消すという方法で出来なくはないかと思いますが、例えば*最近話題のどうぶつの森ポケットキャンプの自分のキャンプ場をARで現実世界にレイアウトしていく*、というようなことをしたいと考えた時、しまう操作にまでいちいちリストからやっていたら面倒ですよね。（注：どうぶつの森ポケットキャンプでは家具をしまうモードがあり、タップするだけで家具をしまうことができます。）

今回上述の別のアドベントカレンダーで製作していたアプリを実現するにあたって、同じような必要性がうまれたのでつまったところなどまとめたいと思います。

## ポイント① タップされたモデルを検知する

今回やりたいことを実現するにあたって大きく２つのポイントがあります。まず一つ目が**どうやってタップされたモデルを検知するか**ということです。

現実世界にモデルを置くときの方法として`ARSCNView`の`hitTest`というメソッドを用い、`worldTransform`から座標を取ってくるというものがよく使われていると思います。
これがあるので「タップされた既に置かれているノードも同じように`hitTest`の結果に情報がのっかってる」というのは自然な推論ですがARKitの仕様ではそうなっていません。

確かに`hitTest`を使うには使うのですが、実は`ARSCNView`ではなくその親クラスとなっている`SCNView`の`hitTest`が今回実現したい機能を備えているのです。

幸い引数名が被っていないので２つの区別はよしなにやってくれるのですが、どっちも片方ずつのことしかできないので例えばモードによってノードを置くかタップされたノードを検知するか変えるという場合には次のように書けます。

```swift:touchesBegan
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    
    if let touchLocation = touches.first?.location(in: sceneView) {
        
        guard let firstHit = sceneView.hitTest(touchLocation, types: .featurePoint).first else {
            
            return
        }
        
        if selectID != 0 {
            
            let hitTransform = SCNMatrix4(firstHit.worldTransform)
            let hitPosition = SCNVector3Make(hitTransform.m41, hitTransform.m42, hitTransform.m43)
            let newNode = ARManager.shared.generateModel(modelIDs[selectID])
            newNode.position = hitPosition

            sceneView.scene.rootNode.addChildNode(newNode)
        } else {
            
            guard let hitObject = sceneView.hitTest(touchLocation, options: nil).first else {
                
                print("no hit")
                return
            }
            // hitObject.nodeにタップされたモデルが入る
        }
    }
}
```

`hitTest(_, types:_)`が`ARSCNView`のもので、`hitTest(_, options: _)`が`SCNView`のものでした。もちろん`ARSCNView`のほうをif文の中に入れてしまってもうごくとは思いますが一旦この書き方にしています。ここらへんは各自の好みにあわせて書き換えてください。

## ポイント② 置いてあるモデルに情報を付与しておく

次のポイントは正直必要ないアプリもあるとは思いますが、**置いてあるモデルに情報を付与しておくにはどうすればいいか**ということです。

モデル名だけでは情報が不十分になるような時必要となりますね。さらに3Dモデルは他のソフトで製作した時...とかなり限定的な状況ではあるのですが少し苦労したので書いておきます。必要ないなという方はまとめまで読み飛ばしてもらって構いません。

まず第一に考えられる方法として情報をもたせた独自の`SCNNode`を継承したカスタムクラスをつくるということが考えられます。ですがこれには以下の問題がありました。

- コードで作成していないモデルについては読み込む時にそのカスタムクラスにキャストできない
- 仮に普通のノードを子供として持つように書いたとしても`hitTest`で検知されるのがカスタムクラスとは限らず情報が失われる可能性がある

二点目に関しては`parent`として取ってこれる可能性がありますが、どちらが検知されるかわからない不確定性をできれば残したくないですよね。


そこで`SCNNode`の`extension`で情報をもたせられないかということを考えます。`extension`にstored propertyを定義できないことはよく知られていることですが、やはり同じことを考える人は多いようでこんな記事を見つけました。

- [SwiftでExtensionのプロパティの黒魔術感をなくす(追記アリ)](http://blog.matsuokah.jp/entry/2017/09/21/234216)
- [objc_getAssociatedObject で必ずnil返ってくる件(解決済み)](http://blog.matsuokah.jp/entry/2017/10/04/004811)

おそらくこれでも動くはずなのですが、`unsafeBitCast`で「サイズ違うポインタはキャストできへんで」と怒られてしまったので、[Stored Properties In Swift Extensions](https://marcosantadev.com/stored-properties-swift-extensions/)を参考にして以下のように書くことにしました。

```swift:SCNNode+Extensions
protocol PropertyStoring {
    
    associatedtype T
    
    func getAssociatedObject(_ key: UnsafeRawPointer!, defaultValue: T) -> T
}

extension PropertyStoring {
    func getAssociatedObject(_ key: UnsafeRawPointer!, defaultValue: T) -> T {
        guard let value = objc_getAssociatedObject(self, key) as? T else {
            return defaultValue
        }
        return value
    }
}

extension SCNNode: PropertyStoring {
    
    typealias T = ObjectData
    
    private struct CustomProperties {
        static var objectData = ObjectData.init(latitude: 0, longitude: 0, object: ARManager.Model.present.rawValue, userID: "unknown")
    }
    
    var objectData: ObjectData {
        
        get {
            return getAssociatedObject(&CustomProperties.objectData, defaultValue: CustomProperties.objectData)
        }
        set {
            return objc_setAssociatedObject(self, &CustomProperties.objectData, newValue, .OBJC_ASSOCIATION_RETAIN)
        }
    }
}
```

実際のアプリではこれを使って以下のように書いています。

```swift:touchesBegan(完全版)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    
    if let touchLocation = touches.first?.location(in: sceneView) {
        
        guard let firstHit = sceneView.hitTest(touchLocation, types: .featurePoint).first else {
            
            return
        }
        
        if selectID != 0 {
            
            let hitTransform = SCNMatrix4(firstHit.worldTransform)
            let hitPosition = SCNVector3Make(hitTransform.m41, hitTransform.m42, hitTransform.m43)
            let newNode = ARManager.shared.generateModel(modelIDs[selectID])
            newNode.position = hitPosition
            
            newNode.objectData = FirestoreHelper.shared.postData(location: MatrixHelper.transformLocation(for: matrix_identity_float4x4, originLocation: ARCLManager.shared.location, location: hitPosition), objectID: modelIDs[selectID])!
            self.addedObjects.append(newNode.objectData)
            
            sceneView.scene.rootNode.addChildNode(newNode)
        } else {
            
            guard let hitObject = sceneView.hitTest(touchLocation, options: nil).first else {
                
                print("no hit")
                return
            }
            
            if hitObject.node.name == ARManager.Model.present.modelName {
                
                let objectData = hitObject.node.objectData
                let newNode = ARManager.shared.generateModel(ARManager.Model(rawValue: objectData.object)!)
                newNode.objectData = objectData
                sceneView.scene.rootNode.replaceChildNode(hitObject.node, with: newNode)
            }
        }
    }
}
```

## まとめ

というわけで当初の目的であった「様々な種類のオブジェクトを任意のタイミングで置いたりしまったりする」という操作が達成できました。

ARKitの活用として「現実に情報を置く」という点にしかフォーカスしていないものが多かったのでこの記事を参考にもっと「現実に置いたものに触れてみる」というところまで発展したアプリがたくさん生まれていってくれたら嬉しいなと思います。


今回実際につくったアプリのリンクも貼っておきます。是非クローンして色々遊んでみてください。（これをもとに発展させたアプリを製作する際は3DモデルがPolyで公開されているCCライセンスのモデルであることに留意しつつ、不安な場合は独自のものを使うようにしてください。）

- [touyou/XmasPresentGo](https://github.com/touyou/XmasPresentGo)