ลองทำ Real-world Isolated game ด้วย ARKit กันเถอะ

ชื่อว่า Developer ส่วนใหญ่คงเคยได้เล่นเกมแนว JRPG ที่เป็นการเดินทางในดินแดนแห่งจินตนาการ เช่น Dragon Quest / Final Fantasy Series กันมาแล้วบ้าง และเชื่อว่าหลายคนเช่นกันที่คงจะมีความใฝ่ฝันที่จะรวมเอาโลกของจินตนาการ มารวมกับโลกแห่งความเป็นจริงแล้วเล่นเกมนั้นๆ บนโลกจริงๆอย่างสนุกสนาน สำหรับ blog ในตอนนี้เป็นจะเป็น blog แนะนำการเขียนโปรแกรมเพื่อรวมโลกจินตนาการ กับโลกจริงด้วย ARKit กันนะครับ (AAPL จงเจริญ !!!)

1_6QWA59gKsKcxwWj2v2zzuw

สำหรับ 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 ที่ต้องเดินเก็บเลเวลกันทั้งวัน แล้วก็หยุดคิดนิดนึงว้าถ้าเลเวลสูงๆแล้ว กว่าจะเลเวลอัพสงสัยขาลากแน่นวล ฮ่าๆๆๆๆ)

1_0jmxaBCN5ghB9n1-YNEVrA

ก่อนจะไปถึงเรื่องการวาง 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) นั่นเอง

1_cwUt7iUYpEZ3-PBCloRbmQ

ทีนี้เราต้องการอะไรบ้างในการพัฒนาด้วย 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

และสุดท้ายนี้นี่คือภาพจากผลงานของเราครับ :)

1_KB8ggkCmPjqcP-Tk22qk-A

1_FxVKARF3kAQYkhfXtpa8lg

References

https://www.cgtrader.com/3d-models/character/fantasy/low-poly-zombie-king
https://developer.apple.com/arkit/