2019年3月20日水曜日

ARKitのサンプルにターゲットカーソルを追加しよう


ARKitのサンプルにターゲットカーソルを追加しよう


ここで取り上げるターゲットカーソルとは公式の計測アプリを起動した時に平面に表示されるカーソルのことです。


このようなカーソルを表示することでユーザーが意図した場所に3Dオブジェクトを置くことができ、ユーザーにとってより使い易いアプリとなります。


ターゲットカーソルの作成


計測アプリではターゲットカーソルを別途作成してアニメーションを付けていますが、今回は簡略化のためSNCPlaneの形を変えることでターゲットカーソルの代わりとします。

計測アプリのようなターゲットカーソルを作りたい場合はこちらの投稿を参考にしてください。

まず、ターゲットカーソルの可視部分となるジオメトリを定義します。

デフォルトの状態ではSCNPlaneは四角の平面となりますが、cornerRadiusに1を指定することで円形にすることができます。

また、色をつけるためにはdiffuseのcontensに任意のUIColorを設定します。

カーソルの下に何があるか見えた方が良いので半透明にしています。

余談ですが今回色を設定しましたが、画像情報を示すUIImageや2Dグラフィックス情報を示すSKSceneなどを指定することもできます。

let targetPlane = SCNPlane(width: 0.2, height: 0.2)
targetPlane.cornerRadius = 1
targetPlane.firstMaterial?.diffuse.contents = UIColor.blue.withAlphaComponent(0.5)
作成したジオメトリ情報をSCNNodeに指定することで飛行機と同じようにAR空間で扱うことができるようになります。

尚、targetNodeは別のViewControllerの別メソッド内でも利用したいためメンバ変数として事前に定義をしてあります。

また、ターゲットカーソルはARSessionが平面を検知したタイミングで表示させたいのでisHiddenはtrueを設定しておきます。

またポイントとしてはデフォルトのSCNPlaneは垂直方向に広がっているため、水平の平面状に表示させるため、x軸を基準に90度回転しておく必要があります。
targetNode = SCNNode(geometry: targetPlane)
targetNode.isHidden = true
targetNode.name = "target"
targetNode.eulerAngles.x =  -Float.pi / 2

画面の中央の座標を取得する


ターゲットカーソルはデバイス画面の中央に表示させたいので、画面の中央の座標を取得して値をscreenCenterというメンバ変数として保持しておきます。

var screenCenter: CGPoint {
    let bounds = sceneView.bounds
    return CGPoint(x: bounds.midX, y: bounds.midY)
}

Swiftではメンバ変数の初期値をクロージャを使って設定することができます。


画面の中央の座標で検知平面のhitTestを行う


過去のARKitのサンプルにタッチイベントを追加しようでも紹介をしましたが、hitTestは3Dオブジェクトだけではなく、ARKitの場合ARKitが検知した平面に対しても行うことができます。

毎フレームごとに画面中央座標で検知平面に対してhitTestを行い、得られた座標にターゲットカーソルを移動させることで、常に画面中央の検知平面上にターゲットカーソルが置かれることになります。

毎フレーム毎の処理はARKitサンプルにライトを追加しようで取り上げましたが、func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval)というメソッドの中で行います。

メソッドの中に次のようなコードを追加します。

DispatchQueue.main.async {
    let results = self.sceneView.hitTest(self.screenCenter, types: [.existingPlaneUsingGeometry])

    if let existingPlaneUsingGeometryResult = results.first(where: { $0.type == .existingPlaneUsingGeometry }) {
        let result = existingPlaneUsingGeometryResult
        let transform = result.worldTransform
        let newPosition = transform.position

        self.targetNode.position = newPosition
    }
}

ここでは下記のような処理を行なっています。

  1. 画面中央の座標でhitTestを行う
  2. hitTestの結果が検知したものがAR空間上の平面であるかを確認
  3. そうであった場合、平面のhitした座標を取得する
  4. 座標を絶対座標に直す
  5. 絶対座標が示す場所へtargetNodeを移動させる

DispatchQueue.main.async {} と transform.position にはちょっとした理由がありますが、将来の投稿で説明をします。

この時点ではこのように書くと思っておいてください。


平面を検知した際の処理を変更する


これまでのサンプルアプリでは平面を検知したタイミングで飛行機のオブジェクトをARAnchorに追加をしていましたが、次のように変更をします。

変更前:
 検知した平面を表すARAnchorに飛行機を追加する

変更後:
 画面の初期化時にHiddenに設定したターゲットカーソルを表示する

単純に次のように変更をします。

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    targetNode.isHidden = false
}

ターゲットカーソルをタップした時に飛行機を表示させる


平面を検知した際にターゲットカーソルを表示するため、飛行機を表示させるためのロジックを追加する必要があります。

今回は見出しそのままでターゲットカーソルをユーザーがタップしたら飛行機を表示させるようにします。

前回の投稿でhitTestを行なった際に見つかった3Dオブジェクトを名前で判断を行なっていましたが、今回は下記のように"shipMesh"の他にターゲットカーソルを示す"target"という名前の条件判断を入れるようにします。

if let result = results.first {
    guard let hitNodeName = result.node.name else { return }

    if hitNodeName == "shipMesh" {
         if let ship = result.node.parent {
             let actMove = SCNAction.move(by: SCNVector3(0, 0, 0.1), duration: 0.2)
             ship.runAction(actMove)
         }
    } else if hitNodeName == "target" {
      .....
    }
}
そしてhitTestの結果targetであった場合、これまで行なっていたように飛行機を表示させます。

