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)のアニメーションの仕組みを知らないとなんじゃこりゃってことになると思うけど、そこんところは次回。

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

 

 じゃまた。


 

iPhoneアプリ開発:AR その4 光あれ

iPhoneアプリ開発:AR 目次

 

 というわけで、今回はロボットアームっす。

 でもその前に、この前の3D文字列に陰影付けします。

 皆さんも、サンプル試してて「文字が白くて立体感に乏しいな」って思ってたんじゃね?

 

↓し、白い!

 

 その1番の理由が3D文字列に陰影付けがされてない点なんすよね。

 普通、光の当たる角度によって暗い面と明るい面ができそうなのに、全面フラットに「白」なわけです。

 

↓普通、立体には明るい面と暗い面があるよね

 

 

 なんでじゃあ、と言っても理由は簡単、私が手抜きして光源を用意してないから。

レンダリング

 3D仮想空間に置かれた三角形平面、もっといえばその三角形を形成する頂点の位置情報、そして仮想カメラの位置や向き情報から、カメラが写し込むであろう画像を作り出す行為をレンダリングって言うんですが…

 そもそも、なんで空間上の位置や向きといったx、y、zの座標値なんかから画像が作り出せるのか?

 この前のリンク先「その(90) 自力でOpenGLしてみる」を読んだ人は、レンダリングが3Dから2Dへの座標変換の計算によって成り立ってるのが、なんとなく想像できてると思うんだけど、そこで割り出した平面への色付けもやっぱり計算なんすよ。

 ぶっちゃけ光のシミュレーション。

シェーディング

 光源から出た光が、物体の表面に当たって反射して目に届くまでを計算してるわけです。レンダリングの一環として行われるこの処理を、一般にシェーディング(shading:陰影付け)って呼んでます。

注意)3D頂点位置から2D頂点位置に変換する部分もシェーディング処理と解釈して、頂点シェーディングって呼んだりもします。

 

 例えば上で書いたように、同じ光量の光でも、面に対して垂直に当たった方が、面は明るくなるはず。それを計算するには光源と面を結ぶ直線と、面から垂直に伸びる線(法線って言って、平面を構成する3つ頂点から算出できる)との角度を計算すればいい。

 法線との角度が0(面に光が垂直に当たってる)に近いほど面は明るくなるはず。

 

 

 

 で、この場合、光源と面を結ぶ直線を得るには、光源の位置が必要。

 なのに光源を設定してない。

 光源がなけりゃ、光線の向きなんて決定しようがないじゃ〜ん。陰影付けできないじゃ〜ん。

 なので、光源が無いとARKitフレームワークは計算するのやめるみたいっす。というかSceneKitフレームワークね。前回、説明し忘れてたけど、ARKitは仮想空間のレンダリングにSceneKitを使う。SCNSceneとかSCNNodeとか、最初の3文字がSCNのクラスはSceneKitから提供されてる。

 とにかく光源がない場合、平面に設定されている素材の色をそのまま表示するみたいなんすよ。というわけで、光源をSCNNodeとしてルートノードに追加投入します。

・・・
class ViewController: UIViewController {
    	・・・
    override func viewDidLoad() {
    		・・・
        sceneView.scene = scene
        //  光源ノードを作り3D仮想空間のルートノードに追加
        let lightNode = SCNNode()
        let light = SCNLight()
        lightNode.light = light
        scene.rootNode.addChildNode(lightNode)
    }

 SceneKitで光源を表現するのはSCNLightってオブジェクト。

SCNLight

 こいつを作ってSCNNodeのlightプロパティに設定すれば、そのノードを仮想光源としてルートノードに追加できるんですよ。

 ちなみにSCNText(SCNGeometry)の時みたいに、SCNNode作成時に引数でSCNLightを渡せるようなイニシャライザは無いみたい。なので引数なしでSCNNodeを作成してからlightプロパティにSCNLightオブジェクトを設定する。イニシャライザがわからん人は「UIViewの派生でイニシャライザって…」を読みましょう。

