巧用 ARKit 和 SpriteKit 從零開始做 AR 遊戲

劉小蠻發表於2017-08-21

巧用 ARKit 和 SpriteKit 從零開始做 AR 遊戲

這篇文章隸屬於 Pusher 特邀作者計劃

ARKit 是一個全新的蘋果框架,它將裝置運動追蹤,相機捕獲和場景處理整合到了一起,可以用來構建擴增實境(Augmented Reality, AR) 的體驗。

在使用 ARKit 的時候,你有三種選項來建立你的 AR 世界:

  • SceneKit,渲染 3D 的疊加內容。
  • SpriteKit,渲染 2D 的疊加內容。
  • Metal,自己為 AR 體驗構建的檢視

在這個教程裡,我們將通過建立一個遊戲來學習 ARKit 和 SpriteKit 的基礎,遊戲是受 Pokemon Go 的啟發,新增了幽靈元素,看下下面這個視訊吧:

每幾秒鐘,就會有一個小幽靈隨機出現在場景裡,同時在螢幕的左下角會有一個計數器不停在增加。當你點選幽靈的時候,它會播放一個音效同時淡出而且計數器也會減小。

專案的程式碼已經放在了 GitHub 上了。

讓我們首先檢查一下開發和執行這個專案的需要哪些東西。

你將會需要的

首先,為了完整的 AR 體驗,ARKit 要求一個帶有 A9 或者更新的處理器的 iOS 裝置。換句話說,你至少需要一臺 iPhone6s 或者有更高處理器的裝置,比如 iPhoneSE,任何版本的 iPad Pro,或者 2017 版的 iPad。

ARKit 是 iOS 11 的一個特性,所以你必須先裝上這個版本的 SDK,並用 Xcode 9 來開發。在寫這篇文章的時候,iOS 11 和 Xcode 9 仍然是在測試版本,所以你要先加入到蘋果開發者計劃,不過蘋果現在也向公眾釋出了免費的開發者賬號。你可以在這裡找到更多關於安裝 iOS 11 beta 的資訊和這裡找到關於安裝 Xcode beta 的資訊。

為了避免之後版本的改動,這個應用的教程是通過 Xcode beta 2 來構建的。
在這個遊戲中,我們需要表示幽靈的圖片和它被移除時的音效。OpenGameArt.org 是一個非常棒的獲取免費遊戲資源的網站。我選了這個幽靈圖片 和這個幽靈音效,當然你也可以用任何你想要用的檔案。

新建專案

開啟 Xcode 9 並且新建一個 AR 應用:

輸入專案的資訊,選擇 Swift 作為開發語言並把 SpriteKit 作為內容技術,接著建立專案:

目前 AR 不能夠在 iOS 模擬器上測試,所以我們需要在真機上進行測試。為此,我們需要開發者賬號來註冊我們的應用。如果暫時沒有的話,把你的開發賬號新增到 Xcode 上並且選擇你的團隊來註冊你的應用:

如果你沒有一個付過費的開發者賬號的話,你會有一些限制,比如你每七天只能夠建立 10 個 App ID 而且你不能夠在你的裝置上安裝超過 3 個以上的應用。

在你第一次在你的裝置上安裝應用的時候,你可能會被要求信任裝置上的證照,就跟著下面的指導:

就像這樣,當應用執行的時候,你會被請求給予攝像頭許可權:

之後,在你觸控螢幕的時候,一個新的精靈會被加到場景上去,並且根據攝像頭的角度來調整位置。

現在這個專案已經搭建完成了,讓我們來看下程式碼吧。

SpriteKit 如何和 ARKit 一起工作

如果你開啟 Main.storyboard,你會發現有個 ARSKView 填滿了整個螢幕:

這個檢視將來自裝置攝像頭的實時視訊,渲染為場景的背景,將 2D 的圖片(以 SpriteKit 的節點)加到 3D 的空間中( 以 ARAnchor 物件)。當你移動裝置的時候,這個檢視會根據錨點( ARAnchor 物件)自動旋轉和縮放這個影像( SpriteKit 節點),所以他們看上去就像是通過攝像頭跟蹤的真實的世界。