ただし、注意したいのが今回は飛行機をARAnchorの子ノードとして設定するのではなく、ターゲットカーソルが示す座標に配置するという点です。

イメージとしては次の絵のような感じです。



なので次の配置は次のように行います。

ship.position = targetNode.position

実際の動作




修正後のコード


import UIKit
import SceneKit
import ARKit

class ViewController: UIViewController, ARSCNViewDelegate, UIGestureRecognizerDelegate {

    @IBOutlet var sceneView: ARSCNView!
    
    var targetNode : SCNNode!
    
    var screenCenter: CGPoint {
        let bounds = sceneView.bounds
        return CGPoint(x: bounds.midX, y: bounds.midY)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Set the view's delegate
        sceneView.delegate = self
        
        // Show statistics such as fps and timing information
        sceneView.showsStatistics = true
        
        // Create a new scene
        let scene = SCNScene(named: "art.scnassets/ship.scn")!
        
        if let ship = scene.rootNode.childNode(withName: "ship", recursively: true) {
            if let particle = SCNParticleSystem(named: "BoostFire.scnp", inDirectory: "art.scnassets") {
                particle.particleSize = 0.3
                if let emitter = ship.childNode(withName: "emitter", recursively: true) {
                    emitter.addParticleSystem(particle)
                }
            }
            ship.opacity = 0
        }
        
        // Create a target
        let targetPlane = SCNPlane(width: 0.2, height: 0.2)
        targetPlane.cornerRadius = 1
        targetPlane.firstMaterial?.diffuse.contents = UIColor.blue.withAlphaComponent(0.5)
        targetNode = SCNNode(geometry: targetPlane)
        targetNode.isHidden = true
        targetNode.name = "target"
        targetNode.eulerAngles.x =  -Float.pi / 2
        scene.rootNode.addChildNode(targetNode)
        
        // Setup Omni Light
        let light = SCNLight()
        light.type = .omni

        let LightNode = SCNNode()
        LightNode.light = light
        LightNode.name = "light"
        LightNode.position = SCNVector3(-1,2,-1)
        scene.rootNode.addChildNode(LightNode)
        
        // Set the scene to the view
        sceneView.scene = scene
        
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tap(_:)))
        tapRecognizer.delegate = self
        sceneView.addGestureRecognizer(tapRecognizer)
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // Create a session configuration
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        
        // Run the view's session
        sceneView.session.run(configuration)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        // Pause the view's session
        sceneView.session.pause()
    }

    @objc func tap(_ tapRecognizer: UITapGestureRecognizer) {
        let touchPoint = tapRecognizer.location(in: self.sceneView)
        
        let results = self.sceneView.hitTest(touchPoint, options: [SCNHitTestOption.searchMode : SCNHitTestSearchMode.all.rawValue])
        
        if let result = results.first {
            guard let hitNodeName = result.node.name else { return }
            
            if hitNodeName == "shipMesh" {
                if let ship = result.node.parent {
                    let actMove = SCNAction.move(by: SCNVector3(0, 0, 0.1), duration: 0.2)
                    ship.runAction(actMove)
                }
            } else if hitNodeName == "target" {
                if let ship = sceneView.scene.rootNode.childNode(withName: "ship", recursively: true) {
                    ship.position = targetNode.position
                    
                    let configuration = ARWorldTrackingConfiguration()
                    sceneView.session.pause()
                    sceneView.session.run(configuration)
                    
                    let completion = {
                        let rotationAction = SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 0, z: 1, duration: 1))
                        ship.runAction(rotationAction)
                    }
                    
                    SCNTransaction.begin()
                    SCNTransaction.animationDuration = 1.5
                    SCNTransaction.completionBlock = completion
                    ship.opacity = 1
                    SCNTransaction.commit()
                    
                    targetNode.isHidden = true
                }
            }
        }
    }
    
    // MARK: - ARSCNViewDelegate
    
/*
    // Override to create and configure nodes for anchors added to the view's session.
    func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
        let node = SCNNode()
     
        return node
    }
*/
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        targetNode.isHidden = false
    }
    
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        guard let lightEstimate = self.sceneView.session.currentFrame?.lightEstimate else { return }
        let ambientIntensity = lightEstimate.ambientIntensity
        let ambientColorTemperature = lightEstimate.ambientColorTemperature
        
        if let lightNode = self.sceneView.scene.rootNode.childNode(withName: "light", recursively: true) {
            guard let light = lightNode.light else { return }
            light.intensity = ambientIntensity
            light.temperature = ambientColorTemperature
        }
        
        DispatchQueue.main.async {
            let results = self.sceneView.hitTest(self.screenCenter, types: [.existingPlaneUsingGeometry])
            
            if let existingPlaneUsingGeometryResult = results.first(where: { $0.type == .existingPlaneUsingGeometry }) {
                let result = existingPlaneUsingGeometryResult
                let transform = result.worldTransform
                let newPosition = transform.position
                
                self.targetNode.position = newPosition
            }
        }
    }
    
    func session(_ session: ARSession, didFailWithError error: Error) {
        // Present an error message to the user
        
    }
    
    func sessionWasInterrupted(_ session: ARSession) {
        // Inform the user that the session has been interrupted, for example, by presenting an overlay
        
    }
    
    func sessionInterruptionEnded(_ session: ARSession) {
        // Reset tracking and/or remove existing anchors if consistent tracking is required
        
    }
}

extension matrix_float4x4 {
    var position : SCNVector3 {
        get {
            return SCNVector3(columns.3.x, columns.3.y, columns.3.z)
        }
    }
}

0 件のコメント:

コメントを投稿