Chapters

Hide chapters

Metal by Tutorials

Second Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: The Player

Section 1: 8 chapters
Show chapters Hide chapters

Section III: The Effects

Section 3: 10 chapters
Show chapters Hide chapters

9. The Scene Graph
Written by Caroline Begbie

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In this chapter, you’ll modify your rendering engine and begin architecting your game engine by abstracting rendering code away from scene management. Game engines can include features such as physics engines and sound. The game engine you’ll produce in this chapter doesn’t have any high-end features, but it’ll help you understand how to integrate other components as well as give you the foundation needed to add complexity later.

For this exercise, you’ll create a scene with a drivable car and a controllable player. This should whet your appetite and provide inspiration for making your own games, which is, after all, what this book is all about!

Scenes

A scene can consist of one or more cameras, lights and models. In the scene you’ll be building, each model can be a prop or a character.

Of course, you can add these objects in your Renderer, but what happens when you want to add some game logic or have them interact with other things? One option is to add this interaction in update(deltaTime:). However, doing so tends to get more impractical as additional interactions are needed. A better way is to create a Scene class and abstract the game logic from the rendering code.

To accomplish this, you’ll first recreate the starter project’s initial render using a Scene class. Once you have that, you’ll be able to move the skeleton around and drive the car without worrying about what Metal and the GPU are doing behind the curtain.

The starter project

Open the starter project for this chapter. For the most part, this project is similar to the previous chapter’s project; however, there are a few changes.

The Scene class

To understand how everything will work together, examine the following diagram:

class Scene {
  var sceneSize: CGSize
  
  init(sceneSize: CGSize) {
    self.sceneSize = sceneSize
    setupScene()
  }
  
  func setupScene() {
    // override this to add objects to the scene
  }
}
var cameras = [Camera()]
var currentCameraIndex = 0
var camera: Camera  {
  return cameras[currentCameraIndex]
}

The scene graph

In your game, you may want to have trees, houses and animated characters. Some of these objects may depend upon other objects. For example, a car may contain a driver and its passengers. As the car moves, its occupants should move along at the same speed, as if they were inside the car.

var parent: Node?
var children: [Node] = []
final func add(childNode: Node) {
  children.append(childNode)
  childNode.parent = self
}
  
final func remove(childNode: Node) {
  for child in childNode.children {
    child.parent = self
    children.append(child)
  }
  childNode.children = []
  guard let index = (children.firstIndex {
    $0 === childNode
  }) else { return }
  children.remove(at: index)
  childNode.parent = nil
}
let rootNode = Node()
var renderables: [Renderable] = []
var uniforms = Uniforms()
var fragmentUniforms = FragmentUniforms()
final func update(deltaTime: Float) {
  uniforms.projectionMatrix = camera.projectionMatrix
  uniforms.viewMatrix = camera.viewMatrix
  fragmentUniforms.cameraPosition = camera.position
  
  updateScene(deltaTime: deltaTime)
  update(nodes: rootNode.children, deltaTime: deltaTime)
}
  
private func update(nodes: [Node], deltaTime: Float) {
  nodes.forEach { node in
    node.update(deltaTime: deltaTime)
    update(nodes: node.children, deltaTime: deltaTime)
  }
}

func updateScene(deltaTime: Float) {
  // override this to update your scene
}
final func add(node: Node, parent: Node? = nil, 
               render: Bool = true) {
  if let parent = parent {
    parent.add(childNode: node)
  } else {
    rootNode.add(childNode: node)
  }
  guard render == true,
   let renderable = node as? Renderable else {
    return
  }
  renderables.append(renderable)
}
final func remove(node: Node) {
  if let parent = node.parent {
    parent.remove(childNode: node)
  } else {
    for child in node.children {
      child.parent = nil
    }
    node.children = []
  }
  guard node is Renderable,
    let index = (renderables.firstIndex {
      $0 as? Node === node
    }) else { return }
  renderables.remove(at: index)
}
var scene: Scene?
let scene = scene,
for model in models {
  model.update(deltaTime: deltaTime)
}
scene.update(deltaTime: deltaTime)
for model in models {
  renderEncoder.pushDebugGroup(model.name)
  model.render(renderEncoder: renderEncoder,
               uniforms: uniforms,
               fragmentUniforms: fragmentUniforms)
  renderEncoder.popDebugGroup()
}
for renderable in scene.renderables {
  renderEncoder.pushDebugGroup(renderable.name)
  renderable.render(renderEncoder: renderEncoder,
                    uniforms: scene.uniforms,
                    fragmentUniforms: scene.fragmentUniforms)
  renderEncoder.popDebugGroup()
}
func sceneSizeWillChange(to size: CGSize) {
  for camera in cameras {
    camera.aspect = Float(size.width / size.height)
  }
  sceneSize = size
}
sceneSizeWillChange(to: sceneSize)
func mtkView(_ view: MTKView, 
             drawableSizeWillChange size: CGSize) {
  scene?.sceneSizeWillChange(to: size)
}
renderer?.camera.rotate(delta: delta)
renderer?.scene?.camera.rotate(delta: delta)
class GameScene: Scene {
  let ground = Model(name: "ground.obj")
  let car = Model(name: "racing-car.obj")
  let skeleton = Model(name: "skeleton.usda")
}
override func setupScene() {
  ground.tiling = 32
  add(node: ground)
  car.rotation = [0, .pi / 2, 0]
  car.position = [-0.8, 0, 0]
  add(node: car)
  skeleton.position = [1.6, 0, 0]
  skeleton.rotation = [0, .pi, 0]
  add(node: skeleton)
  skeleton.runAnimation(name: "idle")
  camera.position = [0, 1.2, -4]
}
let scene = GameScene(sceneSize: metalView.bounds.size)
renderer?.scene = scene

