ARKitのための3D数学

この記事は最終更新日から1年以上が経過しています。

スクリーンショット 2018-08-29 23.58.57.png

この記事はiOSDC2018で発表した内容のまとめ、そして続きになります。

ARKitやSceneKitは用意されたAPIを使えば色々なことが簡単にできてしまいますが、
高度なことをしようとすると、空間ベクトル、座標変換などの算数(数学)の知識が必要になることに気づくでしょう。

例えば以下の例を見てみましょう。

カメラの前にスタンプを置く カメラの前に文字を書く
image line

カメラの前に文字を書いたり、スタンプを書いたりする際、一度カメラ座標で考えてからワールド座標に変換すると簡単に表現することができたりします。

ということで、本記事ではARKitを使いこなすために自分が勉強した3Dプログラミングと基本的な算数(数学)について分かりやすく説明します。

ARKitで使う座標系

まず、基本ですが、ARKitでは右手座標系(画面の手前がz)を使います。

3Dをやったことないと、直感的にzは画面の奥の方を示すイメージをしてしまうので注意です。(僕がそうでした)

それでは各座標系について説明します。

まずは座標まとめ

スクリーンショット 2018-08-31 19.20.18.png

今回説明するのは、ワールド座標系、オブジェクト座標系、カメラ座標系、スクリーン座標系です。1つずつ簡単に解説していきます。

ワールド座標系

スクリーンショット 2018-08-25 19.42.27.png

ARKitのSessionをRunして空間を認識した時に決まる座標系。ワールド空間は、これより大きな外側の座標空間で表現できません。

実世界で考えると、すべての国が載っている世界地図というイメージ。AR空間に置く物体は、この座標系で絶対位置を求められます。

オブジェクト座標系

スクリーンショット 2018-08-25 19.34.53.png

世界地図に対して日本地図とか、東京の地図みたいなイメージ。
上図では、飛行機自身のローカル座標系です。

例えばこの座標系に飛行機と鳥がいて、鳥は飛行機の横1mの位置にいるという状況を考えましょう。

飛行機がこの座標系の原点である場合、飛行機のオブジェクト座標系における鳥の座標は(1, 0, 0) といった感じで示されます。

カメラ座標系

スクリーンショット 2018-08-25 19.46.27.png

カメラ座標系は、オブジェクト座標系の1つです。上図でいう左側のカメラのローカル座標系です。

ARKitを起動した後、ユーザーはスマホを空間上で動かしますよね。このときにカメラはワールド座標系の座標を変化してしまうので、カメラからの相対位置を表現するときにこのカメラ座標系が役に立つわけです。

スクリーン座標系

UIKitでおなじみの座標系。左上が原点xは右が正、yは下が正。(Macは違うみたい)

カメラから見た景色は、スクリーン上に投影されて表現されるわけです。

カメラ座標とスクリーン座標の関係

スクリーンショット 2018-08-29 9.31.06.png

黄色で囲まれた部分がスクリーン上に映る範囲(視錐台のnearからfarの間)です。カメラ座標系の原点はスクリーンの後ろにあります。

スクリーンショット 2018-09-21 21.40.43.png

カメラ座標系で、スクリーン上に映る範囲のz座標はARKitでは、0.001~1000mです。それに対して、それをスクリーン座標系のzで表現すると0~1.0になります。

注意するべきなのは、これらは比で表現できないことです。投影するときの座標変換はもっと複雑で以下のような曲線の式になります。

スクリーンショット 2018-08-29 10.22.19.png
http://www.alecjacobson.com/weblog/?p=3835

カメラ座標系のzに関してはSCNCameraのドキュメントに、スクリーン座標系のzに関してはunprojectPointメソッドやprojectPointのメソッドのドキュメントに書いてあるのでチェックしてみましょう。

飛行機をカメラの30cm前に置く時

具体的に座標系をどう使いこなすのかの例をあげます。以下のようにカメラの30cm前に飛行機を置きたいとき、どのようなコードを書くのか紹介します。

GIFイメージ-0677D152F51B-1.gif

まずカメラ座標で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

https://developer.apple.com/documentation/scenekit/scnnode/1407990-convertposition

使用例

カメラ座標系をワールド座標系に変換

// to: の引数をnilにするとワールド座標系に変換される
let positionWorld = cameraNode.convertPosition(positionCamera, to: nil)

カメラ座標系を飛行機オブジェクトのローカル座標系に変換

let positionPlaneLocal = cameraNode.convertPosition(positionCamera, to: airPlaneNode)

ワールド座標系 -> スクリーン座標系

func projectPoint(_ point: SCNVector3) -> SCNVector3

https://developer.apple.com/documentation/scenekit/scnscenerenderer/1524089-projectpoint

使用例

ワールド座標系のある位置をスクリーンに投影

let position = SCNVector3(x: 0, y: 0, z: 1)

// ワールド座標系 -> スクリーン座標系
var screenPos = sceneView.projectPoint(position)

スクリーン座標系 -> ワールド座標系

func unprojectPoint(_ point: SCNVector3) -> SCNVector3

https://developer.apple.com/documentation/scenekit/scnscenerenderer/1522631-unprojectpoint

使用例

スクリーンのタップした位置を取得してワールド座標に変換

@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のメソッドで拡大縮小

スクリーンショット 2018-08-29 23.45.31.png

scaleが1.0だった場合は以下のメソッドで物体の大きさが2倍になります。

let ship1 = node.childNode(withName: "ship1", recursively: true)!
ship1.scale = SCNVector3(2.0, 2.0, 2.0)

SceneKitのメソッドで回転

スクリーンショット 2018-08-29 23.40.58.png

