ARKit 从零到一:教你编写 AR 立方体、平面检测与视觉效果、放置几何体并应用物理学

1,548 阅读18分钟
原文链接: mp.weixin.qq.com

教你编写 AR 立方体

本文将会使用 ARKit 创建一个相当简单的 hello world AR app,结束时就可以在增强世界里放置 3D 立方体,并且可以用 iOS 设备绕着它移动。

要渲染 ARKit 中的 3D 内容,我会使用 SceneKit:developer.apple.com/scenekit/ 这是在 iOS 设备上渲染 3D 图形的框架。如果了解基础的 3D 概念,这玩意儿就相当简单了。

如果等不及看到文章最后,这是 app 的录屏。可以看到用 ARKit 可以在真实世界中放置虚拟物体,并且在移动摄像头时,这个物体会固定在空间中。

虽然这是一个非常简单的 app,我们在后面的文章中继续为其编写更多功能,包括几何检测、3D 物理和很多好玩的东西。

视频

准备

ARKit 目前仅支持 A9/A10 处理器的 iOS 设备。表示你需要一台 iPhone 6s 或以上的设备,当然也可以是 iPad 2017 款或以上的设备。

软件则需要安装:

iOS 11 Beta:developer.apple.com/download/

Xcode 9 Beta:developer.apple.com/download/

这样就准备就绪了。

创建项目

首先打开 Xcode,选择 ARKit 项目模板:

Xcode 9 Beta - 新建项目模板选择器

填上项目信息,确保 Content Technology 选项选择了 “SceneKit”。默认可能是 “SpriteKit”,这是用来做 2D 渲染的,我们需要的是 “SceneKit”,这才是用于 3D 渲染的。

在你的设备上运行项目,如果没有异常的话应该能看见实时摄像头视频,还有一架飞机的 3D 模型,这个模型被放置在了物理空间中。

来回移动,看看飞机是如何固定在真实世界中的:

这个初始项目中的例子其实比马上要创建的项目更高级,但重点是学习如何从头编写 ARKit 项目,所以打开项目,移除 viewDidLoad 方法中所有的代码(除了 super 调用的那句)。

ARKit 核心类

ARSCNView——这是一个助手类,帮我们用 SceneKit 渲染的 3D 内容来增强实时摄像头视图。这个类做了下面几件事:

  • 在视图中渲染设备摄像头的实时视频流,并就其设置为 3D 场景的背景

  • ARKit 的 3D 坐标系会匹配 SceneKit 的 3D 坐标系,所以此视图渲染的对象会自动匹配增强后的 ARKit 世界视图

  • 自动移动虚拟 SceneKit 3D 摄像头来匹配 ARKit 追踪到的 3D 位置,所以不需要再写代码连接 ARKit 移动事件与 SceneKit 3D 渲染。

ARSession——每个增强现实会话都都需要有一个 ARSession 实例。它负责控制摄像头、聚合所有来自设备的传感器数据等等以构建无缝体验。ARSCNView 实例已经有 ARSession 实例,只需要在开始的时候配置一下。

ARWorldTrackingSessionConfiguration——这个类会告诉 ARSession,在真实世界中追踪用户时需要使用六个自由度,roll、pitch、yaw 以及 X轴、Y轴、Z轴上的变换。如果不用这个类,就只能创建在同一个点旋转查看增强内容的 AR 体验。有了这个类,就可以在 3D 空间里绕着物体移动了。如果你不需要在 X轴、Y轴、Z轴上的变换,用户就会在投影增强内容时保持在固定位置,这时可以用 ARSessionConfiguration 类替代此类来初始化 ARSession 实例。

本文仅需使用这三个类,当然 ARKit 还有很多类,但我们才刚起步,这些就足够了。回到项目,可以看到在 viewWillAppear 方法中初始化了 ARSession 实例,self.sceneView 指向一个 ARSCNView 实例。

override func viewWillAppear(_ animated: Bool) {

        super.viewWillAppear(animated)

        // Create a session configuration

        let configuration = ARWorldTrackingSessionConfiguration()

        // Run the view's session

        sceneView.session.run(configuration)

    }

绘制立方体

