Xcode
iOS
SceneKit
Swift
ARKit
ARKitDay 12

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

More than 1 year has passed since last update.

この記事はARKit Advent Calendar 2017の12日目の記事として書かれました。

今回解説していることを用いたアプリについての解説をLife is Tech ! メンター Advent Calendar 2017の12日目の記事として公開しているのでよければあわせてお読みください。


はじめに

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

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

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

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

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

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

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

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

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

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

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として取ってこれる可能性がありますが、どちらが検知されるかわからない不確定性をできれば残したくないですよね。

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

おそらくこれでも動くはずなのですが、unsafeBitCastで「サイズ違うポインタはキャストできへんで」と怒られてしまったので、Stored Properties In Swift Extensionsを参考にして以下のように書くことにしました。

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)
        }
    }
}

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

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ライセンスのモデルであることに留意しつつ、不安な場合は独自のものを使うようにしてください。)