這個介面是通過 ViewController.swift 這個類來管理的。首先,在 viewDidLoad 方法中,它開啟了介面的一些除錯選項,然後通過這個自動生成的場景 Scene.sks 來建立 SpriteKit 場景:

    override func viewDidLoad() {
      super.viewDidLoad()

      // 設定檢視的代理
      sceneView.delegate = self

      // 展示資料,比如 fps 和節點數
      sceneView.showsFPS = true
      sceneView.showsNodeCount = true

      // 從 'Scene.sks' 載入 SKScene
      if let scene = SKScene(fileNamed: "Scene") {
        sceneView.presentScene(scene)
      }
    }複製程式碼

接著,viewWillAppear 方法通過 ARWorldTrackingSessionConfiguration 類來配置這個會話。這個會話( ARSession 物件)負責管理建立 AR 體驗所需要的運動追蹤和影像處理:

    override func viewWillAppear(_ animated: Bool) {
      super.viewWillAppear(animated)

      // 建立會話配置
      let configuration = ARWorldTrackingSessionConfiguration()

      // 執行檢視的會話
      sceneView.session.run(configuration)
    }複製程式碼

你可以用 ARWorldTrackingSessionConfiguration 類來配置該會話通過六個自由度(6DOF)中追蹤物體的移動。三個旋轉角度:

  • Roll,在 X-軸 的旋轉角度
  • Pitch,在 Y-軸 的旋轉角度
  • Yaw,在 Z-軸 的旋轉角度

和三個平移值:

  • Surging,在 X-軸 上向前向後移動。
  • Swaying,在 Y-軸 上左右移動。
  • Heaving,在 Z-軸 上上下移動。

或者,你也可以用 ARSessionConfiguration ,它提供了 3 個自由度,支援低效能裝置的簡單運動追蹤。

往下幾行,你會發現這個方法 view(_ view: ARSKView, nodeFor anchor: ARAnchor) -> SKNode? 。當一個錨點被新增的時候,這個方法為即將新增到場景上的錨點提供了一個自定義節點。在當前的情況下,它會返回一個 SKLabelNode 來展示這個面向使用者的 emoji :

    func view(_ view: ARSKView, nodeFor anchor: ARAnchor) -> SKNode? {
      // 為加上檢視會話的錨點增加和配置節點
      let labelNode = SKLabelNode(text: "?")
      labelNode.horizontalAlignmentMode = .center
      labelNode.verticalAlignmentMode = .center
      return labelNode;
    }複製程式碼

但是這個錨點什麼時候建立的呢?

它是在 Scene.swift 檔案中完成的,在這個管理 Sprite 場景(Scene.sks)的類中,特別地,這個方法中:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
      guard let sceneView = self.view as? ARSKView else {
        return
      }

      // 通過攝像頭當前的位置建立錨點
      if let currentFrame = sceneView.session.currentFrame {
        // 建立一個往攝像頭前面平移 0.2 米的轉換
        var translation = matrix_identity_float4x4
        translation.columns.3.z = -0.2
        let transform = simd_mul(currentFrame.camera.transform, translation)

        // 在會話上新增一個錨點
        let anchor = ARAnchor(transform: transform)
        sceneView.session.add(anchor: anchor)
      }
    }複製程式碼

就像你從註釋中可以看到的,它通過攝像頭當前的位置建立了一個錨點,然後新建了一個矩陣來把錨點定位在攝像頭前 0.2m 處,並把它加到場景中。

ARAnchor 使用一個 4×4 的矩陣 來代表和它相對應的物件在一個三維空間中的位置,角度或者方向,和縮放。

在 3D 程式設計的世界裡,矩陣用來代表圖形化的轉換比如平移,縮放,旋轉和投影。通過矩陣的乘法,多個轉換可以連線成一個獨立的變換矩陣。

這是一篇關於轉換背後的數學很好的博文。同樣的,在核心動畫指南中關於操作 3D 介面中層級一章 中你也可以找到一些常用轉換的矩陣配置。

回到程式碼中,我們以一個特殊的矩陣開始(matrix_identity_float4x4):

1.0   0.0   0.0   0.0  // 這行代表 X
0.0   1.0   0.0   0.0  // 這行代表 Y
0.0   0.0   1.0   0.0  // 這行代表 Z
0.0   0.0   0.0   1.0  // 這行代表 W複製程式碼

