[ARKit]2-苹果官方AR场景交互Demo解读

4,643 阅读8分钟

说明

本文与代码地址中的README.md文件搭配阅读,效果更佳.

ARKit系列文章目录

2017年的WWDC,苹果演示过ARKit的一个Demo,名为AR Interaction,不仅演示了ARKit的效果,还演示了AR应用的设计原则,交互逻辑.因此苹果叫Handling 3D Interaction and UI Controls in Augmented Reality

即:处理增强现实中的3D交互和UI控制.

下面我们就来分三步,研究学习一下这个项目:

  • 基本结构
  • 主要类的逻辑
  • 几个有趣的方法

基本结构

如下图,总共分为以下几个部分:控制器,控制器的分类,处理虚拟物体交互类,自定义手势,自定义ARView,虚拟物体及其加载器,聚焦框,顶部状态子控制器,底部列表子控制器.

WX20180121-165229@2x.png

ViewController:UI设置,代理设置,AR属性配置,生命周期 ViewController+ARSCNViewDelegate:AR场景更新,节点添加,错误信息 ViewController+Actions:界面UI操作,按钮点击,触摸等 ViewController+ObjectSelection:虚拟物体的加载,移动

主要类的逻辑

识别平面

WX20180121-200837@2x.png

加载虚拟物体

WX20180121-204134@2x.png

移动虚拟物体

WX20180121-213240@2x.png

几个有趣的方法

VirtualObjectARView类

这个类的HitTestRay结构体中,intersectionWithHorizontalPlane(atY planeY: Float)方法,需要求出射线原点到(与平面)交点的距离,这里用到了线性代数中点乘的概念:射线原点到交点的向量归一化后方向向量的倍数,其实就是距离.但是因为交点坐标尚不确定(只有y值确定,一定在平面上),所以两者都点乘上平面法线向量,巧妙地消去了x和z的值,得到了距离.

其实也可以用初中知识,相似三角形来理解,红色为归一化后的方向向量:

WX20180121-230114@2x.png

下面讲得这个方法已经变更了,因为ARKit后来推出了识别竖直平面的功能,官方demo中相应逻辑也做了变更,具体请看更新后的注释版代码.

另外还有worldPosition(fromScreenPosition position: CGPoint, objectPosition: float3?, infinitePlane: Bool = false)方法,求点击屏幕后,从屏幕中心发出的射线,命中的锚点或特征点云的位置.共分了5步:

  1. 先用hitTest(position, types: .existingPlaneUsingExtent)获取命中的平面,有的话直接返回;
  2. hitTestWithFeatures(position, coneOpeningAngleInDegrees: 18, minDistance: 0.2, maxDistance: 2.0).first获取射线锥体范围内找到的特征点云,暂不返回;
  3. 如果允许在无限大平面内查找,或者上一步的锥体范围内没找到,则返回无穷大平面上的交点;
  4. 如果不允许在无穷大平面上查找,且第2步找到了特征点,则返回第2步中的点;
  5. 最后,如果还没有合适的,那就找射线附近离得最近的特征点,然后造出一个合适的点;
    /**
     Hit tests from the provided screen position to return the most accuarte result possible.
     Returns the new world position, an anchor if one was hit, and if the hit test is considered to be on a plane.
     从指定的屏幕位置发起命中测试,返回最精确的结果.
     返回新世界坐标位置,命中平面的锚点.
     */
    func worldPosition(fromScreenPosition position: CGPoint, objectPosition: float3?, infinitePlane: Bool = false) -> (position: float3, planeAnchor: ARPlaneAnchor?, isOnPlane: Bool)? {
        /*
         1. Always do a hit test against exisiting plane anchors first. (If any
            such anchors exist & only within their extents.)
         1. 优先对已存在的平面锚点进行命中测试.(如果有锚点存在&在他们的范围内)
        */
        let planeHitTestResults = hitTest(position, types: .existingPlaneUsingExtent)
        
        if let result = planeHitTestResults.first {
            let planeHitTestPosition = result.worldTransform.translation
            let planeAnchor = result.anchor
            
            // Return immediately - this is the best possible outcome.
            // 直接返回 - 这是最佳的输出.
            return (planeHitTestPosition, planeAnchor as? ARPlaneAnchor, true)
        }
        
        /*
         2. Collect more information about the environment by hit testing against
            the feature point cloud, but do not return the result yet.
         2. 根据命中测试遇到的特征点云,收集更多环境信息,但是暂不返回结果.
        */
        let featureHitTestResult = hitTestWithFeatures(position, coneOpeningAngleInDegrees: 18, minDistance: 0.2, maxDistance: 2.0).first
        let featurePosition = featureHitTestResult?.position

        /*
         3. If desired or necessary (no good feature hit test result): Hit test
            against an infinite, horizontal plane (ignoring the real world).
         3. 如果需要的话(没有发现足够好的特征命中测试结果):命中测试遇到一个无限大的水平面(忽略真实世界).
        */
        if infinitePlane || featurePosition == nil {
            if let objectPosition = objectPosition,
                let pointOnInfinitePlane = hitTestWithInfiniteHorizontalPlane(position, objectPosition) {
                return (pointOnInfinitePlane, nil, true)
            }
        }
        
        /*
         4. If available, return the result of the hit test against high quality
            features if the hit tests against infinite planes were skipped or no
            infinite plane was hit.
         4. 如果可用的话,当命中测试遇到无限平面被忽略或者没有遇到无限平面,则返回命中测试遇到的高质量特征点.
        */
        if let featurePosition = featurePosition {
            return (featurePosition, nil, false)
        }
        
        /*
         5. As a last resort, perform a second, unfiltered hit test against features.
            If there are no features in the scene, the result returned here will be nil.
         5. 最后万不得已时,执行备份方案,返回未过滤的命中测试遇到的特征点.
            如果场景中没有特征点,返回结果将是nil.
        */
        let unfilteredFeatureHitTestResults = hitTestWithFeatures(position)
        if let result = unfilteredFeatureHitTestResults.first {
            return (result.position, nil, false)
        }
        
        return nil
    }