下面我要用 SceneKit 来绘制 3D 立方体。SceneKit 有一些基础类,SCNScene 是所有 3D 内容的容器,可以向其添加多个 3D 几何体,分别是不同的位置、旋转、缩放等等。

要向 scene 中添加内容,首先要创建 Geometry,geometry 可以是负责的形状,也可以是简单的形状如球、立方体、平面等等。然后将 geometry 包装为 scene node 并将其添加到 scene 中。然后 SceneKit 会遍历 scene graph 并渲染内容。

为了添加 scene 并绘制立方体(cube), 需要在 viewDidLoad 方法里添加如下代码:

override func viewDidLoad() {

        super.viewDidLoad()

        // 存放所有 3D 几何体的容器

        let scene = SCNScene()

        // 想要绘制的 3D 立方体

        let boxGeometry = SCNBox(width: 0.1, height: 0.1, length: 0.1, chamferRadius: 0.0)

        // 将几何体包装为 node 以便添加到 scene

        let boxNode = SCNNode(geometry: boxGeometry)

        // 把 box 放在摄像头正前方

        boxNode.position = SCNVector3Make(0, 0, -0.5)

        // rootNode 是一个特殊的 node,它是所有 node 的起始点

        scene.rootNode.addChildNode(boxNode)

        // 将 scene 赋给 view

        sceneView.scene = scene

    }

ARKit 中的坐标单位为米,所以我们就创建了一个 10x10x10 厘米的盒子。

ARKit 和 SceneKit 的坐标系看起来就像这样:

因为摄像头面对负 Z 轴方向,所有上面的代码就是把 box 置于摄像头前 -0.5 单位。

ARSession 开始时,摄像头 position 被初始化为 X=0, Y=0, Z=0。

如果现在运行此例,应该可以看见浮在空中的小小 3D 立方体,尝试绕着它走动,它还会保留原处。它应该是全方位无死角的,哪怕从下面、从上面看过去。

如果想在 3D 场景中添加一些默认光照以便看清立方体的边缘,可以设置 SCNScene 实例的 autoenablesDefaultLighting 属性:

sceneView.autoenablesDefaultLighting = true

后面的文章里会为它添加更高级的光照。

示例代码

所有的示例代码都在这里:github.com/josephchang…

平面检测与视觉效果

ARKit——检测平面并绘制地板

在第一个 hello world ARKit app 里我们给项目做了初始设置,并在真实世界里渲染了一个 3D 立方体,在用户移动时也能保持追踪。

这篇文章中,我会尝试从真实世界中获取 3D 几何体并给它添加视觉效果。检测几何体对于增强现实 app 来说非常重要,因为要让用户感觉在和真实世界交互,就必须要知道用户是否敲击了桌面,或是正在看向地板,亦或是与其它表面进行像生活中一样的交互。本文会搞定平面检测,后面的文章则会使用这些平面并在真实世界里放置虚拟物体。

ARKit 可以检测水平面(我猜测 ARKit 未来能够检测更复杂的 3D 几何体,但应该要等到深度感应摄像头的发布,也许就是 iPhone 8 吧…)。检测到平面后,我会给它加上视觉效果来显示平面的尺寸和角度。下面的视频展示了实际效果:

视频

注意:本文需要你参考此处的代码:github.com/josephchang…

计算机视觉概念

写代码前,有必要了解一下 ARKit 的背后原理,因为这项技术还不完美,在某些情况下 app 的表现还会受到影响。

增强现实的目标是往真实世界中的特定点插入虚拟内容,并且在真实世界中移动时还能对此虚拟内容保持追踪。ARKit 的基本流程包括从 iOS 设备摄像头中读取视频帧,对每一帧的图片进行处理并获得特征点。特征有很多很多,但我们需要从图片中找出能在多个帧中都被追踪到的特征。特征可能是物体的某个角,或是有纹理的织物的某条边等等。有很多种方式可以生成这些特征,可以在网上了解更多(例如搜索 SIFT)但目前我们只需知道,每张图片里会产生多个唯一标识的特征就足够了。

获得某张图片的特征后,就可以从多个帧中追踪这些特征,随着用户在世界中移动,就可以利用相应的特征点来估算 3D 姿态信息,例如当前摄像头的位置和特征的位置。用户移动地越多,就会获得越多的特征,并优化这些估算的 3D 姿态信息。

