ARKit教程07_第五章:表面检测

前言

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

正文

在本章中,我们将学习如何检测真实世界的曲面以及如何正确管理这些曲面的更新。还将学习如何创建一个焦点光标,通过光线投射将其置于检测到的曲面之上。

可以在Chapter04这个项目上继续开发。你可以拷贝一份代码,也可以新建一个项目,把原来的实现逻辑再写一遍。这一张我们需要一些扩展代码,这些代码:

  • GameUtils: 包含基本转换函数,可将弧度转换为度,反之亦然。在处理旋转和角度时或用得上这些函数。

  • Generics: 添加 arc4random()的通用版本,这是生成随机值的函数。

  • Random+Extension: 将 random() 函数扩展名添加到 Double 类型,以便可以轻松地在指定范围内生成随机 Double 值。

  • SCNVector3+Extension: 使用一些矢量数学函数扩展 SCNVector3 类型。现在,你可以添加、乘法和删除矢量,获取矢量长度,查找矢量之间的角度,甚至计算与其他矢量的距离。

添加game states

我们接下来需要实现的效果是检测到一个表面,之后再做其他的操作。

定义game states

首先定义此游戏的所有可能游戏状态。

ViewController.swift添加一个枚举:

// MARK: - Game State
enum GameState: Int16 {
case detectSurface  // Scan playable surface (Plane Detection On)
case pointToSurface // Point to surface to see focus point (Plane Detection Off)
case swipeToPlay    // Focus point visible on surface, swipe up to play
}
  • detectSurfaceARKit 需要一段时间才能了解其环境和检测表面。当游戏处于此状态时, 用户必须扫描其周围环境以寻找合适的水平表面,如餐桌。一旦用户确信 ARKit 检测到了表面,他们可以点击Start按钮以进入下一个状态。

  • pointToSurface: 用户现在必须将设备指向检测到的曲面之一,使焦点光标变得可见。焦点光标显示目标点,指示扑克骰子的投掷位置。

  • swipeToPlay: 一旦用户可以看到焦点,他们可以向上滑动,将手中的骰子投向对焦光标。

添加游戏状态信息

现在,你已经定义了一些游戏状态,现在需要一种方法来通知用户他们可以在每个状态下做什么。

首先,添加一些新的属性:

var gameState: GameState = .detectSurface 
var statusMessage: String = ""

上面的代码主要做了如下工作:

  • gameState: 这是实际的游戏状态属性;它将包含游戏的当前状态。将此选项设置为默认状态:detectSurface

  • statusMessage: 它包含要向用户显示的说明;说明会根据游戏状态而变化。

至此,我们需要一个更新状态的函数:

func updateStatus() {
// 1
switch gameState {
case .detectSurface:
  statusMessage = "Scan entire table surface...\nHit START when ready!"
case .pointToSurface:
  statusMessage = "Point at designated surface first!"
case .swipeToPlay:
  statusMessage = "Swipe UP to throw!\nTap on dice to collect it again."
}
// 2
self.statusLabel.text = trackingStatus != "" ?
  "\(trackingStatus)" : "\(statusMessage)"
 }

上述代码把实时的gameState状态信息呈现给用户。现在拒用这个更新状态的方法了。

renderer(_:updateAtTime):里面的这一行代码可以注释掉了:

//self.statusLabel.text = self.trackingStatus

状态更新的操作最好放在主线程执行:

 func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
    DispatchQueue.main.async {
      //self.statusLabel.text = self.trackingStatus
      self.updateStatus()
      self.updateFocusNode()
    }
}

锚点

你们对于锚点了解多少?

ARKit 使用附加到 3D 内容的虚拟锚点。其主要目的是在玩家移动设备时保持 3D 内容相对于真实世界的位置。

ARAnchor 对象包含一个实际变换,该变换保持其位置和方向。锚点不是可见元素,它不是可见元素。它只是一个在ARKit场景中维护的对象。默认情况下,ARKit 将每个 ARAnchor 与一个空的 SCNNode 配对。我们所要做的就是将 3D 内容添加为该节点的子节点。

ARPlaneAnchor 对象是一种专用锚点类型,包含真实世界变换(位置和方向),包含其他平面信息,包括中心点、方向和曲面范围。然后,可以使用此信息创建相应的 SceneKit 平面节点。

其实,还有一个 ARFaceAnchor 锚点类型,后面会做介绍。现在,我们将只关注ARPlane锚点。

检测表面

要使 ARKit 检测真实表面需要启用ARConfiguration对象。

要启用该标志,转到初始化部分,并在 sceneView.session.run(config)之前在initARSession() 内添加以下行:

config.planeDetection = .horizontal

ARKit 现在将开始检测水平表面,并为每个检测到的表面自动生成 ARPlaneAnchor 实例。

