iPhoneアプリ開発:AR 目次

 

 ということで、ちゃっちゃとアニメーション対応します。

 一番問題なのは、ドラッグ中の指位置変化のたびにアニメーションをやっちゃってること。

 こいつはARとか3Dとは関係なくUIPanGestureRecognizerの扱い方の問題っすな。前回言ったように[ー、0、+]の三層スイッチにするべきなんですよ。

 そのためにはドラッグエリアを三層に分けて、違う層に切り替わった時だけ反応するようにすればいい。

 

 

 あと、プルプル指先が震える人対策として、層の間に無効領域を作るとモアベターよ。

 

 

 でもってドラッグ開始位置を角度0としてるのもダメダメ。

 

 

 ドラッグを開始した時の角度から相対で回転させたいわけですよ。

 

 

 そういうわけで、回転させる角度をtranslation.yから割出さずに、固定値にして加算するようにします。

	node.eulerAngles.z = Float(translation.y / 100.0)

としてたのを

	node.eulerAngles.z += 1.0

とかにするわけですな(= で代入じゃなく += で加算してる点に注意)。

 これで、アニメーションの時間と回転角度が両方固定されるのでいつでも同じ速度で動くことになる。

 2πラジアンが360度なんで、1.0ラジアンは(1 / (2 x 3.14)) x 360で60度くらいっす。5秒間で60度回る。

 で、停止領域や指が放された時に回転を止める。それが、これ。

class ViewController: UIViewController {
	・・・
    /// ドラッグ中の指の存在領域を示す -1:負回転 0:停止 1:正回転
    private var directionRegion = 0
    
    /// 前回moveBoomが呼び出された時のdirectionRegionの値を記録
    private var lastDirectionRegion = 0
    
