この記事はiOSDC2018で発表した内容のまとめ、そして続きになります。
ARKitやSceneKitは用意されたAPIを使えば色々なことが簡単にできてしまいますが、
高度なことをしようとすると、空間ベクトル、座標変換などの算数(数学)の知識が必要になることに気づくでしょう。
例えば以下の例を見てみましょう。
カメラの前にスタンプを置く | カメラの前に文字を書く |
---|---|
カメラの前に文字を書いたり、スタンプを書いたりする際、一度カメラ座標で考えてからワールド座標に変換すると簡単に表現することができたりします。
ということで、本記事ではARKitを使いこなすために自分が勉強した**3Dプログラミングと基本的な算数(数学)**について分かりやすく説明します。
ARKitで使う座標系
まず、基本ですが、ARKitでは右手座標系(画面の手前がz)を使います。
3Dをやったことないと、直感的にzは画面の奥の方を示すイメージをしてしまうので注意です。(僕がそうでした)
それでは各座標系について説明します。
まずは座標まとめ
今回説明するのは、ワールド座標系、オブジェクト座標系、カメラ座標系、スクリーン座標系です。1つずつ簡単に解説していきます。
ワールド座標系
ARKitのSessionをRunして空間を認識した時に決まる座標系。ワールド空間は、これより大きな外側の座標空間で表現できません。
実世界で考えると、すべての国が載っている世界地図というイメージ。AR空間に置く物体は、この座標系で絶対位置を求められます。
オブジェクト座標系
世界地図に対して日本地図とか、東京の地図みたいなイメージ。
上図では、飛行機自身のローカル座標系です。
例えばこの座標系に飛行機と鳥がいて、鳥は飛行機の横1mの位置にいるという状況を考えましょう。
飛行機がこの座標系の原点である場合、飛行機のオブジェクト座標系における鳥の座標は(1, 0, 0)
といった感じで示されます。
カメラ座標系
カメラ座標系は、オブジェクト座標系の1つです。上図でいう左側のカメラのローカル座標系です。
ARKitを起動した後、ユーザーはスマホを空間上で動かしますよね。このときにカメラはワールド座標系の座標を変化してしまうので、カメラからの相対位置を表現するときにこのカメラ座標系が役に立つわけです。
スクリーン座標系
UIKitでおなじみの座標系。左上が原点xは右が正、yは下が正。(Macは違うみたい)
カメラから見た景色は、スクリーン上に投影されて表現されるわけです。
カメラ座標とスクリーン座標の関係
黄色で囲まれた部分がスクリーン上に映る範囲(視錐台のnearからfarの間)です。カメラ座標系の原点はスクリーンの後ろにあります。
カメラ座標系で、スクリーン上に映る範囲のz座標はARKitでは、0.001~1000mです。それに対して、それをスクリーン座標系のzで表現すると0~1.0になります。
注意するべきなのは、これらは比で表現できないことです。投影するときの座標変換はもっと複雑で以下のような曲線の式になります。
http://www.alecjacobson.com/weblog/?p=3835カメラ座標系のzに関してはSCNCameraのドキュメントに、スクリーン座標系のzに関してはunprojectPointメソッドやprojectPointのメソッドのドキュメントに書いてあるのでチェックしてみましょう。
飛行機をカメラの30cm前に置く時
具体的に座標系をどう使いこなすのかの例をあげます。以下のようにカメラの30cm前に飛行機を置きたいとき、どのようなコードを書くのか紹介します。
まずカメラ座標で30cm前を表現してから、スクリーン座標で指のxyを取得し、世界座標に変換してから飛行機を置きます。
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
// カメラ座標系で30cm前
let infrontOfCamera = SCNVector3(x: 0, y: 0, z: -0.3)
// カメラ座標系 -> ワールド座標系
guard let cameraNode = sceneView.pointOfView else { return }
let pointInWorld = cameraNode.convertPosition(infrontOfCamera, to: nil)
// ワールド座標系 -> スクリーン座標系
var screenPos = sceneView.projectPoint(pointInWorld)
// スクリーン座標系で
// x, yだけ指の位置に変更
// zは変えない
let finger = recognizer.location(in: nil)
screenPos.x = Float(finger.x)
screenPos.y = Float(finger.y)
// ワールド座標に戻す
let finalPosition = sceneView.unprojectPoint(screenPos)
// nodeを置く
let airPlaneNode = airPlane
airPlaneNode.position = finalPosition
sceneView.scene.rootNode.addChildNode(airPlaneNode)
}
SceneKitのメソッドで座標変換
では、具体的な座標変換メソッドを紹介します。
ローカルA座標系 <-> ローカルB座標系
func convertPosition(_ position: SCNVector3,
to node: SCNNode?) -> SCNVector3
使用例
カメラ座標系をワールド座標系に変換
// to: の引数をnilにするとワールド座標系に変換される
let positionWorld = cameraNode.convertPosition(positionCamera, to: nil)
カメラ座標系を飛行機オブジェクトのローカル座標系に変換
let positionPlaneLocal = cameraNode.convertPosition(positionCamera, to: airPlaneNode)
ワールド座標系 -> スクリーン座標系
func projectPoint(_ point: SCNVector3) -> SCNVector3
使用例
ワールド座標系のある位置をスクリーンに投影
let position = SCNVector3(x: 0, y: 0, z: 1)
// ワールド座標系 -> スクリーン座標系
var screenPos = sceneView.projectPoint(position)
スクリーン座標系 -> ワールド座標系
func unprojectPoint(_ point: SCNVector3) -> SCNVector3
使用例
スクリーンのタップした位置を取得してワールド座標に変換
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
// スクリーン座標系
let finger = recognizer.location(in: nil)
let pos = SCNVector3(finger.x, finger.y, 0.996)
// ワールド座標に戻す
let finalPosition = sceneView.unprojectPoint(pos)
// nodeを置く
airPlaneNode.position = finalPosition
sceneView.scene.rootNode.addChildNode(airPlaneNode)
}
SceneKitのメソッドで拡大縮小
scaleが1.0だった場合は以下のメソッドで物体の大きさが2倍になります。
let ship1 = node.childNode(withName: "ship1", recursively: true)!
ship1.scale = SCNVector3(2.0, 2.0, 2.0)
SceneKitのメソッドで回転
重要なのは、SceneKit, ARKitは右手座標系なので回転は右ねじの法則に従うことです。
rotationを使う方法
let ship2 = node.childNode(withName: "ship2", recursively: true)!
ship2.rotation = SCNVector4(x: 1,
y: 0,
z: 0,
w: -.pi / 4)
この方法では、軸と回転角で回転を定義します。
SCNNodeのpivot
と、ここで定義したx,y,zの点を結ぶ線が軸です。
pivotは原点を示します。特別変更しない場合はこのローカル座標系の(0, 0, 0)
です。
また .pi
は数学でおなじみのπです。180度を示します。
Euler angleを使う方法
let ship2 = node.childNode(withName: "ship2", recursively: true)!
ship2.eulerAngles.x = -.pi / 4
上でrotationで行ったことと同じことは、このコードで表現できます。個人的にはこちらの方が直感的でわかりやすいです。
以下にeulerAnglesのx,y,zをそれぞれ変更したらどう変わるかの図を示します。
x
ship2.eulerAngles.x = -.pi / 4
y
ship2.eulerAngles.y = -.pi / 4
z
ship2.eulerAngles.z = -.pi / 4
ARKit(SceneKit)を使いこなそう
以上がARKit, SceneKitのメソッドを使った座標空間でした。ここまでを概念的に理解すればARKitの実装で困ることはないでしょう。しかし、数学的な理解も含めて置くと、もっと高度なことができるし汎用的な3Dプログラミングの知識を得ることができます。
というのも、SCNNode
にはtransform
というPropertyがあります。
ここまでに紹介した方法で、座標変換や拡大縮小、回転などを行うことができますが、以下の3D数学を勉強することで、transform
を使った変換を理解することができます。というのもtransform
の型はSCNMatrix4
で、matrixは行列という意味だからです。
座標変換の数学的な表現
それでは行列の話です。
ARにおける座標変換は、回転したり、大きくなったり、位置が変わる幾何学的な表現ですが、数学的に計算する時には「ベクトル * 行列」 と表現することができます。
2Dの座標変換
まずは、2Dでイメージするとわかりやすいです。
式
\begin{pmatrix}
2 & 1 \\
-1 & 2
\end{pmatrix}
\begin{pmatrix}
1 \\
1
\end{pmatrix}
幾何学的に、回転したり、大きくしたりすることが確認できると思います。
3Dの座標変換
次は3Dで考えてみましょう。
式
\begin{pmatrix}
0.707 & -0.707 & 0 \\
1.250 & 1.250 & 0 \\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1 \\
1 \\
1
\end{pmatrix}
この場合では行列の3行目が[0, 0, 1]
であるため、+zは変化していません。
結果的にポットは、z軸を中心に45度回転し、縦長にスケーリングされています。
では、どういう行列を掛けると回転して、どういうのを掛けるとスケーリングするのでしょうか?
z軸の周りをθ回転させる変換行列
\begin{pmatrix}
cosθ & sinθ & 0 \\
-sinθ & cosθ & 0 \\
0 & 0 & 1
\end{pmatrix}
x軸方向に2倍, yは3倍, zは4倍にスケーリングする行列
\begin{pmatrix}
2 & 0 & 0 \\
0 & 3 & 0 \\
0 & 0 & 4
\end{pmatrix}
組み合わせると
\begin{pmatrix}
2 & 0 & 0 \\
0 & 3 & 0 \\
0 & 0 & 4
\end{pmatrix}
\begin{pmatrix}
cosθ & sinθ & 0 \\
-sinθ & cosθ & 0 \\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1 \\
1 \\
1
\end{pmatrix}
z軸の周りをθ回転させたあとに、スケーリングすることができます。
4Dの座標変換
最後に4Dについて解説します。3次元まではx, y, zで示すのでイメージしやすいですが、空間は3次元でしか表わせないのに。4次元目を使うことがあります。
4次元目はなんでしょうか? 時空を超えるのでしょうか?
違います。計算用に使うだけです。
4Dベクトルの4つ目の成分はw
で、同次座標とも呼ばれます。
理解しやすくするためにまずは2Dの物理空間に対する3Dの同次空間とはどういう意味なのか考えてみましょう。
図のような (x, y, w)
という形をしている2Dの同次座標を調べてみます。3D空間上の平面w=1にある物理的な2Dの点(x, y)
は同次空間で(x, y, 1)
と表されます。ここで、同次座標(x, y, w)
は物理的な2Dの点(x/w, y/w)
に対応づけられます。
つまり、w=5の同次空間の点(5, 10, 5)
は物理的な2D空間に投影すると、(1, 2)
になるということです。逆に言うと、(1, 2)
には(3, 6, 3)
,(4, 8, 4)` というような同次空間上の点が無限にあるということです。
では、**次元を一個上にあげて、物理的3D空間に対する4Dの同次空間で考えてみましょう。**4Dという空間を幾何学的に理解する必要はありません(理解できない)。3Dの一個上にそういう空間があることだけイメージできれば良いです。
この考え方で、物理的な3Dの点は4D内のw=1の「平面」内に存在していると考えられます。4Dの点は(x, y, z, w)
という形であり、4Dの点を対応する3Dの点(x/w, y/w, z/w)
を作る「平面」に投影します。
あえてイメージできない4D空間を使うのには理由があります。
線形変換で平行移動を表現するためです。
3次元座標に3*3の正方行列をかけることで、幾何学的に回転やスケーリングと言った線形変換が可能なことを先程述べましたが、このままだと、ポットは原点から離れられないのです。
そのため4Dを使います。
wが常に1であると仮定します。これにより、3Dベクトル(x, y, z)
は常に4Dで(x, y, z, 1)
と表されます。
また、3×3の変換行列はすべて4Dでは次のように表すことができます。
\begin{pmatrix}
m11 & m12 & m13 \\
m21 & m22 & m22 \\
m31 & m32 & m33
\end{pmatrix}
=>
\begin{pmatrix}
m11 & m12 & m13 & 0 \\
m21 & m22 & m22 & 0 \\
m31 & m32 & m33 & 0 \\
0 & 0 & 0 & 1
\end{pmatrix}
3×4や4×3の行列式は計算できませんが、4×4なら計算することができます。4行目にΔx, Δy, Δzと示すことによって平行移動を示すことができるのです。
以下は3次元の単位に、4行目、4列目を足して、平行移動を表現した式です。
\begin{pmatrix}
1 & 0 & 0 & Δx \\
0 & 1 & 0 & Δy \\
0 & 0 & 1 & Δz \\
0 & 0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x \\
y \\
z \\
1
\end{pmatrix}
=
\begin{pmatrix}
x + Δx \\
y + Δy \\
z + Δz \\
1
\end{pmatrix}
x, y, zだけに着目するとそれぞれΔ分平行移動することができています。このように4Dで考えることで平行移動を表現することができるのです。
アフィン変換
先に述べた4次元の行列を使って座標変換することをアフィン変換と呼びます。
赤い部分が線形変換で、青いところが平行移動です。
アフィン変換を使うと線形変換と平行移動が1つの行列で表現できるわけです。
SCNNodeのtransform
拡大縮小、拡大、せん断(投影)、平行移動は4*4の行列で表現できることがわかったと思います。これをSCNNodeのtransformに適用することで、ARKit(SceneKit)でもアフィン変換をすることができます。
あるnodeのtransformをx軸を中心に90度回転させる
transformを使って計算する際も自前でゴリゴリ掛け算足し算をして行列を算出するのはクールではありません。ここはSCNMatrix4を使うのがいいと思います。
例えばあるnodeのtransform(右側の1~16で示される行列)をx軸を中心にθ度回転させる行列は以下です。
\begin{pmatrix}
1 & 0 & 0 & 0\\
0 & cosθ & -sinθ & 0\\
0 & sinθ & cosθ & 0\\
0 & 0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1 & 2 & 3 & 4 \\
5 & 6 & 7 & 8 \\
9 & 10 & 11 & 12 \\
13 & 14 & 15 & 16
\end{pmatrix}
これをSCNMatrix4を使って表現すると以下のようになります。
// あるnodeのtransform
let transform: SCNMatrix4 = SCNMatrix4(m11: 1, m12: 2, m13: 3, m14: 4,
m21: 5, m22: 6, m23: 7, m24: 8,
m31: 9, m32: 10, m33: 11, m34: 12,
m41: 13, m42: 14, m43: 15, m44: 16)
// x軸を中心に90度の回転
let rotate = SCNMatrix4Rotate(
transform,
.pi/2,
1,
0,
0
)
// 行列の積。上の図の行列式と同じ意味。
let mult = SCNMatrix4Mult(rotate, transform)
// 型は場合に応じて変更
let matrixFloat: simd_float4x4 = matrix_float4x4(mult)
行列の積はかける順番によってアウトプットが変わるので注意が必要です。
例: タップした位置のxyzを取得したい
SCNVector3ではなくtransformでしか値を取得できないケースがあります。HitTestを得る場合などです。
var sceneView: ARSCNView!
let point: CGPoint = CGPoint(x: 0, y: 0)
if let hitestResult = sceneView.hitTest(point, types: .featurePoint).first {
let column3 = hitestResult.worldTransform.columns.3
// ここに注目!!
let float: float3 = float3(column3.x, column3.y, column3.z)
let position: SCNVector3 = SCNVector3(float)
}
hitTestというメソッドを使ってタップした位置の3D座標xyzを取得したい時があると思いますが、この時取得できるのはhitTestの4次元配列transformです。
この場合に4行目
\begin{pmatrix}
Δx \\
Δy \\
Δz \\
1
\end{pmatrix}
のxyzを取り出したのが
let float: float3 = float3(column3.x, column3.y, column3.z)
のコードです。ΔxΔyΔzは平行移動を示す値ですよね。
原点にこの平行移動の座標変換をかけると求める3D座標の点を求めることができるのです。
まとめ
以上、ARKitで使う座標系の紹介、座標変換メソッド、そして数学的な表現を紹介しました。両方理解して 自由自在に物体を操っていきたいですね。
参考文献
- SceneKit | Apple Developer Documentation
- 実例で学ぶゲーム3D数学 (画像を一部こちらから拝借しています )
サンプルコード
iOSDCでの発表スライド