ARKit 教學:偵測水平面以及使用 SceneKit 添加 3D 物件


擴增實境(Augmented Reality)有著前所未有的力量擴展我們的世界,讓我們與世界互動的方式不再一樣。隨著 iPhone X 的發表,這個世界已準備好擁抱 AR。而我們正在歷史的一刻,正在一個偉大的開端。AR 的潛力是無止盡的。

開始之前

本次教學建立在先前 ARKit 教學內容之上。如果你還沒有準備好,可以參考我們先前的教學內容。此外,如果可以的話,先為你的 App 找到一個平坦的地方就在好不過了。

我們將會學到什麼?

在這次的教學中,我們專注在 ARKit 的水平面上。我們會先製作一個洋面(水平面),然後放入一艘船在上面(3D 物件)。

arkit-ship-1

或者用燈光建立一支大艦隊!

arkit-horizontal-plane-2

接下來,你會學到關於 ARKit 中的水平面知識。最後我希望在完成這次的教學後,你可以在你的 ARKit 專案中能順利的使用水平面。

什麼是水平面

所以當我們說到 ARKit 中的水平面時,這個水平面(Horizontal Plane)是什麼東西呢?當我們在 ARKit 中偵測到一個水平面,就技術上來說我們偵測到一個 ARPlaneAnchor。那什麼是 ARPlaneAnchor? ARPlaneAnchor 基本來說是一個包含了被偵測到的水平面資訊的物件。

以下是 Apple 對於 ARPlaneAnchor 的官方敘述:

Information about the position and orientation of a real-world flat surface detected in a world-tracking AR session.

- Apple’s Documentation

讓我們開始製作 App 吧

我們將以這個專案開始,所以我們可以專注在 ARKit 的實作上。在 Xcode 打開專案後稍微看一下程式碼內容。我已經在 Storyboard 裡建立好了一個 ARSCNView。

arkit-starter-project

Build 及 Run 這個專案來做個快速測試。你應該會在 iOS 模擬器上看到以下畫面:

arkit-horizontal-plane-3

在相機授權上點擊 OK 以獲得相機權限。如此一來你應該可以看到你的相機畫面。

水平面偵測

偵測一個水平面是件很簡單的事情,這都要感謝 Apple 工程師們。

只要在 ViewController 裡將以下程式碼放入 setUpSceneView() 中就可以了:

configuration.planeDetection = .horizontal

藉由設定 planeDetection 屬性為 .horizontal,來告訴 ARKit 找尋任何的水平面。一旦 ARKit 偵測到了一個水平面,水平面就會被加入到 sceneView 的 Session 中。

為了要偵測水平面,我們必須調用 ARSCNViewDelegate Protocol。在以下的 ViewController 類別裡,建立一個 ViewController 類別 Extension 來實作這個 Protocol。

extension ViewController: ARSCNViewDelegate {

}

現在在這個類別 Extension 中實作 renderer(_:didAdd:for:) 方法:

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {

}

這個 Protocol 方法會在每次有 ARAnchor 被加進 sceneView 的 Session 時被呼叫。一個 ARAnchor 物件代表著 3D 空間中一個物理上的位置及方向。我們會在稍後使用 ARAnchor 來偵測水平面。

接下來,回到 setUpSceneView() 。在 setUpSceneView() 裡面將 sceneView 的 Delegate 指派給 ViewController

如果你想要的話,也可以設定 sceneView 的除錯選項(Debug Options)來顯示特徵點(Feature Point)。這可以幫助你找到足夠的特徵點位置來偵測水平面。而一個水平面是由很多的特徵點所組成。一旦偵測到足夠的特徵點來識別水平面,renderer(_:didAdd:for:) 就會被呼叫。

現在,你的 setUpSceneView() 應該會長的像這樣:

func setUpSceneView() {
    let configuration = ARWorldTrackingConfiguration()
    configuration.planeDetection = .horizontal
    
    sceneView.session.run(configuration)
    
    sceneView.delegate = self
    sceneView.debugOptions = [ARSCNDebugOptions.showFeaturePoints]
}