    /// ドラッグを検出した
    @objc func moveBoom(_ panRecognizer:UIPanGestureRecognizer) {
        if panRecognizer.state == .changed {
            
            let translation = panRecognizer.translation(
            	in: panRecognizer.view)
            
            //  ドラッグ中の指の存在領域の決定 停止領域の上下20ポイント分は
            //  無効な領域になっていてdirectionRegionを変化させない
            if translation.y < -50 {
                directionRegion = 1
            } else if translation.y > 50 {
                directionRegion = -1
            } else if translation.y > -30 
            && translation.y < 30 {
                directionRegion = 0
            }
            
            if lastDirectionRegion != directionRegion {
                //  領域が変わった
                lastDirectionRegion = directionRegion
                
                if let node = excavator.childNode(
                	withName: "boom", recursively: true) {
                    //  まず停止:画面上の角度を設定することで
                    //  アニメーション中なら強制停止になる
                    node.eulerAngles.z = node.presentation.eulerAngles.z
                    if directionRegion != 0 {
                        //  停止領域でないなら領域方向に一定速度で
                        //  アニメーションする
                        SCNTransaction.begin()
                        SCNTransaction.animationDuration = 5
                        node.eulerAngles.z += 1.0 * Float(directionRegion)
                        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
            }
        }
    }

サンプル:

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

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

 

 ドラッグ開始点から上下50ポイント以上が回転起動領域っす。停止領域は上下30ポイント以下として無効領域も用意しました。

 

 

 や〜っと望んだ動きに近づいた。

 

 あとは、60度回転じゃなく、回転起動領域を押してる間中は一定速度で動き続けて、人間の関節みたいに限界の角度になるとそれ以上回転しなくなるようにすれば完璧。

 一つの方法としては、事前に自分の現在の角度から可動範囲を計算して、それに見合ったアニメーション時間と角度を指定する方法。これは、今持ってる知識で実現可能っす。

 こんな感じ。

  if lastDirectionRegion != directionRegion {
		・・・
    if directionRegion != 0 {
        //  -0.5π 〜 +0.5π の範囲を可動範囲とする
        //  現在の角度から、範囲内の残り可動角を算出
        let left = (0.5 * Float.pi * Float(directionRegion)) 
        	- node.eulerAngles.z
        if abs(left) > 0.0 {
            //  可動範囲内なので動かす
            
            //  回転速度は固定 1/5(ラジアン/秒)
            let velocity:Float = 1.0 / 5.0
            
            //  角度に必要な時間を、動作速度から割り出す
            let duration = abs(left) / velocity
            SCNTransaction.begin()
            SCNTransaction.animationDuration = CFTimeInterval(duration)
            node.eulerAngles.z += left
            SCNTransaction.commit()
        }
    }
  }

サンプル:

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

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

 

 もっと根本的な対応方法としては、レンダリング・ループでアニメーションを直接コントロールする方法がある。

レンダリング・ループ

 アニメーションの原理に暗い人は、なんだそれってことになると思いますが…

 2D、3D関係なくアニメーションってのは、少しずつ変化してる静止画を高速で切り替えて、見ている人間の脳を錯覚させることで成り立ってるんですな。

 

 

 そのため絶えず、そして素早く画像を更新する処理が必要になるわけですよ。

 だいたい1秒間に60回も更新すればかなり滑らかっす。

 60回/秒と書いてもいいけど、普通はヘルツ(Hz)という単位を使って60Hzって書きます。

注意)VRなんかだと100Hzくらいでやった方が良いって言われてるけど、iPhoneの液晶画面自体は60Hzで更新されてるので、いくら内部で100Hzで更新しても意味がない。これがiPad Pro (10.5 インチ) または iPad Pro 12.9 インチ (第 2 世代)の液晶画面だと120Hzになる。60Hzはブラウン管の頃の日本やアメリカのテレビ放送の規格っすね。ヨーロッパのテレビ放送だと50Hz。ちなみに日本のアニメは8Hzが基本。

 

 そういうわけで、今回のようにブームの回転アニメーションでも、SceneKitはブームのpresentation側eulerAngles.zをちょっとだけ変化→その値でレンダリング→できた画像を表示、て作業を60Hzで延々と繰り返してるんですよ。

 これがレンダリング・ループ。

 

 

 ↓SceneKitでのレンダリングループの詳細が知りたい人はここを見ましょう。

https://developer.apple.com/documentation/scenekit/scnscenerendererdelegate

 

 で、この時、どんなアニメーションをするか(回転?、移動?、拡大縮小?、どのくらいの量?)という情報が必要なわけで、これを管理してるのがCAAnimationってオブジェクトです。

CAAnimation

 AppleのSceneKitのアニメーションについて書かれたドキュメントによると、例えば今回のeulerAngles.zプロパティの回転アニメーションなら、このクラスの派生であるCABasicAnimationを作成して使えばいいみたいっす。

 こんな感じ。

        let animation = CABasicAnimation(keyPath: "eulerAngles.z")
        animation.toValue = node.eulerAngles.z + left
        animation.duration = CFTimeInterval(duration)

 イニシャライザのkeyPath:引数に指定してる文字列は、どのプロパティをアニメーションさせるかって指定で、eulerAnglesプロパティのzプロパティっていうような階層構造もswiftの書式と同じようにドット(.)で繋いで表現できるようになっとります。イニシャライザがわからん人は「UIViewの派生でイニシャライザって…」、プロパティがわからん人は「そしてiPhoneアプリへ」ね。

 

  eulerAngles.z  ← プロパティの階層構造は、ドットで繋いで表現する

 

 あとはtoValueプロパティにアニメーション終端側でのeulerAngles.zの値、durationプロパティにそのアニメーション時間を指定(CFTimeIntervalという型なんでキャストしてる)すれば最低限の準備は完了。toValueに対応するfromValueプロパティてのもあるけど、未設定の場合はアニメーション直前のeulerAngles.zの値を使うって決まりになってるので、それでいいなら指定する必要はない。

 こうして作成したオブジェクトをブームノードにaddAnimationメッセージで登録すると、ブームノードの指定したプロパティについてアニメーションが引き起こされるってわけです。

注意)全てのプロパティがアニメーション対応してるわけではない。プロパティのヘルプをみると対応してるものはAnimatableって書いている。ヘルプの出し方がわからん人は「または私は如何にして心配するのを止めて…」を読みましょう。

