說明
譯者注:本文是Raywenderlich上《ARKit by Tutorials》免費章節的翻譯,是原書第8章.原書7~9章完成了一個時空門app.
官網原文地址www.raywenderlich.com/195388/buil…
本文是我們書籍ARKit by Tutorials中的第8章,“新增物體到你的世界”.這本書向你展示瞭如何用蘋果的擴增實境框架ARKit,來構建五個沉浸式的,好看的AR應用.開始吧!
在本系列上一章中,你已經學會了如何用ARKit建立你的app並探測水平面.在本章中,你將繼續構建你的app並通過SceneKit新增3D虛擬內容到相機場景中.在本章結束,你將會學到:
- 處理session打斷
- 放置物體到探測出的水平面
在開始之前,點選 資料下載 來下載專案資料,並開啟starter資料夾下的starter工程.
開始
現在你已經能夠探測並渲染水平面了,還需要在session被打斷時重置狀態.當app進入後臺時,或當多個app處於前臺時ARSession就會被打斷.一旦被打斷後,視訊捕捉就會失敗,ARSession也不能再接收到感測器的資料來追蹤了.當app返回前臺時,渲染出的平面仍然顯示在檢視上.然而,如果你的裝置已經改變了位置或朝向,那麼ARSession追蹤就不再有效了.這時你就需要重啟session.
ARSCNViewDelegate實現了ARSessionObserver的協議.這個協議包含了一些方法,會在ARSession被打斷或出錯時被呼叫.
開啟PortalViewController.swift,並新增下面的代理方法實現到已存在的類擴充套件中.
// 1
func session(_ session: ARSession, didFailWithError error: Error) {
// 2
guard let label = self.sessionStateLabel else { return }
showMessage(error.localizedDescription, label: label, seconds: 3)
}
// 3
func sessionWasInterrupted(_ session: ARSession) {
guard let label = self.sessionStateLabel else { return }
showMessage("Session interrupted", label: label, seconds: 3)
}
// 4
func sessionInterruptionEnded(_ session: ARSession) {
// 5
guard let label = self.sessionStateLabel else { return }
showMessage("Session resumed", label: label, seconds: 3)
// 6
DispatchQueue.main.async {
self.removeAllNodes()
self.resetLabels()
}
// 7
runSession()
}
複製程式碼
程式碼詳解:
- session(_:, didFailWithError:) 會在session失敗時呼叫.在失敗時,session會暫停並不再接收感測器的資料.
- 這裡設定sessionStateLabel中的文字為session失敗上報的錯誤資訊.showMessage(_:, label:, seconds:) 方法將資訊展示在特定label中幾秒鐘.
- sessionWasInterrupted(_:) 會在視訊捕捉被打斷時呼叫,如app進入後臺後.除非打斷狀態結束,否則不會再有新的視訊幀更新.這裡我們在label上展示"Session interrupted"資訊3秒鐘.
- sessionInterruptionEnded(_:) 方法會在session打斷狀態結束後被呼叫.session會從打斷前的狀態繼續執行.如果裝置移動過,所有錨點都會偏移.這避免偏移,就重啟session.
- 在螢幕上展示"Session resume"3秒鐘.
- 移除先前渲染的物體,重置所有label.我們稍後會實現這個方法.因為這些方法要更新UI,所有在主執行緒中呼叫.
- 重啟session.runSession() 重置了session配置並用新的配置重新開始追蹤.
你會看到有一些編譯錯誤.實現缺失的方法就可以解決這些錯誤.
在PortalViewController的其他變數下面新增一些變數:
var debugPlanes: [SCNNode] = []
複製程式碼
你將會使用debugPlanes陣列來儲存在debug模式下渲染的所有水平面.
然後,在resetLabels() 下面新增新方法:
// 1
func showMessage(_ message: String, label: UILabel, seconds: Double) {
label.text = message
label.alpha = 1
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
if label.text == message {
label.text = ""
label.alpha = 0
}
}
}
// 2
func removeAllNodes() {
removeDebugPlanes()
}
// 3
func removeDebugPlanes() {
for debugPlaneNode in self.debugPlanes {
debugPlaneNode.removeFromParentNode()
}
self.debugPlanes = []
}
複製程式碼
- 你定義了一個幫助方法來在一個UILabel上展示資訊文字,並持續顯示一段時間.一旦時間過後,就重置label的visibility和text.
- removeAllNodes() 方法移除所有當前新增在場景上的SCNNode物件.目前,你只需移除渲染出的水平面就好.
- 這個方法從場景中移除所有渲染出的水平面,並重置了debugPlanes陣列.
現在,在renderer(_:, didAdd:, for:) 中,#if DEBUG對應的**#endif**預處理指令前:
self.debugPlanes.append(debugPlaneNode)
複製程式碼
這樣就將新增到場景的水平面也加入到debugPlanes陣列中.
注意,在runSession() 中,session執行中需要傳入一個配置:
sceneView?.session.run(configuration)
複製程式碼
將上面替換為:
sceneView?.session.run(configuration,
options: [.resetTracking, .removeExistingAnchors])
複製程式碼
這裡,你執行sceneView關聯的ARSession時,傳入一個configuration物件和一個ARSession.RunOptions陣列,陣列中有兩個設定項:
- resetTracking:session不會沿用上一個配置的裝置位置和運動追蹤情況.
- removeExistingAnchor:session上一個配置的錨點物件會被移除.
執行一下app,試著檢測一個水平面.
現在將app退到後臺再重新開啟.看到上一次渲染出的水平面已經從場景中移除,app重置了label以給使用者顯示正確的說明.命中測試
現在你已經準備好在檢測出的水平面上放置物體了.你將使用ARSCNView的命中測試來檢測,使用者手指在螢幕上的觸控對應虛擬場景的哪裡.一個檢視座標下的2D點,實際對應著3D座標空間中的一條線.命中測試就是一個找到這條線上物體的過程.
開啟PortalViewController.swift,新增下列變數.
var viewCenter: CGPoint {
let viewBounds = view.bounds
return CGPoint(x: viewBounds.width / 2.0, y: viewBounds.height / 2.0)
}
複製程式碼
上面這段程式碼,你設定變數viewCenter為PortalViewController的檢視中心.
現在新增下面的方法:
// 1
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 2
if let hit = sceneView?.hitTest(viewCenter, types: [.existingPlaneUsingExtent]).first {
// 3
sceneView?.session.add(anchor: ARAnchor.init(transform: hit.worldTransform))
}
}
複製程式碼
程式碼解釋:
- ARSCNView已經啟用了觸控.當使用者點選檢視時,touchesBegan() 被呼叫,並傳入一個有UITouch物件的集合,以及一個代表觸控事件的UIEvent.你重寫這個觸控處理方法來給sceneView上新增一個ARAnchor.
- 呼叫sceneView物件的hitTest(_:, types:) 方法.這個hitTest方法有兩個引數.它接收一個檢視座標系的CGPoint,此處為螢幕的中心,還有一個ARHitTestResult型別用於搜尋.
這裡你使用existingPlaneUsingExtent結果型別,它會搜尋從viewCenter發出的射線與場景中水平面的交點,並且水平面的面積是有限的.
hitTest(_:, types:) 是所有命中測試的結果陣列,排序為從近到遠.我們選擇射線相交的第一個平面.這樣,只要螢幕中心有渲染出的水平面,你就能隨時從hitTest(_:, types:) 中拿到結果. - 向ARSession新增一個ARAnchor,這個位置就是以後3D物體被放置的位置.ARAnchor物件被初始化並帶有一個變換矩陣,定義了錨點在世界座標系中的旋轉,平移和縮放.
錨點新增後,ARSCNView會在代理方法renderer(_:didAdd:for:) 中收到回撥.從這裡開始你將處理時空門的渲染了.
新增準心
在你新增時空門到場景中之前,還需要向檢視中新增最後一個東西.在一段文章中,你實現了檢測裝置螢幕中心的sceneView上的命中測試.在本段中,你將會給螢幕中心的檢視上新增一個標記,來幫助使用者定位裝置.
開啟Main.storyboard.進入Object Library,搜尋一個View物件.拖拽一個view物件到PortalViewController.
將view的名字改為Crosshair.新增約束確保其中心對準父控制元件中心.將width和height設定為10.在Size Inspector頁面中,約束應該是這樣子:
進入到Attributes inspector標籤頁,將背景顏色改為Light Gray Color.選中assistant editor,你會看到PortalViewController.swift在右側.按住Ctrl從storyboard中的Crosshair上拖拽屬性到PortalViewController程式碼中,放在sceneView上方.
在IBOutlet中輸入名字為crosshair並點選Connect.
執行app.注意有一個灰色正方形view在螢幕中央.這就是我們剛才新增的crosshair view. 現在在PortalViewController類擴充套件中的ARSCNViewDelegate方法中,新增下列程式碼./ 1
func renderer(_ renderer: SCNSceneRenderer,
updateAtTime time: TimeInterval) {
// 2
DispatchQueue.main.async {
// 3
if let _ = self.sceneView?.hitTest(self.viewCenter,
types: [.existingPlaneUsingExtent]).first {
self.crosshair.backgroundColor = UIColor.green
} else { // 4
self.crosshair.backgroundColor = UIColor.lightGray
}
}
}
複製程式碼
程式碼含義:
- 這個方法是SCNSceneRendererDelegate協議的一部分,它被ARSCNViewDelegate實現了.這個協議包含了一系列回撥方法,可以用來在渲染過程的不同時間執行一些操作.renderer(_: updateAtTime:) 會在每一幀被精確呼叫,可以用來執行一些每幀都需要的邏輯.
- 執行程式碼來探測是否螢幕中心落在已經檢測出的水平面上,並在主執行緒更新UI.
- 這裡在sceneView上執行一個命中測試,來確定檢視中心確實和水平面相交了.如果至少檢測到了一個結果,crosshair背景色變成綠色.
- 如果命中測試沒有返回任何結果,則crosshair的背景色重設為淺灰色.
執行app.
四處移動裝置,以便探測並渲染出水平面,如下左圖所示.現在移動你的裝置讓裝置螢幕中心落在平面內,如下右圖所示.注意中心view的顏色變成了綠色.
新增一個狀態機
現在你已經建立起一個app,能探測平面並放置一個ARAnchor,你可以開始新增時空門了.
為了追蹤app的狀態,在PortalViewController中新增下列變數:
var portalNode: SCNNode? = nil
var isPortalPlaced = false
複製程式碼
儲存一個SCNNode型別的portalNode物件來表示你的時空門,並使用isPortalPlaced來表示時空門是否已被渲染在場景中.
在PortalViewController中新增下列方法:
func makePortal() -> SCNNode {
// 1
let portal = SCNNode()
// 2
let box = SCNBox(width: 1.0,
height: 1.0,
length: 1.0,
chamferRadius: 0)
let boxNode = SCNNode(geometry: box)
// 3
portal.addChildNode(boxNode)
return portal
}
複製程式碼
這裡我們定義了makePortal() 方法,它可以配置並渲染時空門.共做了下面幾件事:
- 建立一個代表時空門的SCNNode物件.
- 該步初始化一個SCNBox物件,它是一個立方體,並使用這個立方體作為幾何體建立一個SCNode物件.
- 將boxNode作為子節點新增到你的portal並返回時空門節點.
這裡,makePortal() 只是建立一個包含立方體物體的時空門節點作為佔位.
現在,用下面的方法替換renderer(_:, didAdd:, for:) 和renderer(_:, didUpdate:, for:) :
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
DispatchQueue.main.async {
// 1
if let planeAnchor = anchor as? ARPlaneAnchor,
!self.isPortalPlaced {
#if DEBUG
let debugPlaneNode = createPlaneNode(
center: planeAnchor.center,
extent: planeAnchor.extent)
node.addChildNode(debugPlaneNode)
self.debugPlanes.append(debugPlaneNode)
#endif
self.messageLabel?.alpha = 1.0
self.messageLabel?.text = """
Tap on the detected \
horizontal plane to place the portal
"""
}
else if !self.isPortalPlaced {// 2
// 3
self.portalNode = self.makePortal()
if let portal = self.portalNode {
// 4
node.addChildNode(portal)
self.isPortalPlaced = true
// 5
self.removeDebugPlanes()
self.sceneView?.debugOptions = []
// 6
DispatchQueue.main.async {
self.messageLabel?.text = ""
self.messageLabel?.alpha = 0
}
}
}
}
}
func renderer(_ renderer: SCNSceneRenderer,
didUpdate node: SCNNode,
for anchor: ARAnchor) {
DispatchQueue.main.async {
// 7
if let planeAnchor = anchor as? ARPlaneAnchor,
node.childNodes.count > 0,
!self.isPortalPlaced {
updatePlaneNode(node.childNodes[0],
center: planeAnchor.center,
extent: planeAnchor.extent)
}
}
}
複製程式碼
程式碼說明:
- 只有在新增到場景上的錨點是一個ARPlaneAnchor,並且isPortalPlaced為false時,你才需要新增一個水平面到場景中來展示被探測到的平面,
- 如果被新增的錨點不是一個ARPlaneAnchor,並且時空門節點仍然沒有被放置上去,那麼這一定是個使用者手動點選螢幕而新增的錨點.
- 通過呼叫makePortal() 來建立時空門節點.
- renderer(_:, didAdd:, for:) 會在SCNNode物件即node新增到場景時呼叫.你想要將時空門節點放置在node位置處.所以你將時空門節點新增為node的子節點上,並且設定isPortalPlaced為true來表示時空門節點已經被新增過了.
- 為了清理場景,你移除所有渲染出的水平面,並重置debugOptions,這樣螢幕上就不再有特徵點了.
- 在主執行緒更新messageLabel,重置其text並隱藏它.
- 在renderer(_:, didUpdate:, for:) 中,只有當錨點是ARPlaneAnchor,且節點至少有一個子節點,而且時空門還沒有被新增過時,你才更新渲染出的水平面,
最後,用下面的程式碼替換removeAllNodes() .
func removeAllNodes() {
// 1
removeDebugPlanes()
// 2
self.portalNode?.removeFromParentNode()
// 3
self.isPortalPlaced = false
}
複製程式碼
這個方法用來從場景中清理並移除所有渲染出的物體.詳情如下:
- 移除所有渲染出的水平面.
- 從父節點中移除portalNode.
- 將isPortalPlaced變數改為false來重置狀態.
執行app;讓app探測到一個水平面,然後當螢幕上的準心變綠時,點選螢幕.你將會看到一個扁平的,巨大的白色立方體.
這個就是你的時空門的佔位節點.在下一章節中,你將會給時空門新增一些牆壁和通道.還會給牆壁新增一些紋理,讓它們看起來更真實.下一步做什麼?
這些內容相當有趣!這裡做一下本章總結:
- 你能夠在app進入後臺時,探測並處理ARSession的打斷.
- 你理解了命中測試是如何在ARSCNView和探測到的水平面中起作用的.
- 你可能使用命中測試的結果來放置ARAnchors和SCNNode物件. 在下一章,也就是本系列的最後一部分中,你將會把所有東西組合起來,新增牆壁和天花板,並給場景新增一點燈光照明!
如果你喜歡本系列教程,請購買本書的完整版,ARKit by Tutorials, available on our online store.
本章資料下載