        let lightNode = SCNNode()
        let light = SCNLight()
        lightNode.light = light

 どやあ!

 

 

 ほら、めちゃめちゃ立体感出たでしょ。

 この場合、光源からの光が、3D文字列平面を照らしてる状態。

 文字列平面には、デフォルトで白色不透明つや消し素材の情報が設定されてるので、こんな感じでシェーディングされる。

 この素材情報のことを一般にマテリアル(Material:生地・素材)って呼びます。

マテリアル

 プラスティックな素材、木の素材、金属の素材、いろいろな素材があって、それぞれに表面の質感が異なるわけですよ。

 で、平面のシェーディングでは、この素材による質感の違いを、色、ざらつき、反射率、透明度といった値を元に計算するようになってて、それを管理するのがSCNMaterialって呼ばれるオブジェクトっす。

SCNMaterial

 SceneKitでは、こいつをジオメトリごとに設定できるようになってます。しかも複数設定できるようにもなってて、SCNGeometryのmaterialsってプロパティに配列で設定するようになってるんですな。

 今回のSCNTextだと、デフォルトでSCNMaterialが1つ設定済みで、白色不透明つや消しな素材情報を持つSCNMaterialが設定されてます。

 なので、もし3D文字列の色を変えたければ、このSCNMaterialの持つ色情報を変えてやればいいわけでよ。例えばパッションピンクにするならこう。

        geometry.firstMaterial?.diffuse.contents 
        	= UIColor(hue: 0.9, saturation: 1, brightness: 1, alpha: 1)

 UIColorがわからん人は「色々と脱線」、作成時のhue:…, saturation:…, brightness:…, alpha:…ていう引数がわからん人は「CALayerで完璧」を読みましょう。

 firstMaterialプロパティてのは、SCNGeometryのmaterialsに設定されたSCNMaterial配列の先頭を取り出すプロパティです。配列が空の場合、firstMaterialプロパティはnilを戻すんで、「?」でチェックしてる。

 やってることは

        if geometry.materials.count > 0 { // 配列の要素数が0より多いなら
           geometry.materials[0].diffuse.contents 
           	= UIColor(hue: 0.9, saturation: 1, brightness: 1, alpha: 1)
        }

と同じなんだけど、firstMaterialを使った方が見た目スッキリするんでね。nilや「?」がわからん人は「アンラップしてチン♪」を読みましょう。

 

↓ピンキーすぎてオメーにゃ無理だよ的な…

 

サンプル:

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

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

 

 ついでにARSCNViewをiPhoneの画面いっぱいに表示するように調整してます。

