开始之前
本次教学建立在先前 ARKit 教学内容之上。如果你还没有准备好,可以参考我们先前的教学内容。此外,如果可以的话,先为你的 App 找到一个平坦的地方就在好不过了。
我们将会学到什麽?
在这次的教学中,我们专注在 ARKit 的水平面上。我们会先制作一个洋面(水平面),然后放入一艘船在上面(3D 物件)。

或者用灯光建立一支大舰队!

接下来,你会学到关于 ARKit 中的水平面知识。最后我希望在完成这次的教学后,你可以在你的 ARKit 专案中能顺利的使用水平面。
作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:1012951431,不管你是大牛还是小白都欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!
什麽是水平面
所以当我们说到 ARKit 中的水平面时,这个水平面(Horizontal Plane)是什麽东西呢?当我们在 ARKit 中侦测到一个水平面,就技术上来说我们侦测到一个 ARPlaneAnchor。那什麽是 ARPlaneAnchor? ARPlaneAnchor 基本来说是一个包含了被侦测到的水平面资讯的物件。
以下是 Apple 对于 ARPlaneAnchor 的官方叙述:
Information about the position and orientation of a real-world flat surface detected in a world-tracking AR session.
- Apple’s Documentation
让我们开始制作 App 吧
我们将以这个专案开始,所以我们可以专注在 ARKit 的实作上。在 Xcode 打开专案后稍微看一下程式码内容。我已经在 Storyboard 裡建立好了一个 ARSCNView。

Build 及 Run 这个专案来做个快速测试。你应该会在 iOS 模拟器上看到以下画面:

