Apple Engine

Apple, iPhone, iOS, その周辺のことについて

ARKit でタップするカメラに追従する仮想オブジェクト

 

ARKit で仮想オブジェクト動かす際、タップ後スワイプして移動を行うことがあるが、
面倒なのでこういう UI / UX もありかなと試してみることにした。
あと過去の記事で書いたものが実現できるか試してみたかったので。

ARKit とは書いているが SceneKit の機能しか使用していないため、SceneKit を使用したゲームなどでも使うことができる。

ちなみに新規性のあるネタではなく、Microsoft HoloLens でのウインドウ移動が元ネタ。

 

今回使用している SIMD について

iOS 11 の SceneKit から SIMD 命令が使用できるようになっていた。
以前もこのブログで記事にしているが、雑に説明すると複数のデータ(レーン)を1回の処理で実行する並列化の形。

通常は SISD の形をとっており変数単位で計算を行うが、
SIMD は画像のように X,Y,Z,W の計算を1度で行う事ができ高速かつ記述が簡単である。

f:id:x67x6fx74x6f:20180515183839p:plain

SIMD - Wikipedia

 

例えば、simd では計算用のオペレータや関数が存在しているため、直接四則演算が可能だが SCNVector3 では計算できないため中身を取り出す必要がある。

f:id:x67x6fx74x6f:20181221153135p:plain
simd_float3 と SCNVector3

 

ARKit や SceneKit で扱う simd はメリットしかないので積極的に使用していった方がよいだろう。

(自分が記事を書く場合 simd を説明するのが面倒なので SCNVector で説明していることもあるが、Apple のサンプルのノードではほぼ simd で処理している)

 

AR のカメラに追従させる仕組みとは?

やっている事はいたって簡単で ARKit のカメラノードで addChildNode を使用して仮想オブジェクトを子にしているだけ。
追従をやめたい場合は他のノードに addChildNode し直す。

仮想オブジェクトをカメラノードの子のノードにする際、気をつける点はカメラノードのローカル座標に設置されるため、座標値と回転をカメラに合わせて変更する必要がある。

今回の処理はカスタムのコンストレイントや SceneRenderer の delegate でも可能だが共に毎フレーム処理が走るため、カメラの子ノードに設定する方が端末には優しいはず。

 

実装してみる

仮想オブジェクトをタップするとカメラに追従し、もう一度タップすると最初にタップした位置に戻る。  

 

シーンファイル

追従する仮想オブジェクトは空のノードの子にする。
カメラノードの子になった際の位置や回転を補正する計算や オブジェクト自体が移動、回転、拡大縮小などのアニメーションが行われている場合の計算を簡単にするため。

f:id:x67x6fx74x6f:20181221153231p:plain
ルートノードのオブジェクトは親に空ノードが追加されている

 

位置を戻す際に使用するグローバル変数

Dictionary にノードの名前をキーとして Transform (simd_float4x4) で保持させる。
そのため、仮想オブジェクトの名前がかぶるとおかしくなるので注意。

 

AR プロジェクトとタップイベントの作成

Xcode で新規プロジェクト開き(Command + Shift + N)、
「Augmented Reality App」を選択して、いつも通り viewDidLoad() でタップイベントを設定する。

override func viewDidLoad() {
    super.viewDidLoad()

    ・
    ・
    ・
    
    // add a tap gesture recognizer
    let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
    sceneView.addGestureRecognizer(tapGesture)
}

 

こちらもいつも通り viewDidLoad() の下にタップ時の関数を追加。
タップすると配置した仮想オブジェクトが赤くフェードする。

@objc func handleTap(_ gestureRecognize: UIGestureRecognizer) {
    
    // check what nodes are tapped
    let p = gestureRecognize.location(in: sceneView)
    let hitResults = sceneView.hitTest(p, options: [:])
    
    if hitResults.count > 0 {
        // retrieved the first clicked object
        let result: AnyObject = hitResults[0]
        
        // get its material
        let material = result.node!.geometry!.firstMaterial!
        
        // highlight it
        SCNTransaction.begin()
        SCNTransaction.animationDuration = 0.5
        
        // on completion - unhighlight
        SCNTransaction.completionBlock = {
            SCNTransaction.begin()
            SCNTransaction.animationDuration = 0.5
            
            material.emission.contents = UIColor.black
            
            SCNTransaction.commit()
        }
        
        material.emission.contents = UIColor.red
        
        SCNTransaction.commit()
    }
}

 

