ARKit教程11_第八章:在门户应用中添加物体

前言

ARKit感兴趣的同学,可以订阅ARKit教程专题
源代码地址在这里

正文

在上一章中,我们学习了如何设置 iOS 应用以使用 ARKit 会话和检测水平平面。在本章中,我们将构建应用,并通过 SceneKit3D 虚拟内容添加到摄像机场景中。并且可以了解:

  • 如何处理会话中断。
  • 将对象放置在检测到的水平平面上。

开始

到目前为止,我们可以检测和渲染水平平面,如果有任何中断,则需要重置会话的状态。当应用移动到后台或多个应用程序位于前台时, ARSession 将中断。一旦中断,视频捕获将失败, ARSession 将无法执行任何跟踪,因为它将不再接收所需的传感器数据。当应用返回到前台时,渲染的平面仍将存在于视图中。但是,如果设备已更改其位置或旋转, ARSession 跟踪将不再工作。这是我们需要重新启动会话时需要做的工作。

ARSCNViewDelegate 实现 ARSession 观察员协议。此协议包含 ARSession 检测到中断或会话错误时调用的方法。

 func session(_ session: ARSession, didFailWithError error: Error) {
    guard let label = self.sessionStateLabel else {return}
    showMessage(error.localizedDescription, label: label, second: 3.0)
}

func sessionWasInterrupted(_ session: ARSession) {
    guard let label = self.sessionStateLabel else {return}
    showMessage("Session interrupted", label: label, second: 3.0)
}

func sessionInterruptionEnded(_ session: ARSession) {
    guard let label = self.sessionStateLabel else {return}
    showMessage("Session resumed", label: label, second: 3.0)
    DispatchQueue.main.async {
        self.removeAllNodes()
        self.resetLabels()
    }
    runSession()
}

上面的代码作用如下:

  • 1: 当session失败的时候执行session(_:, didFailWithError:)。 发生故障时,会话暂停,并且不接收传感器数据。
  • 2: 在这里,我们将sessionStateLabel文本设置为由于会话失败而报告的错误消息。. showMessage(_:, label:, seconds:)在指定标签中显示给定秒数中的消息。
  • 3: 当视频捕获因应用移动到后台而中断时调用sessionWasInterrupted(_:)方法。在中断结束之前,不会提供其他帧更新。在这里,我们可以在标签中显示"会话中断"消息 3 秒钟。
  • 4: 当session中断停止的时候执行sessionInterruptionEnded(_:)。中断结束后,会话将继续从上次已知状态运行。如果设备已移动,则任何锚点都将未对齐。为了避免这种情况,需要重新启动会话。
  • 5: 在屏幕上显示"Session resumed"消息 3 秒钟。
  • 6:删除以前渲染的对象并重置所有标签。我们将很快实现这些方法。这些方法更新 UI,因此需要在主线程上调用它们。
  • 7:重新启动会话。运行runSession()重置会话配置,并重新启动使用新配置的跟踪。

我们需要添加以下代码:

var debugPlanes: [SCNNode] = []

我们将使用调试平面,它是 SCNNode 对象的数组,用于在调试模式下跟踪所有渲染的水平平面。

我们在resetLabels()方法中添加如下代码:

func showMessage(_ message: String, label: UILabel, second: Double){
    label.text = message
    label.alpha = 1
    DispatchQueue.main.asyncAfter(deadline: .now() + second) {
        if label.text == message{
            label.text = ""
            label.alpha = 0.0
        }
    }
}

func removeAllNodes(){
    removeDebugPlanes()
    self.portalNode?.removeFromParentNode()
    self.isPortalPlaced = false
}

func removeDebugPlanes(){
    for debugPlaneNode in self.debugPlanes {
        debugPlaneNode.removeFromParentNode()
    }
    debugPlanes = []
}

以上代码作用如下:

  • 1: 定义一个方法,以在给定的 UILabel 中显示指定持续时间(以秒为单位)的消息字符串。一旦指定的秒数通过,我们可以重置标签的可见度和内容。
  • 2: removeAllNodes()删除添加到场景中的所有现有 SCNNode 对象。目前,我们只在此处删除渲染的水平平面。
  • 3: 此方法从场景中删除所有渲染的水平平面并重置调试平面数组。

renderer(_:, didAdd:, for:)函数中,在#if DEBUG#endif之间添加如下代码:

self.debugPlanes.append(debugPlaneNode)

这会将刚刚添加到场景的水平平面添加到调试平面数组中。