注意:我们也可以使用.vertical来检测垂直曲面。

创建一个新的平面:

添加新平面锚点时,可以使用下面函数创建相应的可视组件。

func createARPlaneNode( 
    planeAnchor: ARPlaneAnchor, color: UIColor) -> SCNNode { 
    // Add code here
 }

函数传入 ARPlanAnchor 以及 UIColor。现在,我们拥有生成 SceneKit 平面节点所需的所有信息。

首先,生成平面几何体。在createARPlaneNode()函数中添加以下内容:

let planeGeometry = SCNPlane(
width: CGFloat(planeAnchor.extent.x), 
height: CGFloat(planeAnchor.extent.z))

这将使用锚点的范围为平面的宽度和长度生成平面所需的几何体。

创建平面所需材质

现在,我们需要通过创建材质为几何体提供一些纹理。我们需要在createARPlaneNode()函数中添加如下代码:

let planeMaterial = SCNMaterial() 
planeMaterial.diffuse.contents ="ARResource.scnassets/Textures/Surface_diffuse.png" 
planeGeometry.materials = [planeMaterial]

上述代码创建一个新的材质,然后将其漫反射.内容属性设置到 Surface_diffuse.png 中包含的纹理。平面现在将具有纹理而不是平面颜色。

创建平面节点

接下来我们把下面的代码添加到createARPlaneNode()函数中:

// 1 - Create plane node 
let planeNode = SCNNode(geometry: planeGeometry) 
// 2 planeNode.position = SCNVector3Make( 
planeAnchor.center.x, 0, planeAnchor.center.z) 
// 3 
planeNode.transform = SCNMatrix4MakeRotation(-Float.pi / 2, 1, 0, 0) 
// 4 
return planeNode

上面的代码作用如下:

  • 1: 这将通过传入生成的平面几何来创建新平面节点。
  • 2: 这将基于锚点的中心点设置平面节点的位置。
  • 3: 默认情况下,SCNPlane 生成的几何体是直立的,需要围绕 x 轴顺时针旋转平面 90 度,才能将平面平放在曲面上。
  • 4: 最后,新创建的平面将返回给调用者。

处理新的平面锚点

现在,我们已经拥有了能够创建 SceneKit 平面的帮助器函数,是时候使用它了。

激活平面检测后,ARKit 将自动开始为其检测到的每个水平表面创建 ARPlane锚点。

将调用相应的renderer(_:didAdd:for)代理来通知新添加的锚点。我们只需等待事件触发并为锚点创建相应的 SceneKit 平面。

我们可以在renderer(_:didAdd:for)代理方法中这么处理:

// MARK: - Plane Management
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
    DispatchQueue.main.async {
        
        let planeNode = self.createARPlaneNode(planeAnchor: planeAnchor,
                                               color: UIColor.yellow.withAlphaComponent(0.5))
        node.addChildNode(planeNode)
    }
}

上述代码作用如下:

  • 1: 通过代理方法来接收一个 SCNNode。这是一个新的空 SceneKit 节点。
  • 2: 对ARPlaneAnchor类型的节点做处理,过滤掉其他类型的节点。
  • 3: 把相关操放在主线程执行。
  • 4: 调用刚刚创建的 createPlane() 函数,将锚点信息与颜色一起传入进来。
  • 5: 提供的平面节点将添加为 ARKit 创建的节点的子节点。

更新平面

ARKit 可能最初未检测到整个表面,因此,当用户移动时,我们可能需要使用新信息更新先前检测到的平面。

获取平面几何体

我们需要另一个函数来更新具有新位置、方向和尺寸的现有平面节点。

func updateARPlaneNode( 
  planeNode: SCNNode, planeAchor: ARPlaneAnchor) { // Add code here 
}

我们需要更新平面几何体。在updateARPlaneNode()函数中添加以下代码:

let planeGeometry = planeNode.geometry as! SCNPlane 
planeGeometry.width = CGFloat(planeAchor.extent.x) 
planeGeometry.height = CGFloat(planeAchor.extent.z)

这将从平面节点检索以前生成的平面几何体;然后,它根据提供的平面锚点更新其宽度和高度信息。

更新平面位置信息

接下来需要处理的是平面的位置,在updateARPlaneNode()函数中添加下面的代码:

planeNode.position = SCNVector3Make(planeAchor.center.x, 0, planeAchor.center.z)

这将使用平面锚点提供的位置信息更新平面节点位置。

平面锚点更新的相关处理

最后,我们需要充分利用新的帮助器功能。如果以前检测到的曲面必须使用新信息进行更新,ARKit 将触发renderer(_:didUpdate:for)代理方法。我们可以在代理方法中添加如下的代码:

