iPhoneアプリ開発:AR 目次

 

 前回最後に登場したviewDidAppearメソッドは、「ARKitだ!」で紹介したviewWillAppear、viewWillDisappearのお仲間です。

 viewWillAppearがself.viewが表示される直前なのに対し、viewDidAppearは表示直後に呼ばれるようになっとります。

 なので、これをViewControllerのクラスでオーバーライドすることで、画面が表示されたタイミングがわかるわけ。でもって、そのタイミングでアニメーションをさせてるわけっすよ。

 オーバーライドがわからん人は「こんにちはデベロッパの世界」ね。

注意)本来はARSessionでライブ画像表示が始まってからやるべき。そこらへんはいずれ。

class ViewController: UIViewController {
 	・・・
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        SCNTransaction.begin()
        SCNTransaction.animationDuration = 5
        if let node = excavator.childNode(withName: "boom", 
        	recursively: true) {
            node.eulerAngles.z = -0.6
        }
        if let node = excavator.childNode(withName: "arm", 
        	recursively: true) {
            node.eulerAngles.z = -1.2
        }
        SCNTransaction.commit()
    }

 

 やってることは、子ノード側のモデル空間を、そのモデル空間のz軸回りで回転させるってことです。

 ムーブ部をz軸周りに-0.6ラジアン傾け、同時にアーム部をz軸周りに-1.2ラジアン傾けさせてます。

注意)ラジアンは0〜2π(π:3.14)で0〜360度を表現する角度の単位。

 

 

 

 

 これは、傾けたいノードのeulerAnglesプロパティにラジアン角を設定することで指定可能。

eulerAnglesプロパティ

 positionプロパティが平行移動なのに対し、eulerAnglesプロパティが回転てわけです。x、y、zのプロパティを持ってて、それぞれx軸での回転角、y軸での回転角、z軸での回転角を指定できるようになってる。

 

  SCNNodeオブジェクト.eulerAngles.z = 回転角(ラジアン)

 

 というようにx、y、z個別に指定もできるし

 

  SCNNodeオブジェクト.eulerAngles 

    = SCNVector3(x軸回転角, y軸回転角, z軸回転角)

 

てなふうに一気に指定することもできます。

 オイラー角といって3次元回転角を指定する時の定番です。

 それぞれに指定されてる角度でx、y、z軸、順繰りに回転させる決まりなんですが、回転させる軸の順番によって、最終的な回転後の向きが変わってしまう点には要注意。詳しくはWikiでアニメーション付きで説明されてるんで読みましょう。

 

 https://ja.wikipedia.org/wiki/オイラー角

 

 3次元の回転は厄介なので、可能ならx、y、zのどれか1つだけ変更するのが無難でしょう。ここでは紹介だけしとくけど、任意軸を使った回転にはroitationプロパティを使うのが吉。クォータニオンを使うorientationプロパティてのもあります。

注意)でもって、positionやpivotを含め、それぞれのプロパティにSIMD(single instruction multiple data)を使うバージョンがあったりします。ただし、こっちは値の型にSIMDが指定されるってだけで機能自体は変わりません。スピード狂はSIMD版を使う。

 

 ま、そこらへんはいいとして、プロパティを設定するには対象となるノードを探す必要があるわけですよ。そのためにやってるのがchildNodeメッセージの送信っす。

childNode(withName:,recursively:)メッセージ

 こいつは自分の子ノードの中から、指定された名前の子ノードを探して戻せってメッセージで、引数withName:に探したいノード名を指定します。

 もし見つからなければnilが戻される。

 

  SCNNodeオブジェクト.childNode(withName: "arm", recursively: true)

 

 引数recursively:は自身の子ノード内に、名前と一致する子ノードが見つからない時の動作の指定。子ノードの、そのまた子ノードの中も探すならtrueを指定する。

 つまりtrueで自分の子孫の全検索です。今回ならtrueにすることで、肩→ブーム→アーム→バケットと探して行くことになるっす。

 nilが戻される場合もあるんで

    if let node = excavator.childNode(…) {
        node.eulerAngles.z = -0.6
    }

として、nilでない(見つかった)時だけ、見つけたSCNNodeに角度を指定してます。を使って

    excavator.childNode(…)?.eulerAngles.z = -0.6

でもOK。何言ってるのかわからん人は「アンラップしてチン♪」を読みましょう。

