ARKit介绍
AR 全称 Augmented Reality(增强现实)是一种在视觉上呈现虚拟物体与现实场景结合的技术。Apple 公司在 2017 年 6 月正式推出了 ARKit,iOS 开发者可以在这个平台上使用简单便捷的 API 来开发 AR 应用程序。为了获得 ARKit 的完整功能,需要 A9 及以上芯片。其实也就是大部分运行 iOS 11 的设备,包括 iPhone 6S。
研究过程中,做了一个卷尺的Demo,现在介绍下项目中用到的技术点。
项目实践
iOS 平台的 AR 应用通常由 ARKit 和渲染引擎两部分构成:
ARKit
ARKit 的 ARSession 负责管理每一帧的信息。ARSession 做了两件事:拍摄图像并获取传感器数据;对数据进行分析处理后逐帧输出。如下图:
设备追踪
设备追踪确保了虚拟物体的位置不受设备移动的影响。在启动 ARSession 时需要传入一个 ARSessionConfiguration 的子类对象,以区别三种追踪模式:
- ARFaceTrackingConfiguration
- ARWorldTrackingConfiguration
- AROrientationTrackingConfiguration
其中 ARFaceTrackingConfiguration 可以识别人脸的位置、方向以及获取拓扑结构。此外,还可以探测到预设的 52 种丰富的面部动作,如眨眼、微笑、皱眉等等。ARFaceTrackingConfiguration 需要调用支持 TrueDepth 的前置摄像头进行追踪。
本项目主要是使用ARWorldTrackingConfiguration进行追踪,获取特征点。
追踪步骤
// 创建一个 ARSessionConfiguration.
// 暂时无需在意 ARWorldTrackingSessionConfiguration.
let configuration = ARWorldTrackingSessionConfiguration()
// Create a session.
let session = ARSession()
// Run.
session.run(configuration)
从上面的代码看,运行一个 ARSession 的过程是很简单的,那么 ARSession 的底层如何进行世界追踪的呢?
- 首先,ARSession 底层使用了 AVCaputreSession 来获取摄像机拍摄的视频(一帧一帧的图像序列)。
- 其次,ARSession 底层使用了 CMMotionManager 来获取设备的运动信息(比如旋转角度、移动距离等)
- 最后,ARSession 根据获取的图像序列以及设备的运动信息进行分析,最后输出 ARFrame,ARFrame 中就包含有渲染虚拟世界所需的所有信息。
追踪信息点
AR-World 的坐标系如下,当我们运行 ARSession 时设备所在的位置就是 AR-World 的坐标系原点。
在这个 AR-World 坐标系中,ARKit 会追踪以下几个信息:
- 追踪设备的位置以及旋转,这里的两个信息均是相对于设备起始时的信息。
- 追踪物理距离(以“米”为单位),例如 ARKit 检测到一个平面,我们希望知道这个平面有多大。
- 追踪我们手动添加的希望追踪的点,例如我们手动添加的一个虚拟物体。
追踪如何工作
苹果文档中对世界追踪过程是这么解释的:ARKit使用视觉惯性测距技术,对摄像头采集到的图像序列进行计算机视觉分析,并且与设备的运动传感器信息相结合。ARKit 会识别出每一帧图像中的特征点,并且根据特征点在连续的图像帧之间的位置变化,然后与运动传感器提供的信息进行比较,最终得到高精度的设备位置和偏转信息。
- 上图中划出曲线的运动的点代表设备,可以看到以设备为中心有一个坐标系也在移动和旋转,这代表着设备在不断的移动和旋转。这个信息是通过设备的运动传感器获取的。
- 动图中右侧的黄色点是 3D 特征点。3D特征点就是处理捕捉到的图像得到的,能代表物体特征的点。例如地板的纹理、物体的边边角角都可以成为特征点。上图中我们看到当设备移动时,ARKit 在不断的追踪捕捉到的画面中的特征点。
- ARKit 将上面两个信息进行结合,最终得到了高精度的设备位置和偏转信息。
ARWorldTrackingConfiguration
ARWorldTrackingConfiguration 提供 6DoF(Six Degree of Freedom)的设备追踪。包括三个姿态角 Yaw(偏航角)、Pitch(俯仰角)和 Roll(翻滚角),以及沿笛卡尔坐标系中 X、Y 和 Z 三轴的偏移量:
不仅如此,ARKit 还使用了 VIO(Visual-Inertial Odometry)来提高设备运动追踪的精度。在使用惯性测量单元(IMU)检测运动轨迹的同时,对运动过程中摄像头拍摄到的图片进行图像处理。将图像中的一些特征点的变化轨迹与传感器的结果进行比对后,输出最终的高精度结果。
从追踪的维度和准确度来看,ARWorldTrackingConfiguration 非常强悍。但如官方文档所言,它也有两个致命的缺点:
- 受环境光线质量影响
- 受剧烈运动影响
由于在追踪过程中要通过采集图像来提取特征点,所以图像的质量会影响追踪的结果。在光线较差的环境下(比如夜晚或者强光),拍摄的图像无法提供正确的参考,追踪的质量也会随之下降。
追踪过程中会逐帧比对图像与传感器结果,如果设备在短时间内剧烈的移动,会很大程度上干扰追踪结果。
追踪状态
世界追踪有三种状态,我们可以通过 camera.trackingState 获取当前的追踪状态。
从上图我们看到有三种追踪状态:
- Not Available:世界追踪正在初始化,还未开始工作。
- Normal: 正常工作状态。
- Limited:限制状态,当追踪质量受到影响时,追踪状态可能会变为 Limited 状态。
与 TrackingState 关联的一个信息是 ARCamera.TrackingState.Reason,这是一个枚举类型:
- case excessiveMotion:设备移动过快,无法正常追踪。
- case initializing:正在初始化。
- case insufficientFeatures:特征过少,无法正常追踪。
- case none:正常工作。
我们可以通过 ARSessionObserver 协议去获取追踪状态的变化,比较简单,可以直接查看接口文档。
ARFrame
ARFrame 中包含有世界追踪过程获取的所有信息,ARFrame 中与世界追踪有关的信息主要是:anchors 和 camera:
- camera: 含有摄像机的位置、旋转以及拍照参数等信息。
var camera: [ARCamera]
- ahchors: 代表了追踪的点或面。
var anchors: [ARAnchor]
ARAnchor
- ARAnchor 是空间中相对真实世界的位置和角度。
- ARAnchor 可以添加到场景中,或是从场景中移除。基本上来说,它们用于表示虚拟内容在物理环境中的锚定。所以如果要添加自定义 anchor,添加到 session 里就可以了。它会在 session 生命周期中一直存在。但如果你在运行诸如平面检测功能,ARAnchor 则会被自动添加到 session 中。
- 要响应被添加的 anchor,可以从 current ARFrame 中获得完整列表,此列表包含 session 正在追踪的所有 anchor。
- 或者也可以响应 delegate 方法,例如 add、update 以及 remove,session 中的 anchor 被添加、更新或移除时会通知。
ARCamera
每个 ARFrame 都会包含一个 ARCamera。ARCamera 对象表示虚拟摄像头。虚拟摄像头就代表了设备的角度和位置。
- ARCamera 提供了一个 transform。transform 是一个 4x4 矩阵。提供了物理设备相对于初始位置的变换。
- ARCamera 提供了追踪状态(tracking state),通知你如何使用 transform,这个在后面会讲。
- ARCamera 提供了相机内部功能(camera intrinsics)。包括焦距和主焦点,用于寻找投影矩阵。投影矩阵是 ARCamera 上的一个 convenience 方法,可用于渲染虚拟你的几何体。
场景解析
场景解析主要功能是对现实世界的场景进行分析,解析出比如现实世界的平面等信息,可以让我们把一些虚拟物体放在某些实物处。ARKit 提供的场景解析主要有平面检测、场景交互以及光照估计三种,下面逐个分析。
平面检测(Plane detection)
- ARKit 的平面检测用于检测出现实世界的水平面。
上图中可以看出,ARkit 检测出了两个平面,图中的两个三维坐标系是检测出的平面的本地坐标系,此外,检测出的平面是有一个大小范围的。
- 平面检测是一个动态的过程,当摄像机不断移动时,检测到的平面也会不断的变化。下图中可以看到当移动摄像机时,已经检测到的平面的坐标原点以及平面范围都在不断的变化。
- 此外,随着平面的动态检测,不同平面也可能会合并为一个新的平面。下图中可以看到已经检测到的平面随着摄像机移动合并为了一个平面。
- 开启平面检测
开启平面检测很简单,只需要在 run ARSession 之前,将 ARSessionConfiguration 的 planeDetection 属性设为 true 即可。
// Create a world tracking session configuration.
let configuration = ARWorldTrackingSessionConfiguration()
configuration.planeDetection = .horizontal
// Create a session.
let session = ARSession()
// Run.
session.run(configuration)
- 平面的表示方式
当 ARKit 检测到一个平面时,ARKit 会为该平面自动添加一个 ARPlaneAnchor,这个 ARPlaneAnchor 就表示了一个平面。
- 当 ARKit 系统检测到新平面时,ARKit 会自动添加一个 ARPlaneAnchor 到 ARSession 中。我们可以通过 ARSessionDelegate 获取当前 ARSession 的 ARAnchor 改变的通知,主要有以下三种情况:
新加入了 ARAnchor
func session(_ session: ARSession, didAdd anchors: [ARAnchor])
对于平面检测来说,当新检测到某平面时,我们会收到该通知,通知中的 ARAnchor 数组会包含新添加的平面,其类型是 ARPlaneAnchor,我们可以像下面这样使用:
func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
for anchor in anchors {
if let anchor = anchor as? ARPlaneAnchor {
print(anchor.center)
print(anchor.extent)
}
}
}
ARAnchor 更新
func session(_ session: ARSession, didUpdate anchors: [ARAnchor])
从上面我们知道当设备移动时,检测到的平面是不断更新的,当平面更新时,会回调这个接口。
删除 ARAnchor
func session(_ session: ARSession, didRemove anchors: [ARAnchor])
当手动删除某个 Anchor 时,会回调此方法。此外,对于检测到的平面来说,如果两个平面进行了合并,则会删除其中一个,此时也会回调此方法。
场景交互(Hit-testing)
Hit-testing 是为了获取当前捕捉到的图像中某点击位置有关的信息(包括平面、特征点、ARAnchor 等)。
原理图如下
当点击屏幕时,ARKit 会发射一个射线,假设屏幕平面是三维坐标系中的 xy 平面,那么该射线会沿着 z 轴方向射向屏幕里面,这就是一次 Hit-testing 过程。此次过程会将射线遇到的所有有用信息返回,返回结果以离屏幕距离进行排序,离屏幕最近的排在最前面。
ARFrame 提供了 Hit-testing 的接口:
func hitTest(_ point: CGPoint, types: ARHitTestResult.ResultType) -> [ARHitTestResult]
上述接口中有一个 types 参数,该参数表示此次 Hit-testing 过程需要获取的信息类型。ResultType 有以下四种:
- featurePoint
表示此次 Hit-testing 过程希望返回当前图像中 Hit-testing 射线经过的 3D 特征点。如下图:
- estimatedHorizontalPlane
表示此次 Hit-testing 过程希望返回当前图像中 Hit-testing 射线经过的预估平面。预估平面表示 ARKit 当前检测到一个可能是平面的信息,但当前尚未确定是平面,所以 ARKit 还没有为此预估平面添加 ARPlaneAnchor。如下图:
- existingPlaneUsingExtent
表示此次 Hit-testing 过程希望返回当前图像中 Hit-testing 射线经过的有大小范围的平面。
上图中,如果 Hit-testing 射线经过了有大小范围的绿色平面,则会返回此平面,如果射线落在了绿色平面的外面,则不会返回此平面。
- existingPlane
表示此次 Hit-testing 过程希望返回当前图像中 Hit-testing 射线经过的无限大小的平面。
上图中,平面大小是绿色平面所展示的大小,但 exsitingPlane 选项表示即使 Hit-testing 射线落在了绿色平面外面,也会将此平面返回。换句话说,将所有平面无限延展,只要 Hit-testing 射线经过了无限延展后的平面,就会返回该平面。
示例代码如下
// Adding an ARAnchor based on hit-test
let point = CGPoint(x: 0.5, y: 0.5) // Image center
// Perform hit-test on frame.
let results = frame. hitTest(point, types: [.featurePoint, .estimatedHorizontalPlane])
// Use the first result.
if let closestResult = results.first {
// Create an anchor for it.
anchor = ARAnchor(transform: closestResult.worldTransform)
// Add it to the session.
session.add(anchor: anchor)
}
上面代码中,Hit-testing 的 point(0.5, 0.5)代表屏幕的中心,屏幕左上角为(0, 0),右下角为(1, 1)。 对于 featurePoint 和 estimatedHorizontalPlane 的结果,ARKit 没有为其添加 ARAnchor,我们可以使用 Hit-testing 获取信息后自己为 ARSession 添加 ARAnchor,上面代码就显示了此过程。
光照估计(Light estimation)
上图中,一个虚拟物体茶杯被放在了现实世界的桌子上。
当周围环境光线较好时,摄像机捕捉到的图像光照强度也较好,此时,我们放在桌子上的茶杯看起来就比较贴近于现实效果,如上图最左边的图。但是当周围光线较暗时,摄像机捕捉到的图像也较暗,如上图中间的图,此时茶杯的亮度就显得跟现实世界格格不入。
针对这种情况,ARKit 提供了光照估计,开启光照估计后,我们可以拿到当前图像的光照强度,从而能够以更自然的光照强度去渲染虚拟物体,如上图最右边的图。
光照估计基于当前捕捉到的图像的曝光等信息,给出一个估计的光照强度值(单位为 lumen,光强单位)。默认的光照强度为 1000lumen,当现实世界较亮时,我们可以拿到一个高于 1000lumen 的值,相反,当现实世界光照较暗时,我们会拿到一个低于 1000lumen 的值。
ARKit 的光照估计默认是开启的,当然也可以通过下述方式手动配置:
configuration.isLightEstimationEnabled = true
获取光照估计的光照强度也很简单,只需要拿到当前的 ARFrame,通过以下代码即可获取估计的光照强度:
let intensity = frame.lightEstimate?.ambientIntensity
SceneKit
渲染是呈现 AR world 的最后一个过程。此过程将创建的虚拟世界、捕捉的真实世界、ARKit 追踪的信息以及 ARKit 场景解析的的信息结合在一起,渲染出一个 AR world。渲染过程需要实现以下几点才能渲染出正确的 AR world:
- 将摄像机捕捉到的真实世界的视频作为背景。
- 将世界追踪到的相机状态信息实时更新到 AR world 中的相机。
- 处理光照估计的光照强度。
- 实时渲染虚拟世界物体在屏幕中的位置。
如果我们自己处理这个过程,可以看到还是比较复杂的,ARKit 为简化开发者的渲染过程,为开发者提供了简单易用的使用 SceneKit(3D 引擎)以及 SpriteKit(2D 引擎)渲染的视图ARSCNView以及ARSKView。当然开发者也可以使用其他引擎进行渲染,只需要将以上几个信息进行处理融合即可。
SceneKit 的坐标系
我们知道 UIKit 使用一个包含有 x 和 y 信息的 CGPoint 来表示一个点的位置,但是在 3D 系统中,需要一个 z 参数来描述物体在空间中的深度,SceneKit 的坐标系可以参考下图:
这个三维坐标系中,表示一个点的位置需要使用(x,y,z)坐标表示。红色方块位于 x 轴,绿色方块位于 y 轴,蓝色方块位于 z 轴,灰色方块位于原点。在 SceneKit 中我们可以这样创建一个三维坐标:
let position = SCNVector3(x: 0, y: 5, z: 10)
SceneKit 中的场景和节点
我们可以将 SceneKit 中的场景(SCNScene)想象为一个虚拟的 3D 空间,然后可以将一个个的节点(SCNNode)添加到场景中。SCNScene 中有唯一一个根节点(坐标是(x:0, y:0, z:0)),除了根节点外,所有添加到 SCNScene 中的节点都需要一个父节点。
下图中位于坐标系中心的就是根节点,此外还有添加的两个节点 NodeA 和 NodeB,其中 NodeA 的父节点是根节点,NodeB 的父节点是 NodeA:
SCNScene 中的节点加入时可以指定一个三维坐标(默认为(x:0, y:0, z:0)),这个坐标是相对于其父节点的位置。这里说明两个概念:
- 本地坐标系:以场景中的某节点(非根节点)为原点建立的三维坐标系
- 世界坐标系:以根节点为原点创建的三维坐标系称为世界坐标系。
上图中我们可以看到 NodeA 的坐标是相对于世界坐标系(由于 NodeA 的父节点是根节点)的位置,而 NodeB 的坐标代表了 NodeB 在 NodeA 的本地坐标系位置(NodeB 的父节点是 NodeA)。
SceneKit 中的摄像机
有了 SCNScene 和 SCNNode 后,我们还需要一个摄像机(SCNCamera)来决定我们可以看到场景中的哪一块区域(就好比现实世界中有了各种物体,但还需要人的眼睛才能看到物体)。摄像机在 SCNScene 的工作模式如下图:
上图中包含以下几点信息:
- SceneKit 中 SCNCamera 拍摄的方向始终为 z 轴负方向。
- 视野(Field of View)是摄像机的可视区域的极限角度。角度越小,视野越窄,反之,角度越大,视野越宽。
- 视锥体(Viewing Frustum)决定着摄像头可视区域的深度(z 轴表示深度)。任何不在这个区域内的物体将被剪裁掉(离摄像头太近或者太远),不会显示在最终的画面中。
在 SceneKit 中我们可以使用如下方式创建一个摄像机:
let scene = SCNScene()
let cameraNode = SCNNode()
let camera = SCNCamera()
cameraNode.camera = camera
cameraNode.position = SCNVector3(x: 0, y: 0, z: 0)
scene.rootNode.addChildNode(cameraNode)
SCNView
最后,我们需要一个 View 来将 SCNScene 中的内容渲染到显示屏幕上,这个工作由 SCNView 完成。这一步其实很简单,只需要创建一个 SCNView 实例,然后将 SCNView 的 scene 属性设置为刚刚创建的 SCNScene,然后将 SCNView 添加到 UIKit 的 view 或 window 上即可。示例代码如下:
let scnView = SCNView()
scnView.scene = scene
vc.view.addSubview(scnView)
scnView.frame = vc.view.bounds