请注意,在 runSession() 中,会话使用给定的配置执行:

sceneView?.session.run(configuration)

用以下代码替换上面的代码:

sceneView?.session.run(configuration, options: [.resetTracking, .removeExistingAnchors])

在这里,我们通过传递configuration对象和 ARSession.RunOptions 数组,使用以下运行选项运行与ARSession关联的 ARSessionView:

  • 1: resetTracking: 会话不会继续从以前的配置的设备位置和运动跟踪。
  • 2: removeExistingAnchors: 将删除与其上一个配置中与会话关联的所有锚点对象。

运行应用,效果如下:

现在将应用发送到后台,然后重新打开应用。 请注意,以前渲染的水平平面将从场景中删除,应用将重置标签以向用户显示正确的说明。

命中测试

现在,我们可以开始将对象放置在检测到的水平平面上。我们将使用 ARSCNView 的命中测试来检测屏幕上用户手指的触摸,以查看他们在虚拟场景中的着陆位置。视图坐标空间中的 2D 点可以引用 3D 坐标空间中线段沿线的任何点。命中测试是查找位于此线段的世界对象的过程。

打开ViewController.swift 并且添加下面的代码:

private var viewCenter: CGPoint{
    return view.center
}

上面的代码将viewCenter设置为view的中心位置。

我们还需要添加以下代码:

 override func touchesBegan(_ touches: Set, with event: UIEvent?) {
    if let hit = sceneView.hitTest(viewCenter, types: [.existingPlaneUsingExtent]).first {
        sceneView.session.add(anchor: ARAnchor.init(transform: hit.worldTransform))
    }
}

上面代码作用如下:

  • 1: 启动ARSCNView 的触摸事件。当用户点击视图时,使用一组 UITouch 对象和定义触摸事件的 UIEvent 的时候就会调用 touchBegan()方法。重写此触摸处理方法以将 ARAnchor 添加到场景视图中。

  • 2: 我们在sceneView对象中调用hitTest(_:, types:)hitTest函数有两个参数。它在视图的坐标系中采用 CGPoint类型参数,在这个应用中, 我们采用屏幕中心这个位置作为参数值。

    在这里,我们使用existingPlaneUsingExtent的返回值类型,该类型搜索来自viewCenter的光线与任何检测到的水平平面相交的点,同时考虑平面的有限范围。

    hitTest(_:, types:)的结果是从最近到最远排序的所有命中测试结果的数组。我们选择光线相交的第一个平面。只要屏幕中心落在渲染水平面内, 我们就会得到hitTest(_:, types:)的结果。

  • 3: 我们将ARAnchor添加到ARSession中放置对象的位置。使用变换矩阵初始化ARAnchor对象,该变换矩阵定义锚点在世界坐标中的旋转,平移和缩放。

添加锚点后,ARSCNView在委托方法renderer(_:didAdd:for:)中接收回调。这是我们处理呈现门户的地方。

添加十字准线

在将门户添加到场景之前,还需要在视图中添加最后一件事。我们需要添加一个标志性的view来给用户一个提示屏幕中央的位置:

ARKit教程11_第八章:在门户应用中添加物体_第1张图片
@IBOutlet weak var crosshair: UIView!

我们在renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval)方法中添加如下代码:

 func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
    DispatchQueue.main.async {
        if let _ = self.sceneView.hitTest(self.viewCenter, types: [.existingPlaneUsingExtent]).first{
            self.crosshair.backgroundColor = UIColor.green
        }else{
            self.crosshair.backgroundColor = UIColor.lightGray
        }
    }
}

上面的代码作用如下:

  • 1: 此方法是SCNSceneRendererDelegate协议的一部分,该协议由ARSCNViewDelegate实现。它包含回调,可用于在呈现期间的不同时间执行操作。renderer(_: updateAtTime:)每帧只调用一次,应该用于执行任何每帧逻辑。
  • 2: 运行代码以检测屏幕中心是否落在现有检测到的水平平面中,并相应地在主队列上更新UI
  • 3: 这将使用viewCentersceneView执行命中测试,以确定视图中心是否确实与水平平面相交。如果检测到至少一个结果,则十字准线视图的背景颜色将更改为绿色。
  • 4: 如果命中测试未返回任何结果,则十字准线视图的背景颜色将重置为浅灰色。

移动设备,使其检测并渲染水平平面,如左侧所示。现在移动设备,使设备屏幕的中心位于平面内,如右图所示。

IMG_0067.PNG

添加状态机

