前言
擴增實境(Augmented Reality)是一種在視覺上呈現虛擬物體與現實場景結合的技術。Apple 公司在 2017 年 6 月正式推出了 ARKit,iOS 開發者可以在這個平臺上使用簡單便捷的 API 來開發 AR 應用程式。
本文將結合美團到餐業務場景,介紹一種基於位置服務(LBS)的 AR 應用。使用 AR 的方式展現商家相對使用者的位置,這會給使用者帶來身臨其境的沉浸式體驗。下面是實現效果:
專案實現
iOS 平臺的 AR 應用通常由 ARKit 和渲染引擎兩部分構成:
ARKit 是連線真實世界與虛擬世界的橋樑,而渲染引擎是把虛擬世界的內容渲染到螢幕上。本部分會圍繞這兩個方面展開介紹。
ARKit
ARKit 的 ARSession 負責管理每一幀的資訊。ARSession 做了兩件事:拍攝影像並獲取感測器資料;對資料進行分析處理後逐幀輸出。如下圖:
裝置追蹤
裝置追蹤確保了虛擬物體的位置不受裝置移動的影響。在啟動 ARSession 時需要傳入一個 ARSessionConfiguration 的子類物件,以區別三種追蹤模式:
- ARFaceTrackingConfiguration
- ARWorldTrackingConfiguration
- AROrientationTrackingConfiguration
其中 ARFaceTrackingConfiguration 可以識別人臉的位置、方向以及獲取拓撲結構。此外,還可以探測到預設的 52 種豐富的面部動作,如眨眼、微笑、皺眉等等。ARFaceTrackingConfiguration 需要呼叫支援 TrueDepth 的前置攝像頭進行追蹤,顯然不能滿足我們的需求,這裡就不做過多的介紹。下面只針對使用後置攝像頭的另外兩種型別進行對比。
ARWorldTrackingConfiguration
ARWorldTrackingConfiguration 提供 6DoF(Six Degree of Freedom)的裝置追蹤。包括三個姿態角 Yaw(偏航角)、Pitch(俯仰角)和 Roll(翻滾角),以及沿笛卡爾座標系中 X、Y 和 Z 三軸的偏移量:
不僅如此,ARKit 還使用了 VIO(Visual-Inertial Odometry)來提高裝置運動追蹤的精度。在使用慣性測量單元(IMU)檢測運動軌跡的同時,對運動過程中攝像頭拍攝到的圖片進行影像處理。將影像中的一些特徵點的變化軌跡與感測器的結果進行比對後,輸出最終的高精度結果。
從追蹤的維度和準確度來看,ARWorldTrackingConfiguration 非常強悍。但如官方文件所言,它也有兩個致命的缺點:
- 受環境光線質量影響
- 受劇烈運動影響
由於在追蹤過程中要通過採集影像來提取特徵點,所以影像的質量會影響追蹤的結果。在光線較差的環境下(比如夜晚或者強光),拍攝的影像無法提供正確的參考,追蹤的質量也會隨之下降。
追蹤過程中會逐幀比對影像與感測器結果,如果裝置在短時間內劇烈的移動,會很大程度上干擾追蹤結果。追蹤的結果與真實的運動軌跡有偏差,那麼使用者看到的商家位置就不準確。
AROrientationTrackingConfiguration
AROrientationTrackingConfiguration 只提供對三個姿態角的追蹤(3DoF),並且不會開啟 VIO。
Because 3DOF tracking creates limited AR experiences, you should generally not use the AROrientationTrackingConfiguration class directly. Instead, use the subclass ARWorldTrackingConfiguration for tracking with six degrees of freedom (6DOF), plane detection, and hit testing. Use 3DOF tracking only as a fallback in situations where 6DOF tracking is temporarily unavailable.
通常來講,因為 AROrientationTrackingConfiguration 的追蹤能力受限,官方文件不推薦直接使用。但是鑑於:
- 對三個姿態角的追蹤,已經足以正確的展現商家相對使用者的位置了。
- ARWorldTrackingConfiguration 的高精度追蹤,更適合於距離較近的追蹤。比如裝置相對桌面、地面的位移。但是商家和使用者的距離動輒幾百米,過於精確的位移追蹤意義不大。
- ARWorldTrackingConfiguration 需要規範使用者的操作、確保環境光線良好。這對使用者來說很不友好。
最終我們決定使用 AROrientationTrackingConfiguration。這樣的話,即便是在夜晚,甚至遮住攝像頭,商家的位置也能夠正確的進行展現。而且劇烈晃動帶來的影響很小,商家位置雖然會出現短暫的角度偏差,但是在感測器數值穩定下來後就會得到校準。
座標軸
ARKit 使用笛卡爾座標系度量真實世界。ARSession 開啟時的裝置位置即是座標軸的原點。而 ARSessionConfiguration 的 worldAlignment 屬性決定了三個座標軸的方向,該屬性有三個列舉值:
- ARWorldAlignmentCamera
- ARWorldAlignmentGravity
- ARWorldAlignmentGravityAndHeading
三種列舉值對應的座標軸如下圖所示:
對於 ARWorldAlignmentCamera 來說,裝置的姿態決定了三個座標軸的方向。這種座標設定適用於以裝置作為參考系的座標計算,與真實地理環境無關,比如用 AR 技術丈量真實世界物體的尺寸。
對於 ARWorldAlignmentGravity 來說,Y 軸方向始終與重力方向平行,而其 X、Z 軸方向仍然由裝置的姿態確定。這種座標設定適用於計算擁有重力屬性的物體座標,比如放置一排氫氣球,或者執行一段籃球下落的動畫。
對於 ARWorldAlignmentGravityAndHeading 來說,X、Y、Z 三軸固定朝向正東、正上、正南。在這種模式下 ARKit 內部會根據裝置偏航角的朝向與地磁真北(非地磁北)方向的夾角不斷地做出調整,以確保 ARKit 座標系中 -Z 方向與我們真實世界的正北方向吻合。有了這個前提條件,真實世界位置座標才能夠正確地對映到虛擬世界中。顯然,ARWorldAlignmentGravityAndHeading 才是我們需要的。
商家座標
商家座標的確定,包含水平座標和垂直座標兩部分:
水平座標
商家的水平位置只是一組經緯度值,那麼如何將它對應到 ARKit 當中呢?我們通過下圖來說明:
藉助 CLLocation 中的 distanceFromLocation:location
方法,可以計算出兩個經緯度座標之間的距離,返回值單位是米。我們可以以使用者的經度 lng1、商家的緯度 lat2 作一個輔助點(lng1, lat2),然後分別計算出輔助點距離商家的距離 x、輔助點距離使用者的距離 z。ARKit 座標系同樣以米為單位,因而可以直接確定商家的水平座標(x, -z)。
垂直座標
對商家地址進行中文分詞可以提取出商戶所在樓層數,再乘以一層樓大概的高度,以此確定商家的垂直座標 y 值:
卡片渲染
通常我們想展示的資訊,都是通過 UIView 及其子類來實現。但是 ARKit 只負責建立真實世界與虛擬世界的橋樑,渲染的部分還是要交給渲染引擎來處理。Apple 給我們提供了三種可選的引擎:
- Metal
- SpriteKit
- SceneKit
強大的 Metal 引擎包含了 MetalKit、Metal 著色器以及標準庫等等工具,可以更高效地利用 GPU,適用於高度定製化的渲染要求。不過 Metal 對於當前需求來說,有些大材小用。
SpriteKit 是 2D 渲染引擎,它提供了動畫、事件處理、物理碰撞等介面,通常用於製作 2D 遊戲。SceneKit 是 3D 渲染引擎,它建立在 OpenGL 之上,支援多通道渲染。除了可以處理 3D 物體的物理碰撞和動畫,還可以呈現逼真的紋理和粒子特效。SceneKit 可以用於製作 3D 遊戲,或者在 App 中加入 3D 內容。
雖然我們可以用 SpriteKit 把 2D 的卡片放置到 3D 的 AR 世界中,但是考慮到擴充套件性,方便之後為 AR 頁面新增新的功能,這裡我們選用 3D 渲染引擎 SceneKit。
我們可以直接通過建立 ARSCNView 來使用 SceneKit。ARSCNView 是 SCNView 的子類,它做了三件事:
- 將裝置攝像頭捕捉的每一幀的影像資訊作為 3D 場景的背景
- 將裝置攝像頭的位置作為 3D 場景的攝像頭(觀察點)位置
- 將 ARKit 追蹤的真實世界座標軸與 3D 場景座標軸重合
卡片資訊
SceneKit 中使用 SCNNode 來管理 3D 物體。設定 SCNNode 的 geometry 屬性可以改變物體的外觀。系統已經給我們提供了例如 SCNBox、SCNPlane、SCNSphere 等等一些常見的形狀,其中 SCNPlane 正是我們所需要的卡片形狀。藉助 UIGraphics 中的一些方法可以將繪製好的 UIView 渲染成一個 UIImage 物件。根據這張圖片建立 SCNPlane,以作為 SCNNode 的外觀。
卡片大小
ARKit 中的物體都是近大遠小。只要固定好 SCNPlane 的寬高,ARKit 會自動根據距離的遠近設定 SCNPlane 的大小。這裡列出一個在螢幕上具體的畫素數與距離的粗略計算公式,為筆者在開發過程中摸索的經驗值:
也就是說,假如 SCNPlane 的寬度為 30,距離使用者 100 米,那麼在螢幕上看到這個 SCNPlane 的寬度大約為 \(530 / 100 \times 30 = 159\) pt。
卡片位置
對於距離使用者過近的商家卡片,會出現兩個問題:
- 由於 ARKit 自動將卡片展現得近大遠小,身邊的卡片會大到遮住了視野
- 前文提到的 ARSession 使用 AROrientationTrackingConfiguration 追蹤模式,由於沒有追蹤裝置的水平位移,當使用者走向商家時,並不會發覺商家卡片越來越近
這裡我們將距離使用者過近的卡片對映到稍遠的位置。如下圖所示,距離使用者的距離小於 d 的卡片,會被對映到 d-k ~ d 的區間內。
假設某商家距離使用者的真實距離為 x,對映後的距離為 y,對映關係如下:
這樣既解決了距離過近的問題,又可以保持卡片之間的遠近關係。使用者位置發生位移到達一定閾值後,會觸發一次新的網路請求,根據新的使用者位置來重新計算商家的位置。這樣隨著使用者的移動,卡片的位置也會持續地更新。
卡片朝向
SceneKit 會在渲染每一幀之前,根據 SCNNode 的約束自動調整卡片的各種行為,比如碰撞、位置、速度、朝向等等。SCNConstraint 的子類中 SCNLookAtConstraint 和 SCNBillboardConstraint 可以約束卡片的朝向。
SCNLookAtConstraint 可以讓卡片始終朝向空間中某一個點。這樣相鄰的卡片會出現交叉現象,使用者看到的卡片資訊很可能是不完整的。使用 SCNBillboardConstraint 可以解決這個問題,讓卡片的朝向始終與攝像頭的朝向平行。
下面是建立卡片的示例程式碼:
// 位置
SCNVector nodePosition = SCNVectorMake(-200, 5, -80);
// 外觀
SCNPlane *plane = [SCNPlane planeWithWidth:image.size.width
height:image.size.height];
plane.firstMaterial.diffuse.contents = image;
// 約束
SCNBillboardConstraint *constraint = [SCNBillboardConstraint billboardConstraint];
constraint.freeAxes = SCNBillboardAxisY;
SCNNode *node = [SCNNode nodeWithGeometry:plane];
node.position = nodePosition;
node.constraints = @[constraint];
複製程式碼
優化
遮擋問題
如果同一個方向的商家數量有很多,那麼卡片會出現互相重疊的現象,這會導致使用者只能看到離自己近的卡片。這是個比較棘手的問題,如果在螢幕上平鋪卡片的話,既犧牲了對商家高度的感知,又無法體現商家距離使用者的遠近關係。
點選散開的互動方式
經過漫長的討論,我們最終決定採取點選重疊區域後,卡片向四周分散的互動方式來解決重疊問題,效果如下:
下面圍繞點選和投射兩個部分,介紹該效果的實現原理。
點選
熟悉 Cocoa Touch 的朋友都瞭解,UIView 的層級結構是通過 hit-testing 來判斷哪個檢視響應事件的,在 ARKit 中也不例外。
ARSCNView 可以使用兩種 hit-testing:
- 來自 ARSCNView 的
hitTest:types:
方法:查詢點選的位置所對應的真實世界中的物體或位置 - 來自 SCNSceneRenderer 協議的
hitTest:options:
方法:查詢點選位置所對應的虛擬世界中的內容。
顯然,hitTest:options:
才是我們需要的。在 3D 世界中的 hit-testing 就像一束鐳射一樣,向點選位置的方向發射,hitTest:options:
的返回值就是被鐳射穿透的所有卡片的陣列。這樣就可以檢測到使用者點選的位置有哪些卡片發生了重疊。
投射
這裡簡單介紹一下散開的實現原理。SCNSceneRenderer 協議有兩個方法用來投射座標:
projectPoint:
:將三維座標系中點的座標,投射到螢幕座標系中unprojectPoint:
:將螢幕座標系中的點的座標,投射到三維座標系中
其中螢幕座標系中的點也是個 SCNVector3,其 z 座標代表著深度,從 0.0(近裁面)到 1.0(遠裁面)。散開的整體過程如下:
散開後,點選空白處會恢復散開的狀態,回到初始位置。未參與散開的卡片會被淡化,以突出重點,減少視覺壓力。
後臺聚類
對於排布比較密集的商家,卡片的重疊現象會很嚴重。點選散開的卡片數量太多對使用者不是很友好。後臺在返回使用者附近的商家資料時,按照商家的經緯度座標,使用 K-Means 聚類演算法進行二維聚類,將距離很近的商家聚合為一個卡片。由於這些商家的位置大體相同,可以採用一個帶有數字的卡片來代表幾個商家的位置:
閃爍問題
實測中發現,距離較近的卡片在重疊區域會發生閃爍的現象:
這裡要引入一個 3D 渲染引擎普遍要面對的問題——可見性問題。簡單來說就是螢幕上哪些物體應該被展示,哪些物體應該被遮擋。GPU 最終應該在螢幕上渲染出所有應該被展示的畫素。
可見性問題的一個典型的解決方案就是畫家演算法,它像一個頭腦簡單的畫家一樣,先繪製最遠的物體,然後一層層的繪製到最近的物體。可想而知,畫家演算法的效率很低,繪製較精細場景會很消耗資源。
深度緩衝
深度緩衝彌補了畫家演算法的缺陷,它使用一個二維陣列來儲存當前螢幕中每個畫素的深度。如下圖所示,某個畫素點渲染了深度為 0.5 的畫素,並儲存該畫素的深度:
下一幀時,當另外一個物體的某個畫素也在這個畫素點渲染時,GPU 會對該畫素的深度與緩衝區中的深度進行比較,深度小者被保留並被存入緩衝區,深度大者不被渲染。如下圖所示,該畫素點下一幀要渲染的畫素深度為 0.2,比緩衝區儲存的 0.5 小,其深度被儲存,並且該畫素被渲染在螢幕上:
顯然,深度緩衝技術相比畫家演算法,可以極大地提升渲染效率。但是它也會帶來深度衝突的問題。
深度衝突
深度緩衝技術在處理具有相同深度的畫素點時,會出現深度衝突(Z-fighting)現象。這些具有相同深度的畫素點在競爭中只有一個“勝出”,顯示在螢幕上。如下圖所示:
如果這兩個畫素點交替“勝出”,就會出現我們視覺上的閃爍效果。由於每個卡片都被設定了 SCNBillboardConstraint 約束,始終朝向攝像頭方向。攝像頭輕微的角度變化,都會引起卡片之間出現部分重合。與有厚度的物體不同,卡片之間的深度關係變化很快,很容易出現多個卡片在螢幕同一個位置渲染的情況。所以經常會出現閃爍的現象:
為了解決這 Bug 般的體驗,最終決定犧牲深度緩衝帶來的渲染效率。SceneKit 為我們暴露了深度是否寫入、讀取緩衝區的介面,我們將其禁用即可:
plane.firstMaterial.writesToDepthBuffer = NO;
plane.firstMaterial.readsFromDepthBuffer = NO;
複製程式碼
由於卡片內容內容相對簡單,禁用緩衝區對幀率幾乎沒什麼影響。
總結
在到餐業務場景中,以 AR+LBS 的方式展現商家資訊,可以給使用者帶來沉浸式的體驗。本文介紹了 ARKit 的一些使用細節,總結了在開發過程中遇到的問題以及解決方案,希望可以給其他開發者帶來一點參考價值。
作者簡介
曹宇,美團 iOS 開發工程師。2017年加入美團到店餐飲事業群,參與美團客戶端美食頻道開發工作。
招聘資訊
到店餐飲技術部,負責美團和點評兩個平臺的美食頻道相關業務,服務於數以億計使用者,通過更好的榜單、真實的評價和完善的資訊為使用者提供更好的決策支援,致力於提升使用者體驗。我們同時承載所有餐飲商戶端線上流量,為餐飲商戶提供多種營銷工具,提升餐飲商戶營銷效率,最終達到讓國人“Eat Better、Live Better”的美好願景!我們的團隊需要經驗豐富的FE方向高階/資深工程師和技術專家,歡迎有興趣的同學投遞簡歷至wangying49#meituan.com。