至于平面检测,就是在获得一定数量的 3D 特征点后,尝试在这些点中安装一些平面,然后根据尺度、方向和位置找出最匹配的那个。ARKit 会不断分析 3D 特征点并在代码中报告找到的平面。

下面是我的 iPad 看向窗帘时的截图。可以看到织物有良好的纹理,所以追踪到了大量的唯一特征,每个十字都是 ARKit 找到的唯一特征。

ARKit 检测特征点——织物窗帘

下一张图是我的桌子,注意并没有多少特征点:

ARKit 检测特征点——反光的桌子上特征很少

所以一定要注意 ARKit 需要看向能检测出许多有用特征点的内容。可能检测不出特征点的情况如下:

  1. 光线差——没有足够的光或光线过强的镜面反光。尝试避免这些光线差的环境。

  2. 缺少纹理——如果摄像头指向一面白墙,那也没法获得特征,ARKit 也去无法找到并追踪用户。尝试避免看向纯色、反光表面等地方。

  3. 快速移动——通常情况下检测和估算 3D 姿态只会借助图片,如果摄像头移动太快图片就会糊,从而导致追踪失败。但 ARKit 会利用视觉惯性里程计,综合图片信息和设备运动传感器来估计用户转向的位置。因此 ARKit 在追踪方面非常强大。

在后面的文章里我会测试不同的环境,以便了解追踪的效果。

添加 Debug 的视觉效果

开始前有必要给应用添加一些 debug 信息,比如渲染 ARKit 报告的世界原点以及渲染 ARKit 检测到的特征点,有助于了解当前区域追踪是否良好。所以,为我们的 ARSCNView 实例开启 debug 选项:

sceneView.debugOptions = [ARSCNDebugOptions.showWorldOrigin, ARSCNDebugOptions.showFeaturePoints]

检测平面几何体

如果想在 ARKit 里检测水平面,可以通过设置 session configuration 对象的 planeDetection 属性来指定。这个值可以被设置为 ARPlaneDetectionHorizontal 或 ARPlaneDetectionNone。

设置该属性后,就会开始收到 ARSCNViewDelegate 协议 delegate 方法的回调。这其中有很多方法,首先要使用的是:

/**

有新的 node 被映射到给定的 anchor 时调用。

@param renderer 将会用于渲染 scene 的 renderer。

@param node 映射到 anchor 的 node。

@param anchor 新添加的 anchor。

*/

- (void)renderer:(id <SCNSceneRenderer>)renderer

      didAddNode:(SCNNode *)node

       forAnchor:(ARAnchor *)anchor {

}

每次 ARKit 自认为检测到了平面时都会调用此方法。其中有两个信息,node 和 anchor。SCNNode 实例是 ARKit 创建的 SceneKit node,它设置了一些属性如 orientation(方向)和 position(位置),然后还有一个 anchor 实例,包含此锚点的更多信息,例如尺寸和平面的中心点。

anchor 实例实际上是 ARPlaneAnchor 类型,从中我们可以得到平面的 extent(范围)和 center(中心点)信息。

渲染平面

有了上述信息,现在可以在虚拟世界里绘制 SceneKit 3D 平面了。创建一个继承自 SCNNode 的 Plane 类。在构造方法中创建平面并相应调整其大小:

// 用 ARPlaneAnchor 实例中的尺寸来创建 3D 平面几何体

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

let planeNode = SCNNode(geometry: planeGeometry)

// 将平面 plane 移动到 ARKit 报告的位置

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

// SceneKit 里的平面默认是垂直的,所以需要旋转90度来匹配 ARKit 中的平面

planeNode.transform = SCNMatrix4MakeRotation(Float(Double.pi/2), 1, 0, 0)

// 因为继承自 SCNNode,所以将新的 node 添加给自己

addChildNode(planeNode)

现在有了 Plane 类,回到 ARSCNViewDelegate 的回调方法,每次 ARKit 报告新的 Anchor 时都可以创建新的平面了:

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {

        guard let anchor = anchor as? ARPlaneAnchor else {

            return

        }

        let plane = Plane(withAnchor: anchor)

        node.addChildNode(plane)

    }