如果你想知道 W 是什麼:

如果 w == 1,那麼這個向量 (x, y, z, 1) 是空間中的一個位置。

如果 w == 0,那麼這個向量 (x, y, z, 0) 是一個方向。

www.opengl-tutorial.org/beginners-t…

接著,Z-軸列的第三個值改為了 -0.2 代表著在這個軸上有平移(負的 z 值代表著把物件放置到攝像頭之前)。
如果你這個時候列印了平移矩陣值的話,你會看見它列印了一個向量陣列,每個向量代表了一列。

[ [1.0, 0.0,  0.0, 0.0 ],
  [0.0, 1.0,  0.0, 0.0 ],
  [0.0, 0.0,  1.0, 0.0 ],
  [0.0, 0.0, -0.2, 1.0 ]
]複製程式碼

這樣子可能看起來更簡單一點:

0     1     2     3    // 列號
1.0   0.0   0.0   0.0  // 這一行代表著 X
0.0   1.0   0.0   0.0  // 這一行代表著 Y
0.0   0.0   1.0  -0.2  // 這一行代表著 Z
0.0   0.0   0.0   1.0  // 這一行代表著 W複製程式碼

接著,這個矩陣會乘上當前攝像頭幀的平移矩陣得到最後用來放置新錨點的矩陣。舉個例子,假設是如下的相機轉換矩陣(以一個列的陣列的形式):

[ [ 0.103152, -0.757742,   0.644349, 0.0 ],
  [ 0.991736,  0.0286687, -0.12505,  0.0 ],
  [ 0.0762833, 0.651924,   0.754438, 0.0 ],
  [ 0.0,       0.0,        0.0,      1.0 ]
]複製程式碼

那麼相乘的結果將是:

[ [0.103152,   -0.757742,   0.644349, 0.0 ],
  [0.991736,    0.0286687, -0.12505,  0.0 ],
  [0.0762833,   0.651924,   0.754438, 0.0 ],
  [-0.0152567, -0.130385,  -0.150888, 1.0 ]
]複製程式碼

這裡是關於矩陣如何相乘的更多資訊,這是一個矩陣乘法計算器

現在你知道這個例子是如何工作的了,讓我們修改它來建立我們的遊戲吧。

構建 SpriteKit 的場景

在 Scene.swift 的檔案中,讓我們加上如下的配置:

    class Scene: SKScene {

      let ghostsLabel = SKLabelNode(text: "Ghosts")
      let numberOfGhostsLabel = SKLabelNode(text: "0")
      var creationTime : TimeInterval = 0
      var ghostCount = 0 {
        didSet {
          self.numberOfGhostsLabel.text = "\(ghostCount)"
        }
      }
      ...
    }複製程式碼

我們增加了兩個標籤,一個代表了場景中的幽靈的數量,控制幽靈產生的時間間隔,和幽靈的計數器,它有個屬性觀察器,每當它的值變化的時候,標籤就會更新。

接下來,下載幽靈移除時播放的音效,並把它拖到專案中:

把下面這行加到類裡面:

let killSound = SKAction.playSoundFileNamed("ghost", waitForCompletion: false)複製程式碼

我們稍後呼叫這個動作來播放音效。

didMove 方法中,我們把標籤加到場景中:

    override func didMove(to view: SKView) {
      ghostsLabel.fontSize = 20
      ghostsLabel.fontName = "DevanagariSangamMN-Bold"
      ghostsLabel.color = .white
      ghostsLabel.position = CGPoint(x: 40, y: 50)
      addChild(ghostsLabel)

      numberOfGhostsLabel.fontSize = 30
      numberOfGhostsLabel.fontName = "DevanagariSangamMN-Bold"
      numberOfGhostsLabel.color = .white
      numberOfGhostsLabel.position = CGPoint(x: 40, y: 10)
      addChild(numberOfGhostsLabel)
    }複製程式碼

你可以用像 iOS Fonts 的站點來視覺化的選擇標籤的字型。

這個位置座標代表著螢幕左下角的部分(相關程式碼稍後會解釋)。我選擇把它們放在螢幕的這個區域是為了避免轉向的問題,因為場景的大小會隨著方向改變而變化,但是,座標保持不變,會引起標籤顯示超過螢幕或者在一些奇怪的位置(可以通過重寫 didChangeSize 方法或者使用 UILabels 替換 SKLabelNodes 來解決這一問題)。

