说明
ARKit系列文章目录
译者注:本文是Raywenderlich上《ARKit by Tutorials》免费章节的翻译,是原书第8章.原书7~9章完成了一个时空门app.
官网原文地址www.raywenderlich.com/195388/buil…
本文是我们书籍ARKit by Tutorials中的第8章,“添加物体到你的世界”.这本书向你展示了如何用苹果的增强现实框架ARKit,来构建五个沉浸式的,好看的AR应用.开始吧!
在本系列上一章中,你已经学会了如何用ARKit建立你的app并探测水平面.在本章中,你将继续构建你的app并通过SceneKit添加3D虚拟内容到相机场景中.在本章结束,你将会学到:
- 处理session打断
- 放置物体到探测出的水平面
在开始之前,点击 资料下载 来下载项目资料,并打开starter文件夹下的starter工程.
开始
现在你已经能够探测并渲染水平面了,还需要在session被打断时重置状态.当app进入后台时,或当多个app处于前台时ARSession就会被打断.一旦被打断后,视频捕捉就会失败,ARSession也不能再接收到传感器的数据来追踪了.当app返回前台时,渲染出的平面仍然显示在视图上.然而,如果你的设备已经改变了位置或朝向,那么ARSession追踪就不再有效了.这时你就需要重启session.
ARSCNViewDelegate实现了ARSessionObserver的协议.这个协议包含了一些方法,会在ARSession被打断或出错时被调用.
打开PortalViewController.swift,并添加下面的代理方法实现到已存在的类扩展中.
// 1
func session(_ session: ARSession, didFailWithError error: Error) {
// 2
guard let label = self.sessionStateLabel else { return }
showMessage(error.localizedDescription, label: label, seconds: 3)
}
// 3
func sessionWasInterrupted(_ session: ARSession) {
guard let label = self.sessionStateLabel else { return }
showMessage("Session interrupted", label: label, seconds: 3)
}
// 4
func sessionInterruptionEnded(_ session: ARSession) {
// 5
guard let label = self.sessionStateLabel else { return }
showMessage("Session resumed", label: label, seconds: 3)
// 6
DispatchQueue.main.async {
self.removeAllNodes()
self.resetLabels()
}
// 7
runSession()
}
复制代码
代码详解:
- session(_:, didFailWithError:) 会在session失败时调用.在失败时,session会暂停并不再接收传感器的数据.
- 这里设置sessionStateLabel中的文本为session失败上报的错误信息.showMessage(_:, label:, seconds:) 方法将信息展示在特定label中几秒钟.
- sessionWasInterrupted(_:) 会在视频捕捉被打断时调用,如app进入后台后.除非打断状态结束,否则不会再有新的视频帧更新.这里我们在label上展示"Session interrupted"信息3秒钟.
- sessionInterruptionEnded(_:) 方法会在session打断状态结束后被调用.session会从打断前的状态继续运行.如果设备移动过,所有锚点都会偏移.这避免偏移,就重启session.
- 在屏幕上展示"Session resume"3秒钟.
- 移除先前渲染的物体,重置所有label.我们稍后会实现这个方法.因为这些方法要更新UI,所有在主线程中调用.
- 重启session.runSession() 重置了session配置并用新的配置重新开始追踪.
你会看到有一些编译错误.实现缺失的方法就可以解决这些错误.
在PortalViewController的其他变量下面添加一些变量:
var debugPlanes: [SCNNode] = []
复制代码
你将会使用debugPlanes数组来保存在debug模式下渲染的所有水平面.
然后,在resetLabels() 下面添加新方法:
// 1
func showMessage(_ message: String, label: UILabel, seconds: Double) {
label.text = message
label.alpha = 1
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
if label.text == message {
label.text = ""
label.alpha = 0
}
}
}
// 2
func removeAllNodes() {
removeDebugPlanes()
}
// 3
func removeDebugPlanes() {
for debugPlaneNode in self.debugPlanes {
debugPlaneNode.removeFromParentNode()
}
self.debugPlanes = []
}
复制代码
- 你定义了一个帮助方法来在一个UILabel上展示信息文本,并持续显示一段时间.一旦时间过后,就重置label的visibility和text.
- removeAllNodes() 方法移除所有当前添加在场景上的SCNNode对象.目前,你只需移除渲染出的水平面就好.
- 这个方法从场景中移除所有渲染出的水平面,并重置了debugPlanes数组.
现在,在renderer(_:, didAdd:, for:) 中,#if DEBUG对应的**#endif**预处理指令前:
self.debugPlanes.append(debugPlaneNode)
复制代码
这样就将添加到场景的水平面也加入到debugPlanes数组中.
注意,在runSession() 中,session执行中需要传入一个配置:
sceneView?.session.run(configuration)
复制代码
将上面替换为:
sceneView?.session.run(configuration,
options: [.resetTracking, .removeExistingAnchors])
复制代码
这里,你运行sceneView关联的ARSession时,传入一个configuration对象和一个ARSession.RunOptions数组,数组中有两个设置项:
- resetTracking:session不会沿用上一个配置的设备位置和运动追踪情况.
- removeExistingAnchor:session上一个配置的锚点对象会被移除.
运行一下app,试着检测一个水平面.
现在将app退到后台再重新打开.看到上一次渲染出的水平面已经从场景中移除,app重置了label以给用户显示正确的说明.命中测试
现在你已经准备好在检测出的水平面上放置物体了.你将使用ARSCNView的命中测试来检测,用户手指在屏幕上的触摸对应虚拟场景的哪里.一个视图坐标下的2D点,实际对应着3D坐标空间中的一条线.命中测试就是一个找到这条线上物体的过程.
打开PortalViewController.swift,添加下列变量.
var viewCenter: CGPoint {
let viewBounds = view.bounds
return CGPoint(x: viewBounds.width / 2.0, y: viewBounds.height / 2.0)
}
复制代码
上面这段代码,你设置变量viewCenter为PortalViewController的视图中心.
现在添加下面的方法:
// 1
override func touchesBegan(_ touches: Set, with event: UIEvent?) {
// 2
if let hit = sceneView?.hitTest(viewCenter, types: [.existingPlaneUsingExtent]).first {
// 3
sceneView?.session.add(anchor: ARAnchor.init(transform: hit.worldTransform))
}
}
复制代码
代码解释:
- ARSCNView已经启用了触摸.当用户点击视图时,touchesBegan() 被调用,并传入一个有UITouch对象的集合,以及一个代表触摸事件的UIEvent.你重写这个触摸处理方法来给sceneView上添加一个ARAnchor.
- 调用sceneView对象的hitTest(_:, types:) 方法.这个hitTest方法有两个参数.它接收一个视图坐标系的CGPoint,此处为屏幕的中心,还有一个ARHitTestResult类型用于搜索.
这里你使用existingPlaneUsingExtent结果类型,它会搜索从viewCenter发出的射线与场景中水平面的交点,并且水平面的面积是有限的.
hitTest(_:, types:) 是所有命中测试的结果数组,排序为从近到远.我们选择射线相交的第一个平面.这样,只要屏幕中心有渲染出的水平面,你就能随时从hitTest(_:, types:) 中拿到结果. - 向ARSession添加一个ARAnchor,这个位置就是以后3D物体被放置的位置.ARAnchor对象被初始化并带有一个变换矩阵,定义了锚点在世界坐标系中的旋转,平移和缩放.
锚点添加后,ARSCNView会在代理方法renderer(_:didAdd:for:) 中收到回调.从这里开始你将处理时空门的渲染了.
添加准心
在你添加时空门到场景中之前,还需要向视图中添加最后一个东西.在一段文章中,你实现了检测设备屏幕中心的sceneView上的命中测试.在本段中,你将会给屏幕中心的视图上添加一个标记,来帮助用户定位设备.
打开Main.storyboard.进入Object Library,搜索一个View对象.拖拽一个view对象到PortalViewController.
将view的名字改为Crosshair.添加约束确保其中心对准父控件中心.将width和height设置为10.在Size Inspector页面中,约束应该是这样子:
进入到 Attributes inspector标签页,将背景颜色改为 Light Gray Color.选中assistant editor,你会看到PortalViewController.swift在右侧.按住Ctrl从storyboard中的Crosshair上拖拽属性到PortalViewController代码中,放在sceneView上方.
在IBOutlet中输入名字为crosshair并点击Connect.
运行app.注意有一个灰色正方形view在屏幕中央.这就是我们刚才添加的crosshair view. 现在在 PortalViewController类扩展中的 ARSCNViewDelegate方法中,添加下列代码./ 1
func renderer(_ renderer: SCNSceneRenderer,
updateAtTime time: TimeInterval) {
// 2
DispatchQueue.main.async {
// 3
if let _ = self.sceneView?.hitTest(self.viewCenter,
types: [.existingPlaneUsingExtent]).first {
self.crosshair.backgroundColor = UIColor.green
} else { // 4
self.crosshair.backgroundColor = UIColor.lightGray
}
}
}
复制代码
代码含义:
- 这个方法是SCNSceneRendererDelegate协议的一部分,它被ARSCNViewDelegate实现了.这个协议包含了一系列回调方法,可以用来在渲染过程的不同时间执行一些操作.renderer(_: updateAtTime:) 会在每一帧被精确调用,可以用来执行一些每帧都需要的逻辑.
- 运行代码来探测是否屏幕中心落在已经检测出的水平面上,并在主线程更新UI.
- 这里在sceneView上执行一个命中测试,来确定视图中心确实和水平面相交了.如果至少检测到了一个结果,crosshair背景色变成绿色.
- 如果命中测试没有返回任何结果,则crosshair的背景色重设为浅灰色.
运行app.
四处移动设备,以便探测并渲染出水平面,如下左图所示.现在移动你的设备让设备屏幕中心落在平面内,如下右图所示.注意中心view的颜色变成了绿色.
添加一个状态机
现在你已经建立起一个app,能探测平面并放置一个ARAnchor,你可以开始添加时空门了.
为了追踪app的状态,在PortalViewController中添加下列变量:
var portalNode: SCNNode? = nil
var isPortalPlaced = false
复制代码
储存一个SCNNode类型的portalNode对象来表示你的时空门,并使用isPortalPlaced来表示时空门是否已被渲染在场景中.
在PortalViewController中添加下列方法:
func makePortal() -> SCNNode {
// 1
let portal = SCNNode()
// 2
let box = SCNBox(width: 1.0,
height: 1.0,
length: 1.0,
chamferRadius: 0)
let boxNode = SCNNode(geometry: box)
// 3
portal.addChildNode(boxNode)
return portal
}
复制代码
这里我们定义了makePortal() 方法,它可以配置并渲染时空门.共做了下面几件事:
- 创建一个代表时空门的SCNNode对象.
- 该步初始化一个SCNBox对象,它是一个立方体,并使用这个立方体作为几何体创建一个SCNode对象.
- 将boxNode作为子节点添加到你的portal并返回时空门节点.
这里,makePortal() 只是创建一个包含立方体物体的时空门节点作为占位.
现在,用下面的方法替换renderer(_:, didAdd:, for:) 和renderer(_:, didUpdate:, for:) :
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
DispatchQueue.main.async {
// 1
if let planeAnchor = anchor as? ARPlaneAnchor,
!self.isPortalPlaced {
#if DEBUG
let debugPlaneNode = createPlaneNode(
center: planeAnchor.center,
extent: planeAnchor.extent)
node.addChildNode(debugPlaneNode)
self.debugPlanes.append(debugPlaneNode)
#endif
self.messageLabel?.alpha = 1.0
self.messageLabel?.text = """
Tap on the detected \
horizontal plane to place the portal
"""
}
else if !self.isPortalPlaced {// 2
// 3
self.portalNode = self.makePortal()
if let portal = self.portalNode {
// 4
node.addChildNode(portal)
self.isPortalPlaced = true
// 5
self.removeDebugPlanes()
self.sceneView?.debugOptions = []
// 6
DispatchQueue.main.async {
self.messageLabel?.text = ""
self.messageLabel?.alpha = 0
}
}
}
}
}
func renderer(_ renderer: SCNSceneRenderer,
didUpdate node: SCNNode,
for anchor: ARAnchor) {
DispatchQueue.main.async {
// 7
if let planeAnchor = anchor as? ARPlaneAnchor,
node.childNodes.count > 0,
!self.isPortalPlaced {
updatePlaneNode(node.childNodes[0],
center: planeAnchor.center,
extent: planeAnchor.extent)
}
}
}
复制代码
代码说明:
- 只有在添加到场景上的锚点是一个ARPlaneAnchor,并且isPortalPlaced为false时,你才需要添加一个水平面到场景中来展示被探测到的平面,
- 如果被添加的锚点不是一个ARPlaneAnchor,并且时空门节点仍然没有被放置上去,那么这一定是个用户手动点击屏幕而添加的锚点.
- 通过调用makePortal() 来创建时空门节点.
- renderer(_:, didAdd:, for:) 会在SCNNode对象即node添加到场景时调用.你想要将时空门节点放置在node位置处.所以你将时空门节点添加为node的子节点上,并且设置isPortalPlaced为true来表示时空门节点已经被添加过了.
- 为了清理场景,你移除所有渲染出的水平面,并重置debugOptions,这样屏幕上就不再有特征点了.
- 在主线程更新messageLabel,重置其text并隐藏它.
- 在renderer(_:, didUpdate:, for:) 中,只有当锚点是ARPlaneAnchor,且节点至少有一个子节点,而且时空门还没有被添加过时,你才更新渲染出的水平面,
最后,用下面的代码替换removeAllNodes() .
func removeAllNodes() {
// 1
removeDebugPlanes()
// 2
self.portalNode?.removeFromParentNode()
// 3
self.isPortalPlaced = false
}
复制代码
这个方法用来从场景中清理并移除所有渲染出的物体.详情如下:
- 移除所有渲染出的水平面.
- 从父节点中移除portalNode.
- 将isPortalPlaced变量改为false来重置状态.
运行app;让app探测到一个水平面,然后当屏幕上的准心变绿时,点击屏幕.你将会看到一个扁平的,巨大的白色立方体.
这个就是你的时空门的占位节点.在下一章节中,你将会给时空门添加一些墙壁和通道.还会给墙壁添加一些纹理,让它们看起来更真实.下一步做什么?
这些内容相当有趣!这里做一下本章总结:
- 你能够在app进入后台时,探测并处理ARSession的打断.
- 你理解了命中测试是如何在ARSCNView和探测到的水平面中起作用的.
- 你可能使用命中测试的结果来放置ARAnchors和SCNNode对象. 在下一章,也就是本系列的最后一部分中,你将会把所有东西组合起来,添加墙壁和天花板,并给场景添加一点灯光照明!
如果你喜欢本系列教程,请购买本书的完整版,ARKit by Tutorials, available on our online store.
本章资料下载