web-dev-qa-db-ja.com

ARKIT:PanGestureを使用してオブジェクトを移動する(正しい方法)

オブジェクトを画面上でドラッグして移動する方法に関するStackOverflowの回答をたくさん読んでいます。 .featurePointsに対するヒットテストを使用するものもあれば、ジェスチャ変換を使用するものや、オブジェクトのlastPositionを追跡するものもあります。しかし正直なところ、誰もが期待するように機能するものはありません。

.featurePointsに対するヒットテストでは、指をドラッグしたときに常にfeaturepointにヒットするわけではないため、オブジェクトがすべての場所にジャンプします。なぜみんながこれを提案し続けるのか分かりません。

このようなソリューションは機能します: SceneKitを使用してSRKNodeをARKitにドラッグ

しかし、オブジェクトは実際には指に追従しません。数歩踏むか、オブジェクトまたはカメラの角度を変更した瞬間に、オブジェクトを移動しようとすると、x、zがすべて反転します。それをするために。

Apple Demoと同じくらい良いオブジェクトを移動したいのですが、Appleのコードを見ると、非常に奇妙で複雑すぎて少し理解できません。移動するテクニックオブジェクトはとても美しく、誰もがオンラインで提案するものにさえ近づいていません https://developer.Apple.com/documentation/arkit/handling_3d_interaction_and_ui_controls_in_augmented_reality

それを行うためのより簡単な方法があるはずです。

12
omarojo

Claessonsの回答に自分のアイデアの一部を追加しました。ノードをドラッグすると、遅れが発生しました。ノードが指の動きに追従できないことがわかりました。

ノードをよりスムーズに移動させるために、現在移動中のノードを追跡する変数を追加し、位置をタッチの場所に設定しました。

_    var selectedNode: SCNNode?
_

また、_.categoryBitMask_値を設定して、編集(移動)するノードのカテゴリを指定します。デフォルトのビットマスク値は1です。

カテゴリビットマスクを設定する理由は、さまざまな種類のノードを区別し、選択するノード(移動など)を指定するためです。

_    enum CategoryBitMask: Int {
        case categoryToSelect = 2        // 010
        case otherCategoryToSelect = 4   // 100
        // you can add more bit masks below . . .
    }
_

次に、viewDidLoad()UILongPressGestureRecognizerを追加しました。

_        let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressed))
        self.sceneView.addGestureRecognizer(longPressRecognizer)
_

以下は、ノードのドラッグを開始する長押しを検出するために使用したUILongPressGestureRecognizerです。

まず、locationからタッチrecognizerViewを取得します

_    @objc func longPressed(recognizer: UILongPressGestureRecognizer) {

       guard let recognizerView = recognizer.view as? ARSCNView else { return }
       let touch = recognizer.location(in: recognizerView)

_

次のコードは、長押しが検出されると1回実行されます。

ここでは、hitTestを実行して、タッチされたノードを選択します。ここでは、_.categoryBitMask_オプションを指定して、次のカテゴリのノードのみを選択することに注意してください:_CategoryBitMask.categoryToSelect_

_       // Runs once when long press is detected.
       if recognizer.state == .began {
            // perform a hitTest
            let hitTestResult = self.sceneView.hitTest(touch, options: [SCNHitTestOption.categoryBitMask: CategoryBitMask.categoryToSelect])

            guard let hitNode = hitTestResult.first?.node else { return }

            // Set hitNode as selected
            self.selectedNode = hitNode
_

次のコードは、ユーザーが指を離すまで定期的に実行されます。ここで別のhitTestを実行して、ノードを移動させる平面を取得します。

_        // Runs periodically after .began
        } else if recognizer.state == .changed {
            // make sure a node has been selected from .began
            guard let hitNode = self.selectedNode else { return }

            // perform a hitTest to obtain the plane 
            let hitTestPlane = self.sceneView.hitTest(touch, types: .existingPlane)
            guard let hitPlane = hitTestPlane.first else { return }
            hitNode.position = SCNVector3(hitPlane.worldTransform.columns.3.x,
                                           hitNode.position.y,
                                           hitPlane.worldTransform.columns.3.z)
_

指が画面から離れたときは、ノードの選択を解除してください。

_        // Runs when finger is removed from screen. Only once.
        } else if recognizer.state == .ended || recognizer.state == .cancelled || recognizer.state == .failed{

            guard let hitNode = self.selectedNode else { return }

            // Undo selection
            self.selectedNode = nil
        }
    }