	override func viewDidLoad() {
        super.viewDidLoad()

        // ARシーンを表示するビューの作成とself.view小画面としての登録
        sceneView = ARSCNView(frame: self.view.bounds)
        sceneView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        self.view.addSubview(sceneView)

 self.view.boundsやautoresizingMaskがわからん人は「重箱の隅をつつくようにネチネチと進めてみる」を読みましょう。

 

 ま、それはいいとして、取り出したSCNMaterialのdiffuseプロパティには、マテリアルの拡散反射成分の情報がオブジェクトとして設定されてます。
 SCNMaterialには、素材の質感情報として、ざらつき具合にはroughness、金属感具合にはmetalnessといった特性ごとの設定用プロパティがあるんですよ。

 で、diffuseは無光沢の質感設定用です。肌とか粘土、ピンポン球のような艶のないプラスティックの表面なんかの質感ですな。デフューズ(diffuse:拡散)と表現します。これらに設定された値が互いに影響しあって最終的な質感を作り上げる。

 

 diffuse:拡散

 metalness:金属

 roughness:粗さ

  ・

  ・

 

 例えばテカテカの金属でも、表面をザラザラにして鏡面反射を抑えるようにすれば、粘土みたいな質感(diffuse:拡散)になるわけで、これを磨き上げて表面をなめらか(roughness:粗さ)にすることで、その金属が持つ独自のテカリ(metalness:金属)が出るわけです。

 ちなみroughnessやmetalnessが使われるのは、シェーディング時の演算に物理ベースを指定した場合のみで、指定しなかったらspecularってプロパティが使われます。

 ここらへんは、いずれ質感表現の時に改めて取り上げるとして、とりま、今はdiffuseに色を設定して3D文字列の拡散色をパッションピンクにしちゃってます。

 diffuseやmetalness、roughnessといった設定用プロパティにはSCNMaterialPropertyというクラスのオブジェクトが設定されているんで、このオブジェクトが持つプロパティをいじることで設定が変更できます。

SCNMaterialProperty

 こいつが持ってるcontentsプロパティは、型にAny?が指定されて、目的に応じたオブジェクトが設定できるようになってるんですよ。

 型がわからん人は「ループの話」、Any?がわからん人は「アンラップしてチン♪」を読みましょう。

 例えばroughnessなんかだと、粗さ具合を数値のオブジェクトであるNSNumberで設定したりとか、その分布を2D画像として表したUIImageを設定したりとかできる。

 diffuseの場合は色を指定することになるんだけど、単色ならUIColorを使い設定します。もし表面の場所によって色を切り替えたいなら色の分布、つまり通常の画像をUIImageとして設定ですね。

 UIImageがわからん人は「スクールカーストとかヒエラルキとか」ね。

 今回は全面単一色なのでUIColorを設定する。で、こうなるわけです。

 登録した光源はpositionプロパティを設定してないので、初期値の(0,0,0)つまり仮想空間の原点に配置することになるんで、iPhone実機をアプリ起動時の位置から動かさない限り、iPhoneのある位置から3D文字列に向かって光がさしてる状態っす。

 

 ところで、この光線と、面の交差角が180度以上開くと、面には全く光が当たらないので、3D文字列の側面なんかは真っ黒になってるわけなんですが…

 それ自体は計算的に正しいんだけど…

 

 

 だけど、現実の世界ではそうはならずに、うっすらだけど明るくなるはず。

 こんな感じに

 

 

 なぜなのか?

 これは光があっちゃこっちゃに反射して、裏側にも回り込んでるのが理由です。

 面に当たる光は、光源からの直接光だけじゃないってことですな。

 

 

 光があっちゃこっちゃ反射して混ざり合った方向性のない光。こういう光を環境光と呼びます。

環境光

 環境光を考慮してレンダリングすると、一層リアリティが増すんですが、環境光を真面目に計算するなんてのは結構負荷のかかる話なんで、CGIの世界では、とりあえず、どの面にも均等に当たる特殊な光源てのを無理やり作り出して、仮想空間に追加登録してごまかしたりします。

 

 用意する光源はSCNLightオブジェクトなんだけど、typeプロパティに.ambient、colorプロパティに灰色を指定して、最初の光源より弱めの光量の環境光にする。

       
       		・・・
       geometry.firstMaterial?.diffuse.contents 
        	= UIColor(hue: 0.9, saturation: 1, brightness: 1, alpha: 1)
        
        //  環境光の追加
        let ambientLightNode = SCNNode()
        let ambientLight = SCNLight()
        ambientLight.type = .ambient
        ambientLight.color = UIColor(white: 0.5, alpha: 1)
        ambientLightNode.light = ambientLight
        scene.rootNode.addChildNode(ambientLightNode)
    }


 上のサンプルでもコメントアウトで用意してるので、解除して試してみてください。

注意)メンドくさがりさんは、1行1行 // を取りはぶかずに、コメントアウトしてる6行を選択状態にしてcommandキー + / キーで、一気にコメントアウトを解除したりする。

 

 ↓こだわりの…

 

 といったところで、シェーディングの話は一旦終了。

 ここからはロボットアームいってみます。

多関節

 やりたいことは関節の表現なんで、見た目にはあまりこだわらず、SceneKit既存の形状を使って作ってみます。

 