現在,為了在固定的時間間隔建立幽靈,我們需要一個定時器。這個更新方法會在每一幀(平均 60 次每秒)渲染之前被呼叫,可以像下面這樣幫助我們:

    override func update(_ currentTime: TimeInterval) {
      // 在每一幀渲染之前呼叫
      if currentTime > creationTime {
        createGhostAnchor()
        creationTime = currentTime + TimeInterval(randomFloat(min: 3.0, max: 6.0))
      }
    }複製程式碼

引數 currentTime 代表著當前應用中的時間,所以如果它大於 creationTime 所代表的時間,一個新的幽靈錨點會建立, creationTime 也會增加一個隨機的秒數,在這個例子裡面,是在 3 到 6 秒。

這是 randomFloat 的定義:

    func randomFloat(min: Float, max: Float) -> Float {
      return (Float(arc4random()) / 0xFFFFFFFF) * (max - min) + min
    }複製程式碼

createGhostAnchor 方法中,我們需要獲取場景的介面:

    func createGhostAnchor(){
      guard let sceneView = self.view as? ARSKView else {
        return
      }

    }複製程式碼

接著,因為在接下來的函式中我們都要與弧度打交道,讓我們先定義一個弧度的 360 度:

    func createGhostAnchor(){
      ...

      let _360degrees = 2.0 * Float.pi

    }複製程式碼

現在,為了把幽靈放置在一個隨機的位置,我們分別建立一個隨機 X-軸旋轉和 Y-軸旋轉矩陣:

    func createGhostAnchor(){
      ...

       let rotateX = simd_float4x4(SCNMatrix4MakeRotation(_360degrees * randomFloat(min: 0.0, max: 1.0), 1, 0, 0))

      let rotateY = simd_float4x4(SCNMatrix4MakeRotation(_360degrees * randomFloat(min: 0.0, max: 1.0), 0, 1, 0))

    }複製程式碼

幸運的是,我們不需要去手動地建立這個旋轉矩陣,有一些函式可以返回一個表示旋轉,平移或者縮放的轉換資訊矩陣。

在這個例子中,SCNMatrix4MakeRotation 返回了一個表示旋轉變換的矩陣。第一個引數代表了旋轉的角度,要用弧度的形式。在這個表示式 _360degrees * randomFloat(min: 0.0, max: 1.0) 中得到一個在 0 到 360 度中的隨機角度。

剩下的 SCNMatrix4MakeRotation 的引數,代表了 X,Y 和 Z 軸各自的旋轉,這就是為什麼我們第一次呼叫的時候把 1 作為 X 的引數,而第二次的時候把 1 作為 Y 的引數。

SCNMatrix4MakeRotation 的結果通過 simd_float4x4 結構體轉換為一個 4x4 的矩陣。

如果你正在使用 Xcode 9 Beta 1 的話,你應該用 SCNMatrix4ToMat4 ,在 Xcode 9 Beta 2 中它被 simd_float4x4 替換了。

我們可以通過矩陣乘法來組合兩個旋轉矩陣:

    func createGhostAnchor(){
      ...
      let rotation = simd_mul(rotateX, rotateY)

    }複製程式碼

接著,我們建立一個 Z-軸是 -1 到 -2 之間的隨機值的轉換矩陣。

    func createGhostAnchor(){
      ...
      var translation = matrix_identity_float4x4
      translation.columns.3.z = -1 - randomFloat(min: 0.0, max: 1.0)

    }複製程式碼

組合旋轉和位移矩陣:

    func createGhostAnchor(){
      ...
      let transform = simd_mul(rotation, translation)

    }複製程式碼

建立並把這個錨點加到該會話中:

    func createGhostAnchor(){
      ...
      let anchor = ARAnchor(transform: transform)
      sceneView.session.add(anchor: anchor)

    }複製程式碼

並且增加幽靈計數器:

    func createGhostAnchor(){
      ...
      ghostCount += 1
    }複製程式碼