注意)今回なら見つからなけりゃ変だから、?じゃなく!指定しておいてnilが戻されたら例外が投げられるようにしてもいい。ただし開発中ね。リリース時に例外投げちゃうとリジェクトされるので気をつけましょう。

 

 で、この検索で使うノードの名前はSCNNodeのnameプロパティで指定できるようになってます。

nameプロパティ

 自分が設定したい名前を使っていいです。「太郎」でも「花子」でもいいけど、普通、なんのノードかわかりやすい名前にする。今回なら「上腕」、「前椀」て日本語でもいいんだけど、言語はね〜、なにかとトラブルの元だから極力半角英数使いましょう。私は「boom」、「arm」にしてます。

    ・・・
    func createExcavator() -> SCNNode {
    	・・・
        let boom = SCNCone(topRadius: 0.3, bottomRadius: 0.4, height: 3)
        let boomNode = SCNNode(geometry: boom)
        boomNode.name = "boom"  //  baseNodeから検索できるように名前をつける
    	・・・
        let arm = SCNCone(topRadius: 0.2, bottomRadius: 0.3, height: 2)
        let armNode = SCNNode(geometry: arm)
        armNode.name = "arm"  //  baseNodeから検索できるように名前をつける
    	・・・
    }

SCNNodeのアニメーション

 で、このeulerAnglesプロパティ設定による回転をアニメーションにしたかったので、プロパティ設定の前後をSCNTransaction.begin()とSCNTransaction.commit()で囲んでます。

 SCNNodeのプロパティのいくつかはアニメーション対応していて、上記のbegin()から、commit()されるまでの間に行われるアニメーション対応のプロパティへの設定は全てアニメーションになるんすよ。

        SCNTransaction.begin()
			・・・
		SCNTransaction.commit()

 でもって、このアニメーションの時間は、begin()から、commit()されるまでにSCNTransactionクラスオブジェクトのanimationDurationプロパティに設定することで秒単位で設定できます。

        SCNTransaction.begin()
        SCNTransaction.animationDuration = 5
			・・・
		SCNTransaction.commit()

 今回なら5秒ってことになるわけです。

 クラスオブジェクトがわからん人は「色々と脱線」ね。

 実はbegin()、commit()で囲まなくても、animationDurationプロパティを0より大きな値にするだけでアニメーションするようになるんだけど、それだと、このアニメーションは1秒で、こっちは5秒でっていったグループ分けができないんすよ。

 

 と解説が終わったところで、今回は、このeulerAnglesプロパティを試すために、指の上下ドラッグにブーム部の角度を連動させてみるっす。

 まずは、画面にUIPanGestureRecognizerを登録だ!

UIPanGestureRecognizer

 映画でいう左右に振るカメラワークって意味のパンって名前になってますが、こいつが画面上のドラッグを検出するオブジェクト。

 「タップしてドン」で使ったUITapGestureRecognizer同様、UIGestureRecognizerから派生してます。登録方法自体も同じ。ちょっと違うのはジェスチャーを検出した時に呼び出してもらうメソッドの引数の型。

 UITapGestureRecognizerじゃなくUIPanGestureRecognizerにします。