_
3
Susan Kim

ちょっと遅い答えですが、これを解決するのにもいくつか問題があったことを知っています。結局、ジェスチャレコグナイザが呼び出されるたびに2つの個別のヒットテストを実行することで、その方法を見つけました。

まず、3dオブジェクトのヒットテストを実行して、現在オブジェクトを押しているかどうかを検出します(オプションを指定しないと、featurePoints、平面などを押した場合の結果が得られます)。これを行うには、SCNHitTestOption.categoryBitMaskvalueを使用します。 ヒットテストが機能するためには、事前に正しい.categoryBitMask値をオブジェクトノードとそのすべての子ノードに割り当てる必要があることに注意してください。私はそのために使用できる列挙型を宣言します:

enum BodyType : Int {
    case ObjectModel = 2;
}

投稿した.categoryBitMask値に関する質問への回答で明らかになったように こちら 、ビットマスクに割り当てる値を検討することが重要です。

以下は、現在押しているオブジェクトを選択するためにUILongPressGestureRecognizerと組み合わせて使用​​するコードです。

guard let recognizerView = recognizer.view as? ARSCNView else { return }

let touch = recognizer.location(in: recognizerView)

let hitTestResult = self.sceneView.hitTest(touch, options: [SCNHitTestOption.categoryBitMask: BodyType.ObjectModel.rawValue])
guard let modelNodeHit = hitTestResult.first?.node else { return }

その後、自分が押している飛行機を見つけるために、2回目のヒットテストを実行します。タイプ.existingPlaneUsingExtentを使用して、オブジェクトを平面のエッジよりも遠くに移動したくない場合、または.existingPlaneを使用して、検出された平面サーフェスに沿ってオブジェクトを無期限に移動できます。

 var planeHit : ARHitTestResult!

 if recognizer.state == .changed {

     let hitTestPlane = self.sceneView.hitTest(touch, types: .existingPlane)
     guard hitTestPlane.first != nil else { return }
     planeHit = hitTestPlane.first!
     modelNodeHit.position = SCNVector3(planeHit.worldTransform.columns.3.x,modelNodeHit.position.y,planeHit.worldTransform.columns.3.z)

 }else if recognizer.state == .ended || recognizer.state == .cancelled || recognizer.state == .failed{

     modelNodeHit.position = SCNVector3(planeHit.worldTransform.columns.3.x,modelNodeHit.position.y,planeHit.worldTransform.columns.3.z)

 }

GitHubリポジトリ を作成しましたが、ARAnchorsを試しているときに試してみました。私の方法を実際に確認したい場合は、チェックアウトできますが、他の人が使用するつもりで作成したわけではないため、未完成です。また、開発ブランチは、childNodeがより多いオブジェクトの一部の機能をサポートする必要があります。

編集:==================================

明確にするために、通常のジオメトリではなく.scnオブジェクトを使用する場合は、オブジェクトを作成するときに、オブジェクトのすべての子ノードを反復処理して、次のように各子のビットマスクを設定する必要があります。

 let objectModelScene = SCNScene(named:
        "art.scnassets/object/object.scn")!
 let objectNode =  objectModelScene.rootNode.childNode(
        withName: "theNameOfTheParentNodeOfTheObject", recursively: true)
 objectNode.categoryBitMask = BodyType.ObjectModel.rawValue
 objectNode.enumerateChildNodes { (node, _) in
        node.categoryBitMask = BodyType.ObjectModel.rawValue
    }

次に、hitTestResultを取得した後、ジェスチャレコグナイザで

let hitTestResult = self.sceneView.hitTest(touch, options: [SCNHitTestOption.categoryBitMask: BodyType.ObjectModel.rawValue])

親ノードを見つける必要があります。そうしないと、押したばかりの個々の子ノードを移動する可能性があるためです。これを行うには、先ほど見つけたノードのノードツリーを再帰的に検索します。

guard let objectNode = getParentNodeOf(hitTestResult.first?.node) else { return }

次のようにgetParentNodeメソッドを宣言します