Grouping nodes

To get an idea of how to update the scene logic, you’re going to have the skeleton drive the car off to the right. But there’s a hitch! You’re only going to move the car model each frame, not the skeleton.

override func updateScene(deltaTime: Float) {
  car.position.x += 0.02
}

add(node: skeleton)
skeleton.runAnimation(name: "idle")
add(node: skeleton, parent: car)
skeleton.runAnimation(name: "sit")
var worldTransform: float4x4 {
  if let parent = parent {
    return parent.worldTransform * self.modelMatrix
  }
  return modelMatrix
}
uniforms.modelMatrix = modelMatrix * currentLocalTransform
uniforms.modelMatrix = worldTransform * currentLocalTransform

skeleton.position = [-0.35, -0.2, -0.35]
skeleton.rotation = [0, 0, 0]

First-person camera

In this section, you’ll create a first-person camera which places you in the driving seat. Once inside the car, you’ll be able to drive around the scene using the traditional W-A-S-D keys. Before you begin, take a moment to review some important files.

var player: Node?
let inputController = InputController()
public func updatePlayer(deltaTime: Float) {
  guard let player = player else { return }
}

private func updatePlayer(deltaTime: Float) {
  inputController.updatePlayer(deltaTime: deltaTime)
}
updatePlayer(deltaTime: deltaTime)
if let gameView = metalView as? GameView {
  gameView.inputController = scene.inputController
}
var forwardVector: float3 {
  return normalize([sin(rotation.y), 0, cos(rotation.y)])
}

var rightVector: float3 {
  return [forwardVector.z, forwardVector.y, -forwardVector.x]
}
var translationSpeed: Float = 2.0
var rotationSpeed: Float = 1.0
let translationSpeed = deltaTime * self.translationSpeed
let rotationSpeed = deltaTime * self.rotationSpeed
var direction: float3 = [0, 0, 0]
for key in directionKeysDown {
  switch key {
  case .w:
    direction.z += 1
  case .a:
    direction.x -= 1
  case.s:
    direction.z -= 1
  case .d:
    direction.x += 1
  case .left, .q:
    player.rotation.y -= rotationSpeed
  case .right, .e:
    player.rotation.y += rotationSpeed
  default:
    break
  }
}
if direction != [0, 0, 0] {
  direction = normalize(direction)
  player.position +=
    (direction.z * player.forwardVector
      + direction.x * player.rightVector)
    * translationSpeed
}
inputController.player = camera

Intercepting key presses

That’s cool, but what if you want to drive the car in place of the skeleton? By setting up the C key as a special key, you’ll be able to jump into the car at any time. But first, you need to remove the driving code that you created earlier.

car.position.x += 0.02
case c = 8
var inCar = false
extension GameScene: KeyboardDelegate {
  func keyPressed(key: KeyboardControl, 
                  state: InputState) -> Bool {
    return true
  } 
}
inputController.keyboardDelegate = self
switch key {
case .c where state == .ended:
  let camera = cameras[0]
  if !inCar {
    remove(node: skeleton)
    remove(node: car)
    add(node: car, parent: camera)
    car.position = [0.35, -1, 0.1]
    car.rotation = [0, 0, 0]
    inputController.translationSpeed = 10.0
  }
  inCar = !inCar
  return false
default:
  break
}