		node.addAnimation(animation, forKey: "boom move")

 forKey:引数で指定してるのはアニメーションの識別子。addAnimationメッセージてとこから推測できると思うけど、CAAnimationは複数登録できるんで、その識別のためにプログラマが適当に名前を決めることになってます。あとあと識別する気がないならnilを指定してもいい。

 今回はブームを動かしてるので"boom move"って名付けました。

 こんな感じでCAAnimation派生のオブジェクトを直接作って指示するアニメーションはexplicit(エクスプリシット:露骨)なアニメーションと呼ばれます。

explicitアニメーション

 で、まあ、アニメーションするのはするのはいいけど、こんなの毎回書くの面倒だよね、ソースも読み辛くなるし〜、ということで用意されたのがプロパティを設定したら自動的にアニメーションする仕組み。こっちはimplict(インプリシット:暗黙裡)なアニメーションと呼ばれてます。

implictアニメーション

 こっちが、これまで書いてきたSCNTransactionを使ったアニメーションなわけです。

 で、implictと違ってexplicitアニメーションを途中で止める場合は、やっぱり露骨に作業する必要があります。

		node.eulerAngles.z = node.presentation.eulerAngles.z

とやるだけでは止まってくれない。

 アニメーションしてるノードにremoveAnimationメッセージで、止めたいアニメーションを削除する指定が必要になります。削除することが強制停止になるわけです。

		node.removeAnimation(forKey: "boom move")

 forKey:引数に止めたいアニメーションの識別子(addAnimation時に付けたやつ)を指定ね。ちなみに、時間がきてアニメーションが終わった場合は自動的に削除されるようになってるのでremoveAnimationメッセージを送る必要はない。

 で、ここでimplictとの重要な違いが1つ。

 強制停止だろうとアニメーション完了での停止だろうと、explicitアニメーションの場合は、アニメーションが止まるとオリジナル側のnode.eulerAngles.zの位置に戻ります。

 強制停止の場合、removeAnimationの前にオリジナル側のeulerAngles.zをpresentation側のeulerAngles.zで更新してるので問題ないんだけど、完了での停止での対応をしてないんですよ。なので、ドラッグ中にアニメーションが完了するとアニメーション直前に設定されてる位置に戻っちゃうんですな。

 なので、implictと同じようにしたいなら次のような処理を追加する必要がある。

        let animation = CABasicAnimation(keyPath: "eulerAngles.z")
        animation.toValue = node.eulerAngles.z + left
        animation.duration = CFTimeInterval(duration)
        animation.fromValue = node.eulerAngles.z
        SCNTransaction.disableActions = true
        node.eulerAngles.z += left
        SCNTransaction.disableActions = false

 まずfromValueプロパティに現在のeulerAngles.zの値を設定して、その後オリジナル側のeulerAngles.zをアニメーション到達点の値に設定する。

 その際、implictアニメーションが発生するのを避けるためにSCNTransactionのdisableActionsプロパティをfalseにしておく。

disableActionsプロパティ

 このプロパティがtrueならアニメーション対応プロパティを設定してもimplictアニメーションを発生させないってのが決まりです。

 他のやり方として、アニメーション終了時も自動では削除させない、終了時にオリジナル側の値で表示しないという設定にする手もあります。

        let animation = CABasicAnimation(keyPath: "eulerAngles.z")
        animation.toValue = node.eulerAngles.z + left
        animation.duration = CFTimeInterval(duration)
        animation.isRemovedOnCompletion = false
        animation.fillMode = kCAFillModeForwards

