ลองทำ Real-world Isolated game ด้วย ARKit กันเถอะ
ชื่อว่า Developer ส่วนใหญ่คงเคยได้เล่นเกมแนว JRPG ที่เป็นการเดินทางในดินแดนแห่งจินตนาการ เช่น Dragon Quest / Final Fantasy Series กันมาแล้วบ้าง และเชื่อว่าหลายคนเช่นกันที่คงจะมีความใฝ่ฝันที่จะรวมเอาโลกของจินตนาการ มารวมกับโลกแห่งความเป็นจริงแล้วเล่นเกมนั้นๆ บนโลกจริงๆอย่างสนุกสนาน สำหรับ blog ในตอนนี้เป็นจะเป็น blog แนะนำการเขียนโปรแกรมเพื่อรวมโลกจินตนาการ กับโลกจริงด้วย ARKit กันนะครับ (AAPL จงเจริญ !!!)
สำหรับ Idea ในการสร้างเกมของเรา ก็คงจะมี 2 Part ด้วยกันนั่นก็คือในส่วนของ Geo Fence ซึ่งจะใช้ในการ Notify ผู้เล่นของเราว่า ในบริเวณนี้จะมี Scene ที่เราสามารถเข้าร่วม event ของเกมได้ ส่วนนี่เราจะยังไม่สนใจ เพราะคนพูดกันเยอะละ เราจะมา focus ในส่วนของ ARScene ซึ่งจะประกอบไปด้วยส่วนเล็กๆดังนี้
AR Scene หรือพื้นที่ในการแสดงผลของ AR
Trigger Area และ Event ที่จะทำการ trigger เหตุการณ์ในเกมขึ้นมา
Model หรือ เหตุการณ์ในเกมที่จะแสดงขึ้นมาบน AR Scene
โดย event ที่เราจะ trigger ขึ้นมาก็คือ เราต้องเดินเข้าไปที่ Trigger Plane (นึกถึงสมัยเกม Final Fantasy ที่ต้องเดินเก็บเลเวลกันทั้งวัน แล้วก็หยุดคิดนิดนึงว้าถ้าเลเวลสูงๆแล้ว กว่าจะเลเวลอัพสงสัยขาลากแน่นวล ฮ่าๆๆๆๆ)
ก่อนจะไปถึงเรื่องการวาง model ใน AR Scene เราต้องเข้าใจ Axis ในโลกของ AR Scene ก่อน สมมติว่าเรากำหนดให้ Camera ของ device เป็นจุดศูนย์กลางของ AR Scene ตัว device จะอยู่ที่พิกัด (x, y, z) = (0, 0, 0) นั่นเอง ซึ่งอันนี้สำคัญมาก เพราะสิ่งของต่างๆที่เราจะวางก็จะอ้างอิงจากพิกัดนี้นั่นเอง โดยแกน y จะเป็นแกนในแนวสูง x เป็นแกนในแนวขวาง และ z เป็นแกนในแนวลึกจากตัวเราไป ยกตัวอย่างเช่น เราต้องการวางของให้อยู่ข้างหน้าเราไป 1 หน่วย เราก็จะวางของไว้ที่พิกัด (x, y, z) =>(0, 0, -1) นั่นเอง
ทีนี้เราต้องการอะไรบ้างในการพัฒนาด้วย ARKit ที่ว่านี้ หลักๆเลยก็จะประกอบด้วย
- iDevice ที่สนับสนุน iOS11 (Beta น่ากลัวสัสๆ)
- XCode 9 (Beta อีกละ น่ากลัวสัสๆอีกละ)
- maxOS 10.13 High Sierra (อันนี้ผมไม่ได้อัพเกรดนะ ใจไม่ด้านพอแต่มันรันได้อ่ะ)
ทุกอย่าง Download ได้จากลิงค์เดียวที่ https://developer.apple.com/arkit/ ที่เดียวจบ
หลังจากได้ของทุกอย่างมาเรียบร้อยแล้ว ทีนี้ก็เป็นเรื่องง่ายละ เพราะทุกอย่างสามารถอยู่ใน Controller เดียวได้เลยสำหรับ POC แบบโง่ๆของเรา (จะแปะไว้สำหรับตรงท้ายๆของ Article นะครับ) ฺ
ก่อนอื่นโค้ดนี้เราเขียนใน AngelHack BKK Hackathon นะครับ อย่าหวังถึงความสวยงาม (Before you complain this is fucking Hackathon code) LOLz
เอาล่ะทีนี้เรามาดูโค้ด Piece by Piece ในส่วนที่สำคัญๆละกันนะครับ เริ่มจากในส่วนของการสร้าง trigger plane ก่อนนะครับ
func createPlane(center: SCNVector3, size :CGFloat, color: UIColor) -> SCNNode {
let planeGeometry = SCNPlane(width: size, height: size)
let planeNode = SCNNode(geometry: planeGeometry)
let greenMaterial = SCNMaterial()
planeNode.eulerAngles = SCNVector3(x: GLKMathDegreesToRadians(-90), y: 0, z: 0)
planeNode.position = center
greenMaterial.diffuse.contents = color
greenMaterial.transparency = 0.5
planeGeometry.materials = [greenMaterial]
return planeNode
}
ในส่วนนี้จะเป็นการสร้าง Trigger Plane Object ที่เราจะวางไว้เพื่อเป็น Trigger point ภายใน Scene ของเรานั่นเอง โดยปกติแล้ว Object ภายใน AR Scene ของ AR Kit นั้นจะเป็น Object ที่ derive จาก SCNNode นั่นเอง จาก createPlane method เราจะเห็นว่ามีการสร้าง SCNPlane ซึ่งเป็น geometry แบบที่หน้าตาเป็นแผ่นบางๆ ซึ่งเราจะเอามาที่วางบนพื้นทีหลังนะครับ
override func viewDidLoad() {
super.viewDidLoad()
/// Set the view's delegate
sceneView.delegate = self
// Show statistics such as fps and timing information
sceneView.showsStatistics = true
// This is the marker/trigger plane
let planeNode = createPlane(center: planeCenter, size: planeSizeCGFloat, color: UIColor.green)
// Add marker/trigger plane into scene
sceneView.scene.rootNode.addChildNode(planeNode)
}
ในการ Setup ViewController นั้นเราก็จะ create planeNode และนำไปวางบน Scene เพื่อเป็น marker สำหรับผู้เล่นที่จะเดินเข้าไปหา เพื่อ trigger เหตุการณ์ในเกมขึ้นมานั่นเอง
func loadObjects() {
groupNode = SCNNode()
groupNode!.addChildNode(loadNode(file: "art.scnassets/Lowpoly_tree_sample.dae",
loc: SCNVector3(x: -1.0, y:-1.4, z:-4),
scale: SCNVector3(x: 0.07, y:0.07, z:0.07)))
groupNode!.addChildNode(loadNode(file: "art.scnassets/ship.scn",
loc: SCNVector3(x: 9, y:7, z:-4),
scale: SCNVector3(x: 20.0, y:20.0, z:20.0)))
sceneView.scene.rootNode.addChildNode(groupNode!)
}
func loadNode(file: String, loc:SCNVector3, scale: SCNVector3) -> SCNNode {
let loadingObjNode = SCNNode()
let loadingScene = SCNScene(named: file)!
let nodeArray = loadingScene.rootNode.childNodes
loadingObjNode.position = loc
loadingObjNode.scale = scale
for childNode in nodeArray {
loadingObjNode.addChildNode(childNode as SCNNode)
}
return loadingObjNode
}
ในส่วนของ loadNode นั้นจะเป็น method ในการโหลด 3d model จากในไฟล์ตามพารามิเตอร์ file ขึ้นมา และทำการโหลด object ลงใน Scene ด้วย method loadObject
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
let timeInterval = NSDate().timeIntervalSince1970
let unixTimestamp = NSDate(timeIntervalSince1970: timeInterval)
let dateFormatter = DateFormatter()
dateFormatter.timeZone = TimeZone(abbreviation: "GMT") //Set timezone that you want
dateFormatter.locale = NSLocale.current
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" //Specify your format that you want
let strDate = dateFormatter.string(from: unixTimestamp as Date)
let position = sceneView.session.currentFrame?.camera.transform.columns.3;
// if position change => do check trigger
if(position != nil) {
EventTrigger(position: position!)
}
}
func EventTrigger(position: simd_float4) {
let x = position.x
let z = position.z
if (x > (planeCenter.x - planeSize/2) && x < (planeCenter.x + planeSize/2) &&
z > (planeCenter.z - planeSize/2) && z < (planeCenter.z + planeSize/2)) {
if(groupNode == nil) {
debugPrint(x,z,planeCenter)
loadObjects()
}
}
}
โดยในการ trigger เหตุการณ์ขึ้นมานั้น เราทำได้โดยการ implement method renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) ซึ่งจะถูก trigger เมื่อมีการ update scene ซึ่งในกรณีของเรานั้นสิ่งที่เราสนใจคือ position ของ camera และ trigger plane นั่นเอง และเมื่อ camera ขยับเข้าไปใกล้ trigger plane หรือ แตะขอบเขตของ trigger plane เราก็จะทำการ trigger ด้วย method EventTrigger ที่จะทำการเพิ่ม Scene Objects ลงไปใน scene นั่นเอง และใน listing ต่อไปนี้ก็จะเป็น sample code สำหรับ Scenario ง่ายๆที่เราทำกันสนุกๆในงาน AngelHack BKK 2017
import UIKit
import SceneKit
import ARKit
import CoreGraphics
class ViewController: UIViewController, ARSCNViewDelegate {
@IBOutlet var sceneView: ARSCNView!
var groupNode: SCNNode? = nil
let tap = UITapGestureRecognizer()
let planeSize: Float = 1
let planeSizeCGFloat: CGFloat = 1
let planeCenter = SCNVector3(x: 0, y: -1.5, z: -2)
override func viewDidLoad() {
super.viewDidLoad()
/// Set the view's delegate
sceneView.delegate = self
// Show statistics such as fps and timing information
sceneView.showsStatistics = true
// This is the marker/trigger plane
let planeNode = createPlane(center: planeCenter, size: planeSizeCGFloat, color: UIColor.green)
// Add marker/trigger plane into scene
sceneView.scene.rootNode.addChildNode(planeNode)
// This part for add guesture to clear all model from scene
tap.numberOfTapsRequired = 2
tap.addTarget(self, action: #selector(self.removeObj))
view.isUserInteractionEnabled = true
view.addGestureRecognizer(tap)
}
func createPlane(center: SCNVector3, size :CGFloat, color: UIColor) -> SCNNode {
let planeGeometry = SCNPlane(width: size, height: size)
let planeNode = SCNNode(geometry: planeGeometry)
let greenMaterial = SCNMaterial()
planeNode.eulerAngles = SCNVector3(x: GLKMathDegreesToRadians(-90), y: 0, z: 0)
planeNode.position = center
greenMaterial.diffuse.contents = color
greenMaterial.transparency = 0.5
planeGeometry.materials = [greenMaterial]
return planeNode
}
@objc func removeObj() {
// Remove node from trigger #1
groupNode?.removeFromParentNode()
groupNode = nil
}
func loadObjects() {
groupNode = SCNNode()
groupNode!.addChildNode(loadNode(file: "art.scnassets/Lowpoly_tree_sample.dae",
loc: SCNVector3(x: -1.0, y:-1.4, z:-4),
scale: SCNVector3(x: 0.07, y:0.07, z:0.07)))
groupNode!.addChildNode(loadNode(file: "art.scnassets/ship.scn",
loc: SCNVector3(x: 9, y:7, z:-4),
scale: SCNVector3(x: 20.0, y:20.0, z:20.0)))
sceneView.scene.rootNode.addChildNode(groupNode!)
}
func loadNode(file: String, loc:SCNVector3, scale: SCNVector3) -> SCNNode {
let loadingObjNode = SCNNode()
let loadingScene = SCNScene(named: file)!
let nodeArray = loadingScene.rootNode.childNodes
loadingObjNode.position = loc
loadingObjNode.scale = scale
for childNode in nodeArray {
loadingObjNode.addChildNode(childNode as SCNNode)
}
return loadingObjNode
}
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
let timeInterval = NSDate().timeIntervalSince1970
let unixTimestamp = NSDate(timeIntervalSince1970: timeInterval)
let dateFormatter = DateFormatter()
dateFormatter.timeZone = TimeZone(abbreviation: "GMT") //Set timezone that you want
dateFormatter.locale = NSLocale.current
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" //Specify your format that you want
let strDate = dateFormatter.string(from: unixTimestamp as Date)
let position = sceneView.session.currentFrame?.camera.transform.columns.3;
// if position change => do check trigger
if(position != nil) {
EventTrigger(position: position!)
}
}
func EventTrigger(position: simd_float4) {
let x = position.x
let z = position.z
if (x > (planeCenter.x - planeSize/2) && x < (planeCenter.x + planeSize/2) &&
z > (planeCenter.z - planeSize/2) && z < (planeCenter.z + planeSize/2)) {
if(groupNode == nil) {
debugPrint(x,z,planeCenter)
loadObjects()
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Create a session configuration
let configuration = ARWorldTrackingSessionConfiguration()
configuration.planeDetection = .horizontal
// Run the view's session
sceneView.session.run(configuration)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Pause the view's session
sceneView.session.pause()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Release any cached data, images, etc that aren't in use.
}
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
switch camera.trackingState {
case .notAvailable:
debugPrint("not available")
case .limited:
debugPrint("limited")
case .normal:
debugPrint("normal")
}
}
func session(_ session: ARSession, didFailWithError error: Error) {
// Present an error message to the user
}
func sessionWasInterrupted(_ session: ARSession) {
// Inform the user that the session has been interrupted, for example, by presenting an overlay
}
func sessionInterruptionEnded(_ session: ARSession) {
// Reset tracking and/or remove existing anchors if consistent tracking is required
}
}
สำหรับใครที่สนใจ code ที่ทำใน Angel Hack Bangkok มาขอได้นะครับ แต่ว่า code มัน WTF มาก เลยค่อนข้างละอายที่จะแชร์ในสาธารณะ LOLz
และสุดท้ายนี้นี่คือภาพจากผลงานของเราครับ :)
References
https://www.cgtrader.com/3d-models/character/fantasy/low-poly-zombie-king
https://developer.apple.com/arkit/