位置を保持するための Dictionary を設定する

「@IBOutlet var sceneView: ARSCNView!」の下あたりに以下の命令を追加。
これの内容をチェックして仮想オブジェクトがカメラに追従しているか調べる。

private var nodeDic:Dictionary<String, simd_float4x4> = [:]

 

仮想オブジェクトタップ後にカメラに追従させる

handleTap 関数の「let result: AnyObject = hitResults[0]」の下にまず定数を設定していく。

ARKit の ARSCNView からカメラノードを取得する。

let cameraNode = sceneView.pointOfView!

 

でもって、その下でタップから結果からノードを取得する。
タップされたジオメトリのノードを返すため parent で親のノードを取得し、こちらをカメラの子ノードにする。

let followGeometryNode = result.node! as SCNNode
let followNode = followGeometryNode.parent!

cameraNode.addChildNode(followNode)

 

そのままだと、addChildNodeしただけなので、タップ後カメラノードの原点から followNode が配置され正面を向いて配置されてしまいいきなり移動して不自然。
傾きが 0 であるためカメラに仮想オブジェクトが向いてしまう。

「cameraNode.addChildNode(followNode)」の下に以下を追加。

followNode.simdTransform = cameraNode.simdConvertTransform(followNode.simdTransform, from: nil)

simdConvertTransform でカメラの座標から followNode の座標へ変換し他ので、タップした位置に followNode が調整される。
from が nil になっているのでワールド座標から行なっている。

実機にビルドすると仮想オブジェクトタップするとカメラに追従する。

 

ちなみに convertTransform(simdConvertTransform)で行なっている処理は cameraNode のトランスフォームを逆行列にし回転を変更し followNode のワールド座標での位置を渡すことで同じものとなる。

 

仮想オブジェクトを再度タップすると元の位置へ戻す

先ほど作成したタップ関数の中身

let followGeometryNode = result.node! as SCNNode
・
・
・
followNode.simdTransform = cameraNode.simdConvertTransform(followNode.simdTransform, from: nil)

 

以下のように変更しビルドする。

let followGeometryNode = result.node! as SCNNode
let followNode = followGeometryNode.parent!
let nodeName = followNode.name!
let nodeInfo = nodeDic[nodeName]

if (nodeInfo != nil) {
    sceneView.scene.rootNode.addChildNode(followNode)
    
    followNode.simdWorldTransform = nodeDic[nodeName]!
    
    nodeDic[nodeName] = nil
} else {
    // Save Node Position
    nodeDic[nodeName] = followNode.simdWorldTransform
    
    cameraNode.addChildNode(followNode)
    
    followNode.simdTransform = cameraNode.simdConvertTransform(followNode.simdTransform, from: nil)
}

 

内容的には followNode の名前を取得し nodeName に設定し、
グローバルで設定した nodeDic へ nodeName のキーを中身を読み込む nodeInfo 定数を設定。

nodeInfo がからの場合はまだカメラに追従していない状態であるため、 nodeDic に followNode ワールド座標を保存。

再度、仮想オブジェクトをタップした場合、nodeInfo 中身があるため true 側の処理をする。 処理内容は followNode をルートノードに戻し、followNode に保持した座標を渡して戻し、nodeDic をからにする。

 

追従する場合も、戻す場合も、タップ時のエフェクトが同じでわかりづらいので、
「material.emission.contents = UIColor.red」を以下に変更する。

if(nodeInfo != nil){
    material.emission.contents = UIColor.white
}else{
    material.emission.contents = UIColor.red
}

 

サンプルファイル

記事に書いた機能に加え、スイッチでオフにすると再度仮想オブジェクトタップでタップした位置に留まる処理を追加している。
処理的にはカメラに追従させる際に使った simdConvertTransform で逆のことして戻している。

github.com 

 

まとめ

動作サンプルなので雑に書いているが応用すれば、追従するウインドウや
ものをとって飛ばしたり、物理判定を使用したり、ゲームなどでも使用できると思われる。