現在唯一剩下沒有加的就是當使用者觸控一個幽靈並移動它的程式碼。首先重寫 touchesBegan 來獲取到觸控的物體:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
      guard let touch = touches.first else {
        return
      }

    }複製程式碼

接著獲取該觸控在 AR 場景中的位置:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
      ...
      let location = touch.location(in: self)

    }複製程式碼

獲取在該位置的所有節點:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
      ...
      let hit = nodes(at: location)

    }複製程式碼

獲取第一個節點(如果有的話),檢查這個節點是不是代表著一個幽靈(記住標籤同樣也是一個節點):

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
      ...
      if let node = hit.first {
        if node.name == "ghost" {

        }
      }
    }複製程式碼

如果就這個節點的話,組合淡出和音效動作,建立一個動作序列並執行它,同時減小幽靈的計數器:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
      ...
      if let node = hit.first {
        if node.name == "ghost" {

          let fadeOut = SKAction.fadeOut(withDuration: 0.5)
          let remove = SKAction.removeFromParent()

          // 組合淡出和音效動畫
          let groupKillingActions = SKAction.group([fadeOut, killSound])
          // 建立動作序列
          let sequenceAction = SKAction.sequence([groupKillingActions, remove])

          // 執行動作序列
          node.run(sequenceAction)

          // 更新計數
          ghostCount -= 1

        }
      }
    }複製程式碼

到這裡,我們的場景已經完成了,現在我們開始處理 ARSKView 的檢視控制器。

構建檢視控制器

在 viewDidLoad 中,不再載入 Xcode 為我們建立的場景,讓我們通過這種方式來建立我們的場景:

    override func viewDidLoad() {
      ...

      let scene = Scene(size: sceneView.bounds.size)
      scene.scaleMode = .resizeFill
      sceneView.presentScene(scene)
    }複製程式碼

這會確保我們的場景可以填滿整個介面,甚至整個螢幕(在 Main.storyboard 中定義的 ARSKView 填滿了整個螢幕)。這同樣也有助於把遊戲的標籤定位在螢幕的左下角,根據場景中定義的位置座標。

現在,現在是時候新增幽靈圖片了。在我的例子中,圖片的格式原來是 SVG ,所以我轉換到了 PNG ,並且為了簡單起見,只加了圖片中的前 6 個幽靈,建立了 2X 和 3X 版本(我沒看見建立 1X 版本的地方,因此採用了縮放策略的裝置不能夠正常的執行這個應用)。

把圖片拖到 Assets.xcassets 中:

注意影像名字最後的數字 - 這會幫我們隨機選擇一個圖片建立 SpriteKit 節點。用這個替換 view(_ view: ARSKView, nodeFor anchor: ARAnchor) 中的程式碼:

    func view(_ view: ARSKView, nodeFor anchor: ARAnchor) -> SKNode? {
      let ghostId = randomInt(min: 1, max: 6)

      let node = SKSpriteNode(imageNamed: "ghost\(ghostId)")
      node.name = "ghost"

      return node
    }複製程式碼

我們給所有的節點同樣的名字 ghost ,所以在移除它們的時候我們可以識別它們。

當然,不要忘了 randomInt 方法:

    func randomInt(min: Int, max: Int) -> Int {
      return min + Int(arc4random_uniform(UInt32(max - min + 1)))
    }複製程式碼

現在我們已經完成了所有工作!讓我們來測試它吧!

測試應用

在真機上執行這個應用,賦予攝像頭許可權,並且開始在所有方向中尋找幽靈:

每 3 到 6 秒就會出現一個新的幽靈,計數器也會更新,每當你擊中一個幽靈的時候就會播放一個音效。

試著讓計數器歸零吧!

結論

關於 ARKit 有兩個非常棒的地方。第一是隻需要幾行程式碼我們就能建立神奇的 AR 應用,第二個,我們也能學習到 SpriteKit 和 SceneKit 的知識。 ARKit 實際上只有很少的量的類,更重要的是去學會如何運用上面提到的框架,而且稍加調整就能創造出 AR 體驗。

你可以通過增加遊戲規則,引入獎勵分數或者改變影像和聲音來擴充套件這個應用。同樣的,使用 Pusher,你可以同步遊戲狀態來增加多人遊戲的特性。

記住你可以在這個 GitHub 倉庫中找到 Xcode 專案。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章