[ARKit]12-[譯]在ARKit中建立一個時空門App:新增物體

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

說明

ARKit系列文章目錄

譯者注:本文是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()
}
複製程式碼

程式碼詳解:

  1. session(_:, didFailWithError:) 會在session失敗時呼叫.在失敗時,session會暫停並不再接收感測器的資料.
  2. 這裡設定sessionStateLabel中的文字為session失敗上報的錯誤資訊.showMessage(_:, label:, seconds:) 方法將資訊展示在特定label中幾秒鐘.
  3. sessionWasInterrupted(_:) 會在視訊捕捉被打斷時呼叫,如app進入後臺後.除非打斷狀態結束,否則不會再有新的視訊幀更新.這裡我們在label上展示"Session interrupted"資訊3秒鐘.
  4. sessionInterruptionEnded(_:) 方法會在session打斷狀態結束後被呼叫.session會從打斷前的狀態繼續執行.如果裝置移動過,所有錨點都會偏移.這避免偏移,就重啟session.
  5. 在螢幕上展示"Session resume"3秒鐘.
  6. 移除先前渲染的物體,重置所有label.我們稍後會實現這個方法.因為這些方法要更新UI,所有在主執行緒中呼叫.
  7. 重啟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 = []
}
複製程式碼
  1. 你定義了一個幫助方法來在一個UILabel上展示資訊文字,並持續顯示一段時間.一旦時間過後,就重置label的visibility和text.
  2. removeAllNodes() 方法移除所有當前新增在場景上的SCNNode物件.目前,你只需移除渲染出的水平面就好.
  3. 這個方法從場景中移除所有渲染出的水平面,並重置了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陣列,陣列中有兩個設定項:

  1. resetTracking:session不會沿用上一個配置的裝置位置和運動追蹤情況.
  2. removeExistingAnchor:session上一個配置的錨點物件會被移除.

執行一下app,試著檢測一個水平面.

[ARKit]12-[譯]在ARKit中建立一個時空門App:新增物體
現在將app退到後臺再重新開啟.看到上一次渲染出的水平面已經從場景中移除,app重置了label以給使用者顯示正確的說明.
[ARKit]12-[譯]在ARKit中建立一個時空門App:新增物體

命中測試

現在你已經準備好在檢測出的水平面上放置物體了.你將使用ARSCNView的命中測試來檢測,使用者手指在螢幕上的觸控對應虛擬場景的哪裡.一個檢視座標下的2D點,實際對應著3D座標空間中的一條線.命中測試就是一個找到這條線上物體的過程.

開啟PortalViewController.swift,新增下列變數.

var viewCenter: CGPoint {
  let viewBounds = view.bounds
  return CGPoint(x: viewBounds.width / 2.0, y: viewBounds.height / 2.0)
}
複製程式碼

上面這段程式碼,你設定變數viewCenterPortalViewController的檢視中心.

現在新增下面的方法:

// 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))      
  }
}
複製程式碼

程式碼解釋:

  1. ARSCNView已經啟用了觸控.當使用者點選檢視時,touchesBegan() 被呼叫,並傳入一個有UITouch物件的集合,以及一個代表觸控事件的UIEvent.你重寫這個觸控處理方法來給sceneView上新增一個ARAnchor.
  2. 呼叫sceneView物件的hitTest(_:, types:) 方法.這個hitTest方法有兩個引數.它接收一個檢視座標系的CGPoint,此處為螢幕的中心,還有一個ARHitTestResult型別用於搜尋.
    這裡你使用existingPlaneUsingExtent結果型別,它會搜尋從viewCenter發出的射線與場景中水平面的交點,並且水平面的面積是有限的.
    hitTest(_:, types:) 是所有命中測試的結果陣列,排序為從近到遠.我們選擇射線相交的第一個平面.這樣,只要螢幕中心有渲染出的水平面,你就能隨時從hitTest(_:, types:) 中拿到結果.
  3. ARSession新增一個ARAnchor,這個位置就是以後3D物體被放置的位置.ARAnchor物件被初始化並帶有一個變換矩陣,定義了錨點在世界座標系中的旋轉,平移和縮放.

錨點新增後,ARSCNView會在代理方法renderer(_:didAdd:for:) 中收到回撥.從這裡開始你將處理時空門的渲染了.

新增準心

在你新增時空門到場景中之前,還需要向檢視中新增最後一個東西.在一段文章中,你實現了檢測裝置螢幕中心的sceneView上的命中測試.在本段中,你將會給螢幕中心的檢視上新增一個標記,來幫助使用者定位裝置.

開啟Main.storyboard.進入Object Library,搜尋一個View物件.拖拽一個view物件到PortalViewController.

[ARKit]12-[譯]在ARKit中建立一個時空門App:新增物體

將view的名字改為Crosshair.新增約束確保其中心對準父控制元件中心.將widthheight設定為10.在Size Inspector頁面中,約束應該是這樣子:

