如何在 AR 中完成一個簡單的點擊移動虛擬物體操做?

在 AR 開發中,咱們常常須要經過手機屏幕與 AR 物體進行交互,好比旋轉、縮放和移動。node

通常來講,縮放最簡單,根據捏合手勢對物體進行放大縮小就好了; 旋轉通常是固定一個軸,常見是y軸,就是豎直方向不動,根據手勢在屏幕上的移動進行水平旋轉; 可是移動就稍微麻煩一點,在二維的手機屏幕上,操做三維物體的位置並不容易。常見的作法是點擊屏幕時,將物體固定在手機前,跟隨手機移動,鬆開後放下物體。git

徹底跟隨手機

徹底跟隨手機其實很簡單,就是直接將物體放在相機結點下(pointOfView) github

建立 Xcode 的默認 AR 項目,只須要多保存一個 var shipNode:SCNNode!就能夠了,其他代碼不變:

@IBOutlet var sceneView: ARSCNView!
    var shipNode:SCNNode!
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Set the view's delegate
        sceneView.delegate = self
        
        // Show statistics such as fps and timing information
        sceneView.showsStatistics = true
        
        // Create a new scene
        let scene = SCNScene(named: "art.scnassets/ship.scn")!
        // 此處保存一下 shipNode
        shipNode = scene.rootNode.childNode(withName: "ship", recursively: true)!
        // Set the scene to the view
        sceneView.scene = scene
    }
    // 不用改
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // Create a session configuration
        let configuration = ARWorldTrackingConfiguration()

        // Run the view's session
        sceneView.session.run(configuration)
    }
    // 不用改
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        // Pause the view's session
        sceneView.session.pause()
    }
複製代碼

而後添加 touch 相關手勢swift

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first
        let firstResult = sceneView.hitTest((touch?.location(in: sceneView))!, options: nil).first
        if let node = firstResult?.node {
            //這裏由於 shipNode 自身沒有 geometry 因此 hitTest 找到了它的子結點:shipMesh,因此它的父結點纔是 shipNode
            if node == shipNode || node.parent == shipNode {
                // 將 shipNode 從sceneView.scene.rootNode座標系下,轉換到sceneView.pointOfView座標系下
                let matrixInPOV = sceneView.scene.rootNode.simdConvertTransform(shipNode.simdTransform, to: sceneView.pointOfView)
                // 添加到相機結點下
                sceneView.pointOfView?.addChildNode(shipNode)
                shipNode.simdTransform = matrixInPOV
                shipNode.opacity = 0.5;//半透明
                
            }
        }
    }
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        if shipNode.opacity < 1 {//這裏偷懶,用透明度作個判斷
            // 將 shipNode 從sceneView.pointOfView座標系下,轉換到sceneView.scene.rootNode座標系下,傳 nil 默認就是 scene.rootNode
            let matrixInRoot = sceneView.pointOfView!.simdConvertTransform(shipNode.simdTransform, to: nil)
            // 回到原來的結點下
            sceneView.scene.rootNode.addChildNode(shipNode)
            shipNode.simdTransform = matrixInRoot
            shipNode.opacity = 1;
        }
    }
複製代碼

但這種方式有個缺點,就是會改變物體的旋轉。因此更多使用下面的方式session

跟隨手機移動但不旋轉

跟隨手機移動但不旋轉也不麻煩,只是不能直接將移動放置在手機結點下(pointOfView),而是須要不斷計算手機座標系下的位置,並移動物體。 app

這裏須要保存一下飛機在相機中的位置,因此添加一個 var positionInPOV:simd_float3 = simd_make_float3(0, 0, 0)

@IBOutlet var sceneView: ARSCNView!
    var shipNode:SCNNode!
    var positionInPOV:simd_float3 = simd_make_float3(0, 0, 0)
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Set the view's delegate
        sceneView.delegate = self
        
        // Show statistics such as fps and timing information
        sceneView.showsStatistics = true
        
        // Create a new scene
        let scene = SCNScene(named: "art.scnassets/ship.scn")!
        shipNode = scene.rootNode.childNode(withName: "ship", recursively: true)!
       // 須要成爲 session 的代理,以便在每次刷新時更新位置
        sceneView.session.delegate = self
        // Set the scene to the view
        sceneView.scene = scene
    }
複製代碼