注意:实际的代码里为了让视觉效果更好看,我还给 SCNPlane 几何体设置了网格 material,我精简了上面为了代码,这样看起来更简洁。

更新平面

如果运行上面的代码,走来走去看看,可以发现虚拟世界里会渲染新的平面,但是平面不会正确扩大。ARKit 会持续分析场景,如果发现 Plane 比预想的更大/更小,就会更新平面的范围 extent 值。所以需要更新 SceneKit 已渲染的 Plane。

从从一个 ARSCNViewDelegate 方法中获取更新信息:

func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {

        // 查看此平面当前是否正在渲染

        guard let plane = planes[anchor.identifier] else {

            return

        }

        plane.update(anchor: anchor as! ARPlaneAnchor)

    }

在 Plane 类的 update 方法里,更新 plane 的宽度和高度:

func update(anchor: ARPlaneAnchor) {

        planeGeometry.width = CGFloat(anchor.extent.x);

        planeGeometry.height = CGFloat(anchor.extent.z);

        // plane 刚创建时中心点 center 为 0,0,0,node transform 包含了变换参数。

        // plane 更新后变换没变但 center 更新了,所以需要更新 3D 几何体的位置

        position = SCNVector3Make(anchor.center.x, 0, anchor.center.z)

    }

现在有了平面渲染和更新,打开 app 看看吧。我给 SCNPlane 几何体添加了科幻风的网格纹理,这里省略了此部分,但你可以在源代码里查看。

获取结果

下面贴了几张文章开头视频的截图,这些是我在房子里走来走去时检测到的平面:

这是楼梯上的灭火器箱,ARKit 正确找出边缘,而且平面的角度也完全正确,符合其高出地板的表面。

这是楼梯的地面,可以看到在我移动时 ARKit 也在不断发现新平面,这挺有意思,因为如果你在开发某个 app,用户需要先在空间里转一圈,然后才能放东西,所以应该在几何体成为可用状态时为用户提供良好的视觉提示。

下面这张图和上一张图是同一个场景,但几秒之后,ARKit 就把两个平面合并为同一个平面。注意,在 ARSCNViewDelegate 回调里你要处理 ARKit 删除某个 ARPlaneAnchor 实例的情况,也就是说该平面被合并掉了。

这儿很有意思,因为我站在这层楼梯的上一层,距离有3米远并且光线不好,但 ARKit 还是找出来这个平面,好厉害!

这是在小小的窗台上捕获的平面。注意平面的边缘超出了实际的表面。

识别心得

这是我对平面检测的几点心得:

  1. 不要期望平面会完全贴合表面,从视频中可以看到,虽然检测到了平面但角度可能不完全正确,所以如果你在开发的 AR app 需要获得非常精确的几何体来提供更好的效果,你可能会失望。

  2. 边缘检测不是特别好,实际的平面范围有时会太大或大小,所以不要尝试做需要准确边缘的 app

  3. 追踪功能很强,速度也很快。可以看到我在视频中移动时,对真实世界的平面检测相当有效,即使快速移动摄像头,效果也同样很好

  4. 我被特征捕获惊艳到,哪怕光线不足、距离3-5米远,ARKit 仍然能找到那些平面。

示例代码

所有的示例代码都在这里:github.com/josephchang…

放置几何体并应用物理学

上一篇文章里我们用 ARKit 来检测真实生活中的平面,然后给这些平面添加了视觉效果。在这篇文章中,我会开始向 AR 体验添加虚拟内容,并与检测到的平面进行交互。

在这篇文章的最后,我们能够把小方块丢到世界里,并在小方块上应用逼真的物理学,以便它们彼此相互作用,同时还会制造迷你冲击波来让小方块飞出去。

下面是实际的演示视频,可以看到首先获取了水平面,然后添加了一些 3D 小方块并与场景交互,最后制造了一些迷你冲击波来让小方块飞出去:

视频

和以前一样,你可以参照这里的代码:github.com/josephchang…

命中测试

在第一篇教程中,我们可以在任意 X,Y,Z 位置插入虚拟 3D 内容,并在真实世界里渲染和追踪。现在我们掌握了平面检测,我想添加一些内容并与这些平面交互。完成后从 app 中看过去,你的桌子、椅子、地板等等上面就像都摆了东西。