class ViewController: UIViewController {
 	・・・
    override func viewDidLoad() {
        super.viewDidLoad()
	        ・・・
        //  ドラッグ検出用に登録
        let panGestureRecognizer = UIPanGestureRecognizer(
        	target: self, action: #selector(moveBoom(_:)))
        self.view.addGestureRecognizer(panGestureRecognizer)
    }
 	・・・

    /// ドラッグを検出した
    @objc func moveBoom(_ panRecognizer:UIPanGestureRecognizer) {
	}

 それとUITapGestureRecognizerは、タップを検出するだけでおしまいだったけど、UIPanGestureRecognizerの場合はもうちょっと考慮する点があります。

 大きくわけて3段階のステート(state:状況)に対応する必要があるんですな。

 

 1、ドラッグの開始(パンとして検出された)

 2、ドラッグ中の指先位置変化

 3、ドラッグの終了(指先が画面から放れた)


 特に2はドラッグ中に指先位置が変化するたびに呼び出されるので、その都度対応することになるっす。とりま、今回はドラッグの開始と終了では特になにもせずに、指先位置が変化する検出に対し、上へのドラッグなら時計回り、下なら反時計回りに回転させることにします。

    @objc func moveBoom(_ panRecognizer:UIPanGestureRecognizer) {
        if panRecognizer.state == UIGestureRecognizerState.changed {
            let translation = panRecognizer.translation(
            	in: panRecognizer.view)
            if let node = excavator.childNode(
            	withName: "boom", recursively: true) {
                node.eulerAngles.z = Float(translation.y / 100.0)
            }
        }
    }

 先に話したように、呼び出しに指定したメソッドmoveBoomは、ドラッグの開始、指先位置の変化、ドラッグの終了と、色々な状況で呼び出されるので、状況に応じて動作を変えるなら、自分がどの状況で呼び出されたかを把握する必要があります。

 このために使うのがUIGestureRecognizerのstateプロパティ。

stateプロパティ

 こいつはUIGestureRecognizerStateというenum型で、呼び出されたときの状況が設定されてます。こいつが.changedになっていれば指先位置の変化で呼び出されたことになるんで、その時だけ対応するようにしてる。

 enumがわからん人は「キャッチして」ね。

        if panRecognizer.state == .changed {
        	・・・
        }

 で、ここでドラッグ開始位置からの相対距離を調べてるのがこれ。

        if panRecognizer.state == .changed {
            let translation = panRecognizer.translation(
            	in: panRecognizer.view)
            ・・・
        }

 UIGestureRecognizerにtranslationメッセージを送ることで、ドラッグ開始位置から現在の指先までの相対距離が得られるんですな。

translation(in:)メッセージ

 戻されるのはCGPointで、引数in:で指定されるUIView上での相対位置となります。yの正方向が下という点に注意しましょう。

 

 

 ここでは引数in:にUIPanGestureRecognizer(UIGestureRecognizer)のviewプロパティを指定してる。こいつはUIPanGestureRecognizerを登録したUIViewを示すようになってるのでself.viewを指定したことになる。

 

  panRecognizer.translation( in: panRecognizer.view)

 

 あとは、この値を適当な割合でラジアン度に変換してブームノードのeulerAngles.zに設定すればブームが回転するわけっす。ここでは100ポイントで1ラジアンになるようにしてる。

 

        if panRecognizer.state == UIGestureRecognizerState.changed {
            let translation = panRecognizer.translation(
            	in: panRecognizer.view)
            if let node = excavator.childNode(
            	withName: "boom", recursively: true) {
                node.eulerAngles.z = Float(translation.y / 100.0)
            }
        }

注意)translationのyプロパティの型はCGFloat。このためSCNVector3型のzプロパティの型であるFloat型にキャストしてます。

 

サンプル:

 http://tetera.jp/xcc/book-sample/excavator-2.zip

 実機で動かす時はTeamを自分のApple IDにしてね

 

 ドラッグを開始するとブームが直立して、指先の上下ドラッグに合わせて、ブームが左右に動くはず。

 ただ〜、機敏に反応するのはいいんですが、そのせいで重量感がない。

 で、ゆっくり動かそうとここでアニメーションを指定すると〜

        if panRecognizer.state == UIGestureRecognizerState.changed {
            let translation = panRecognizer.translation(
            	in: panRecognizer.view)
            SCNTransaction.begin()
            SCNTransaction.animationDuration = 5
            if let node = excavator.childNode(
            	withName: "boom", recursively: true) {
                node.eulerAngles.z = Float(translation.y / 100.0)
            }
            SCNTransaction.commit()
        }

 重量物らしくヌメ〜て動きになるんですが、違和感があるんすよ。

 指を放してもしばらく動くし、長い距離をドラッグして指を放した後、すぐにもう一度同じ方向に短い距離でドラッグして手を放すと、逆回転したりするんだよね。

 

 これはドラッグ開始点で角度を0度にしてるせいと、指を放したときにアニメーションを停止してないせい。

 そもそも、指の位置が変わるたびにeulerAnglesを設定してるのも問題。なんせ5秒のアニメーション指定してるんで、ドラッグ中は何回も設定することになって、その都度、先に指定したアニメーションが途中で中断されて、新しいアニメーションに切り替えられるって状態が繰り返されることになるんすよ。ここら辺をちゃんと対応してくれて、それなりの動きをするSceneKit自体はすごいんですが…

 このままじゃあ、へんてこ操作感になっちゃうよね。

 