func getParentNodeOf(_ nodeFound: SCNNode?) -> SCNNode? { 
    if let node = nodeFound {
        if node.name == "theNameOfTheParentNodeOfTheObject" {
            return node
        } else if let parent = node.parent {
            return getParentNodeOf(parent)
        }
    }
    return nil
}

.scnオブジェクトの親ノードになるため、objectNodeで任意の操作を自由に実行できます。つまり、それに適用されるすべての変換が子ノードにも適用されます。

3
A. Claesson

短い答え:Appleデモプロジェクトのように、この素​​晴らしい流暢なドラッグ効果を得るには、Appleデモプロジェクト(Handling 3Dインタラクション。反対側では、コードを初めて見たときにコードが混乱する可能性があることに同意します。床面に配置されたオブジェクトの正しい動きを計算するのは決して簡単ではありません-常にすべての場所や視野角からです。これは複雑なコード構成であり、この優れたドラッグ効果を実行しています。Appleこれを達成するために素晴らしい仕事をしましたが、私たちにとってそれをあまり簡単にしませんでした。

完全な回答:必要な結果を得るためにARインタラクションテンプレートを削除すると悪夢になりますが、十分な時間を費やせばうまくいくはずです。ゼロから始めたい場合は、基本的に共通のSwift ARKit/SceneKit Xcodeテンプレート(宇宙船を含むもの))の使用を開始します。

また、アップルのARインタラクションテンプレートプロジェクト全体が必要です。 (リンクはSO質問に含まれています)最後に、VirtualObjectと呼ばれるものをドラッグできるはずです。これは実際には特別なSCNNodeです。さらに、Nice Focus Squareがあります。これは、最初にオブジェクトを配置したり、床や壁を追加したりするなど、どんな目的にも役立ちます(ドラッグ効果とフォーカススクエアの使用法の一部のコードは、一種のマージまたはリンクされているため、フォーカススクエアなしで実行すると、より複雑)

はじめに:以下のファイルをAR Interactionテンプレートから空のプロジェクトにコピーします。

  • Utilities.Swift(通常、このファイルにはExtensions.Swiftという名前を付けます。必要な基本的な拡張機能がいくつか含まれています)
  • FocusSquare.Swift
  • FocusSquareSegment.Swift
  • ThresholdPanGesture.Swift
  • VirtualObject.Swift
  • VirtualObjectLoader.Swift
  • VirtualObjectARView.Swift

次のように、UIGestureRecognizerDelegateをViewControllerクラス定義に追加します。