水平面視覺化

現在,App 會在每次新的 ARAnchor 被加入 sceneView 時收到通知,而我們或許會對新加進的 ARAnchor 是什麼樣子感到興趣。

於是,更新 renderer(_:didAdd:for:) 方法:

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    // 1
    guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
    
    // 2
    let width = CGFloat(planeAnchor.extent.x)
    let height = CGFloat(planeAnchor.extent.z)
    let plane = SCNPlane(width: width, height: height)
    
    // 3
    plane.materials.first?.diffuse.contents = UIColor.transparentLightBlue
    
    // 4
    let planeNode = SCNNode(geometry: plane)
    
    // 5
    let x = CGFloat(planeAnchor.center.x)
    let y = CGFloat(planeAnchor.center.y)
    let z = CGFloat(planeAnchor.center.z)
    planeNode.position = SCNVector3(x,y,z)
    planeNode.eulerAngles.x = -.pi / 2
    
    // 6
    node.addChildNode(planeNode)
}

讓我一行一行帶你了解這些程式碼的意思吧。

  1. 我們將視為 ARPlaneAnchor 的 anchor 參數安全解包(unwrap)以確認我們有關於現實世界平面的資訊
  2. 在這邊,我們建立一個 SCNPlane 來視覺化 ARPlaneAnchor。SCNPlane 是一個單面的平面幾何矩形。我們拿解包的 ARPlaneAnchor 裡的 Extent 中的 X 及 Y 屬性來建立一個 SCNPlane。ARPlaneAnchor Extent 是指被偵測到的平面的估計大小。我們取 Extent 的 X 及 Y 來做為 SCNPlane 的高與寬。接著我們給這個平面上一層亮藍色來模擬水的樣子。
  3. 我們用我們剛建立好的 SCNPlane 幾何形來初始化 SCNNode
  4. 我們初始化 xy 以及 z 常數來表示 planeAnchor 中心的 X、Y、Z 座標。這是為了 planeNode 的座標位置。我們逆時針旋轉 planeNode 的 X尤拉角 90 度,否則 planeNode 會垂直立於桌上。如果是順時針旋轉,就會變成魔術般的錯覺畫面了,因為 SceneKit 預設使用一側的材質來渲染 SCNPlane 。
  5. 最後,我們將 planeNode 作為子節點放入至新增加的 SceneKit 節點上

Build 及 Run 這個專案。現在你應該能夠偵測及視覺化水平面了

arkit-detected-plane

擴展水平面

隨著 ARKit 收到關於環境的額外訊息,我們可能會希望擴展我們先前偵測的水平面來作更大的平面或更精確地呈現新的資訊。

於是,我們實作 renderer(_:didUpdate:for:) 吧:

func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {

}

這個方法會在更新 SceneKit 節點的屬性好對應相對應的錨點時被呼叫。這可以讓 ARKit 改善對水平面位置及範圍的估算。

node 參數是錨點更新後的座標位置。anchor 參數則提供了錨點更新後的寬與高。使用這兩個參數,我們可以更新先前實作的 SCNPlane 來對應出更新寬高後的的座標位置。

接下來,將以下程式碼放入到renderer(_:didUpdate:for:) 中:

// 1
guard let planeAnchor = anchor as?  ARPlaneAnchor,
    let planeNode = node.childNodes.first,
    let plane = planeNode.geometry as? SCNPlane
    else { return }

// 2
let width = CGFloat(planeAnchor.extent.x)
let height = CGFloat(planeAnchor.extent.z)
plane.width = width
plane.height = height

// 3
let x = CGFloat(planeAnchor.center.x)
let y = CGFloat(planeAnchor.center.y)
let z = CGFloat(planeAnchor.center.z)
planeNode.position = SCNVector3(x, y, z)

