如何在 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
复制代码
相关文章
相关标签/搜索