👹

RealityKitでスクリーン座標からワールド座標を取得する

2023/02/10に公開

前置き

スクリーン座標からワールド座標を取得したい場面はたくさんあると思います。
今回、RealityKitで実装したのでどなたかの参考になれば幸いです。

本記事のきっかけ

下記のwwdcの動画で、Visionのハンドトラッキングを利用して人差し指の先端からエフェクトを出すサンプルアプリを紹介しています。どうやらトラッキングした人差し指からスクリーン座標を取り出し、それをワールド座標に変換しているようです。
https://developer.apple.com/videos/play/wwdc2021/10039

これをRealityKitで実装しようとしたところUnityやSceneKitと比べ、とても大変だったので記事を書こうと思いました。

本記事の対象読者

UnityやSwiftのARKit,SceneKit,RealityKitを触ったことがある方。
座標変換,行列,三角関数が少しでもわかる方

Unity

まず、Unityでは下記コードで実装できます。
あらかじめ、Z値を決め打ちしておき(下記コードではカメラから2m奥)ScreenToWorldPointメソッドに入れるだけです。とてもシンプルです。

Unity.cs
// タッチポジションを取得した前提
var screenPos = new Vector3(touchPos.x, touchPos.y, 2f);  
// ワールド座標に変換 (unproject)
var worldPos = Camera.main.ScreenToWorldPoint(screenPos); 

https://docs.unity3d.com/ja/2019.4/ScriptReference/Camera.WorldToScreenPoint.html

SceneKit

続いてSceneKitを見てみます。SceneKitでは下記のコードです。
SCNVector3Makeを利用してSCNVector3を作成し、unprojectPointメソッドに入れてあげればワールド座標を取得することができます。
注意点としてはUnityと違って Z値は0~1にする必要があります。これはARCameraの視錐台におけるクリッピング平面上を深度値として扱うからです。(なのでnearのクリッピング平面を取りたい場合は0。farのクリッピング平面を取りたい場合は1を入れます。)

SceneKit.swift
// タッチポジションを取得した前提。割と奥の方のクリッピング平面を取ってくる。
let screenPos = SCNVector3Make(Float(touchPos.x), Float(touchPos.y), 0.9)
// ワールド座標に変換 (unproject)
let worldPos = sceneView.unprojectPoint(screenPos)

さすが歴史の長いSceneKit。Unityに負けず劣らず、とても簡単でした。
すでにSceneKitはAppleから見放されており、新規で機能が追加されることはおそらくなさそうですが、依然として豊富なAPIは便利だなと思いました。

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

RealityKit

やっとRealityKitにたどり着きました。RealityKitはSceneKitに変わって登場した比較的新しいAppleの3Dレンダリングのフレームワークです。

RealityKitでは下記のコードになります。

Realitykit.swift
func cgPointToWorldspace(_ cgPoint: CGPoint,offsetFromCamera: SIMD3<Float> ) -> SIMD3<Float> {
            // ① CameraTransformからカメラ前方に平行移動する行列を作成
        let camForwardPoint = cameraTransform.matrix.position +
        (cameraTransform.matrix.forwardVector * offsetFromCamera.z)
            var col0 = SIMD4<Float>(1, 0, 0, 0)
            var col1 = SIMD4<Float>(0, 1, 0, 0)
            var col2 = SIMD4<Float>(0, 0, 1, 0)
            var col3 = SIMD4<Float>(camForwardPoint.x, camForwardPoint.y, camForwardPoint.z, 1)
            let planePosMatrix = float4x4(col0, col1, col2, col3)
            // ② 回転行列の作成
            let camRotMatrix = float4x4(cameraTransform.rotation)
            // ③ x軸周りで90度回転させる行列の作成
            col0 = SIMD4<Float>(1, 0, 0, 0)
            col1 = SIMD4<Float>(0, 0, 1, 0)
            col2 = SIMD4<Float>(0, -1, 0, 0)
            col3 = SIMD4<Float>(0, 0, 0, 1)
            let axisFlipMatrix = float4x4(col0, col1, col2, col3)
	    // 最終的な行列を作成
            let rotatedPlaneAtPoint = planePosMatrix * camRotMatrix * axisFlipMatrix
	    // 作成したクリッピング平面を元にunproject
            let projectionAtRotatedPlane = unproject(cgPoint, ontoPlane: rotatedPlaneAtPoint) ?? camForwardPoint
            let verticalOffset = cameraTransform.matrix.upVector * offsetFromCamera.y
            let horizontalOffset = cameraTransform.matrix.rightVector * offsetFromCamera.x
            return projectionAtRotatedPlane + verticalOffset + horizontalOffset
        }
	