// 1 
func renderer(_ renderer: SCNSceneRenderer,

    didUpdate node: SCNNode, for anchor: ARAnchor) { 
// 2 
    guard let planeAnchor = anchor as? ARPlaneAnchor else { return } 
      // 3 
      DispatchQueue.main.async { 
    // 4 
    self.updateARPlaneNode(planeNode: node.childNodes[0], planeAchor: planeAnchor) 
     }
  }

上面的代码作用如下:

  • 1: 这个方法里面接收到的参数是SCNNode,这是你之前存在平面里面的节点。

  • 2: 只是对ARPlaneAnchor类型的节点做操作,过滤掉其他类型的节点

  • 3:把上述操作放在主线程中执行。

  • 4:最后,这将调用新的 updatePlane() 函数。这个函数需要传入第一个子节点以及关联的平面锚点。

创建焦点节点

现在,这个应用可以检测表面,之前的一个模型,可以用上了:

image.png

Ray casting

光线投射是从屏幕中心(焦点)将虚拟光线投射到虚拟场景,同时查找与 3D 对象的交集的过程。

在现场。在此特定情况下,要查找场景中的光线和平面节点之间的交点。

光线与平面相交后,该交点位置将用于放置焦点节点。

创建聚焦点

我们首先需要定义用于光线投射测试的屏幕位置;这通常是屏幕的中心。在这种情况下,焦点节点比正常节点大一些。

我们添加一个成员变量保存焦点的位置信息:

var focusPoint:CGPoint!

现在需要初始化该位置。将以下代码行添加到 initSceneView() 的底部:

focusPoint = CGPoint(x: view.center.x, y: view.center.y + view.center.y * 0.25)

这将使用屏幕高度低于视图中心点 25% 的位置初始化对焦点。

方向更改的处理

要监听方向的更改,需要一个通知方法:

NotificationCenter.default.addObserver(self, selector: #selector(ViewController.orientationChanged), name: UIDevice.orientationDidChangeNotification, object: nil)

具体的实现如下:

@objc func orientationChanged() { 
    focusPoint = CGPoint(x: view.center.x, y: view.center.y + view.center.y * 0.25) 
}

上述代码将焦点更新到视图中心点以下 25% 的位置。

更新焦点节点

在焦点准备就绪后,我们还需要另一个函数,该函数将根据屏幕的焦点持续更新焦点节点。

func updateFocusNode() {
    // 1 
    let results = self.sceneView.hitTest(self.focusPoint, types: [.existingPlaneUsingExtent]) 
    // 2 
    if results.count == 1 {
        if let match = results.first {
            // 3
            let t = match.worldTransform
            // 4 
            self.focusNode.position = SCNVector3( x: t.columns.3.x, y: t.columns.3.y, z: t.columns.3.z) self.gameState = .swipeToPlay 
            } 
        } else { 
            // 5 
            self.gameState = .pointToSurface 
        }
    }

上述代码作用如下:

  • 1: sceneView.hitTest()执行光线投射测试。用户向它提供要触发光线的屏幕位置;还需要提供要查找的对象类型。在这种情况下,.existingPlaneUsingExtent指定我们仅根据其范围查找检测到的平面。然后,命中将存储在结果中。

  我们还可以根据其他类型(如featurePoints(要素点)、estimatedHorizontalPlane(估计水平平面)和existingPlane(现有平面))执行光线强制转换。

  • 2: 只寻找第一个命中结果。找到后,即可更新焦点节点。
  • 3: 将使用命中结果的worldTransform,该矩阵包含位置、方向和缩放信息。
  • 4: 根据命中结果变换矩阵更新焦点节点的位置。位置信息可以在变换矩阵的第三列中找到。
  • 5: 最终,如果没有找到命中结果,程序应继续指示用户指向检测到的表面。

要完成操作,需要用updateFocusNode()方法来替代renderer(_:updateAtTime)

self.updateFocusNode()

可能前面说这么多有一些不太明白,运行一下程序,看看效果吧:

现在,检测到的表面;焦点节点也应弹出。

现在会有平面重叠的现象。ARKit 有时可能会将多个检测到的平面合并到单个平面中。为此, ARKit 需要在创建新平面之前删除旧平面信息。这些操作,我们可以在renderer(_:didRemove:for)代理方法中做处理。

    func removeARPlaneNode(node: SCNNode) {
    for childNode in node.childNodes {
        childNode.removeFromParentNode()
    }
  }

  func renderer(_ renderer: SCNSceneRenderer, didRemove node: SCNNode, for anchor: ARAnchor) {
    guard anchor is ARPlaneAnchor else { return }
    DispatchQueue.main.async {
        self.removeARPlaneNode(node: node)
    }
   }
上一章 目录 下一章
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349