在相机授权上点击 OK 以获得相机权限。如此一来你应该可以看到你的相机画面。
水平面侦测
侦测一个水平面是件很简单的事情,这都要感谢 Apple 工程师们。
只要在 ViewController 裡将以下程式码放入 setUpSceneView() 中就可以了:
configuration.planeDetection = .horizontal
藉由设定 planeDetection 属性为 .horizontal,来告诉 ARKit 找寻任何的水平面。一旦 ARKit 侦测到了一个水平面,水平面就会被加入到 sceneView 的 Session 中。
为了要侦测水平面,我们必须调用 ARSCNViewDelegate Protocol。在以下的 ViewController 类别裡,建立一个 ViewController 类别 Extension 来实作这个 Protocol。
extension ViewController: ARSCNViewDelegate {}
现在在这个类别 Extension 中实作 renderer(_:didAdd:for:) 方法:
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {}
这个 Protocol 方法会在每次有 ARAnchor 被加进 sceneView 的 Session 时被呼叫。一个 ARAnchor 物件代表著 3D 空间中一个物理上的位置及方向。我们会在稍后使用 ARAnchor 来侦测水平面。
接下来,回到 setUpSceneView() 。在 setUpSceneView() 裡面将 sceneView 的 Delegate 指派给 ViewController。
如果你想要的话,也可以设定 sceneView 的除错选项(Debug Options)来显示特徵点(Feature Point)。这可以帮助你找到足够的特徵点位置来侦测水平面。而一个水平面是由很多的特徵点所组成。一旦侦测到足够的特徵点来识别水平面,renderer(_:didAdd:for:) 就会被呼叫。
现在,你的 setUpSceneView() 应该会长的像这样:
func setUpSceneView() {let configuration = ARWorldTrackingConfiguration()configuration.planeDetection = .horizontalsceneView.session.run(configuration)sceneView.delegate = selfsceneView.debugOptions = [ARSCNDebugOptions.showFeaturePoints]}
水平面视觉化
现在,App 会在每次新的 ARAnchor 被加入 sceneView 时收到通知,而我们或许会对新加进的 ARAnchor 是什麽样子感到兴趣。
于是,更新 renderer(_:didAdd:for:) 方法:
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {// 1guard let planeAnchor = anchor as? ARPlaneAnchor else { return }// 2let width = CGFloat(planeAnchor.extent.x)let height = CGFloat(planeAnchor.extent.z)let plane = SCNPlane(width: width, height: height)// 3plane.materials.first?.diffuse.contents = UIColor.transparentLightBlue// 4let planeNode = SCNNode(geometry: plane)// 5let x = CGFloat(planeAnchor.center.x)let y = CGFloat(planeAnchor.center.y)let z = CGFloat(planeAnchor.center.z)planeNode.position = SCNVector3(x,y,z)planeNode.eulerAngles.x = -.pi / 2// 6node.addChildNode(planeNode)}
让我一行一行带你了解这些程式码的意思吧。
- 我们将视为 ARPlaneAnchor 的 anchor 参数安全解包(unwrap)以确认我们有关于现实世界平面的资讯
- 在这边,我们建立一个 SCNPlane 来视觉化 ARPlaneAnchor。SCNPlane 是一个单面的平面几何矩形。我们拿解包的 ARPlaneAnchor 裡的 Extent 中的 X 及 Y 属性来建立一个 SCNPlane。ARPlaneAnchor Extent 是指被侦测到的平面的估计大小。我们取 Extent 的 X 及 Y 来做为 SCNPlane 的高与宽。接著我们给这个平面上一层亮蓝色来模拟水的样子。
- 我们用我们刚建立好的 SCNPlane 几何形来初始化 SCNNode
- 我们初始化
x、y以及z常数来表示planeAnchor中心的 X、Y、Z 座标。这是为了planeNode的座标位置。我们逆时针旋转planeNode的 X尤拉角 90 度,否则planeNode会垂直立于桌上。如果是顺时针旋转,就会变成魔术般的错觉画面了,因为 SceneKit 预设使用一侧的材质来渲染 SCNPlane 。 - 最后,我们将 planeNode 作为子节点放入至新增加的 SceneKit 节点上
Build 及 Run 这个专案。现在你应该能够侦测及视觉化水平面了

扩展水平面
随著 ARKit 收到关于环境的额外讯息,我们可能会希望扩展我们先前侦测的水平面来作更大的平面或更精确地呈现新的资讯。
于是,我们实作 renderer(_:didUpdate:for:) 吧:
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {}
这个方法会在更新 SceneKit 节点的属性好对应相对应的锚点时被呼叫。这可以让 ARKit 改善对水平面位置及范围的估算。
node 参数是锚点更新后的座标位置。anchor 参数则提供了锚点更新后的宽与高。使用这两个参数,我们可以更新先前实作的 SCNPlane 来对应出更新宽高后的的座标位置。
接下来,将以下程式码放入到renderer(_:didUpdate:for:) 中:
// 1guard let planeAnchor = anchor as? ARPlaneAnchor,let planeNode = node.childNodes.first,let plane = planeNode.geometry as? SCNPlaneelse { return }// 2let width = CGFloat(planeAnchor.extent.x)let height = CGFloat(planeAnchor.extent.z)plane.width = widthplane.height = height// 3let x = CGFloat(planeAnchor.center.x)let y = CGFloat(planeAnchor.center.y)let z = CGFloat(planeAnchor.center.z)planeNode.position = SCNVector3(x, y, z)
再一次的,让我一行一行来解释上面的程式码:
- 我们将视为 ARPlaneAnchor 的
anchor参数安全解包。接下来,将node的第一个子节点也安全解包。最后,我们也将视为SCNPlane的planeNode几何形安全解包。我们只取出先前实作的ARPlaneAnchor、SCNNode、SCNplane以及使用相对应的参数更新属性。 - 这裡我们用
planeAnchor范围的 x 及 z 属性来更新plane的宽高。 - 最后,我们将
planeNode的座标位置更新为planeAnchor中心点的 x、y、z 座标
Build 及 Run 专案来确认水平面扩展的实作。

将物件加入至水平面
现在让我们把船隻放到水平面上吧。在初始专案的裡头,我已经包好了一个船型的 3D 物件供你使用。
在 ViewController 类别中插入以下方法来将船隻放在水平面上:
@objc func addShipToSceneView(withGestureRecognizer recognizer: UIGestureRecognizer) {let tapLocation = recognizer.location(in: sceneView)let hitTestResults = sceneView.hitTest(tapLocation, types: .existingPlaneUsingExtent)guard let hitTestResult = hitTestResults.first else { return }let translation = hitTestResult.worldTransform.translationlet x = translation.xlet y = translation.ylet z = translation.zguard let shipScene = SCNScene(named: "ship.scn"),let shipNode = shipScene.rootNode.childNode(withName: "ship", recursively: false)else { return }shipNode.position = SCNVector3(x,y,z)sceneView.scene.rootNode.addChildNode(shipNode)}
这边的程式码与之前教学的内容类似,所以我就不再逐行解释。如果你想要了解更多的话,来看看之前的教学吧。唯一的不同在我们在 types 传送了不一样的参数来侦测 sceneView 中已经存在的平面锚点。
在完成之前,我们还须放入以下程式码:
func addTapGestureToSceneView() {let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(ViewController.addShipToSceneView(withGestureRecognizer:)))sceneView.addGestureRecognizer(tapGestureRecognizer)}
这个方法会将点击手势放入 sceneView 中。
在 viewDidLoad() 中呼叫以下方法好将点击手势放入 sceneView。
addTapGestureToSceneView()
现在如果你 Build 及 Run,你应该能够侦测到一个水平面并视觉化它然后放入一艘超酷的船在上面。

或者一支舰队(使用灯光)

你可以藉由取消 viewDidLoad() 内的 configureLighting() 注解来使用灯光。这个函式非常简单只要两行程式码就可以使用灯光:
sceneView.autoenablesDefaultLighting = truesceneView.automaticallyUpdatesLighting = true
总结
我希望你享受这次的教学内容并且学习到一些有价值的东西。如果你有的话,请藉由分享这篇教学来让我知道。最后,如果你有任何的意见、问题或是建议,欢迎在底下留言。
虽然我不太确定一个人做这个好不好,但你可以在留言中贴上你在什麽地方摆上船隻的照片,我想知道你们会在什麽有趣地方做这件事。
作为参考范例,你可以到 GitHub 上下载最后完成的专案档。