バケット部分:SCNPyramid

 ピラミッド。引数無しイニシャライザなら、高さ1m、底面が1辺1mの正方形になる。

 今回は、このサイズを使用。

ブームおよびアーム部:SCNCone

 円錐。引数無しイニシャライザなら、高さ1m、底面が半径0.5mの円になる。

 引数付きイニシャライザで、上側にも円の半径を指定することで、先が尖ってない円錐台にできる。

 

 

    SCNCone(topRadius: 0.2, bottomRadius: 0.3, height: 2)

       topRadius:上側半径

       bottomRadius:下側半径

       height:高さ

 

 今回は

 

 ブーム側:上側半径:0.3m、下側半径:0.4m、高さ3m

 アーム側:上側半径:0.2m、下側半径:0.3m、高さ2m

 

を指定した。

肩部分:SCNSphere

 球体。引数無しイニシャライザなら、半径0.5mの真球になる。

 今回は、このサイズを使用。

土台部分:SCNBox

 6面体。引数無しイニシャライザなら、1辺が1mの立方体になる。

 今回は、このサイズを使用。

 

 この5つの仮想体ごとにノードを用意して親子関係を組んでいきます。

 

 

 で、前回のおさらいっす。

 親子関係を組む目的は、親側を動かすと子側が自動的に追随して便利だから。

 これは各ノードが、それぞれに3D仮想空間を持っていて、この仮想空間の原点位置やx、y、z軸の向きが親側の仮想空間の座標系に依存してるから。

 

 

 

 各ノードが持つ3D仮想空間をローカル空間とかモデル空間といいます。

モデル空間

 で、各ノードの3D仮想空間は、親ノードの3D仮想空間の状態に合わせて位置や傾きが決まり、その親ノードの3D仮想空間も、そのまた親ノードのというように順繰りに影響受けるんだけど、最終的に一番てっぺんのノードは親を持ってないわけです。

 つまり、このてっぺんのノードの3D仮想空間は誰の影響も受けない固定された空間となるわけで、これを世界空間、ワールド空間っていいます。

ワールド空間

 各ノードに属する仮想体の、このワールド空間上での位置が、見た目の配置ってことになるわけですな。

 

 で、子ノードの空間が親ノードの空間に合わせて動くのはいいんだけど、逆に子側の空間を調整する場合にちょっと考えるべき点があるんですよ。

 一番に回転中心。

 回転させる場合、親ノードの空間に対してどれだけ傾けるかって指定になるんだけど、どこを回転の中心にするのか?

 想像はついてると思うけど、原点が中心になります。

 

 で、ここで問題なのが仮想体のスキンは、モデル空間の原点に対してどう配置されてるかっす。例えばAとBの場合…

 

 

 AもBも傾き具合は同じなんだけど

 

 

 親子関係を結んでる場合、Bの場合、関節が外れちゃうんですよ。

 

 

 で、実際SCNConeやSCNBox、SCNSphereは、原点を中心にした配置で仮想体のスキンを定義してます。SCNPyramidのみ、私達が求めてる位置に原点がある。

 

 

 ちなみに、こんな感じで仮想体(モデル)の形状を定義する空間なのでモデル空間ていうんだと思われ。

 

 それはいいけど、腕の関節曲げたら外れちゃったじゃ、まずいんすよ。

 じゃあどうするかというと、SCNNodeのpivotプロパティを使って原点をずらします。

