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

說明

ARKit系列文章目錄node

譯者注:本文是Raywenderlich上《ARKit by Tutorials》免費章節的翻譯,是原書第8章.原書7~9章完成了一個時空門app.
官網原文地址www.raywenderlich.com/195388/buil…ios


本文是咱們書籍ARKit by Tutorials中的第8章,「添加物體到你的世界」.這本書向你展現瞭如何用蘋果的加強現實框架ARKit,來構建五個沉浸式的,好看的AR應用.開始吧!swift

在本系列上一章中,你已經學會了如何用ARKit創建你的app並探測水平面.在本章中,你將繼續構建你的app並經過SceneKit添加3D虛擬內容到相機場景中.在本章結束,你將會學到:數組

  • 處理session打斷
  • 放置物體到探測出的水平面

在開始以前,點擊 資料下載 來下載項目資料,並打開starter文件夾下的starter工程.session

開始

如今你已經可以探測並渲染水平面了,還須要在session被打斷時重置狀態.當app進入後臺時,或當多個app處於前臺時ARSession就會被打斷.一旦被打斷後,視頻捕捉就會失敗,ARSession也不能再接收到傳感器的數據來追蹤了.當app返回前臺時,渲染出的平面仍然顯示在視圖上.然而,若是你的設備已經改變了位置或朝向,那麼ARSession追蹤就再也不有效了.這時你就須要重啓session.app

ARSCNViewDelegate實現了ARSessionObserver的協議.這個協議包含了一些方法,會在ARSession被打斷或出錯時被調用.框架

打開PortalViewController.swift,並添加下面的代理方法實現到已存在的類擴展中.ssh

// 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()
}
複製代碼

代碼詳解:async

  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配置並用新的配置從新開始追蹤.

你會看到有一些編譯錯誤.實現缺失的方法就能夠解決這些錯誤.ide

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,試着檢測一個水平面.

如今將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)
}
複製代碼

上面這段代碼,你設置變量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.

將view的名字改成Crosshair.添加約束確保其中心對準父控件中心.將widthheight設置爲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
    }
  }
}
複製代碼

代碼含義:

  1. 這個方法是SCNSceneRendererDelegate協議的一部分,它被ARSCNViewDelegate實現了.這個協議包含了一系列回調方法,能夠用來在渲染過程的不一樣時間執行一些操做.renderer(_: updateAtTime:) 會在每一幀被精確調用,能夠用來執行一些每幀都須要的邏輯.
  2. 運行代碼來探測是否屏幕中心落在已經檢測出的水平面上,並在主線程更新UI.
  3. 這裏在sceneView上執行一個命中測試,來肯定視圖中心確實和水平面相交了.若是至少檢測到了一個結果,crosshair背景色變成綠色.
  4. 若是命中測試沒有返回任何結果,則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() 方法,它能夠配置並渲染時空門.共作了下面幾件事:

  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探測到一個水平面,而後當屏幕上的準心變綠時,點擊屏幕.你將會看到一個扁平的,巨大的白色立方體.

這個就是你的時空門的佔位節點.在下一章節中,你將會給時空門添加一些牆壁和通道.還會給牆壁添加一些紋理,讓它們看起來更真實.

下一步作什麼?

這些內容至關有趣!這裏作一下本章總結:

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

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

本章資料下載

相關文章
相關標籤/搜索