說明
學習ARKit前,需要先學習SceneKit,參考SceneKit系列文章目錄
更多iOS相關知識檢視github上WeekWeekUpProject
在本教程中,你將會學習如何製作一個類似Stack AR這樣的遊戲.
本教程將包含以下內容:
- 第1步:利用ARKit識別出平面.
- 第2步:修改上一篇中Stack遊戲場景.
- 第3步:將原3D版遊戲移植到AR場景中.
- 第4步:修復合併後的bug和邏輯錯誤
step1:利用ARKit識別平面
首先,開啟Xcode,新建一個AR專案,選擇swift和SceneKit,建立專案
對storyboard中進行適當改造,新增: 資訊label---顯示AR場景資訊 Play按鈕---識別到場景後點選進入遊戲 reset按鈕---重置AR場景識別和遊戲
此外,還有三個屬性,用來控制場景識別:
// 識別出平面後,放上游戲的基礎節點,相對固定於真實世界場景中
weak var baseNode: SCNNode?
// 識別出平面錨點後,顯示的平面節點,會不斷重新整理大小和位置
weak var planeNode: SCNNode?
// 重新整理次數,超過一定次數才說明這個平面足夠明顯,足夠穩定
var updateCount: NSInteger = 0
複製程式碼
在viewDidLoad方法中,刪除載入預設素材,先用一個空的場景代替,並開啟特徵點顯示(art.scnassets裡面的飛機模型也可以刪除了):
override func viewDidLoad() {
super.viewDidLoad()
playButton.isHidden = true
// Set the view's delegate
sceneView.delegate = self
// Show statistics such as fps and timing information
sceneView.showsStatistics = true
//顯示debug特徵點
sceneView.debugOptions = [ARSCNDebugOptions.showFeaturePoints]
// Create a new scene
let scene = SCNScene()
// Set the scene to the view
sceneView.scene = scene
}
複製程式碼
在viewWillAppear裡面配置追蹤選項
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard ARWorldTrackingConfiguration.isSupported else {
fatalError("""
ARKit is not available on this device. For apps that require ARKit
for core functionality, use the `arkit` key in the key in the
`UIRequiredDeviceCapabilities` section of the Info.plist to prevent
the app from installing. (If the app can't be installed, this error
can't be triggered in a production scenario.)
In apps where AR is an additive feature, use `isSupported` to
determine whether to show UI for launching AR experiences.
""") // For details, see https://developer.apple.com/documentation/arkit
}
//重置介面,引數,追蹤配置
resetAll()
}
private func resetAll() {
//0.顯示按鈕
playButton.isHidden = true
sessionInfoLabel.isHidden = false
//1.重置平面檢測配置,重啟檢測
resetTracking()
//2.重置更新次數
updateCount = 0
sceneView.debugOptions = [ARSCNDebugOptions.showFeaturePoints]
}
複製程式碼
處理Play按鈕點選和reset按鈕點選:
@IBAction func playButtonClick(_ sender: UIButton) {
//0.隱藏按鈕
playButton.isHidden = true
sessionInfoLabel.isHidden = true
//1.停止平面檢測
stopTracking()
//2.不顯示輔助點
sceneView.debugOptions = []
//3.更改平面的透明度和顏色
planeNode?.geometry?.firstMaterial?.diffuse.contents = UIColor.clear
planeNode?.opacity = 1
//4.載入遊戲場景
}
@IBAction func restartButtonClick(_ sender: UIButton) {
resetAll()
}
複製程式碼
這裡說一下resetAll方法裡的問題,一定要先停止追蹤,再重置updateCount,否則,可能重置為0後,又更新了AR場景, updateCount+=1,造成下一次識別出平面後不能顯示出來.
為了更清晰,我們在單獨的extension中處理ARSCNViewDelegate的代理方法,注意這個協議裡除了自帶的方法外,還有SCNSceneRendererDelegate和ARSessionObserver,如果還不夠用,還可以成為session的代理後,使用ARSessionDelegate中的方法:
extension ViewController:ARSCNViewDelegate {
// MARK: - ARSCNViewDelegate
// 識別到新的錨點後,新增什麼樣的node.不實現該代理的話,會新增一個預設的空的node
// ARKit會自動管理這個node的可見性及transform等屬性等,所以一般把自己要顯示的內容新增在這個node下面作為子節點
// func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
//
// let node = SCNNode()
//
// return node
// }
// node新增到新的錨點上之後(一般在這個方法中新增幾何體節點,作為node的子節點)
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
//1.獲取捕捉到的平地錨點,只識別並新增一個平面
if let planeAnchor = anchor as? ARPlaneAnchor,node.childNodes.count < 1,updateCount < 1 {
print("捕捉到平地")
//2.建立一個平面 (系統捕捉到的平地是一個不規則大小的長方形,這裡筆者將其變成一個長方形)
let plane = SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
//3.使用Material渲染3D模型(預設模型是白色的,這裡筆者改成紅色)
plane.firstMaterial?.diffuse.contents = UIColor.red
//4.建立一個基於3D物體模型的節點
planeNode = SCNNode(geometry: plane)
//5.設定節點的位置為捕捉到的平地的錨點的中心位置 SceneKit框架中節點的位置position是一個基於3D座標系的向量座標SCNVector3Make
planeNode?.simdPosition = float3(planeAnchor.center.x, 0, planeAnchor.center.z)
//6.`SCNPlane`預設是豎著的,所以旋轉一下以匹配水平的`ARPlaneAnchor`
planeNode?.eulerAngles.x = -.pi / 2
//7.更改透明度
planeNode?.opacity = 0.25
//8.新增到父節點中
node.addChildNode(planeNode!)
//9.上面的planeNode節點,大小/位置會隨著檢測到的平面而不斷變化,方便起見,再新增一個相對固定的基準平面,用來放置遊戲場景
let base = SCNBox(width: 0.5, height: 0, length: 0.5, chamferRadius: 0);
base.firstMaterial?.diffuse.contents = UIColor.gray;
baseNode = SCNNode(geometry:base);
baseNode?.position = SCNVector3Make(planeAnchor.center.x, 0, planeAnchor.center.z);
node.addChildNode(baseNode!)
}
}
// 更新錨點和對應的node之前呼叫,ARKit會自動更新anchor和node,使其相匹配
func renderer(_ renderer: SCNSceneRenderer, willUpdate node: SCNNode, for anchor: ARAnchor) {
// 只更新在`renderer(_:didAdd:for:)`中得到的配對的錨點和節點.
guard let planeAnchor = anchor as? ARPlaneAnchor,
let planeNode = node.childNodes.first,
let plane = planeNode.geometry as? SCNPlane
else { return }
updateCount += 1
if updateCount > 20 {//平面超過更新20次,捕捉到的特徵點已經足夠多了,可以顯示進入遊戲按鈕
DispatchQueue.main.async {
self.playButton.isHidden = false
}
}
// 平面的中心點可以會變動.
planeNode.simdPosition = float3(planeAnchor.center.x, 0, planeAnchor.center.z)
/*
平面尺寸可能會變大,或者把幾個小平面合併為一個大平面.合併時,`ARSCNView`自動刪除同一個平面上的相應節點,然後呼叫該方法來更新保留的另一個平面的尺寸.(經過測試,合併時,保留第一個檢測到的平面和對應節點)
*/
plane.width = CGFloat(planeAnchor.extent.x)
plane.height = CGFloat(planeAnchor.extent.z)
}
// 更新錨點和對應的node之後呼叫
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
}
// 移除錨點和對應node後
func renderer(_ renderer: SCNSceneRenderer, didRemove node: SCNNode, for anchor: ARAnchor) {
}
// MARK: - ARSessionObserver
func session(_ session: ARSession, didFailWithError error: Error) {
sessionInfoLabel.text = "Session失敗: \(error.localizedDescription)"
resetTracking()
}
func sessionWasInterrupted(_ session: ARSession) {
sessionInfoLabel.text = "Session被打斷"
}
func sessionInterruptionEnded(_ session: ARSession) {
sessionInfoLabel.text = "Session打斷結束"
resetTracking()
}
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
updateSessionInfoLabel(for: session.currentFrame!, trackingState: camera.trackingState)
}
}
複製程式碼
執行一下,順利識別到平面
點選Play按鈕之後,隱藏不需要的UI內容,並停止識別平面
step2:修改3D版Stack遊戲
3D版最終程式碼在這裡koenig-media.raywenderlich.com/uploads/201… 首先,我們要做的是:移除攝像機程式碼,允許cameraControl
3D遊戲中,需要控制攝像機來展現不同場景,包括實現動畫;而AR中,手機就是攝像機,不能再控制攝像機的位置了.因此將原來加在mainCamera上的動作,改為加在scnScene.rootNode上面即可,當然動作方向也需要反轉一下,比如原來gameover方法:
func gameOver() {
let mainCamera = scnScene.rootNode.childNode(withName: "Main Camera", recursively: false)!
let fullAction = SCNAction.customAction(duration: 0.3) { _,_ in
let moveAction = SCNAction.move(to: SCNVector3Make(mainCamera.position.x, mainCamera.position.y * (3/4), mainCamera.position.z), duration: 0.3)
mainCamera.runAction(moveAction)
if self.height <= 15 {
mainCamera.camera?.orthographicScale = 1
} else {
mainCamera.camera?.orthographicScale = Double(Float(self.height/2) / mainCamera.position.y)
}
}
mainCamera.runAction(fullAction)
playButton.isHidden = false
}
複製程式碼
改過之後中的gameOver方法:
func gameOver() {
let fullAction = SCNAction.customAction(duration: 0.3) { _,_ in
let moveAction = SCNAction.move(to: SCNVector3Make(0, 0, 0), duration: 0.3)
self.scnScene.rootNode.runAction(moveAction)
}
scnScene.rootNode.runAction(fullAction)
playButton.isHidden = false
}
複製程式碼
接著,我們在GameScene.scn中編輯場景:
- 刪除相機---程式碼中已經刪除了攝像機,這裡也不需要了;
- 刪除背景圖---AR中不需要背景圖片;
- 新增白色的環境光---AR中可以移動手機,看到方塊後面,所以需要把後面也照亮
- 底座改小一些---因為原來的尺寸:(1,0.2,1)意味著長1米,高0.2米,寬1米.這對於AR場景來說實在太大了.
下一步,修改程式碼中的方塊尺寸,運動速度,完美對齊的匹配精度等 在檔案開頭定義一些全域性常量,方便我們修改
let boxheight:CGFloat = 0.05 //原來為0.2
let boxLengthWidth:CGFloat = 0.4 //原來為1
let actionOffet:Float = 0.6 //原來為1.25
let actionSpeed:Float = 0.011 //原來為0.3
複製程式碼
難度不大,但要修改的地方比較多,認真一些就可以了.
最後,發現方塊的顏色不會改變,所以修改一下顏色,將原來各個節點的:
//以brokenBoxNode為例,其餘類似
brokenBoxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
複製程式碼
改為:
brokenBoxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(red: 0.1 * CGFloat(height % 10), green: 0.03*CGFloat(height%30), blue: 1-0.1 * CGFloat(height % 10), alpha: 1)
複製程式碼
這樣顏色差異更明顯一些.另外新出現的方塊顏色總是和上一個相同,放上去後才改變顏色,是因為建立newNode時利用了原來方塊的geometry導致的. 需要修改addNewBlock方法:
func addNewBlock(_ currentBoxNode: SCNNode) {
// 此處直接新建一個SCNBox
let newBoxNode = SCNNode(geometry: SCNBox(width: CGFloat(newSize.x), height: boxheight, length: CGFloat(newSize.z), chamferRadius: 0))
newBoxNode.position = SCNVector3Make(currentBoxNode.position.x, currentPosition.y + Float(boxheight), currentBoxNode.position.z)
newBoxNode.name = "Block\(height+1)"
// 此處顏色改為height+1層
newBoxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(red: 0.1 * CGFloat((height+1) % 10), green: 0.03*CGFloat((height+1)%30), blue: 1-0.1 * CGFloat((height+1) % 10), alpha: 1)
if height % 2 == 0 {
newBoxNode.position.x = -actionOffet
} else {
newBoxNode.position.z = -actionOffet
}
scnScene.rootNode.addChildNode(newBoxNode)
}
複製程式碼
另外handleTap方法中也需要另外設定顏色,否則放置好的方塊會沒有顏色,會變白色.
currentBoxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(red: 0.1 * CGFloat(height % 10), green: 0.03*CGFloat(height%30), blue: 1-0.1 * CGFloat(height % 10), alpha: 1)
複製程式碼
執行一下,可以看到場景中的物體變小了,攝像機也可以隨便移動了,顏色改變了,後面也有光照了...
step3:合併兩個專案,完成AR版Stack堆方塊遊戲
首先,在ARStack中新增ScoreLabel和點選手勢
然後第2步專案中複製.scn素材,音訊檔案,還有一個分類到第1步專案.
新增一個屬性,代表遊戲節點:
var gameNode:SCNNode?
複製程式碼
複製進入遊戲的程式碼過來,在playButtonClick方法中4.後面繼續寫:
//4.載入遊戲場景
gameNode?.removeFromParentNode()//移除前一次遊戲的場景節點
gameNode = SCNNode()
let gameChildNodes = SCNScene(named: "art.scnassets/Scenes/GameScene.scn")!.rootNode.childNodes
for node in gameChildNodes {
gameNode?.addChildNode(node)
}
baseNode?.addChildNode(gameNode!)
resetGameData() //重置遊戲資料
// 複製過來的程式碼.....
複製程式碼
複製其他程式碼,注意音訊檔案地址改為art.scnassets.
其餘各處的scnView.rootNode.addChildNode()
改為gameNode?.addChildNode(boxNode)
然後,resetAll() 方法中需要重置遊戲的引數,並將resetGameData方法抽出:
private func resetAll() {
//0.顯示按鈕
playButton.isHidden = true
sessionInfoLabel.isHidden = false
//1.重置平面檢測配置,重啟檢測
resetTracking()
//2.重置更新次數
updateCount = 0
sceneView.debugOptions = [ARSCNDebugOptions.showFeaturePoints]
//3.重置遊戲資料
resetGameData()
print("resetAll")
}
private func resetGameData() {
height = 0
scoreLabel.text = "\(height)"
direction = true
perfectMatches = 0
previousSize = SCNVector3(boxLengthWidth, boxheight, boxLengthWidth)
previousPosition = SCNVector3(0, boxheight*0.5, 0)
currentSize = SCNVector3(boxLengthWidth, boxheight, boxLengthWidth)
currentPosition = SCNVector3Zero
offset = SCNVector3Zero
absoluteOffset = SCNVector3Zero
newSize = SCNVector3Zero
}
複製程式碼
並新增從後臺喚醒的監聽,當從後臺進入前臺時,也呼叫resetAll:
NotificationCenter.default.addObserver(forName: NSNotification.Name.UIApplicationWillEnterForeground, object: nil, queue: nil) { (noti) in
self.resetAll()
}
複製程式碼
執行一下,效果出來了
雖然還是有很多問題,不過基本功能已經完成了.
step4:修復合併後的bug和邏輯錯誤
bug主要有兩個:
- 沒對齊被切下的碎片掉落不正常,有些停留在原來位置,飄在空中;
- 級數超過5後,自動下沉,但低於識別平面的部分仍然可見,造成視覺錯誤;
bug1:先來修復第一個bug,碎片掉落不正常的問題.
這是因為方塊的物理形體型別SCNPhysicsBodyType不正確導致的.原來的遊戲中,方塊放好後就不動了,所以設定為.static型別,這種型別在執行Action動作時位置並沒有真正移動,所以需要改為.kinematic型別,這種型別可以讓我們隨意移動,並可以與掉落的碎片碰撞,但自身不受碰撞的影響,一般用於電梯,傳送機等.
需要更改的地方包括GameScene.scn檔案中的底座,playButtonClick方法中的第一個方塊,handleTap方法中已對齊方塊,還有新生成的方塊方法addNewBlock中
//playButtonClick中
boxNode.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: boxNode.geometry!, options: nil))
//handleTap中
currentBoxNode.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: currentBoxNode.geometry!, options: nil))
//addNewBlock中
newBoxNode.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: newBoxNode.geometry!, options: nil))
複製程式碼
再執行一次,碎片掉落,碰撞,都已經正常了.
bug2:下沉到時低於識別平面的方塊仍然可見
這個問題解決起來也很簡單:我們在下沉時遍歷各個節點,發現位置低於某個值,就把它隱藏掉;gomeOver後,再把所有節點顯示出來;
被隱藏的節點不再參與物理效果運算(比如碰撞等),看起來效果不錯.需要注意的是,燈光節點就不要隱藏了.
在handleTap方法中,執行Action之前,新增程式碼,隱藏低於某個高度的節點
gameNode?.enumerateChildNodes({ (node, stop) in
if node.light != nil {//燈光節點不隱藏
return
}
if node.position.y < Float(self.height-5) * Float(boxheight) {
node.isHidden = true
}
})
複製程式碼
在gameOver方法的末尾,新增顯示節點的程式碼
gameNode?.enumerateChildNodes({ (node, stop) in
node.isHidden = false
})
複製程式碼
最終版效果
各個步驟的專案程式碼已釋出在github上github.com/XanderXu/AR…