咱們須要在點擊時,保存這個位置,而後不斷刷新計算它在世界座標中的位置,並更改飛機結點的位置(shipNode.simdPosition):ide

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first
        let firstResult = sceneView.hitTest((touch?.location(in: sceneView))!, options: nil).first
        if let node = firstResult?.node {
            //這裏由於 shipNode 自身沒有 geometry 因此 hitTest 找到了它的子結點:shipMesh
            if node == shipNode || node.parent == shipNode {
                // 將 shipNode 的位置從sceneView.scene.rootNode座標系下,轉換到sceneView.pointOfView座標系下,並保存
                positionInPOV = sceneView.scene.rootNode.simdConvertPosition(shipNode.simdPosition, to: sceneView.pointOfView)
                
                shipNode.opacity = 0.5;
                
            }
        }
    }
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        if shipNode.opacity < 1 {//這裏偷懶,用透明度作個判斷
            shipNode.opacity = 1;
        }
    }
   
    func session(_ session: ARSession, didUpdate frame: ARFrame) {
        if shipNode.opacity < 1 {//這裏偷懶,用透明度作個判斷
           // 計算positionInPOV在sceneView.scene.rootNode座標系下的位置,這裏 nil 就表明了 rootNode;並更新 shipNode 的位置
            let positionInRoot = sceneView.pointOfView!.simdConvertPosition(positionInPOV, to: nil)
            shipNode.simdPosition = positionInRoot
        }
    }
複製代碼

若是你是直接在 Xcode 的默認 AR 項目中更改的,可能會以爲 AR 移動的效果有問題,尤爲是在旋轉的時候,shipNode 位置明顯沒有跟隨手指的位置。 測試

這個問題產生的緣由有兩個:
一是模型太大了,距離較遠致使視覺投影不正確;
二是 shipMesh 相對於 ship 結點有偏移,致使移動時跟隨手指移動的並非看到的飛機,而是 shipNode 的原點。

解決的辦法,一是在座標轉換時,以 shipMesh 的實際位置爲準;二是直接將 shipMesh 相對其父結點的 position 改成 0。這裏爲了簡單,就採用了第二種: 動畫

位置調整後,再拖動或旋轉,就正常了: ui

在某個平面上移動

在平面上移動,其實就是對平面不停地進行hitTest操做,找到屏幕中心與平面的交點,而後移動物體就好了。

詳細的代碼能夠參考 WWDC2018 上的 SwiftShot,裏面不只有各類手勢操做,還有移動的動畫,讓效果更平穩

2018年的 Session 605 - Inside SwiftShot: Creating an AR Game 上演示了一個叫SwiftShot的多人遊戲Demo
其中涉及到的內容很是多。
註釋版代碼

關鍵代碼以下:

func session(_ session: ARSession, didUpdate frame: ARFrame) {
        
        // Update game board placement in physical world
        // 更新遊戲底座在物理世界的位置
        if gameManager != nil {
            // this is main thread calling into init code
            // 主線程調用
            updateGameBoard(frame: frame)
        }
  
    }

// MARK: - Board management
    func updateGameBoard(frame: ARFrame) {
        // Perform hit testing only when ARKit tracking is in a good state.
        // 只有在ARKit追蹤狀態正常時,才執行命中測試
        if case .normal = frame.camera.trackingState {
            // 執行 hitTest
            if let result = sceneView.hitTest(screenCenter, types: [.estimatedHorizontalPlane, .existingPlaneUsingExtent]).first {
                // 當初始化放置時,忽略那些太靠近相機的點
                guard result.distance > 0.5 || sessionState == .placingBoard else { return }
                
                // 更新物體的位置
                gameBoard.update(with: result, camera: frame.camera)
            } else {
                sessionState = .lookingForSurface
                if !gameBoard.isBorderHidden {
                    gameBoard.hideBorder()
                }
            }
        }
    }
複製代碼

總結

本文中使用的最核心的API,其實只有兩個,算上相關聯的也只有四個。

先看前兩個hitTest,用來從屏幕指定位置發射射線,進行命中測試的 API:

// 這是 SceneKit 的 API,主要用來返回被射線命中的 SCNNode 等物體
func hitTest(_ point: CGPoint, options: [SCNHitTestOption : Any]? = nil) -> [SCNHitTestResult]

// 這是 ARKit 的 API,主要用來返回被射線命中的 ARAnchor 等物體
open func hitTest(_ point: CGPoint, types: ARHitTestResult.ResultType) -> [ARHitTestResult]
複製代碼

後兩個則是座標轉換的 API,用來將一個矩陣,從一個座標系轉換到另外一個座標系。另外還有向量和位置的轉換方法,此處再也不列出:

// 將 self (一個SCNNode 對象)座標系下的 transform,轉換到另外一個 node 的座標系下,若是另外一個 node 爲 nil,則轉換到 rootNode 的座標系下
open func simdConvertTransform(_ transform: simd_float4x4, to node: SCNNode?) -> simd_float4x4

// 將 node 對象座標系下的 transform,轉換到 self (一個SCNNode 對象) 的座標系下,若是 node 爲 nil,則表示來自 rootNode 座標系下
open func simdConvertTransform(_ transform: simd_float4x4, from node: SCNNode?) -> simd_float4x4
複製代碼
相關文章
相關標籤/搜索