pivotプロパティ

 pivotプロパティにはアフィン変換用のマトリックスを指定するようになってて、原点をずらすだけじゃなく、x、y、z軸の傾きを変えたりもできるようになっとります。

 これでAの状態にできる。

 アフィン変換用のマトリックスがわからん人は「その(92) マトリックス」を読みましょう。

 で、SceneKitにはSCNMatrix4MakeTranslationという、平行移動用のアフィンマトリックスを作る専用関数があるので、こいつを使ってマトリックスを作りpivotプロパティに設定してやればOK。

 これでモデル空間の任意の位置に原点をずらすことができるんですな。

 

 ノードオブジェクト.pivot = SCNMatrix4MakeTranslation(

               ずらすxの量, ずらすyの量, ずらすzの量)

 

 あとは、どれだけずらすかって問題。

 それには前回のShpereBoxのお仲間であるboundingBoxを使います。

 boundingBoxはモデル空間のx、y、z軸に沿った6面体で、スキン形状を覆う最小空間を表すことになってるんすよ。boundingBoxの各頂点座標はSCNVector3構造体であるmin、maxというプロパティから取り出せます。

 こいつで原点をスキンの底に持って行くための量がわかる。

 

 

 SCNNodeのgeometryプロパティにはイニシャライザのgeometry:引数で指定したSCNGeometryがオプショナル型として設定されてます。例えばブーム用のSCNNodeをboomNode、ブームの形状であるSCNConeをboomとすると次のように記述できる。

	boomNode.pivot = SCNMatrix4MakeTranslation(
		0, boomNode.geometry!.boundingBox.min.y, 0)

 確実に設定されてるのがわかってるのでgeometry?じゃなくgeometry!でアンラップしてる。

 もちろんboomを使って

	boomNode.pivot = SCNMatrix4MakeTranslation(
			0, boom.boundingBox.min.y, 0)

と書いてもいいけど、ここではノードからジオメトリを取り出す一例としてgeometryプロパティを使うようにしますた。

 これでy座標だけ原点が下に移動する。

 

 

 ちなみにpivot調整後のboundingBoxは、新しい原点を元にmin.yやmax.yを返します。なので今回ならmin.yは0、max.yは3になるはず。

 アーム側ノードのpositionは、そのことを利用して設定してる。土台、肩についても同じような調整をしてるので、ソースを読んで自分で解析してみてください。詳細は次回に。

 

 

サンプル:

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

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

 

 どーんと巨大なロボットアームが!

 

 

 ブームやアームが動くのを早く見たいよ〜な、せっかちさんは

    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()
    }

 追加だ!とニヤリ。

 ではでは。

 

iPhoneアプリ開発:AR 目次

 

 てなわけで、無事ライブ映像は表示されたっすか〜?

 まあでも、ライブ映像が表示されるだけじゃねぇ…、AVCaptureSessionを使えば、ライブ映像加工程度は扱えるわけだしね。

 あれだけじゃあ、とてもARKitのサンプルだよとは言えませんな。

 ぜひともライブ映像に3D仮想体を合成せねば。

 

サンプル:AVCaptureSessionを使ったライブ映像のセピア色加工

 http://tetera.jp/xcc/book-sample/avcam.zip

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

 

注意)本来、もうちょっと設定することがあるんだけど、できるだけシンプルに済ましてる。そのため加工した映像は正しい縦横比にならず向きも正しくない。加工自体も非同期処理にすべきだけど、Core Imageフィルターがそこそこ速いのでメインスレッドで実行してる。興味がある人はViewController.swiftソースに出てくるクラスやメソッドを調べてみましょう。


 といってもARSession自体も、ライブ映像の取り込み自体は、内部で上のサンプルのようにAVCaptureSession使ってるんじゃないかとは思うわけですよ。その上でライブ映像と3D仮想体を描いた画像(以後CGI画像て呼ぶ)とを合成してるんじゃないかと。

注意)CGI:Computer Generated Imagery

 

 ちなみにARでのCGI画像は次のようなステップをふんで生成されるわけですが…

 

 1、3D仮想空間を用意

 

 

 2、用意した仮想空間に仮想体を配置

 

 

 3、同様に仮想のカメラを配置

 

 

 4、仮想カメラの向いた先の空間が、レンズを通してどのように見えるか計算

 

 

 この仮想カメラの位置や向きを、iPhone実機のカメラの位置や向きと常時連動させることでARが実現されるわけすね。

 

 

 

 

 それをやってくれるのがARSessionとARSCNViewてわけで、連動するための取り決めとして

 

 1、実際の空間も、仮想空間もARセッション開始時のiPhoneの位置を原点とする

 2、ARセッション開始時の仮想カメラは原点に配置する

 

 

