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を行う
毎フレームごとに画面中央座標で検知平面に対して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
}
}
ここでは下記のような処理を行なっています。
- 画面中央の座標でhitTestを行う
- hitTestの結果が検知したものがAR空間上の平面であるかを確認
- そうであった場合、平面のhitした座標を取得する
- 座標を絶対座標に直す
- 絶対座標が示す場所へ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)
}
}
}