[ARKit]12-[译]在ARKit中创建一个时空门App:添加物体

1,425 阅读11分钟

说明

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()
}

代码详解:

  1. session(_:, didFailWithError:) 会在session失败时调用.在失败时,session会暂停并不再接收传感器的数据.
  2. 这里设置sessionStateLabel中的文本为session失败上报的错误信息.showMessage(_:, label:, seconds:) 方法将信息展示在特定label中几秒钟.
  3. sessionWasInterrupted(_:) 会在视频捕捉被打断时调用,如app进入后台后.除非打断状态结束,否则不会再有新的视频帧更新.这里我们在label上展示"Session interrupted"信息3秒钟.
  4. sessionInterruptionEnded(_:) 方法会在session打断状态结束后被调用.session会从打断前的状态继续运行.如果设备移动过,所有锚点都会偏移.这避免偏移,就重启session.
  5. 在屏幕上展示"Session resume"3秒钟.
  6. 移除先前渲染的物体,重置所有label.我们稍后会实现这个方法.因为这些方法要更新UI,所有在主线程中调用.
  7. 重启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 = []
}
  1. 你定义了一个帮助方法来在一个UILabel上展示信息文本,并持续显示一段时间.一旦时间过后,就重置label的visibility和text.
  2. removeAllNodes() 方法移除所有当前添加在场景上的SCNNode对象.目前,你只需移除渲染出的水平面就好.
  3. 这个方法从场景中移除所有渲染出的水平面,并重置了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数组,数组中有两个设置项:

  1. resetTracking:session不会沿用上一个配置的设备位置和运动追踪情况.
  2. 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)
}

上面这段代码,你设置变量viewCenterPortalViewController的视图中心.

现在添加下面的方法:

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

代码解释:

  1. ARSCNView已经启用了触摸.当用户点击视图时,touchesBegan() 被调用,并传入一个有UITouch对象的集合,以及一个代表触摸事件的UIEvent.你重写这个触摸处理方法来给sceneView上添加一个ARAnchor.
  2. 调用sceneView对象的hitTest(_:, types:) 方法.这个hitTest方法有两个参数.它接收一个视图坐标系的CGPoint,此处为屏幕的中心,还有一个ARHitTestResult类型用于搜索.
    这里你使用existingPlaneUsingExtent结果类型,它会搜索从viewCenter发出的射线与场景中水平面的交点,并且水平面的面积是有限的.
    hitTest(_:, types:) 是所有命中测试的结果数组,排序为从近到远.我们选择射线相交的第一个平面.这样,只要屏幕中心有渲染出的水平面,你就能随时从hitTest(_:, types:) 中拿到结果.
  3. ARSession添加一个ARAnchor,这个位置就是以后3D物体被放置的位置.ARAnchor对象被初始化并带有一个变换矩阵,定义了锚点在世界坐标系中的旋转,平移和缩放.

锚点添加后,ARSCNView会在代理方法renderer(_:didAdd:for:) 中收到回调.从这里开始你将处理时空门的渲染了.

添加准心

在你添加时空门到场景中之前,还需要向视图中添加最后一个东西.在一段文章中,你实现了检测设备屏幕中心的sceneView上的命中测试.在本段中,你将会给屏幕中心的视图上添加一个标记,来帮助用户定位设备.

打开Main.storyboard.进入Object Library,搜索一个View对象.拖拽一个view对象到PortalViewController.

将view的名字改为Crosshair.添加约束确保其中心对准父控件中心.将widthheight设置为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
    }
  }
}

代码含义:

  1. 这个方法是SCNSceneRendererDelegate协议的一部分,它被ARSCNViewDelegate实现了.这个协议包含了一系列回调方法,可以用来在渲染过程的不同时间执行一些操作.renderer(_: updateAtTime:) 会在每一帧被精确调用,可以用来执行一些每帧都需要的逻辑.
  2. 运行代码来探测是否屏幕中心落在已经检测出的水平面上,并在主线程更新UI.
  3. 这里在sceneView上执行一个命中测试,来确定视图中心确实和水平面相交了.如果至少检测到了一个结果,crosshair背景色变成绿色.
  4. 如果命中测试没有返回任何结果,则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() 方法,它可以配置并渲染时空门.共做了下面几件事:

  1. 创建一个代表时空门的SCNNode对象.
  2. 该步初始化一个SCNBox对象,它是一个立方体,并使用这个立方体作为几何体创建一个SCNode对象.
  3. 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)
    }
  }
}

代码说明:

  1. 只有在添加到场景上的锚点是一个ARPlaneAnchor,并且isPortalPlacedfalse时,你才需要添加一个水平面到场景中来展示被探测到的平面,
  2. 如果被添加的锚点不是一个ARPlaneAnchor,并且时空门节点仍然没有被放置上去,那么这一定是个用户手动点击屏幕而添加的锚点.
  3. 通过调用makePortal() 来创建时空门节点.
  4. renderer(_:, didAdd:, for:) 会在SCNNode对象即node添加到场景时调用.你想要将时空门节点放置在node位置处.所以你将时空门节点添加为node的子节点上,并且设置isPortalPlacedtrue来表示时空门节点已经被添加过了.
  5. 为了清理场景,你移除所有渲染出的水平面,并重置debugOptions,这样屏幕上就不再有特征点了.
  6. 在主线程更新messageLabel,重置其text并隐藏它.
  7. renderer(_:, didUpdate:, for:) 中,只有当锚点是ARPlaneAnchor,且节点至少有一个子节点,而且时空门还没有被添加过时,你才更新渲染出的水平面,

最后,用下面的代码替换removeAllNodes() .

func removeAllNodes() {
  // 1
  removeDebugPlanes()
  // 2
  self.portalNode?.removeFromParentNode()
  // 3
  self.isPortalPlaced = false
}

这个方法用来从场景中清理并移除所有渲染出的物体.详情如下:

  1. 移除所有渲染出的水平面.
  2. 从父节点中移除portalNode.
  3. isPortalPlaced变量改为false来重置状态.

运行app;让app探测到一个水平面,然后当屏幕上的准心变绿时,点击屏幕.你将会看到一个扁平的,巨大的白色立方体.

这个就是你的时空门的占位节点.在下一章节中,你将会给时空门添加一些墙壁和通道.还会给墙壁添加一些纹理,让它们看起来更真实.

下一步做什么?

这些内容相当有趣!这里做一下本章总结:

  • 你能够在app进入后台时,探测并处理ARSession的打断.
  • 你理解了命中测试是如何在ARSCNView和探测到的水平面中起作用的.
  • 你可能使用命中测试的结果来放置ARAnchorsSCNNode对象. 在下一章,也就是本系列的最后一部分中,你将会把所有东西组合起来,添加墙壁和天花板,并给场景添加一点灯光照明!

如果你喜欢本系列教程,请购买本书的完整版,ARKit by Tutorials, available on our online store.

本章资料下载