としておいて、あとはセッション中、実際の空間で開始時に決めた原点からのiPhoneのズレを加速度センサ等で測定し、仮想空間の仮想カメラの位置に反映させ続ける、と同時に、実機の向きをジャイロで測定し、仮想カメラの向きも連動させてるわけです。

 

 

 

 なので、ライブ画像に仮想体を表示するために、こっちがやらないとダメな残りの作業は、仮想体を配置した3D仮想空間の提供ってことになるわけっす。

 

 それをやってるのが、前々回提供したARサンプルの残りの部分てわけ。

 

前々回のARサンプル:

 http://tetera.jp/xcc/book-sample/ar.zip

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

class ViewController: UIViewController {
    ・・・
    override func viewDidLoad() {
        super.viewDidLoad()
        ・・・
        // 3D文字列を管理する仮想シーン環境
        let scene = SCNScene()
        
        // 3D文字列(こんにちはAR)の作成と仮想シーン環境への登録
        let geometry = SCNText(string: "こんにちはAR", extrusionDepth: 3)
        let node = SCNNode(geometry:geometry)
        
        // 横の大きさを50cmにする
        let scale = 1 / (geometry.boundingSphere.radius * 2) * 0.5
        node.scale = SCNVector3(x:scale, y:scale, z:scale)
        
        // 配置と登録 前方50cmの空中に配置
        node.position = SCNVector3(-0.25, 0.1, -0.5)
        scene.rootNode.addChildNode(node)
        
        // ARシーンを表示するビューへの仮想シーン環境の登録
        sceneView.scene = scene

    }

 まず、最初に作成してるSCNSceneってのは、3D仮想空間全体を管理するオブジェクトっす。

	let scene = SCNScene()

 ARSCNViewは、このオブジェクトが表現する仮想空間内に自分が用意した仮想カメラを配置して、カメラに映り込む画像をCGI画像として作り出すわけですな。

SCNScene

 で、こいつは自身の3D仮想空間や、その中に配置された仮想体群をSCNNodeというオブジェクトとして管理してます。

SCNNode

 例えば、3D仮想空間に机が置かれて、その上に本が置かれているとしましょう。その場合、SCNSceneは以下の3つのSCNNodeを持つことになる。

 

 1、自身が持つ3D仮想空間を表現したSCNNode(以後、ルートノードと呼ぶ)

 2、机を表現したSCNNode(以後、机ノードと呼ぶ)

 3、本を表現したSCNNode(以後、本机ノードと呼ぶ)

 

 

 で、こいつらSCNNodeは、上の図でも示してるように、それぞれが独自の3D仮想空間と原点を持つことになっていて、互いに親子関係を結べるんですな。そうすることで、子側の仮想空間の原点位置やx、y、z軸の向きを、親側の空間座標から相対的に指定できるようになってます。

 なので、仮想空間があって、その中に机が置かれ、机の上には本が置かれているというなら、上の3つのSCNNodeは、一般的に次のような親子関係にします。

 

 

 もちろん、ルートノードの子ノードとして、机と本ノードを並べることもできるんだけど…

 

 

 机の上に本が置かれているなら、本ノードは、ルートノードじゃなく、机ノードの仮想空間相対で子ノードの原点位置や軸の向きを指定するのが正解っす。そうしておくと、机ノードを回転させるだけで、子側の本ノードも一緒に回転するようになるから。

 

 

 ま、ここらへんの親子関係を組める恩恵は、ロボットアームなんかを動かす時に確かめていきましょう。

 

 ↓でっかいアームとちっちゃいアーム

  

 

 とりあえず、今回は仮想体として3D文字列を1つ作って、SCNSceneの管理する3D仮想空間であるルートノードの子ノードにしたいわけです。