 isRemovedOnCompletionプロパティがアニメーション終了時に自動で削除するかどうかの指定で、fillModeプロパティが終了時の表示をどうするかの指定ね。

 kCAFillModeForwardsでアニメーション終了時の位置のままにする指定になる。

 implictアニメーションであるサンプル:excavator-3をexplicitアニメーションに置き換えたやつをサンプル:excavator-3-exとして置いとくので、興味がある人は比較してみてください。ソースを比較するときは「キャッチして」の初頭で紹介したGit(ソースコントロール機能)を使うといいですよ。

 ヘルプ見ながらisRemovedOnCompletionやfillModeを使うやり方とか試してみるのもいいでしょう。

 

サンプル:

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

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

 

 とまあ、implict、explicitアニメーション、ここら辺の仕組みはUIViewやCALayerと同じっすね。UIViewやCALayerのimplictアニメーション用にはCATransactionてのが用意されてます。

 Appleはアニメーション表現をかなり重視してて、開発者に解放されたiOS 2.0の時からこの仕組みが用意されてるんだけど、もともとはmacOSからCALayerと一緒に流れて来たもので、プロパティに値が設定されるのをKVO(Key Value Observe)て仕組みで見張っていて、これを引き金に裏方としてCAActionというオブジェクトがCAAnimationを作成したりして実行します。

 ここら辺の仕組みに興味が湧いた人は「Core Animationプログラミングガイド」を読みましょう。

 

 Appleの日本語ドキュメントサイト

 

 ちなみに、ここまで書いててなんだけど、SceneKitでのexplicitアニメーションはSCNActionってのを使った方が簡単です。

SCNAction

 紛らわしいけど、上で紹介したCAActionとは役割が違います。役割としてはSpriteKitのSKActionと同じ。explicitアニメーション機能をCAAnimationより使いやすくする目的で用意されたオブジェクトです。

注意)SpriteKit:SceneKitの2D版(こっちが先に作られた)

 

 上のCAAnimation版アニメーションはSCNActionを使うとこうなる。

	let action = SCNAction.rotateBy(x: 0, y: 0, z: CGFloat(left), 
    	duration: TimeInterval(duration))
    node.runAction(action, forKey: "boom move")

 アニメーション停止はこう。

	node.removeAction(forKey: "boom move")

サンプル:

 http://tetera.jp/xcc/book-sample/excavator-4-ex-ac.zip

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

 

 なので、SceneKitでのexplicitアニメーションはSCNActionを積極使用っすかね。

 CAAnimationの方は、主にキャラクターのモーションなんかを、Xcodeとは別の3Dモデラーで用意して、それを利用するために使うことになるでしょう。

 それとSCNAnimationてのもあります。

SCNAnimation

 こっちは2017年に発表されiOS 11から利用可能になったもんで、アニメーション同士の合成で威力を発揮するみたいっす。これもキャラクターのモーション合成なんかでお世話になるかと思われ。

 

 ↓WWDC2017のSCNAnimationについての説明はここ

https://developer.apple.com/videos/play/wwdc2017/604/

 

注意)このページにあるPresentation Slides (PDF)てのがセッション全文で、SCNAnimationは122ページあたりに出てきます。

 

 で、これに加えてPhysics Simulationという物理運動用のアニメーション機構が絡んでくるわけですよ〜。

 

↓Physics Simulationについてはここ

https://developer.apple.com/documentation/scenekit/physics_simulation

 

 ふう。先は長い。

 とりま、そういう使い分けは、これからということで、まずはロボットアームの完全版をね。

 まあ、その3の写真見て気付いてると思うけど、油圧ショベルにするんですよ。ユンボちゃんにするの。

 

 

 油圧ショベルのコントローラにはJIS方式を使います。

 画面上の左右のコントローラで3m近いブームやアームをブンブン動かすから。

 いいも悪いもリモコン次第〜。

 待て次回!