class ViewController: UIViewController, ARSCNViewDelegate, UIGestureRecognizerDelegate {

次のコードを、viewDidLoadの直前の定義セクションのViewController.Swiftに追加します。

// MARK: for the Focus Square
// SUPER IMPORTANT: the screenCenter must be defined this way
var focusSquare = FocusSquare()
var screenCenter: CGPoint {
    let bounds = sceneView.bounds
    return CGPoint(x: bounds.midX, y: bounds.midY)
}
var isFocusSquareEnabled : Bool = true


// *** FOR OBJECT DRAGGING PAN GESTURE - Apple ***
/// The tracked screen position used to update the `trackedObject`'s position in `updateObjectToCurrentTrackingPosition()`.
private var currentTrackingPosition: CGPoint?

/**
 The object that has been most recently intereacted with.
 The `selectedObject` can be moved at any time with the tap gesture.
 */
var selectedObject: VirtualObject?

/// The object that is tracked for use by the pan and rotation gestures.
private var trackedObject: VirtualObject? {
    didSet {
        guard trackedObject != nil else { return }
        selectedObject = trackedObject
    }
}

/// Developer setting to translate assuming the detected plane extends infinitely.
let translateAssumingInfinitePlane = true
// *** FOR OBJECT DRAGGING PAN GESTURE - Apple ***

ViewDidLoadで、シーンを設定する前に次のコードを追加します。

// *** FOR OBJECT DRAGGING PAN GESTURE - Apple ***
let panGesture = ThresholdPanGesture(target: self, action: #selector(didPan(_:)))
panGesture.delegate = self

// Add gestures to the `sceneView`.
sceneView.addGestureRecognizer(panGesture)
// *** FOR OBJECT DRAGGING PAN GESTURE - Apple ***

ViewController.Swiftの最後に次のコードを追加します。

// MARK: - Pan Gesture Block
// *** FOR OBJECT DRAGGING PAN GESTURE - Apple ***
@objc
func didPan(_ gesture: ThresholdPanGesture) {
    switch gesture.state {
    case .began:
        // Check for interaction with a new object.
        if let object = objectInteracting(with: gesture, in: sceneView) {
            trackedObject = object // as? VirtualObject
        }

    case .changed where gesture.isThresholdExceeded:
        guard let object = trackedObject else { return }
        let translation = gesture.translation(in: sceneView)

        let currentPosition = currentTrackingPosition ?? CGPoint(sceneView.projectPoint(object.position))

        // The `currentTrackingPosition` is used to update the `selectedObject` in `updateObjectToCurrentTrackingPosition()`.
        currentTrackingPosition = CGPoint(x: currentPosition.x + translation.x, y: currentPosition.y + translation.y)

        gesture.setTranslation(.zero, in: sceneView)

    case .changed:
        // Ignore changes to the pan gesture until the threshold for displacment has been exceeded.
        break

    case .ended:
        // Update the object's anchor when the gesture ended.
        guard let existingTrackedObject = trackedObject else { break }
        addOrUpdateAnchor(for: existingTrackedObject)
        fallthrough

    default:
        // Clear the current position tracking.
        currentTrackingPosition = nil
        trackedObject = nil
    }
}

// - MARK: Object anchors
/// - Tag: AddOrUpdateAnchor
func addOrUpdateAnchor(for object: VirtualObject) {
    // If the anchor is not nil, remove it from the session.
    if let anchor = object.anchor {
        sceneView.session.remove(anchor: anchor)
    }

    // Create a new anchor with the object's current transform and add it to the session
    let newAnchor = ARAnchor(transform: object.simdWorldTransform)
    object.anchor = newAnchor
    sceneView.session.add(anchor: newAnchor)
}


private func objectInteracting(with gesture: UIGestureRecognizer, in view: ARSCNView) -> VirtualObject? {
    for index in 0..<gesture.numberOfTouches {
        let touchLocation = gesture.location(ofTouch: index, in: view)

        // Look for an object directly under the `touchLocation`.
        if let object = virtualObject(at: touchLocation) {
            return object
        }
    }

    // As a last resort look for an object under the center of the touches.
    // return virtualObject(at: gesture.center(in: view))
    return virtualObject(at: (gesture.view?.center)!)
}


/// Hit tests against the `sceneView` to find an object at the provided point.
func virtualObject(at point: CGPoint) -> VirtualObject? {

    // let hitTestOptions: [SCNHitTestOption: Any] = [.boundingBoxOnly: true]
    let hitTestResults = sceneView.hitTest(point, options: [SCNHitTestOption.categoryBitMask: 0b00000010, SCNHitTestOption.searchMode: SCNHitTestSearchMode.any.rawValue as NSNumber])
    // let hitTestOptions: [SCNHitTestOption: Any] = [.boundingBoxOnly: true]
    // let hitTestResults = sceneView.hitTest(point, options: hitTestOptions)

    return hitTestResults.lazy.compactMap { result in
        return VirtualObject.existingObjectContainingNode(result.node)
        }.first
}

/**
 If a drag gesture is in progress, update the tracked object's position by
 converting the 2D touch location on screen (`currentTrackingPosition`) to
 3D world space.
 This method is called per frame (via `SCNSceneRendererDelegate` callbacks),
 allowing drag gestures to move virtual objects regardless of whether one
 drags a finger across the screen or moves the device through space.
 - Tag: updateObjectToCurrentTrackingPosition
 */
@objc
func updateObjectToCurrentTrackingPosition() {
    guard let object = trackedObject, let position = currentTrackingPosition else { return }
    translate(object, basedOn: position, infinitePlane: translateAssumingInfinitePlane, allowAnimation: true)
}

/// - Tag: DragVirtualObject
func translate(_ object: VirtualObject, basedOn screenPos: CGPoint, infinitePlane: Bool, allowAnimation: Bool) {
    guard let cameraTransform = sceneView.session.currentFrame?.camera.transform,
        let result = smartHitTest(screenPos,
                                  infinitePlane: infinitePlane,
                                  objectPosition: object.simdWorldPosition,
                                  allowedAlignments: [ARPlaneAnchor.Alignment.horizontal]) else { return }

    let planeAlignment: ARPlaneAnchor.Alignment
    if let planeAnchor = result.anchor as? ARPlaneAnchor {
        planeAlignment = planeAnchor.alignment
    } else if result.type == .estimatedHorizontalPlane {
        planeAlignment = .horizontal
    } else if result.type == .estimatedVerticalPlane {
        planeAlignment = .vertical
    } else {
        return
    }

    /*
     Plane hit test results are generally smooth. If we did *not* hit a plane,
     smooth the movement to prevent large jumps.
     */
    let transform = result.worldTransform
    let isOnPlane = result.anchor is ARPlaneAnchor
    object.setTransform(transform,
                        relativeTo: cameraTransform,
                        smoothMovement: !isOnPlane,
                        alignment: planeAlignment,
                        allowAnimation: allowAnimation)
}
// *** FOR OBJECT DRAGGING PAN GESTURE - Apple ***

Focus Squareコードを追加する

// MARK: - Focus Square (code by Apple, some by me)
func updateFocusSquare(isObjectVisible: Bool) {
    if isObjectVisible {
        focusSquare.hide()
    } else {
        focusSquare.unhide()
    }

    // Perform hit testing only when ARKit tracking is in a good state.
    if let camera = sceneView.session.currentFrame?.camera, case .normal = camera.trackingState,
        let result = smartHitTest(screenCenter) {
        DispatchQueue.main.async {
            self.sceneView.scene.rootNode.addChildNode(self.focusSquare)
            self.focusSquare.state = .detecting(hitTestResult: result, camera: camera)
        }
    } else {
        DispatchQueue.main.async {
            self.focusSquare.state = .initializing
            self.sceneView.pointOfView?.addChildNode(self.focusSquare)
        }
    }
}

そして、いくつかの制御機能を追加します。

func hideFocusSquare()  { DispatchQueue.main.async { self.updateFocusSquare(isObjectVisible: true) } }  // to hide the focus square
func showFocusSquare()  { DispatchQueue.main.async { self.updateFocusSquare(isObjectVisible: false) } } // to show the focus square

VirtualObjectARView.SwiftからCOPY!関数smartHitTest全体をViewController.Swiftに(2つ存在するため)

func smartHitTest(_ point: CGPoint,
                  infinitePlane: Bool = false,
                  objectPosition: float3? = nil,
                  allowedAlignments: [ARPlaneAnchor.Alignment] = [.horizontal, .vertical]) -> ARHitTestResult? {

    // Perform the hit test.
    let results = sceneView.hitTest(point, types: [.existingPlaneUsingGeometry, .estimatedVerticalPlane, .estimatedHorizontalPlane])

    // 1. Check for a result on an existing plane using geometry.
    if let existingPlaneUsingGeometryResult = results.first(where: { $0.type == .existingPlaneUsingGeometry }),
        let planeAnchor = existingPlaneUsingGeometryResult.anchor as? ARPlaneAnchor, allowedAlignments.contains(planeAnchor.alignment) {
        return existingPlaneUsingGeometryResult
    }

    if infinitePlane {

        // 2. Check for a result on an existing plane, assuming its dimensions are infinite.
        //    Loop through all hits against infinite existing planes and either return the
        //    nearest one (vertical planes) or return the nearest one which is within 5 cm
        //    of the object's position.
        let infinitePlaneResults = sceneView.hitTest(point, types: .existingPlane)

        for infinitePlaneResult in infinitePlaneResults {
            if let planeAnchor = infinitePlaneResult.anchor as? ARPlaneAnchor, allowedAlignments.contains(planeAnchor.alignment) {
                if planeAnchor.alignment == .vertical {
                    // Return the first vertical plane hit test result.
                    return infinitePlaneResult
                } else {
                    // For horizontal planes we only want to return a hit test result
                    // if it is close to the current object's position.
                    if let objectY = objectPosition?.y {
                        let planeY = infinitePlaneResult.worldTransform.translation.y
                        if objectY > planeY - 0.05 && objectY < planeY + 0.05 {
                            return infinitePlaneResult
                        }
                    } else {
                        return infinitePlaneResult
                    }
                }
            }
        }
    }

    // 3. As a final fallback, check for a result on estimated planes.
    let vResult = results.first(where: { $0.type == .estimatedVerticalPlane })
    let hResult = results.first(where: { $0.type == .estimatedHorizontalPlane })
    switch (allowedAlignments.contains(.horizontal), allowedAlignments.contains(.vertical)) {
    case (true, false):
        return hResult
    case (false, true):
        // Allow fallback to horizontal because we assume that objects meant for vertical placement
        // (like a picture) can always be placed on a horizontal surface, too.
        return vResult ?? hResult
    case (true, true):
        if hResult != nil && vResult != nil {
            return hResult!.distance < vResult!.distance ? hResult! : vResult!
        } else {
            return hResult ?? vResult
        }
    default:
        return nil
    }
}

コピーされた関数に、hitTestに関するいくつかのエラーが表示される場合があります。次のように修正してください:

hitTest... // which gives an Error
sceneView.hitTest... // this should correct it

レンダラーのupdateAtTime関数を実装し、次の行を追加します。

func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
    // For the Focus Square
    if isFocusSquareEnabled { showFocusSquare() }

    self.updateObjectToCurrentTrackingPosition() // *** FOR OBJECT DRAGGING PAN GESTURE - Apple ***
}

最後に、Focus Squareのヘルパー関数をいくつか追加します

func hideFocusSquare() { DispatchQueue.main.async { self.updateFocusSquare(isObjectVisible: true) } }  // to hide the focus square
func showFocusSquare() { DispatchQueue.main.async { self.updateFocusSquare(isObjectVisible: false) } } // to show the focus square

この時点で、インポートされたファイルに数十のエラーと警告が表示される可能性があります。これは、Swift 5でこれを行うと発生し、Swift = 4ファイルXcodeにエラーを修正させるだけです(一部のコードステートメントの名前を変更することがすべてであり、Xcodeが最適です)。

VirtualObject.Swiftに移動して、次のコードブロックを検索します。

if smoothMovement {
    let hitTestResultDistance = simd_length(positionOffsetFromCamera)

    // Add the latest position and keep up to 10 recent distances to smooth with.
    recentVirtualObjectDistances.append(hitTestResultDistance)
    recentVirtualObjectDistances = Array(recentVirtualObjectDistances.suffix(10))

    let averageDistance = recentVirtualObjectDistances.average!
    let averagedDistancePosition = simd_normalize(positionOffsetFromCamera) * averageDistance
    simdPosition = cameraWorldPosition + averagedDistancePosition
} else {
    simdPosition = cameraWorldPosition + positionOffsetFromCamera
}

このブロック全体をコメント化するか、次の1行のコードで置き換えます。

simdPosition = cameraWorldPosition + positionOffsetFromCamera

この時点で、プロジェクトをコンパイルしてデバイス上で実行できるはずです。 Spaceshipと黄色のフォーカスの四角形が表示され、既に機能しているはずです。

最初に言ったように、ドラッグしてオブジェクトを配置できるようにするには、いわゆるVirtualObjectを作成するための関数が必要です。

このサンプル関数を使用してテストします(ビューコントローラーのどこかに追加します)。

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {

    if focusSquare.state != .initializing {
        let position = SCNVector3(focusSquare.lastPosition!)

        // *** FOR OBJECT DRAGGING PAN GESTURE - Apple ***
        let testObject = VirtualObject() // give it some name, when you dont have anything to load
        testObject.geometry = SCNCone(topRadius: 0.0, bottomRadius: 0.2, height: 0.5)
        testObject.geometry?.firstMaterial?.diffuse.contents = UIColor.red
        testObject.categoryBitMask = 0b00000010
        testObject.name = "test"
        testObject.castsShadow = true
        testObject.position = position

        sceneView.scene.rootNode.addChildNode(testObject)
    }
}

注:平面上にドラッグするすべてのものは、SCNNode()ではなくVirtualObject()を使用して設定する必要があります。 VirtualObjectに関するその他すべてはSCNNodeと同じです。

(名前でシーンをロードするなど、一般的なSCNNode拡張も追加できます-インポートされたモデルを参照するときに便利です)

楽しんで!

1
ZAY