[ARKit]12-[譯]在ARKit中建立一個時空門App:新增物體
進入到Attributes inspector標籤頁,將背景顏色改為Light Gray Color.

選中assistant editor,你會看到PortalViewController.swift在右側.按住Ctrl從storyboard中的Crosshair上拖拽屬性到PortalViewController程式碼中,放在sceneView上方.

IBOutlet中輸入名字為crosshair並點選Connect.

[ARKit]12-[譯]在ARKit中建立一個時空門App:新增物體
執行app.注意有一個灰色正方形view在螢幕中央.這就是我們剛才新增的crosshair view.
[ARKit]12-[譯]在ARKit中建立一個時空門App:新增物體
現在在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
    }
  }
}
複製程式碼

程式碼含義:

  1. 這個方法是SCNSceneRendererDelegate協議的一部分,它被ARSCNViewDelegate實現了.這個協議包含了一系列回撥方法,可以用來在渲染過程的不同時間執行一些操作.renderer(_: updateAtTime:) 會在每一幀被精確呼叫,可以用來執行一些每幀都需要的邏輯.
  2. 執行程式碼來探測是否螢幕中心落在已經檢測出的水平面上,並在主執行緒更新UI.
  3. 這裡在sceneView上執行一個命中測試,來確定檢視中心確實和水平面相交了.如果至少檢測到了一個結果,crosshair背景色變成綠色.
  4. 如果命中測試沒有返回任何結果,則crosshair的背景色重設為淺灰色.

執行app.

四處移動裝置,以便探測並渲染出水平面,如下左圖所示.現在移動你的裝置讓裝置螢幕中心落在平面內,如下右圖所示.注意中心view的顏色變成了綠色.

[ARKit]12-[譯]在ARKit中建立一個時空門App:新增物體

新增一個狀態機

現在你已經建立起一個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() 方法,它可以配置並渲染時空門.共做了下面幾件事:

  1. 建立一個代表時空門的SCNNode物件.
  2. 該步初始化一個SCNBox物件,它是一個立方體,並使用這個立方體作為幾何體建立一個SCNode物件.
  3. 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)
    }
  }
}
複製程式碼

程式碼說明:

  1. 只有在新增到場景上的錨點是一個ARPlaneAnchor,並且isPortalPlacedfalse時,你才需要新增一個水平面到場景中來展示被探測到的平面,
  2. 如果被新增的錨點不是一個ARPlaneAnchor,並且時空門節點仍然沒有被放置上去,那麼這一定是個使用者手動點選螢幕而新增的錨點.
  3. 通過呼叫makePortal() 來建立時空門節點.
  4. renderer(_:, didAdd:, for:) 會在SCNNode物件即node新增到場景時呼叫.你想要將時空門節點放置在node位置處.所以你將時空門節點新增為node的子節點上,並且設定isPortalPlacedtrue來表示時空門節點已經被新增過了.
  5. 為了清理場景,你移除所有渲染出的水平面,並重置debugOptions,這樣螢幕上就不再有特徵點了.
  6. 在主執行緒更新messageLabel,重置其text並隱藏它.
  7. renderer(_:, didUpdate:, for:) 中,只有當錨點是ARPlaneAnchor,且節點至少有一個子節點,而且時空門還沒有被新增過時,你才更新渲染出的水平面,

最後,用下面的程式碼替換removeAllNodes() .

func removeAllNodes() {
  // 1
  removeDebugPlanes()
  // 2
  self.portalNode?.removeFromParentNode()
  // 3
  self.isPortalPlaced = false
}
複製程式碼

這個方法用來從場景中清理並移除所有渲染出的物體.詳情如下:

  1. 移除所有渲染出的水平面.
  2. 從父節點中移除portalNode.
  3. isPortalPlaced變數改為false來重置狀態.

執行app;讓app探測到一個水平面,然後當螢幕上的準心變綠時,點選螢幕.你將會看到一個扁平的,巨大的白色立方體.

[ARKit]12-[譯]在ARKit中建立一個時空門App:新增物體
這個就是你的時空門的佔位節點.在下一章節中,你將會給時空門新增一些牆壁和通道.還會給牆壁新增一些紋理,讓它們看起來更真實.

下一步做什麼?

這些內容相當有趣!這裡做一下本章總結:

  • 你能夠在app進入後臺時,探測並處理ARSession的打斷.
  • 你理解了命中測試是如何在ARSCNView和探測到的水平面中起作用的.
  • 你可能使用命中測試的結果來放置ARAnchorsSCNNode物件. 在下一章,也就是本系列的最後一部分中,你將會把所有東西組合起來,新增牆壁和天花板,並給場景新增一點燈光照明!

如果你喜歡本系列教程,請購買本書的完整版,ARKit by Tutorials, available on our online store.

本章資料下載

相關文章