ARKit教程11_第八章:在门户应用中添加物体
前言
对ARKit感兴趣的同学,可以订阅ARKit教程专题
源代码地址在这里
正文
在上一章中,我们学习了如何设置 iOS 应用以使用 ARKit 会话和检测水平平面。在本章中,我们将构建应用,并通过 SceneKit 将 3D 虚拟内容添加到摄像机场景中。并且可以了解:
- 如何处理会话中断。
- 将对象放置在检测到的水平平面上。
开始
到目前为止,我们可以检测和渲染水平平面,如果有任何中断,则需要重置会话的状态。当应用移动到后台或多个应用程序位于前台时, 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<UITouch>, 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来给用户一个提示屏幕中央的位置:
@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: 这将使用viewCenter对sceneView执行命中测试,以确定视图中心是否确实与水平平面相交。如果检测到至少一个结果,则十字准线视图的背景颜色将更改为绿色。
- 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对象作为占位符。现在用以下内容替换SCNSceneRendererDelegate的renderer(_:, 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: 要清理场景,请删除所有渲染的水平平面并重置sceneView的debugOptions,这样处理之后就不再在屏幕上渲染要素点。
- 6: 更新主线程上的messageLabel以重置其文本并将其隐藏。
- 7: 在renderer(_:, didUpdate:, for:)函数中,仅当给定的锚是ARPlaneAnchor时,如果节点至少有一个子节点并且尚未放置门户,则更新渲染的水平面。
removeAllNodes()函数作如下的更新:
func removeAllNodes(){
removeDebugPlanes()
self.portalNode?.removeFromParentNode()
self.isPortalPlaced = false
}
此方法用于清除和从场景中删除所有渲染对象。内部代码作用如下:
- 1: 删除所有渲染的水平平面。
- 2: 然后从其父节点中删除portalNode。
- 3: 将isPortalPlaced变量更改为false以重置状态。
构建并运行应用程序;让应用程序检测水平面,然后在十字准线视图变为绿色时点击屏幕。你会看到一个相当简洁,巨大的白色盒子。
现在是一个空白的盒子。下一章,我们将会对这个空白的盒子做一些更多的工作。
上一章 | 目录 | 下一章 |
---|