再一次的,讓我一行一行來解釋上面的程式碼:

  1. 我們將視為 ARPlaneAnchor 的 anchor 參數安全解包。接下來,將 node 的第一個子節點也安全解包。最後,我們也將視為 SCNPlaneplaneNode 幾何形安全解包。我們只取出先前實作的 ARPlaneAnchorSCNNodeSCNplane 以及使用相對應的參數更新屬性。
  2. 這裡我們用 planeAnchor 範圍的 x 及 z 屬性來更新 plane 的寬高。
  3. 最後,我們將 planeNode 的座標位置更新為 planeAnchor 中心點的 x、y、z 座標

Build 及 Run 專案來確認水平面擴展的實作。

arkit-expand-plane

將物件加入至水平面

現在讓我們把船隻放到水平面上吧。在初始專案的裡頭,我已經包好了一個船型的 3D 物件供你使用。

ViewController 類別中插入以下方法來將船隻放在水平面上:

@objc func addShipToSceneView(withGestureRecognizer recognizer: UIGestureRecognizer) {
    let tapLocation = recognizer.location(in: sceneView)
    let hitTestResults = sceneView.hitTest(tapLocation, types: .existingPlaneUsingExtent)
    
    guard let hitTestResult = hitTestResults.first else { return }
    let translation = hitTestResult.worldTransform.translation
    let x = translation.x
    let y = translation.y
    let z = translation.z
    
    guard let shipScene = SCNScene(named: "ship.scn"),
        let shipNode = shipScene.rootNode.childNode(withName: "ship", recursively: false)
        else { return }
    
    
    shipNode.position = SCNVector3(x,y,z)
    sceneView.scene.rootNode.addChildNode(shipNode)
}

這邊的程式碼與之前教學的內容類似,所以我就不再逐行解釋。如果你想要了解更多的話,來看看之前的教學吧。唯一的不同在我們在 types 傳送了不一樣的參數來偵測 sceneView 中已經存在的平面錨點。

在完成之前,我們還須放入以下程式碼:

func addTapGestureToSceneView() {
    let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(ViewController.addShipToSceneView(withGestureRecognizer:)))
    sceneView.addGestureRecognizer(tapGestureRecognizer)
}

這個方法會將點擊手勢放入 sceneView 中。

viewDidLoad() 中呼叫以下方法好將點擊手勢放入 sceneView

addTapGestureToSceneView()

現在如果你 Build 及 Run,你應該能夠偵測到一個水平面並視覺化它然後放入一艘超酷的船在上面。

ARKit Ship

或者一支艦隊(使用燈光)

arkit-ship-fleet

你可以藉由取消 viewDidLoad() 內的 configureLighting() 註解來使用燈光。這個函式非常簡單只要兩行程式碼就可以使用燈光:

sceneView.autoenablesDefaultLighting = true
sceneView.automaticallyUpdatesLighting = true

總結

我希望你享受這次的教學內容並且學習到一些有價值的東西。如果你有的話,請藉由分享這篇教學來讓我知道。最後,如果你有任何的意見、問題或是建議,歡迎在底下留言。

雖然我不太確定一個人做這個好不好,但你可以在留言中貼上你在什麼地方擺上船隻的照片,我想知道你們會在什麼有趣地方做這件事。

作為參考範例,你可以到 GitHub 上下載最後完成的專案檔。

譯者簡介:楊敦凱-目前於科技公司擔任 iOS Developer,工作之餘開發自有 iOS App同時關注網路上有趣的新玩意、話題及科技資訊。平時的興趣則是與自身專業無關的歷史、地理、棒球。來信請寄到:[email protected]

原文ARKit Tutorial: Detecting Horizontal Planes and Adding 3D Objects with SceneKit


年輕、富創意的Jayven熱愛手機程式設計,善於發掘和凸顯不平凡處,透過文章抒發其獨特性。閒時喜歡做健身、看UFC。希望了解Jayven更多,可以到訪他的Medium平台或在LinkedIn跟他聯繫。

blog comments powered by Disqus
Shares
Share This