ARKitことはじめ:ドラッグ操作の意外な落とし穴と試行錯誤

この記事は「Goodpatch Advent Calendar 2021」の 24 日目の記事です。

今回の担当は iOS エンジニアの田中です。

弊社では社内有志による AR アプリ開発への挑戦が、密かに始まっています。(有志メンバーには Oculus Quest 2 が1台ずつ提供されるという特典も😀)

そんな AR 開発初期に遭遇した落とし穴と試行錯誤、地味ながらも興味深い発見を共有します。

ノードをドラッグして動かすだけ、それなのに...

実装していた機能は以下のようなものです。

  1. 水平平面を検出する
  2. 検出平面をタップすると、その平面にノード(本記事ではキューブ)を追加する
  3. 画面をドラッグ操作(Pan Gesture)することで、そのノードを平面上で平行移動する

AR 上でジェスチャーといっても、実は UIKit でお馴染みの UIGestureRecognizer を、ARSCNView に追加するだけなので、なんら難しくありません。

しかし実際に動かしてみると、想定外の問題が。ドラッグジェスチャーの指の方向にノードの移動方向が追従しない、という現象に見舞われました。

本来であれば、たとえば画面下方向にドラッグすれば、それに従ってノードは手前に動いて欲しいし、左方向にドラッグすれば、ノードは左に動いて欲しい。

しかし実際はそうはならず、指を動かせばノードはとりあえず動くものの、移動方向はデタラメ。当初はその再現性や法則すらも見当つきませんでした。

f:id:ttgp:20211224010853g:plain
青の指がドラッグ位置を示す。指に赤のBoxが追従せず、明後日の方向に動いてしまう...

当初の実装

var lastLocationOfPan: CGPoint? //ドラッグ操作の最終座標(スクリーン座標)