 で、この3D文字列をノードとして作ってるのが

        let geometry = SCNText(string: "こんにちはAR", extrusionDepth: 3)
        let node = SCNNode(geometry:geometry)

てところ。SCNodeオブジェクトを作る時に、引数としてSCNTextオブジェクトを渡してるのがポイントですな。

SCNText

 こいつが3D文字列の形状を表現したオブジェクトっす。

 作成時は、引数stringで作成したい文字列を指定し、引数extrusionDepthでその厚みを指定します。

 

 SCNText(string: "こんにちはAR", extrusionDepth: 3)


 オブジェクトの中身は3D頂点群と、その頂点を結んで構成される三角形平面の集まりっす。

 というのも現在のPCやスマホで描かれるあらゆる3D形状は、三角形平面の集まりで表現されてるからなんですな。球体なんかも三角形平面の集まりで表現します。

 

 

 3D形状は三角形平面の集まりでしか表現できないってわけじゃないけど、三角形平面の集まりで表現すると描画処理が単純になって便利なんですよ。

 3Dキャラクタも結局三角形平面の集まりで表現する。

 

 

注意)コンピュータで、3Dの頂点情報から2D画像を描く方法について興味あるなら「iPhoneアプリ開発、その(90) 自力でOpenGLしてみる」あたりを読んでみましょう。

 三角形平面の集まりで3D形状を表現するのが、いわゆる業界標準になってるのでARKitもそれに従ってます。

スキン/メッシュ

 一般的には、この三角形平面の集まりで表現された3D形状のことを、メッシュとかスキンなんて呼んだりする。なんでスキン(肌)なんて呼ぶのかは3Dキャラクタを動かす段階で納得するで正太郎。

 で、ARKitではこのスキンを管理するオブジェクトをSCNGeometryクラスとして定義してます。

SCNGeometry

 SCNTextはこのクラスの派生なわけですな。3D文字列の他にもいくつか基本の3D形状が用意されてるけど…

 

 

 まあ、あんま使わないっすね。基本、Blenderといった3Dモデラーを使って用意したものを使います。

注意)Blenderについてはその昔、「iPhoneアプリ開発、その(117) SIO2でいいじゃん」でちょっと触れた。SIO2はUnityに取って代わられたけどBlenderは今でも元気ですな。

 

 ここらへんは、ロボットアームを油圧ショベルの変えるときに説明。ビフォア、アフター的な…

 

 → 

 

 ちなみに、ARKitの3D空間座標系の単位はメートルです。なので引数で指定してる文字列の厚みは3mてことになる。

 

 でかくね?

 

 と思った人もいると思うけど、実際でかいです。

 今回の「こんにちはAR」の横幅で70mくらい。

 そのまま配置するとクッソでかい文字列が表示されるんですな。何故にそんなにでかいのって思うかもしれないけど、多分理由はないです。

 2D画像として、フォントサイズに10ポイントを指定して「こんにちはAR」を描くと、横幅70ポイントくらいになるんで、これをそのままメートル単位として使ってるんじゃないかと思われ。勘だけど。

 3D文字列作成時に、Core Text使って各文字のグリフをベジェ曲線で取り出して、三角形平面の頂点を計算してるんじゃないかと…

注意)「Core Text使って各文字のグリフをベジェ曲線…」に興味がある人は「iPhoneアプリ開発、その(240) ヒエログリフのグリフ」を読んでみましょう。

 

 配置する時はスキンが存在するノードの3D仮想空間を適当にスケーリングできるので、スキン自体の大きさは問題ないんですよ。

