## ARKit:简单的增强现实 文/张嘉夫 提及增强现实(Augmented Reality,简称 AR),大部分读者都耳熟能详,却并不了解实际的应用场景。AR 的实现非常复杂,对于任何团队来说都是不小的挑战,其中真正能够提供 AR 应用的团队实在是凤毛麟角。 事实上,大部分用户所了解的 AR 技术仅限诸如 Pokémon Go 游戏、微软 HoloLens 等。而 HoloLens 的开发者套件售价高达3000美元,对于消费者来说实在是不友好。因此目前的 AR 应用一方面范围极为局限,另一方面价格令人望而却步。基于以上原因,企业无法构建独特的 AR 品牌体验,至今还未出现一个有足够用户的平台来吸引企业的研究与开发。 但随着 ARKit 框架在 WWDC 2017的发布,Apple 向前迈出了一大步,即将把 AR 带进主流市场。只要升级到 iOS 11,就有数百万台设备可以使用 AR,API 也延续了苹果的一贯作风,既强大又易于使用。 对于零售业来说,宜家在 WWDC 的 Demo 就是一个很好的案例,说明了 AR 应用程序将会如何帮助品牌吸引消费者并提升购买转化率;而对于娱乐业来说,AR 是可以用来讲故事的全新媒介;对于制造业和硬件工程师,AR 则是全新的便携式工具;设计师可以利用 AR 来创作 3D 内容;教育机构则可用于视觉化教学。 和其他新兴的用户界面(比如语音交互界面)一样,AR 仍然处于早期阶段,还没有成熟的用户体验准则和可用性准则。 也许下一次对用户体验的革命式创新就来自于 AR!如果你对此跃跃欲试的话,可以通过本文来学习 ARKit SDK 的基础知识。 ### ARKit 是如何工作的? ARKit 本质上是一堆框架的协同工作,而其中一些框架是全新推出的。 1. AVFoundation:监测设备的相机输入并将其渲染到屏幕上; 2. CoreMotion:使用内置硬件如陀螺仪、加速计和指南针来监测设备运动; 3. Vision(新推出):应用高性能计算机视觉算法来识别场景中的有用特征; 4. CoreML(新推出):利用预先训练的机器学习模型进行预测。 除了以上,还需要一个渲染库来为 AR 体验生成内容。 1. SceneKit:向 AR 场景中渲染 3D 内容; 2. SpriteKit:向 AR 场景中渲染 2D 内容; 3. Metal:向 AR 场景中渲染 3D 内容,用于高级游戏开发(Apple 对 OpenGL 的替代品)。 这些技术都支持 A9 或以上处理器。 #### 兼容性检查 由于不是所有设备都完全支持 ARKit,所以在使用框架之前一定要检查 ARWorldTrackingSessionConfiguration.isSupported 属性。下面的设备100%支持 ARKit: 1. 所有 iPad Pro 型号; 2. 9.7" iPad (2017); 3. iPhone 7/7 Plus; 4. iPhone 6S/6S Plus; 5. iPhone SE。 这些设备上的 ARWorldTrackingSessionConfiguration 可以提供最精确的 AR 体验,其采用了6个自由度(6DOF): 1. 3个旋转轴:俯仰角、偏航角、翻滚角; 2. 3个平移轴:在 X,Y,Z 上的移动。 只有 A9 及以上设备才支持 6DOF。更早期的设备只支持 3DOF,即3个旋转轴。这些设备不再用 ARWorldTrackingSessionConfiguration 配置,而是 ARSessionConfiguration。注意 ARSessionConfiguration 是不支持平面检测的。 另一个限制是,当 iOS 设备进入分屏模式时,ARKit 会停止仿真。例如在使用 ARSCNView 时,view 会变成白屏直到系统回到单 App 模式。Apple 这样的做是为了防止处理器过载并导致性能低下。所以基于 AR 的 App 应能优雅地处理这种情况。 如果你的 App 只有与 ARKit 相关的功能,可以在 plist 里的 UIRequiredDeviceCapabilities 列里提供值 arkit,这样就不会出现在不支持 ARKit 设备的 App Store 里了。 ### ARKit 基础知识 本文会介绍一些基本的 AR 技术以便为将来的开发奠定基础。通过本文,你将会清楚地了解如何实现以下内容: 1. 向现实世界场景中添加 3D 对象; 2. 检测并视觉化水平面(桌子、地板等); 3. 给现实世界场景添加物理作用; 4. 将 3D 模型锁定到现实世界的对象。 本文的所有代码都可以在 GitHub 上找到。在实例项目里有3个 view controller: 1. SimpleShapeViewController:单指点按会在相机前方添加一个球。双指点按会在相机前添加一个方块。 2. PlaneMapperViewController:环视场景来识别平面,识别到的平面用高亮显示。点按则会在相机前丢下一个方块,这个方块会受到重力影响并撞击平面。 3. PlaneAnchorViewController:点按会在该点执行命中测试(hit-test)来寻找水平面。如果找到则在该平面上锚定一个蜡烛,并在 App 的生命周期中一直存在。 本文仅使用 SceneKit 来处理 AR 场景,而不会涉及 SpriteKit(2D 内容)或 Metal(高级 3D 内容)。 如果你完全不了解 SceneKit 也没问题,仍然可以顺畅阅读不卡壳。但有时间的话,还是建议大家查阅网上的 SceneKit 的基础教程。 下面开始启动 Xcode 9! #### 设置 AR Scene(场景) 首先设置一个 ARSCNView。基本上来说,ARSCNView 就是 ARSession(来自 ARKit)和 SCNView(来自SceneKit)的结合。 1. 此 view 会自动将设备相机的实时视频流渲染为场景背景; 2. view 的 SceneKit 场景 scene 的世界坐标系直接对应 session configuration 建立的 AR 世界坐标系; 3. view 会自动移动 SceneKit 摄像机 camera,使其匹配设备在现实世界中的移动。 下面在 viewDidLoad 里创建我们的 ARSCNView: ``` let sceneView = ARSCNView() sceneView.autoenablesDefaultLighting = true sceneView.antialiasingMode = .multisampling4X ``` 注意也可以在 .xib 里创建 ARSCNView,我在示例项目里就是这么做的。 因为我们没有自己的光源,所以利用 autoenablesDefaultLighting 属性(来自父类SCNView)来让 view 添加漫射光源(diffuse light source)。配合来自 ARKit 的 automaticallyUpdatesLighting 属性(默认为true),这样我们就有了一个持续更新的光源,更新基于对相机流中现实世界光线的分析,非常实用。 antialiasingMode(抗锯齿模式)的设置是可选的,但设置之后我们放置在屏幕上的 3D 对象锯齿边缘会更加平滑,默认为 .none。 我们需要根据视图的显示回调来启动和停止 ARSession: ``` override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if ARWorldTrackingSessionConfiguration.isSupported { let configuration = ARWorldTrackingSessionConfiguration() sceneView.session.run(configuration) } else if ARSessionConfiguration.isSupported { let configuration = ARSessionConfiguration() sceneView.session.run(configuration) } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) sceneView.session.pause() } ``` 大部分情况下,直接使用 ARSessionConfiguration 或其子类(目前只有1个子类)即可,而不用进行调整。如果想要检测水平面,需要设置 ARWorldTrackingSessionConfiguration(会在后面详细介绍) 的 planeDetection 属性。 此外,ARSCNView 还有几个重要的属性: 1. scene:将 3D 内容渲染到 view 中的 SCNScene; 2. session:当前 AR session,包含 configuration 和管理各个 ARAnchor; 3. delegate:可以提供检测到的 ARAnchor 的 SCNNode 实例,例如 ARPlaneAnchor。 以上就是创建一个 AR 场景所需的全部内容。但场景里没有内容的话,也就谈不上”增强“了。因此接下来我们需要: #### 向 AR Scene 中添加 3D 对象 ARKit 会自动匹配 SceneKit 坐标空间与现实世界坐标空间,所以在某个真实的位置放置物体就和在 SceneKit 里用常规方法设置某对象的位置一样简单。只要使用现实世界单位向场景中添加 3D 内容,尺寸就是正确的。SceneKit 里的所有单位都是米。 在 SimpleShapeViewController 里面,我为 view 添加了 gesture recognizer 来接收点击事件。事件发生时,我会在相机前1米处放一个球。为了更好玩,我们可以用随机颜色和半径,同时2指触摸则会生成方块。 如果你从没有用过 SceneKit,下面是快速介绍: 1. SCNScene 是由 SCNNode 组成的层级结构。每个 scene 都有一个 rootNode,可以包含多个摄像机 camera node 和灯光 light node、一个 physicsWorld 以及动画效果。 2. SCNNode 是一个 3D 对象,拥有在父坐标空间里的 position、transform、scale、rotation 和 orientation。每个 node 都可以有 childNodes,同时还有其他属性如 name(基本上都是用来在场景中查找node)和 isHidden 等等。 3. SCNGeometry 是组成 3D 多边形的网格(顶点集合)。一个 SCNNode 只能有一个 geometry。本文会用到 SCNSphere 和 SCNBox,但还有其他很多选择。 4. SCNMaterial 是视觉属性的集合,定义了几何体(geometry)的外观,如颜色或纹理。每个 SCNGeometry 都可以由多种 materials 并互相交互。 5. SCNPhysicsBody 定义了 node 与其它 node 交互的方式,这是物理引擎的一部分。 下面我们要创建一个简单的球体 node,所以先创建一个几何体: ``` let radius = (0.02...0.06).random() // 为了更好玩 let sphere = SCNSphere(radius: CGFloat(radius)) ``` 还需要颜色,所以用随机颜色来创建 material: ``` let color = SCNMaterial() color.diffuse.contents = self.randomColor() // 返回 UIColor sphere.materials = [color] ``` 还可以为 material 提供大量阴影属性来改变几何体的外观。例如,可以将 lightingModel 设置为 .physicallyBased 以获得更真实的光照效果;把 diffuse.contents 设置为 UIImage 来应用位图纹理。 其实还有很多属性可以用,可以去读读 SCNMaterial Documentation。 下面,创建一个 node 来容纳我们的几何体: ``` let sphereNode = SCNNode(geometry: sphere) ``` 我们想把 node 放在相机前方1米的位置,也就是相机坐标空间的 Z 轴上。同时我们也希望几何体能够面向用户。 ``` let camera = self.sceneView.pointOfView! let position = SCNVector3(x: 0, y: 0, z: -1) sphereNode.position = camera.convertPosition(position, to: nil) sphereNode.rotation = camera.rotatio ``` 首先获取摄像头 camera,它是用户在模拟世界中的参考点。 组合 X,Y,Z 坐标并将 node 沿着 Z 轴向后“推”1米即是我们的目标位置。即 node 相对于摄像头的位置。 然后使用 convertPosition 方法将目标位置转换为相对世界本身(nil 即为默认世界坐标空间)。同时还要匹配 camera 的 rotation 以便让 node 面向用户(球体看不出来,但方块 cube 和其它形状就能看出来了)。 最后一步是把 node 添加到场景里: ``` self.sceneView.scene.rootNode.addChildNode(sphereNode) ``` 现在我们触摸屏幕,就可以创建如图1的球和方块。它们会被锚定在现实世界位置上。 图1  创建球体和方块 图1 创建球体和方块 #### 检测并视觉化水平面 下面移步 PlaneMapperViewController,想办法视觉化 ARKit 检测到的水平面。平面检测功能可以通过 ARWorldTrackingSessionConfiguration 的 planeDetection 属性来开启。本文写作时,只支持 PlaneDetection.horizontal(水平面),但 Apple 肯定正在为垂直面而努力。 使用 ARSCNView 时,对应的 SCNSceneRenderer 会自动为所有检测到的 ARPlaneAnchor 实例创建 node,这些 ARPlaneAnchor 由 ARSession 提供。可以通过 ARSCNViewDelegate 协议来获取它们。 首先要让 view controller 采用此协议,然后将 view controller 的引用传递给 sceneView。 ``` sceneView.delegate = self ``` 如果 ARSession 检测到了某个平面,就会收到下面的消息: ``` func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { guard let planeAnchor = anchor as? ARPlaneAnchor else { return } // 对新平面进行处理 } ``` 我们的计划是为生成的 node 添加一个 child node,这个 child node 会用科幻风格的蓝色网格覆盖平面。作为 child node,其所有位置都是相对于 parent node 的坐标空间的。只要匹配 ARPlaneAnchor 的尺寸 size 和中心 center,然后继承 parent node 的 position。 注意,parent node 并没有几何体 geometry,而且它的位置 position 在随后回调 didUpdate 之后才会准确。 下面我们先创建一个 SCNBox 几何体并匹配平面 plane 在 X 轴和 Y 轴上的范围 extent: ``` let plane = SCNBox(width: CGFloat(planeAnchor.extent.x), height: 0.005, length: CGFloat(planeAnchor.extent.z), chamferRadius: 0) ``` 注意这里也可以用 SCNPlane(不过要应用 transform 因为其方向是垂直的),但我发现 SCNPlane 太薄了,如果加上物理引擎效果就不行了。为我们的 SCNBox 提供 5mm 的高度就足够了。 然后添加一个简单的贴图 material 材质: ``` let material = SCNMaterial() let img = UIImage(named: "tron_grid") material.diffuse.contents = img plane.materials = [material] ``` 下面,将 node 的位置匹配平面的 center X 和 Z。Y 轴设置为 -5mm 来平衡几何体的高度。还要注意,planeAnchor.center.y 永远为 0。 ``` let planeNode = SCNNode(geometry: plane) planeNode.position = SCNVector3Make(planeAnchor.center.x, -0.005, planeAnchor.center.z) ``` 最后,将我们的 planeNode 添加为 renderer 提供的 node 的 child。renderer 提供的 node 与所识别的 ARPlaneAnchor 相互关联,随着 ARSession 从传感器收集到越来越多的信息,ARPlaneAnchor 也会随之更新。 ``` node.addChildNode(planeNode) ``` 这段代码写完后,已经可以在屏幕上显示蓝色网格平面了,但还不够好,因为这个平面无法保持更新。 ARKit 会持续监测当前的 anchor 以确定它们是否被移动或改变大小。如果用户在场景里移动设备,计算机视觉算法对环境的理解就会加深,也就会识别出新的水平面,同时已经识别的小平面也有可能会合并为更大的平面。在这个过程中会调用 ARSCNViewDelegate。 在 didAdd 方法的结尾,需要把新 node 的引用存到字典里以便在后面进行更新。 ``` let key = planeAnchor.identifier.uuidString planes[key] = planeNode ``` 如果 ARKit 决定更新某个已存在的平面,只要重新设置一下正确的 geometry 和 position 即可: ``` func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) { guard let planeAnchor = anchor as? ARPlaneAnchor else { return } let key = planeAnchor.identifier.uuidString if let existingPlane = self.planes[key] { if let geo = existingPlane.geometry as? SCNBox { geo.width = CGFloat(planeAnchor.extent.x) geo.length = CGFloat(planeAnchor.extent.z) } existingPlane.position = SCNVector3Make(planeAnchor.center.x, -0.005, planeAnchor.center.z) } } ``` 由于 SCNNode 和 SCNGeometry 都是引用类型,所以对它们的属性做更改也会应用到以后的每一帧。 还有最后一件事,如果平面从场景中移除了,要进行相应的处理。如果多个已存在的小平面合并为一个大平面,就会出现这种情况。调用 removeFromParentNode 来进行清理: ``` func renderer(_ renderer: SCNSceneRenderer, didRemove node: SCNNode, for anchor: ARAnchor) { guard let planeAnchor = anchor as? ARPlaneAnchor else { return } let key = planeAnchor.identifier.uuidString if let existingPlane = self.planes[key] { existingPlane.removeFromParentNode() planes.removeValue(forKey: key) } } ``` 注意 didRemove 不会在用户的视线离开平面(也就是平面不再显示在屏幕上)时调用。即使用户看不见某个 node,它还是会在内存里,而且相对于用户当前指向的位置是固定不动的。 ARKit 的强大正体现在这里。ARKit 的引擎会构建现实世界的数字化表示,而且能够记住之前看到的内容。你可以自己测试一下,在某个房间里放置几个对象,然后走到另一个房间里转几圈再回去。你会惊奇地发现,这些对象依然被锚定在刚刚的位置。 好了,现在运行我们的 PlaneMapperViewController,然后拿着摄像头四处看看。 图2  运行PlaneMapperViewController的结果 图2 运行 PlaneMapperViewController 的结果 需要一段时间才能看到识别的平面,但识别之后就会快速扩大了。你会发现,平面识别并不完美,蒲团的范围并不准确。不过 ARKit 通常能识别多层平面,例如桌子还有下面的地板。 我还发现,向前、向后移动相机有助于 ARKit 更快速地识别平面。 #### 为 AR Scene 添加物理作用 现在我们已经检测到了平面并在相应位置放了 node 以视觉化平面,下面来给场景 scene 添加物理作用。和第一个 Demo 类似,创建逻辑实现在摄像头前1米处丢下方块,但这次我们会给方块加上大理石贴图纹理,这样会显得更加真实: ``` let dimension: CGFloat = 0.2 let cube = SCNBox(width: dimension, height: dimension, length: dimension, chamferRadius: 0) // 设置纹理 // 创建 node(cubeNode) // 设置位置 ``` 我省略了上面的一部分代码,如果想了解可以参照源代码。 下面,为 node 关联 SCNPhysicsBody: ``` let physicsBody = SCNPhysicsBody(type: .dynamic, shape: SCNPhysicsShape(geometry: box, options: nil)) physicsBody.mass = 1.25 physicsBody.restitution = 0.25 physicsBody.friction = 0.75 physicsBody.categoryBitMask = CollisionTypes.shape.rawValue boxNode.physicsBody = physicsBody ``` 物理作用 API 非常简单,.dynamic 设置表示此对象既会受到力(重力)的影响,也会受到碰撞的影响。而 mass、restitution和friction 属性则是物理实体 physics body 的一些属性。categoryBitMask 用于管理碰撞检测,后面会详细介绍。 此外,还要更新生成的平面 node,让它们也有物理实体: ``` let body = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: plane, options: nil)) body.restitution = 0.0 body.friction = 1.0 planeNode.physicsBody = body ``` .kinematic 类型的实体不会被力和碰撞移动,但会导致其他对象(例如 .dynamic 类型的方块)与其碰撞,同时也可以移动(和 .static 类型不同)。 还需要另一个物理实体,我把它称为“世界尽头”,放在真实世界的底部。用于捕获所有自由落体的方块,因为这些方块没有落在任意一个平面 node 上,然后把这些 node 销毁从而防止无限模拟下去。否则如果用户添加了一堆这样的方块,设备的内存就会很快耗尽。 ``` // 尺寸要大,涵盖整个世界 let bottomPlane = SCNBox(width: 1000, height: 0.005, length: 1000, chamferRadius: 0) // 用透明的 material 来隐藏实体 let material = SCNMaterial() material.diffuse.contents = UIColor(white: 1.0, alpha: 0.0) bottomPlane.materials = [material] // 放在下方 10 米处 let bottomNode = SCNNode(geometry: bottomPlane) bottomNode.position = SCNVector3(x: 0, y: -10, z: 0) // 应用物理动理学(kinematic physics), 与 shape 类别碰撞 let physicsBody = SCNPhysicsBody.static() physicsBody.categoryBitMask = CollisionTypes.bottom.rawValue physicsBody.contactTestBitMask = CollisionTypes.shape.rawValue bottomNode.physicsBody = physicsBody sceneView.scene.rootNode.addChildNode(bottomNode) ``` SceneKit 会自动负责方块与检测到的平面 node 之前的碰撞。但如果它掉到了世界尽头,就需要自己写一些逻辑来移除这些发生碰撞的方块 node 了。所以,下面实现 SCNPhysicsContactDelegate 协议: ``` sceneView.scene.physicsWorld.contactDelegate = self ``` 如果检测到了碰撞,就会得到下面的回调: ``` func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) { let mask = contact.nodeA.physicsBody!.categoryBitMask | contact.nodeB.physicsBody!.categoryBitMask if CollisionTypes(rawValue: mask) == [CollisionTypes.bottom, CollisionTypes.shape] { if contact.nodeA.physicsBody!.categoryBitMask == CollisionTypes.bottom.rawValue { contact.nodeB.removeFromParentNode() } else { contact.nodeA.removeFromParentNode() } } } ``` 还记得 categoryBitMask 和 contactTestBitMask 这两个属性吗?它们刚刚被我们添加到物理实体上。使用这两个属性来确定需要移除的 node。下面是 CollisionTypes 的定义: ``` struct CollisionTypes : OptionSet { let rawValue: Int static let bottom = CollisionTypes(rawValue: 1 << 0) static let shape = CollisionTypes(rawValue: 1 << 1) } ``` 现在运行场景,我们可以开始丢方块啦。 图3  运行场景丢方块 图3 运行场景丢方块 我喜欢把方块丢在桌子的边缘,然后静静看着它们滑落。在 Demo 应用里,你也可以隐藏平面的视觉效果,看起来会更加真实。 图4  隐藏平面的视觉效果图 图4 隐藏平面的视觉效果图 #### 将 3D 模型锚定为真实世界物体 上面的 Demo 很好玩,给 AR 世界添加了随机的图形,但为了营造更加沉浸式的体验,我们下面来添加 3D 模型,就像真实世界里的东西一样。为了模糊增强内容和真实世界之间的界限,需要使用高分辨率和精确尺寸的模型。 第一步,也是最难的一步就是找到合适的模型。Xcode 可以导入 Collada (.dae) 和 SceneKit (.scn) 文件,也可以把 .dae 转换为 .scn 来使用更高级的内建编辑功能。3D 模型和纹理都要放在 .scnassets 文件里,.scnassets 会有特殊的逻辑可以规范化模型,并支持 App 瘦身(thinning)和按需加载。 有一些网站上可以下载到免费或付费的模型,并直接导入 SceneKit scene。下面简单介绍几个: 1. Google 3D Warehouse:用 Google 的 SketchUp 软件构建的免费模型。 2. yobi3D:大量中低品质的模型,大部分是免费的。 3. TurboSquid:收集了专业的高质量模型。 在我们的例子里,我用了一个蜡烛模型,如图5所示。 图5  蜡烛模型 图5 蜡烛模型 如果你找到了想放进 AR 环境的模型,这种模型一般是无法直接使用的。需要对模型进行一些微调,以便随后轻松导入 SceneKit。我建议下载 Blender,开源的 3D 建模程序,可以确保模型符合以下标准: 1. 由单一对象组成,以便一键导入 AR scene。 - 使用 Blender,按住 shift 选择多个对象(包括网格)然后选择左侧工具栏上的“Join”就可以组合。 2. 删掉摄像机或光源。AR 场景里不需要这些,因为 ARKit 会负责处理摄像机和光源。 3. 现实世界比例。这是最重要的一点,否则模型可能会和你家一样大。 - 右下角的菜单 Scene→Units 可以改变模型世界的测量单位。 - 可以用左侧的 Grease Pencil→Ruler/Protractor 选项来测量模型。 - 右下角的 Object→Transform 菜单或左上角的 Tools→Transform 菜单可以缩放模型。 - 要将当前缩放比例设置为新的100%,选中对象然后点击 Object→Apply→Scale。 4. 正确定位在局部(local)坐标空间里。 - 例如蜡烛,设置模型的位置使烛台底部在 Y 零处,并在 X 和 Y 轴上居中。 5. 正确的纹理,保证在真实世界环境里的视觉效果。 - 可用在 Xcode 里的 “物理基础(Physically Based)”光照模型效果很好。 需要注意的是,几乎上面的所有操作都可以在 Xcode 本身完成,除了合并对象和改变相对比例。如果你发现需要在 Xcode 里输入极小的比例值,这时可以使用 Blender 来重新调整模型的总体尺寸。 此外,如果你发现在 Xcode 里更新了模型,但在设备上的场景里没有反映,这时可以卸载 App 并清理 build 文件夹。 准备好模型后,开始为 PlaneAnchorViewController 添加逻辑。如果用户点击了 view,就会在场景里执行“命中测试(hit test)”来找出手指点击的水平面。“命中测试”是 ARSCNView 的一个内建功能: ``` @IBAction func tapScreen(sender: UITapGestureRecognizer) { guard sender.state == .ended else { return } let point = sender.location(in: sceneView) let results = sceneView.hitTest(point, types: [. existingPlaneUsingExtent, .estimatedHorizontalPlane]) attemptToInsertMugIn(results: results) } ``` 使用 .existingPlaneUsingExtent 类型来匹配已经识别到的平面。还有一种类型是 .existingPlane,这个类型会忽略边界、把每个平面都看作无限延伸的平面,所以蜡烛可能会被悬空放在平面延伸出去的地方。 estimatedHorizontalPlane 选项则会在没有识别到平面的情况下,利用特征点来近似估算平面,可以增加放置蜡烛的机会。 如果命中测试找到了匹配点,就可以用 3D 模型来实例化一个 node。由于我们已经用 Blender 缩放和居中了模型,所以这部分就相当简单啦。 ``` if let match = results.first { let scene = SCNScene(named: "art.scnassets/candle/candle.scn")! let node = scene.rootNode.childNode(withName: "candle", recursively: true)! let t = match.worldTransform node.position = SCNVector3(x: t.columns.3.x, y: t.columns.3.y, z: t.columns.3.z) sceneView.scene.rootNode.addChildNode(node) } ``` 构建并运行,现在我们就有了许许多多个蜡烛。 图6  运行最终效果图 图6 运行最终效果图 ### 总结 用 ARKit 框架可以做的事有无数种可能,而本文只是抛砖引玉。ARKit 也可以和 SpriteKit、Metal、Vision 和 CoreML 等框架结合使用来实现强大的功能。Apple 通过 ARKit 建立的平台首次让 3D 用户体验变得触手可及。 #### 附录 GitHub 示例代码:https://github.com/josephchang10/arkit-demo