// ジェスチャー操作の設定
func setUpPanGesture() {
    let pan = UIPanGestureRecognizer(target: self, action: #selector(didPan(sender:)))
    pan.minimumNumberOfTouches = 1
    pan.maximumNumberOfTouches = 1
    sceneView.addGestureRecognizer(pan)
}

// ドラッグ操作時の処理
@objc func didPan(sender: UIPanGestureRecognizer) {
    let location = sender.location(in: sceneView)
    switch sender.state {
    case .began:
        lastLocationOfPan = location

    case .changed:
        guard let lastLocation = lastLocationOfPan else { break }
        let deltaX = Float(location.x - lastLocation.x)
        let deltaY = Float(location.y - lastLocation.y)
        translateNode(deltaOnScreenX: deltaX, y: deltaY, coef: CGFloat = 0.001)
        lastLocationOfPan = location

    case .ended, .cancelled:
        lastLocationOfPan = nil

    default:
        break
    }
}

///   - dx: ドラッグ操作のX方向の移動差分(スクリーン座標)
///   - dy: ドラッグ操作のY方向の移動差分(スクリーン座標)
///   - coef: スクリーン座標からAR空間座標に変換する際の係数。スクリーン座標を1/1000した値が直感に近い
func translateNode(_ node: SCNNode, deltaOnScreenX dx: Float, dy: Float, coef: Float = 0.001) {
    guard let node = presentingContentNode else { return }
    let current = node.position
    node.position = .init(current.x + dx * coef,
                          current.y,
                          current.z + dy * coef)
}

ドラッグ操作で生じたタッチ座標の変動を、translateNode(_:deltaOnScreenX:dy:coef:) によりノードの座標に足し合わせています。特に違和感はなさそうですが...

原因は意外かつ単純

色々と触り込んでいくうちに気がついたのは、端末を AR セッション初期化時のまま固定すると、この現象が発生しないというもの。いっぽうで、その状態から端末を右左に振るに従って、ノードが指に追従しなくなる...

どうやら、ノードの移動方向を決定するには、画面上で指がどの方向に動いたかを考慮するだけでは不十分で、AR 空間を形成する世界座標系においてカメラがどの方向を向いているか、つまり端末の回転成分に対する考慮も必要であったらしい、気がついてみれば当たり前のことでした。

補足すると、ARKit における AR 空間の世界座標系の XYZ 軸は、ARConfiguration.worldAlignment.gravity のばあい、重力方向に Y 軸が決まり、右手座標系にもとづいて、画面に対し奥行き方向が Z 軸(手前が正)、画面向かって右方向が X 軸となります。

端末の回転成分を考慮する

端末の回転は、世界座標系におけるXYZ各軸周りの回転成分を組み合わせて定義でき、それらは X/Y/Z に対応しそれぞれ Roll/Pitch/Yaw と呼びます。(絵が下手で恐縮です><)

そして今回のばあい、ノードの移動は水平平面上でのみ発生するため、考慮すべき端末の回転成分は、水平平面と直行する Y 軸つまり Pitch のみとなります。

端末のリアルタイムな回転成分は ARSCNView.session.currentFrame?.camera.eulerAngles として取得でき、Pitch に相当するのは、eulerAngles.y

これを考慮して translateNode(_:deltaOnScreenX:dy:coef:) の実装を修正すると、以下のようになります。 ドラッグ操作の移動距離を、(ドラッグ操作角度 deltaRadian + Pitch角度 pitchRadian ) により求められる合計角度 totalRadius で、Y 軸周りに回転させたものを、ノードの移動ベクトルとしています。

///   - dx: ドラッグ操作のX方向の移動差分(スクリーン座標)
///   - dy: ドラッグ操作のY方向の移動差分(スクリーン座標)
///   - coef: スクリーン座標からAR空間座標に変換する際の係数。スクリーン座標を1/1000した値が直感に近い
func translateNode(_ node: SCNNode, deltaOnScreenX dx: Float, dy: Float, coef: Float = 0.001) {
    guard let node = presentingContentNode else { return }
    guard let cameraAngles = sceneView.session.currentFrame?.camera.eulerAngles else { return }
    let distance = sqrt(dx * dx + dy * dy)
    guard distance > 0 else { return } //deltaCosの計算で分母が0になってはいけない
    let dxIsPositive = dx > 0
    let deltaCos: Float = dy / distance //ドラッグ操作の移動角度のcos値(真下方向を0度とする)
    let deltaRadian = acos(deltaCos) * (dxIsPositive ? 1 : -1) //ドラッグ操作の移動角度(真下方向を0度とし右回り)
    let pitchRadian: Float = cameraAngles.y //カメラの初期位置からの回転角度
    let totalRadian: Float = deltaRadian + pitchRadian
    let current = node.position
    node.position = .init(current.x + distance * sin(totalRadian) * coef,
                          current.y,
                          current.z + distance * cos(totalRadian) * coef)
}

無事、問題解決できました。

f:id:ttgp:20211224010913g:plain
カメラの方向が変わっても指にBoxが追従しています😀

より本質的に解決するならば

ARSCNViewSCNView には UIView と同様に SCNView.hitTest(_:options:) があります。さらにノード間の座標変換についても UIView で見覚えがあるような SCNNode.convertPosition(_:to:) が存在します。

これらによって、

  1. ドラッグ操作のスクリーン座標を検出平面上のにローカル変換し、
    1. で得た座標を、操作対象のノード(キューブ)が属する親ノードのローカル座標に変換する

ことで、この問題はより本質的な解決に導くことができます。 ただし、前述した方法に比べてパフォーマンスは落ちるため、適材適所で使い分けるか、更なる改善が必要。

@objc func didPan(sender: UIPanGestureRecognizer) {
    let location = sender.location(in: sceneView)
    translateContentNodeTo(point: location)
}

///   - point: ドラッグ操作のスクリーン座標
func translateContentNodeTo(point: CGPoint) {
    guard let node = contentNode else { return }
    guard let hitResult = self.hitTest(point, options: nil).first (where: { result in
        // hitTest の結果が検出平面のノードであるならば
        return result.node == detectedPlaneNode
    }) else { return }

    // 検出平面上のローカル座標から、キューブの親ノードローカル座標に変換
    let position = hitResult.node.convertPosition(hitResult.localCoordinates, to: node.parent!)
    node.position = position
}

オマケ( Swift Playgrounds for iPad で動かしてみた)

せっかくなので、先日発表された Swift Playgrounds 4.0 でも動かしてみました。 これまではデバッグのために逐一実機ビルドしなければならなかったもの点が、iPad では App Preview で即座に確認できるのが嬉しいポイントです。

IMG_0024.PNG

ちなみに、ARKit は本記事投稿時点では SwiftUI に対応していないため、SwiftUI で画面実装するには UIViewRepresentable でラップして使う必要があります。詳しくは以下のサンプル実装を参考ください。

github.com

おわりに

アプリエンジニアとしてこれまで扱ってきた2次元を飛び出し、3次元の世界に挑戦すると...かくも単純な落とし穴にハマり、分からないなりにも試行錯誤し解決を得る過程は、歯がゆくも新鮮に感じています。

"Apple Glass" の噂も濃厚になりつつある今日この頃、アプリ開発に熟達した皆さんもそうでない方も、初心に帰ったつもりで挑戦してみてはいかがでしょうか 😄


Goodpatchではデザイン好きなエンジニアの仲間を募集しています。 少しでもご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう!