[ARKit]2-蘋果官方AR場景互動Demo解讀

蘋果API搬運工發表於2018-01-23

說明

本文與程式碼地址中的README.md檔案搭配閱讀,效果更佳.

ARKit系列文章目錄

學習ARKit前,需要先學習SceneKit,參考SceneKit系列文章目錄

更多iOS相關知識檢視github上WeekWeekUpProject

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的使用者體驗.

相關文章