现在我们已经设置了用于检测平面和放置ARAnchor的应用程序,我们可以开始添加门户网站。

要跟踪应用程序的状态,请将以下变量添加到PortalViewController

var portalNode: SCNNode? = nil 
var isPortalPlaced = false

我们将表示门户的SCNNode对象存储在portalNode中,并使用isPortalPlaced来跟踪是否在场景中呈现门户。

我们添加以下代码:

func makePortal() -> SCNNode{
    let portal = SCNNode()
    let box = SCNBox(width: 1.0, height: 1.0, length: 1.0, chamferRadius: 0.0)
    let boxNode = SCNNode(geometry: box)
    portal.addChildNode(boxNode)
    return portal
}

以上代码作用如下:

  • 1: 创建一个SCNNode对象。
  • 2: 这会初始化一个SCNBox对象,它是一个立方体,并使用SCNBox几何体为该框创建一个SCNNode对象。
  • 3: 将boxNode作为子节点添加到门户并返回门户节点。

这里,makePortal()创建一个门户节点,其中包含一个box对象作为占位符。现在用以下内容替换SCNSceneRendererDelegaterenderer(_:, didAdd:, for:)renderer(_:, didUpdate:, for:)方法:

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    DispatchQueue.main.async {
        if let planeAnchor = anchor as? ARPlaneAnchor, !self.isPortalPlaced{
            #if DEBUG
            let debugPlaneNode = createPlaneNode(center: planeAnchor.center, extent: planeAnchor.extent)
            node.addChildNode(debugPlaneNode)
            #endif
            self.messageLabel.alpha = 1.0
            self.messageLabel.text = """
            Tap on the detected \
            horizontal plane to place the portal
            """
        }
        else if !self.isPortalPlaced {
            self.portalNode = self.makePortal()
            if let portal = self.portalNode{
                node.addChildNode(portal)
                self.isPortalPlaced = true
                self.removeDebugPlanes()
                self.sceneView.debugOptions = []
                DispatchQueue.main.async {
                    self.messageLabel.text = ""
                    self.messageLabel.alpha = 0.0
                }
            }
        }
    }
}

func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
    DispatchQueue.main.async {
        if let planeAnchor = anchor as? ARPlaneAnchor, node.childNodes.count > 0, !self.isPortalPlaced{
            updatePlaneNode(node.childNodes[0], center: planeAnchor.center, extent: planeAnchor.extent)
        }
    }
}

上面的代码作用如下:

  • 1: 只有当添加到场景中的锚点是ARPlaneAnchor时,才会向场景添加水平平面以显示检测到的平面,并且仅当isPortalPlaced等于false时才会显示检测到的平面,这意味着尚未放置门户。
  • 2: 如果添加的锚点不是ARPlaneAnchor,并且仍未放置门户网站节点,则必须是用户点击屏幕以放置门户网站时添加的锚点。
  • 3: 通过调用makePortal()我们创建一个门户节点。
  • 4: 使用添加到场景中的SCNNode对象节点调用renderer(_:, didAdd:, for:)。我们希望将门户节点放在节点的位置。因此,我们将门户节点添加为节点的子节点,并将isPortalPlaced设置为true以跟踪已添加门户节点。
  • 5: 要清理场景,请删除所有渲染的水平平面并重置sceneViewdebugOptions,这样处理之后就不再在屏幕上渲染要素点。
  • 6: 更新主线程上的messageLabel以重置其文本并将其隐藏。
  • 7: 在renderer(_:, didUpdate:, for:)函数中,仅当给定的锚是ARPlaneAnchor时,如果节点至少有一个子节点并且尚未放置门户,则更新渲染的水平面。

removeAllNodes()函数作如下的更新:

func removeAllNodes(){
    removeDebugPlanes()
    self.portalNode?.removeFromParentNode()
    self.isPortalPlaced = false
}

此方法用于清除和从场景中删除所有渲染对象。内部代码作用如下:

  • 1: 删除所有渲染的水平平面。
  • 2: 然后从其父节点中删除portalNode。
  • 3: 将isPortalPlaced变量更改为false以重置状态。

构建并运行应用程序;让应用程序检测水平面,然后在十字准线视图变为绿色时点击屏幕。你会看到一个相当简洁,巨大的白色盒子。

现在是一个空白的盒子。下一章,我们将会对这个空白的盒子做一些更多的工作。

上一章 目录 下一章

你可能感兴趣的:(ARKit教程11_第八章:在门户应用中添加物体)