 アニメーションでゆっくり動かす場合、ドラッグの距離で回転角を決めるんじゃなく、ドラッグは右または左回転のスイッチとして使い、同じ方向にドラッグしてる限り一定の速度で回転が行われ、指を放すと停止する。

 そんなのが自然かな。

 

 指を放した時に停止させるには、アニメーションさせずにeulerAngles.zを設定(元々のSCNTransactionのbegin()、commit()で囲まないやり方)すればいい。

 これをUIGestureRecognizerのstateが.ended(終了)か.cancelled(キャンセル:例えば電話なんかでアプリが割り込まれた時)の時に行えば、動作中のアニメーションは止まるんだけど、問題はどんな値を設定するか。

 違和感が無いのは、現在アニメーション中の画面上で表示されている角度。

 画面上で表示されてるブームの角度をどこから得るか?

 先に答えを言っちゃうと、ブームノードのpresentationプロパティに設定されてるSCNNodeを参照します。

 こんな感じ。

    @objc func moveBoom(_ panRecognizer:UIPanGestureRecognizer) {
    	・・・
        //  ドラッグ終了もしくはキャンセル時はアニメーションを強制的に停止
        if panRecognizer.state == .ended 
        || panRecognizer.state == .cancelled {
            if let node = excavator.childNode(
            withName: "boom", recursively: true) {
                node.eulerAngles.z = node.presentation.eulerAngles.z
            }
        }
    }

presentationプロパティ

 presentation側のSCNNodeは、画面上でアニメーションを表示するために使われるSCNNodeで、presentationを持ってる側のノード(以後、オリジナルって呼びます)のコピーと考えればいいです。

 ただし、オリジナル側のSCNNodeのeulerAngles.zの値が、設定直後に設定値に変わるのと違い、こちらはアニメーション中に逐次eulerAngles.zが更新されるようになってて、最終的にオリジナルが持つeulerAngles.zの値になります。

 

 

 これで、ブームを指を放した時の画面上の角度できっちり止めることができる。

 

 ただ〜、厄介なのはpresentation側は、アニメーション強制停止時にオリジナルに設定した値になるみたいで…

 

 1)指先の移動時に実行するSCNTransactionのbegin()、commit()による新規アニメーション作成と、その時に発生する実行中のアニメーションの破棄、それに伴うアニメーション強制停止によるpresentation側のオリジナル側の値への変更

 

のタイミングと

 

 2)ドラッグ終了・キャンセル時のpresentation側の値参照

 

のタイミングの同期が取れないんですよ。

 2)が先に実行されると、presentation側の値は画面上の値じゃなくオリジナルに設定した値になってしまう。

注意)プログラムは上から下に順に実行されるんだから、そんなこと起こらんやろ〜と思うかもしれないけど、アニメーションは非同期処理が絡むんでそう簡単な話にならんのですよ。

 

 なので、とりあえずの対策としては指先の位置移動でアニメーションさせるときは、presentationの値を設定してから古いアニメーションを終わらせるようにします。

 こんな感じ。

    @objc func moveBoom(_ panRecognizer:UIPanGestureRecognizer) {
        if panRecognizer.state == .changed {
            
            //  表示中の角度を設定することで、アニメーションを強制的に停止
            if let node = excavator.childNode(
            	withName: "boom", recursively: true) {
                node.eulerAngles.z = node.presentation.eulerAngles.z
            }

            let translation = panRecognizer.translation(
            	in: panRecognizer.view)

            SCNTransaction.begin()
            SCNTransaction.animationDuration = 5

            if let node = excavator.childNode(
            	withName: "boom", recursively: true) {
                node.eulerAngles.z = Float(translation.y / 100.0)
            }

            SCNTransaction.commit()
        }

        //  ドラッグ終了もしくはキャンセル時はアニメーションを強制的に停止
		if panRecognizer.state == .ended 
        || panRecognizer.state == .cancelled {
            if let node = excavator.childNode(
            withName: "boom", recursively: true) {
                node.eulerAngles.z = node.presentation.eulerAngles.z
            }
        }
    }

 ちょっとぎこちないけど、一応、これで指を放すと止まるようになります。

 ここら辺の話は、SceneKit(もしくはCALayer)のアニメーションの仕組みを知らないとなんじゃこりゃってことになると思うけど、そこんところは次回。

 それまで、最後に追加した処理をつけないとどうなるかとか、別のノード動かしてみたりとか、いろいろサンプルで試してみて。

 

 じゃまた。