在此 app 中,如果用户点击屏幕,就会执行一次命中测试(hit test),即获取屏幕的 2D 坐标,并从摄像头原点处通过屏幕的 2D 坐标(在投影平面上有 3D 位置)发射一道射线到场景中。如果射线与某个平面相交,就会获得命中结果,然后利用射线和平面相交的 3D 坐标,在此 3D 位置放置内容。

这段代码相当简单,ARSCNView 包含一个 hitTest 方法,只要传入屏幕坐标,它会负责从摄像头原点投影射线,并穿过 3D 环境中对应屏幕坐标的点,最后返回结果:

@objc func handleTapFrom(recognizer: UITapGestureRecognizer) {

        // 获取屏幕空间坐标并传递给 ARSCNView 实例的 hitTest 方法

        let tapPoint = recognizer.location(in: sceneView)

        let result = sceneView.hitTest(tapPoint, types: .existingPlaneUsingExtent)

        // 如果射线与某个平面几何体相交,就会返回该平面,以离摄像头的距离升序排序

        // 如果命中多次,用距离最近的平面

        if let hitResult = result.first {

            insertGeometry(hitResult)

        }

    }

有了 ARHitTestResult 就可以得到射线/平面相交点的世界坐标,并在该位置放置虚拟内容。这篇文章里只会插入简单的小方块,后面的文章里则会让物体看起来更真实:

func insertGeometry(_ hitResult: ARHitTestResult) {

        let dimension: CGFloat = 0.1

        let cube = SCNBox(width: dimension, height: dimension, length: dimension, chamferRadius: 0)

        let node = SCNNode(geometry: cube)

        // physicsBody 会让 SceneKit 用物理引擎控制该几何体

        node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: nil)

        node.physicsBody?.mass = 2

        node.physicsBody?.categoryBitMask = CollisionCategory.cube.rawValue

        // 把几何体插在用户点击的点再稍高一点的位置,以便使用物理引擎来掉落到平面上

        let insertionYOffset: Float = 0.5

        node.position = SCNVector3Make(hitResult.worldTransform.columns.3.x, hitResult.worldTransform.columns.3.y + insertionYOffset, hitResult.worldTransform.columns.3.z)

        sceneView.scene.rootNode.addChildNode(node)

          // 将小方块添加到场景中

        boxes.append(node)

    }

添加物理学

AR 理应增强现实世界,所以为了让物体的感觉更加真实,需要添加一些物理学原理来给予重力感。

上面的代码里每个小方块都给定了 physicsBody,以便让 SceneKit 用物理引擎来控制该几何体。每个 ARKit 检测的平面我也给定了 physicsBody,以便让小方块与平面交互(查看 github repo 中的 Plane.swift 类可以获得更多细节)。

停止平面检测

有了一定数量的平面后,就不希望 ARKit 再继续提供新平面了,因为这可能会更新目前已存在的平面,并影响已经添加到世界中的几何体。

在这个 app 里,如果用户用两只手指长按一秒,就会隐藏所有的平面并关闭平面检测。只需更新 ARSession configuration 的 planeDetection 属性并再次 run 一遍 session 即可。默认情况下,session 会保留相同的坐标系以及所有 anchor:

// 获取当前的 session configuration

if let configuration = sceneView.session.configuration as? ARWorldTrackingSessionConfiguration{

              //关闭平面检测和更新

            configuration.planeDetection = .init(rawValue: 0) // ARPlaneDetectionNone

              // 再次 run session 以应用改变

            sceneView.session.run(configuration)

        }

示例代码

所有的示例代码都在这里:https://github.com/josephchang10/ARCube/tree/part3

接下来

下篇文章里会强化之前我们写过的代码,并添加一些 UI 控件来开启/关闭某些功能。还会使用光线和纹理,以便让插入的几何体看起来更加真实。

作者:张嘉夫

链接:http://www.jianshu.com/p/641af448830c

來源:简书

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

一周精选

CocoaChina

C C

iOS App 稳定性指标及监测

一位程序媛眼中的程序员

在GitHub上最受欢迎的大多是库或框架

[iOS]终极横竖屏切换解决方案

让你成为Git和GitHub大神的20个技巧

iOS高级调试&逆向技术

CocoaChina

全球最大苹果开发中文社区