extension float4x4 {

    var upVector: SIMD3<Float> {
        return SIMD3<Float>(columns.1.x, columns.1.y, columns.1.z)
    }

    var rightVector: SIMD3<Float> {
        return SIMD3<Float>(columns.0.x, columns.0.y, columns.0.z)
    }

    var forwardVector: SIMD3<Float> {
        return SIMD3<Float>(-columns.2.x, -columns.2.y, -columns.2.z)
    }

    var position: SIMD3<Float> {
        return SIMD3<Float>(columns.3.x, columns.3.y, columns.3.z)
    }
}

圧倒的なめんどくささです👍

自分の知る限りではUnityやSceneKitのように数行でカメラからの距離を決定し、ワールド座標に変換する方法はありませんでした。(本当はあるのかも。。)
一つ一つ解説していくと長くなってしまうので重要な部分を抜粋します。

  • メソッドの引数
    cgPointToWorldspaceの第一引数にスクリーン座標を入れます。そして第二引数にカメラからのオフセット、つまりZ値を決めています。

  • コメント①の処理
    ARViewのcameraTransformからカメラ行列を取り出します。
    その後、カメラ行列からカメラの位置にオフセットのz値をスカラーとして前方ベクトルに乗算してあげ、それを元に移動行列を作成します。

  • コメント②の処理
    カメラのクオータニオンを行列に変換しただけです。

  • コメント③の処理
    x軸周りに90度回転させます。これが必要な理由ですが、ドキュメントの説明を読むと理解できます。
    https://developer.apple.com/documentation/realitykit/arview/unproject(_:ontoplane:)

The positive Y axis is taken as the normal of the plane.
// 正のY軸は平面の法線とみなされます。

なぜこのようにしたかは、正直僕の知識不足もあり分かりません。
とはいえ、カメラの前方に平行移動したままだと平面の法線はカメラ方向に向きっぱなしです。このままだと使い物にならないので90度回転させる必要があります。

最後に、xとy方向にもオフセットがあれば座標に加算させて終了です。
お疲れ様でした。

おまけ

ここからは余談です。
前置きでも触れた通り、元々ハンドトラッキングから得たスクリーン座標をワールド座標に変換して扱いたかったのがモチベーションです。
それを実現するにはもう少し手を加える必要があります。

ハンドトラッキングをするとVNRecognizedPointはVNPointを継承しているのでlocationからCGPointを取得できます。
https://developer.apple.com/documentation/vision/vnrecognizedpoint
https://developer.apple.com/documentation/vision/vnpoint

取得できるCGPointには注意点があります。
Portrait(つまり通常の縦方向画面)を基準とした座標になってないということです。本記事冒頭の動画12分~13分あたりで言及があるようにLandScapeLeftが基準となります。そのため、Portraitでこの数値を使うと確実に挙動がおかしくなります。
回避策として縦画面で使用する場合はxとyを逆にする必要があります。

guard var normalizedLocation = fingerStatus.indexTip?.location else { return }
normalizedLocation = CGPoint(x: normalizedLocation.y, y: normalizedLocation.x)

加えて、この数値は正規化されているので0~1の数値を返します。当然ですがこれをこのままスクリーン座標としては使えません。
スクリーン座標に変換する必要があります。

let indexTipScreenLocation = VNImagePointForNormalizedPoint(normalizedLocation, Int(screenSize.width),Int(screenSize.height))

このVNImagePointForNormalは指定した画像サイズにおけるコーディネーター座標を返してくれます。
https://developer.apple.com/documentation/vision/2908997-vnimagepointfornormalizedpoint

今回はiPhoneのスクリーンサイズを引数にしたので、実質、下記のコードと同じことをしています。
確認しても同じ数値が返されていました。

let indexTipScreenLocation = normalizedLocation = CGPoint(x: normalizedLocation.x * screenSize.width, y: normalizedLocation.y * screenSize.height)

あとは、先ほど解説したcgPointToWorldspaceメソッドに入れてワールド座標に変換してあげれば終了です。これで人差し指からエフェクトを出せそうです。

稚拙な記事を最後まで読んでいただきありがとうございました。

Discussion