重要なのは、SceneKit, ARKitは右手座標系なので回転は右ねじの法則に従うことです。

rotationを使う方法

let ship2 = node.childNode(withName: "ship2", recursively: true)!
ship2.rotation = SCNVector4(x: 1,
                            y: 0,
                            z: 0,
                            w: -.pi / 4)

この方法では、軸と回転角で回転を定義します。

スクリーンショット 2018-08-29 23.29.52.png

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

スクリーンショット 2018-08-29 23.40.58.png

ship2.eulerAngles.x = -.pi / 4

y

スクリーンショット 2018-08-29 23.41.04.png

ship2.eulerAngles.y = -.pi / 4

z

スクリーンショット 2018-08-29 23.41.12.png

ship2.eulerAngles.z = -.pi / 4

ARKit(SceneKit)を使いこなそう

以上がARKit, SceneKitのメソッドを使った座標空間でした。ここまでを概念的に理解すればARKitの実装で困ることはないでしょう。しかし、数学的な理解も含めて置くと、もっと高度なことができるし汎用的な3Dプログラミングの知識を得ることができます。

というのも、SCNNodeにはtransformというPropertyがあります。

https://developer.apple.com/documentation/scenekit/scnnode/1407964-transform

ここまでに紹介した方法で、座標変換や拡大縮小、回転などを行うことができますが、以下の3D数学を勉強することで、transformを使った変換を理解することができます。というのもtransformの型はSCNMatrix4で、matrixは行列という意味だからです。

座標変換の数学的な表現

それでは行列の話です。

ARにおける座標変換は、回転したり、大きくなったり、位置が変わる幾何学的な表現ですが、数学的に計算する時には「ベクトル * 行列」 と表現することができます。

2Dの座標変換

まずは、2Dでイメージするとわかりやすいです。

(2112)(11)

幾何学的に、回転したり、大きくしたりすることが確認できると思います。

3Dの座標変換

次は3Dで考えてみましょう。

(0.7070.70701.2501.2500001)(111)

この場合では行列の3行目が[0, 0, 1]であるため、+zは変化していません。

結果的にポットは、z軸を中心に45度回転し、縦長にスケーリングされています。

では、どういう行列を掛けると回転して、どういうのを掛けるとスケーリングするのでしょうか?

z軸の周りをθ回転させる変換行列

(cosθsinθ0sinθcosθ0001)

x軸方向に2倍, yは3倍, zは4倍にスケーリングする行列

(200030004)

組み合わせると

(200030004)(cosθsinθ0sinθcosθ0001)(111)

https://ja.wikipedia.org/wiki/%E5%9B%9E%E8%BB%A2%E8%A1%8C%E5%88%97

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では次のように表すことができます。

(m11m12m13m21m22m22m31m32m33)=>(m11m12m130m21m22m220m31m32m3300001)

3×4や4×3の行列式は計算できませんが、4×4なら計算することができます。4行目にΔx, Δy, Δzと示すことによって平行移動を示すことができるのです。

以下は3次元の単位に、4行目、4列目を足して、平行移動を表現した式です。

(100Δx010Δy001Δz0001)(xyz1)=(x+Δxy+Δyz+Δz1)

x, y, zだけに着目するとそれぞれΔ分平行移動することができています。このように4Dで考えることで平行移動を表現することができるのです。

アフィン変換

スクリーンショット 2018-10-03 16.32.43.png

先に述べた4次元の行列を使って座標変換することをアフィン変換と呼びます。

赤い部分が線形変換で、青いところが平行移動です。 
アフィン変換を使うと線形変換と平行移動が1つの行列で表現できるわけです。

SCNNodeのtransform

拡大縮小、拡大、せん断(投影)、平行移動は4*4の行列で表現できることがわかったと思います。これをSCNNodeのtransformに適用することで、ARKit(SceneKit)でもアフィン変換をすることができます。

あるnodeのtransformをx軸を中心に90度回転させる

transformを使って計算する際も自前でゴリゴリ掛け算足し算をして行列を算出するのはクールではありません。ここはSCNMatrix4を使うのがいいと思います。

例えばあるnodeのtransform(右側の1~16で示される行列)をx軸を中心にθ度回転させる行列は以下です。

(10000cosθsinθ00sinθcosθ00001)(12345678910111213141516)

https://ja.wikipedia.org/wiki/%E5%9B%9E%E8%BB%A2%E8%A1%8C%E5%88%97

これを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)

行列の積はかける順番によってアウトプットが変わるので注意が必要です。

参考: SceneKitは行ベクトル?列ベクトル?

例: タップした位置の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行目

(ΔxΔyΔz1)

のxyzを取り出したのが

let float: float3 = float3(column3.x, column3.y, column3.z)

のコードです。ΔxΔyΔzは平行移動を示す値ですよね。

原点にこの平行移動の座標変換をかけると求める3D座標のを求めることができるのです。

まとめ

以上、ARKitで使う座標系の紹介、座標変換メソッド、そして数学的な表現を紹介しました。両方理解して 自由自在に物体を操っていきたいですね。

参考文献

サンプルコード

https://github.com/kboy-silvergym/3DMath-For-ARKit

iOSDCでの発表スライド

https://speakerdeck.com/fujikawakei/arkitfalsetamefalse3dsuan-shu

こちらもチェック

k-boy
YouTuberをやっています。基本はiOSを5年やってますが今の仕事はFlutterだけです。GithubではARKitのサンプルコードを公開したり、UdemyでARKitの動画講座も作ってます。
https://www.youtube.com/channel/UCevPBAKPBSgJIHU-vSeltlw
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント
この記事にコメントはありません。
あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした