Background

In the spirit of #showyourwork, and learning in the open, I’ve decided to document my journey as I teach myself about SceneKit on iOS and OS X, and modern 3D APIs.

Early 1980s

My first foray into 3D was sometime in the early 1980s, when I played with drawing a 3D model of the earth. My inspiration was most likely Robert Tinney’s cover for the May 1979 issue of BYTE magazine:

May 1979 BYTE cover

Unfortunately insipation didn’t equal reality on my home-brewed TRS-80 clone. Its 128×48 graphics mode was a bit limiting, and state-of-the-art 3D on that platform looked like subLogic Flight Simulator:

A screenshot of subLogic Flight Simulator on the TRS-80

Mid 1990s

In the mid 1990s I was experimenting with projecting from 3D to 2D, then drawing directly to Windows’ GDI API. Later, I played around with Microsoft’s WinG API, then Direct3D.

Early 2000s

By about 2000, my insipration was the movie Toy Story, and the game Train Simulator (fuelled by my then two-year-old son’s train obsession). I still have a bunch of books with titles like OpenGL Programming Guide (the “red book”), Advanced RenderMan, Real-Time Rendering, and Advanced Animation and Rendering Techniques.

At the time I played around with modelling the front of a locomotive, in code, and rendering it with OpenGL.

Now

Today’s inspiration are games like The Witness and Monument Valley:

A screenshot of The Witness A screenshot of Monument Valley

Getting started

Enough history. Let’s get started!

Through this series, I’m being guided by Apple’s SceneKit documentation, David Rönnqvist’s SceneKit article from objc.io, and his excellent iBooks book 3D Graphics with Scene Kit.

I’ll be doing all of these steps in a simple single-view application. The scene is presented in an SCNView that completly covers the app’s main view. The SCNView from the storyboard is accessed through an @IBOutlet called sceneView. The complete listing for the view controller can be found at the bottom of this post.

An empty scene

The first step is to create an empty scene. Note that I set the scence view’s background color to black, for that super-spooky look (and to make sure that the simple lighting looks realistic). I also get a reference to the scene’s root node, as a convenience for later:

    let scene = SCNScene()
    sceneView.scene = scene
    sceneView.debugOptions = [] // [.ShowLightExtents, .ShowBoundingBoxes]
    sceneView.backgroundColor = UIColor.blackColor()
    sceneView.allowsCameraControl = true  // allow dragging the camera around
    let rootNode = scene.rootNode
    

An actor

Next up we need something to look at. In this first test, it’s going to be a 1 × 1 × 1 cube with rounded edges. SceneKit provides the SCNBox class which creates some geometry in the shape of a rounded box. Add one of those to a node, and add that node to the root node of the scene, and we have our boxy actor:

    func createBoxNode() -> SCNNode {
        // make a 1×1×1 box
        let box = SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0.1)
        let boxNode = SCNNode(geometry: box)
        return boxNode
    }

    

    let boxNode = createBoxNode()
    rootNode.addChildNode(boxNode)
    

Camera

At this point the scene will render, but with a default light and camera. The position of the default camera means that the cube will fill the entire viewport. To fix that we need our own camera.

    func createCameraNode(lookingAt target: SCNNode) -> SCNNode {
        let lookAt = SCNLookAtConstraint(target: target)
        lookAt.gimbalLockEnabled = true

        let camera = SCNCamera()
        camera.xFov = 60.0;

        let cameraNode = SCNNode()
        cameraNode.camera = camera
        cameraNode.constraints = [lookAt]
        return cameraNode
    }

    

    let cameraNode = createCameraNode(lookingAt: boxNode)
    cameraNode.position = SCNVector3(x: 2, y: 1.5, z: 2.5)
    rootNode.addChildNode(cameraNode)
    

Here I’ve setup a camera with a field-of-view of 60º in the x-axis (xFov), a constraint that keeps the camera looking at the box, and with gimbal-lock enabled to keep the horizon level if the camera moves.

With the camera moved into place, here’s what we can see (remember that we’re still using the default light source):

A cube rendered with default lighting

Lights

So, things don’t look great with the default lighting. Let’s replace the defaults with our own light:

    func createOmniDirectionalLightNode() -> SCNNode {
        let omniLight = SCNLight()
        omniLight.type = SCNLightTypeOmni
        omniLight.color = UIColor.init(red: 1, green: 0.75, blue: 0.75, alpha: 1)
        omniLight.attenuationStartDistance = 1;
        omniLight.attenuationEndDistance = 10;

        let omniLightNode = SCNNode()
        omniLightNode.light = omniLight
        return omniLightNode
    }

    

    let omniLightNode = createOmniDirectionalLightNode()
    omniLightNode.position = SCNVector3(x: 1, y: 1.5, z: 1)
    rootNode.addChildNode(omniLightNode)
    

I’ve placed a light pink (rose gold!) omnidirectional light to the right, above, and in-front of the box. I’ve also used attenuationStartDistance and attenuationEndDistance to make sure that the brightness falls off with distance from the light. Here’s what it looks like now:

A cube rendered with one omnidirectional light

The cube is starting to look better, but some better materials and texture could definitely spruce things up. Next time I’ll look at adding some of those.

ViewController.swift

    class ViewController: UIViewController {
        @IBOutlet weak var sceneView: SCNView!

        override func viewDidLoad() {
            super.viewDidLoad()
    
            // create a brand-new scene
            let scene = SCNScene()
            let rootNode = scene.rootNode

            let boxNode = createBoxNode()
            rootNode.addChildNode(boxNode)
    
            let omniLightNode = createOmniDirectionalLightNode()
            omniLightNode.position = SCNVector3(x: 1, y: 1.5, z: 1)
            rootNode.addChildNode(omniLightNode)
    
            let cameraNode = createCameraNode(lookingAt: boxNode)
            cameraNode.position = SCNVector3(x: 2, y: 1.5, z: 2.5)
            rootNode.addChildNode(cameraNode)
    
            sceneView.pointOfView = cameraNode
            sceneView.scene = scene
        }

        func createBoxNode() -> SCNNode {
            // make a 1x1x1 box
            let box = SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0.1)

            let boxNode = SCNNode(geometry: box)
            return boxNode
        }

        func createOmniDirectionalLightNode() -> SCNNode {
            let omniLight = SCNLight()
            omniLight.type = SCNLightTypeOmni
            omniLight.color = UIColor.init(red: 1, green: 0.75, blue: 0.75, alpha: 1)
            omniLight.attenuationStartDistance = 1;
            omniLight.attenuationEndDistance = 10;

            let omniLightNode = SCNNode()
            omniLightNode.light = omniLight
            return omniLightNode
        }

        func createCameraNode(lookingAt target: SCNNode) -> SCNNode {
            let lookAt = SCNLookAtConstraint(target: target)
            let camera = SCNCamera()

            let cameraNode = SCNNode()
            cameraNode.camera = camera
            cameraNode.constraints = [lookAt]
            return cameraNode
        }
    }