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

 追加だ!とニヤリ。

 ではでは。