苹果在这里给出了一个几乎完美的方案<ARKit1.5后逻辑已变更,请看更新后的代码>:

  1. 在识别到平面时,给出平面位置;
  2. 允许无穷大平面,则返回无穷大平面上的特征点;
  3. 没有时给出射线附近的特征点位置;
  4. 还没有时,仍然返回无穷大平面上的特征点;
  5. 最后,没有时自己利用最近的特征点造出一个位置来;
  6. 连特征点都没有,返回空.

这样充分利用了特征点云数据,即使AR识别不稳定,暂未识别出平面,也能用特征点继续玩AR,当然了,牺牲一些精度再所难免.

另外当识别到平面后,还会把附近的特征点上的物体,慢慢移动到新发现的平面上.这样体验更加完善,不会让暂时性的精度问题一直影响AR体验.

移动是在ViewController+ARSCNViewDelegate中调用下面方法来实现这个移动:

updateQueue.async {
   for object in self.virtualObjectLoader.loadedObjects {
     object.adjustOntoPlaneAnchor(planeAnchor, using: node)
   }
}

FocusSquare类

这个类中,需要将聚焦框总是以特定角度对准摄像机.updateTransform(for position: float3, camera: ARCamera?)这个方法专门来处理这个问题:

  1. 将最新的10个位置求平均值,避免抖动;
  2. 根据其位置到摄像机的距离,控制大小;
  3. 校正Y轴的旋转;

其中校正Y轴其实是为了当人拿着手机,左右转身时,聚焦框不仅保持在手机屏幕中间,还可以同步旋转以始终保持与手机屏幕底边平行.

坐标.png
当人拿着手机左右转身时,手机其实是在竖直和水平状态之间变化的,请看我的灵魂绘画:
WX20180122-153002@2x.png

首先通过let tilt = abs(camera.eulerAngles.x)得到手机的俯仰状态(水平还是竖直),然后分三种情况:

  • 0..<threshold1:几乎竖直状态,直接使用摄像机(即手机)的y轴旋转欧拉角;
  • threshold1..<threshold2:中间状态,计算线性插值系数relativeInRange,然后用normalize()计算最短旋转角度(毕竟向右转270度和向左转90度效果是一样的),最后用线性插值得到混合后的旋转角;
  • default(> threshold2):几乎水平状态,使用手机的yaw偏航值(左右转的角度),即方位角;
        // Correct y rotation of camera square.
        // 校正摄像机的y轴旋转
        guard let camera = camera else { return }
        let tilt = abs(camera.eulerAngles.x)
        let threshold1: Float = .pi / 2 * 0.65
        let threshold2: Float = .pi / 2 * 0.75
        let yaw = atan2f(camera.transform.columns.0.x, camera.transform.columns.1.x)
        var angle: Float = 0
        
        switch tilt {
        case 0..<threshold1:
            angle = camera.eulerAngles.y
            
        case threshold1..<threshold2:
            let relativeInRange = abs((tilt - threshold1) / (threshold2 - threshold1))
            let normalizedY = normalize(camera.eulerAngles.y, forMinimalRotationTo: yaw)
            angle = normalizedY * (1 - relativeInRange) + yaw * relativeInRange
            
        default:
            angle = yaw
        }
        eulerAngles.y = angle

其中求偏航值用到了atan2f(y,x)这个求方位角的函数,只要传入对应的y值,x值,就可以得到(x,y)点相对坐标原点的夹角

let yaw = atan2f(camera.transform.columns.0.x, camera.transform.columns.1.x)

矩阵基础相关

这其中用到了矩阵的相关知识:数学课本与微软D3D用的是左手准则(行主序),而OpenGL与苹果SceneKit用的是右手准则(列主序).排列如下:

图片 1.png

其实每个矩阵就相当于一个小的局部坐标系,其中(Tx,Ty,Tz)相当于局部坐标系相对于世界坐标系的偏移量.(Xx,Xy,Xz)是局部坐标系的X轴位置,(Yx,Yy,Yz)是局部坐标系的Y轴位置,(Zx,Zy,Zz)是局部坐标系的Z轴位置.最后右下角的1相当于全局缩放比例,一般不调整.

所以在计算atan2f()时,其实是用到了(Yx,Xx)来计算方位角:

  • 竖直状态下:朝前时为(0,1);向左和向右均为(0,0);所以竖直状态下实际上不能区分左右,因此这种情况上面使用了Y轴的欧拉角;
  • 水平状态下:朝前时为(0,1);向左为(-1,0),向右为(1,0);

WX20180122-130624@2x.png

结束

苹果的这个Demo算是给出了AR应用开发的最佳实践,不仅从技术层面充分发挥出ARKit的全部潜力(截止2018年初),而且保证了良好的用户体验和交互逻辑.

如果需要开发自己的AR应用,建议模仿这个Demo的交互逻辑,增强自己app的用户体验.