ノードが持つ3D仮想空間のスケーリング

 ノードが持つ3D仮想空間のスケーリングとは、親ノード側の空間から見た場合のことです。

 例えば、親子関係を結んだ机ノードと本ノードで、本ノードのスキンが2mくらいで定義されてて、そのままだと机に置けない場合、子供である本ノード側の3D仮想空間をスケーリングすることで、机に置けるサイズにできるんですな。

 

 

 この時、変わるのは親ノード側の3D仮想空間からみた子ノード側の3D仮想空間の大きさであって、子ノード側3D仮想空間内部の座標系はなんら変化してない点に注目。

 なので、子ノードのスキン(この例だと本の形状を定義する頂点座標群)の座標データは何もいじらなくていい。本人はあくまで2mの本としてスキンを定義しとけばいいわけですよ。

 

 実際、私は以下の作業でノードの持つ3D仮想空間をスケーリングして、表示される3D文字列の横幅を50cmにしてるわけです。

        let scale = 1 / (geometry.boundingSphere.radius * 2) * 0.5
        node.scale = SCNVector3(x:scale, y:scale, z:scale)

 ここで参照してるSCNTextのboundingSphereプロパティには、3D文字列スキン全体を覆う最小の球体が設定されてて、その半径がradiusプロパティから取り出せるようになってます。

 

 

 なので、この値を2倍すれば球体の直径、つまり「こんにちはAR」スキンの幅が求まるわけです。で、その逆数をとることで、「こんにちはAR」スキンの幅を1mにするための3D仮想空間のスケーリング比が求まる。

 

 1 / 「こんにちはAR」スキンの幅

 

 このスケーリング比を、そのまま3D文字列ノード(SCNNode)のscaleプロパティにSCNVector3として指定すれば、3D文字ノードが持つ3D仮想空間がスケーリングされ、親ノード側の3D仮想空間での3D文字列の幅は1mになる。私は0.5を追加でかけて0.5mにしてます。

SCNVector3

 こいつは3次元ベクトルの要素であるx、y、zをひとまとめにした構造体。CGPointの3次元版と思ったらいいです。x、y、z均等にスケーリングさせるために全部同じ値にしてる。

 

 最後に、この3D文字列ノードを、親ノードであるSCNSceneの3D仮想空間のどこに置くかだけど、先に書いたようにARセッション開始時は仮想カメラが原点に置かれることになってんですよ。

 なので、3D文字列はそのカメラの手前にくるように配置してます。それが3D文字列ノード(SCNNode)のpositionプロパティへのSCNVector3の設定。x軸上で-0.25m, y軸上で0.1m, z軸上で-0.5mのところに3D文字列ノードが持つ仮想空間の原点がくるように指定してる。

        node.position = SCNVector3(-0.25, 0.1, -0.5)

 3D文字列ノードが持つ仮想空間の原点は、文字列の左下あたりです。

 

 この原点が、親側の仮想空間の座標(-0.25m, 0.1m, -0.5m)にくるように配置ってわけです。

 

 

 ちなみにARKitの3D座標系は右手系というやつです。これを把握せずに座標扱うとトラブルので注意しましょう。z軸の進行方向が逆になるんですな。

 

 

注意)OpenGLやMetalの3D座標系も右手系。Direct Xは左手系(右手系に切り替え可能)っす。

 

 SCNSceneの管理する3D仮想空間はSCNNodeとして作成され、SCNSceneのrootNodeプロパティに設定済みなので、このノードにaddChildNodeメッセージで3D文字列ノードを子ノードとして追加すれば、SCNSceneの管理する3D仮想空間に3D文字列という仮想体が配置されることになります。

        scene.rootNode.addChildNode(node)

 これで3D仮想空間の準備ができたので、こいつを使ってねとARSCNViewにしらせれば作業はおしまい。

 これはARSCNViewのsceneプロパティにSCNSceneを設定することで行います。

        sceneView.scene = scene

 これでARセッションが始まると、ARSCNViewは用意した仮想カメラを、sceneプロパティに設定されたSCNSceneが持つ3D仮想空間に配置して、CGI画像を作り出しライブ映像に合成していくことになる。

 サンプルのソースをいじり、スケーリングや配置位置を変えて色々試してみてね〜。

 

 じゃまた。