if !inCar {
if inCar {
  remove(node: car)
  add(node: car)
  car.position = camera.position + (camera.rightVector * 1.3)
  car.position.y = 0
  car.rotation = camera.rotation
  inputController.translationSpeed = 2.0
} else {

Orthographic projection

Sometimes it’s a little tricky to see what’s happening in a scene. To help, you can build a top-down camera that shows you the whole scene without any perspective distortion, otherwise known as orthographic projection.

class OrthographicCamera: Camera {
  var rect = Rectangle(left: 10, right: 10,
                       top: 10, bottom: 10)

  override init() {
    super.init()
  }
  
  init(rect: Rectangle, near: Float, far: Float) {
    super.init()
    self.rect = rect
    self.near = near
    self.far = far
  }
  
  override var projectionMatrix: float4x4 {
    return float4x4(orthographic: rect, near: near, far: far)
  }
}
let orthoCamera = OrthographicCamera()
orthoCamera.position = [0, 2, 0]
orthoCamera.rotation.x = .pi / 2
cameras.append(orthoCamera)
override func sceneSizeWillChange(to size: CGSize) {
  super.sceneSizeWillChange(to: size)
  let cameraSize: Float = 10
  let ratio = Float(sceneSize.width / sceneSize.height)
  let rect = Rectangle(left: -cameraSize * ratio, 
                       right: cameraSize * ratio,
                       top: cameraSize, 
					   bottom: -cameraSize)
  orthoCamera.rect = rect
}
case .key0:
  currentCameraIndex = 0
case .key1:
  currentCameraIndex = 1

Third-person camera

You wrote the skeleton out of the game, but what if you want to play as the skeleton? You still want to be in first-person while driving the car, but when you’re out of the car, the skeleton should walk about the scene, and the camera should follow.

skeleton.position = [1.6, 0, 0]
skeleton.rotation = [0, .pi, 0]
add(node: skeleton)
skeleton.runAnimation(name: "idle")
inputController.player = camera
inputController.player = skeleton

class ThirdPersonCamera: Camera {
  var focus: Node
  var focusDistance: Float = 3
  var focusHeight: Float = 1.2
  
  init(focus: Node) {
    self.focus = focus
    super.init()
  }
}
override var viewMatrix: float4x4 {
  position = focus.position - focusDistance 
                 * focus.forwardVector
  position.y = focusHeight
  rotation.y = focus.rotation.y
  return super.viewMatrix
}
let tpCamera = ThirdPersonCamera(focus: skeleton)
cameras.append(tpCamera)
currentCameraIndex = 2

Animating the player

It’s a good idea to animate the skeleton while you’re in control of him. skeleton.usda includes a walking animation, which is what you’ll use. The plan is to animate the skeleton when a key is pressed and freeze him when he’s standing still.

skeleton.runAnimation(name: "idle")
skeleton.runAnimation(name: "walk")
skeleton.currentAnimation?.speed = 2.0
skeleton.pauseAnimation()
case .w, .s, .a, .d:
  if state == .began {
    skeleton.resumeAnimation()
  }
  if state == .ended {
    skeleton.pauseAnimation()
  }

Simple collisions

An essential element of games is collision detection. Imagine how boring it would be if platformer games didn’t let you collect power-ups like gems and oil cans?

let scene = GameScene(sceneSize: metalView.bounds.size)
let scene = CarScene(sceneSize: metalView.bounds.size)
let debugRenderBoundingBox = false

let physicsController = PhysicsController()
func checkCollisions() -> Bool {
  return false
}
guard let node = dynamicBody else { return false }
let nodeRadius = max((node.size.x / 2), (node.size.z / 2))
let nodePosition = node.worldTransform.columns.3.xyz
for body in staticBodies  {
  let bodyRadius = max((body.size.x / 2), (body.size.z / 2))
  let bodyPosition = body.worldTransform.columns.3.xyz
  let d = distance(nodePosition, bodyPosition)
  if d < (nodeRadius + bodyRadius) {
    // There’s a hit
    return true
  }
}
private func updatePlayer(deltaTime: Float) {
  guard let node = inputController.player else { return }
  let holdPosition = node.position
  let holdRotation = node.rotation
  inputController.updatePlayer(deltaTime: deltaTime)
  if physicsController.checkCollisions() {
    node.position = holdPosition
    node.rotation = holdRotation
  }
}
physicsController.dynamicBody = car
for body in bodies {
  physicsController.addStaticBody(node: body)
}

func updateCollidedPlayer() -> Bool {
  // override this
  return false
}
if physicsController.checkCollisions() {
if physicsController.checkCollisions() 
      && !updateCollidedPlayer() {
var holdAllCollided = false
var collidedBodies: [Node] = []
return true
if holdAllCollided {
  collidedBodies.append(body)
} else {
  return true
}
return false 
return collidedBodies.count != 0
collidedBodies = []
physicsController.holdAllCollided = true
override func updateCollidedPlayer() -> Bool {
  for body in physicsController.collidedBodies {
    if body.name == "oilcan.obj" {
      print("power-up")
      remove(node: body)
      physicsController.removeBody(node: body)
      return true
    }
  }
  return false
}

Where to go from here?

In this chapter, you created a simple game engine with an input controller and a physics controller. There are many different architecture choices you can make. The one presented here is overly simplified and only touched the surface of collision detection and physics engines. However, it should give you an idea of how to separate your game code from your Metal rendering code.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now