SpriteKit dynamic light tutorial

SpriteKit dynamic light tutorial
Gamescene with 2d light effects using normal maps

What you are going to learn

  • Create your normal maps using SpriteIlluminator
  • Create a side scrolling scene with parallax scrolling
  • Create a normal mapped sprite
  • Add light effects to the game scene
  • The complete project is on GitHub

Create your normal maps

Copy the assets from to the GitHub project into your project folder. The sprites are located in LightingDemo/LightingDemo/Sprites.

The easiest way to create normal maps for your sprites is to use SpriteIlluminator. For a quick start you simply

  • Drag + drop your sprites on the SpriteIlluminator window
  • Select all sprites
  • Apply Bevel and Emboss effects
  • Publish the normal map images to the sprite directory, using the _n suffix

In the GitHub repository, you'll find a file called spriteilluminator-project.sip. It contains the animation and background images.

The project also contains pre-built normal maps for this tutorial.

Setup your SpriteKit project

Simply create a new empty SpriteKit project for iOS devices.

Configure the project's Device orientation as Landscape right

Drag & drop the Sprites folder onto the project.

Clean the GameScene.swift by replacing it with the following code:

import SpriteKit

class GameScene: SKScene {
    var _scale: CGFloat = 1.0
    var _screenH: CGFloat = 640.0
    var _screenW: CGFloat = 960.0
    override func didMoveToView(view: SKView) {
        _screenH = view.frame.height
        _screenW = view.frame.width
        _scale = _screenW / 1920
    }
}

This code determines the screen size and derives a content scale factor _scale . The scale factor allows showing 1 background tile (1920px) on the screen.

Open GameViewController.swift and change scene.scaleMode = .AspectFill into scene.scaleMode = .ResizeFill :

override func viewDidLoad() {`
    ...
    scene.scaleMode = .ResizeFill
    ...
}

We are only using 1 set of graphics at the highest resolution that we scale down to match the different devices. We are doing this to keep the tutorial as short as possible. For a real project it is highly recommended that you use pre-scaled versions of your graphics that match your devices resolution. This will improve the frame rate.

If you start this project you'll simply see … nothing.

Creating an animated normal mapped sprite

You'll create the animated sprite in a function called initSprite . First you have to call this function from the didMoveToView - simply pace it at the end of the function:

override func didMoveToView(view: SKView) {
    ...
    initSprite()
}

Let's now add our sprite animation. This is a bit more tricky since SpriteKit does not have a way to animate normal mapped sprites out of the box. You have to create a callback action that applies the new frame and the normal map.

Create the initSprite function. This first block creates 2 arrays, with SKTexture objects for the animations phases of the sprite and the normal map. It simply iterates over the animation frames building the file names from the string format. The character animation currently has 8 frames: 1..8.

private func initSprite() {
    var animFrames = [SKTexture]()
    var normals = [SKTexture]()
    for index in 1...8 {
        animFrames.append(SKTexture(imageNamed: String(format:"Sprites/character/%02d.png", index)))
        normals.append(SKTexture(imageNamed: String(format:"Sprites/character/%02d_n.png", index)))
    }

Creates a SKSpriteNode using the first animation frame and normal map as initial data:

    let sprite = SKSpriteNode(texture: animFrames[0], normalMap: normals[0])

Create an SKAction with an actionBlock that switches normal map and sprite at the same time. The speed of the animation can be adjusted using fps . The number of frames is determined from the length of the animFrames array:

let fps = 8.0
let anim = SKAction.customActionWithDuration(1.0, actionBlock: { node, time in
let index = Int((fps * Double(time))) % animFrames.count
(node as! SKSpriteNode).normalTexture = normals[index]
(node as! SKSpriteNode).texture = animFrames[index]
})
sprite.runAction(SKAction.repeatActionForever(anim));

Finally set the sprite's position on the screen, adjusts it's scale and add it to the scene. End the function:

    sprite.position = CGPoint(x: _screenW / 2, y: _screenH / 2 - 75.0 * _scale)
    sprite.setScale(_scale)
    sprite.lightingBitMask = 1
    addChild(sprite)
}

The important line is sprite.lightingBitMask = 1 which assigns the light to a light source. The lights have a property called lightCategoryBitMask . A light shines on a sprite if at least one bit is set in both — the lightCategoryBitMask and lightingBitMask .

Compile and run. You should now see our character walking in front of a gray screen. Nice - but where is the light?

SpriteKit Light Tutorial: Red and blue light

Adding a light source

A light is implemented using an SKLightNode . The node itself is invisible. This is why you'll add a sprite with a light symbol to have a better reference where it is positioned.

First add a variable to store the light's sprite and a variable to store the ambient light color. Add this to the top of the GameScene class:

var _lightSprite:SKSpriteNode?
var _ambientColor:UIColor?

Extend didMoveToView to init the ambient light color and the light:

override func didMoveToView(view: SKView) {
    ...
    _ambientColor = UIColor.darkGrayColor()
    initSprite()
    initLight()
}

Now add a function initLight . Start with creating a sprite to represent the light as a visible object in the scene. There's a small lightbulb icon which will do fine. Also set the position and scale of the sprite:

private func initLight() {
    _lightSprite = SKSpriteNode(imageNamed: "Sprites/lightbulb.png")
    _lightSprite?.setScale(_scale * 2)
    _lightSprite?.position = CGPointMake(_screenW - 100, _screenH - 100)
    addChild(_lightSprite!);

Now create an SKLightNode and attach it to the sprite. Note that if you don't want the light bulb icon you can simply add the light to the scene instead. It's derived from SKNode so it contains all the functions for positioning etc.

    var light = SKLightNode();
    light.position = CGPointMake(0,0)
    light.falloff = 1
    light.ambientColor = _ambientColor
    light.lightColor = UIColor.whiteColor()
    _lightSprite?.addChild(light)
}

The light has it's own color - lightColor which you can change to create different effects. ambientColor is a background light - it does not have any direction and shines on all pixels of a sprite. falloff changes how far the light is visible.

Compile and run - way better! Now you see the light's effect.

SpriteKit Light Tutorial: Lit normal mapped sprite

Moving the light source

To make the light movable simply add these functions to the GameScene class. They adjust the light sprite and it's connected child light source to the touch position:

override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) {
    for touch: AnyObject in touches {
        _lightSprite?.position = touch.locationInNode(self)
    }
}

override func touchesMoved(touches: Set<NSObject>, withEvent event: UIEvent) {
    for touch: AnyObject in touches {
        _lightSprite?.position = touch.locationInNode(self)
    }
}

Adding some background

Let's finally add some background. A scrolling background fits this scene best. To give the scene some more depth, you'll add 2 layers: A foreground which scrolls with the character and a background.

The background is far away — so the influence of the light source should not be visible. This is easily done by adding a background sprite that is tinted by our ambient light. Set the ambient color on the sprite's color property and set the colorBlendFactor depending on how intense the tinting should be.

No need to apply any light source or normal map.

The function to add a background tile is simple:

private func addBackgroundTile(spriteFile: String) -> SKSpriteNode {
    var background:SKSpriteNode
    background = SKSpriteNode(imageNamed:spriteFile);
    background.color = _ambientColor!
    background.colorBlendFactor = 0.75
    background.anchorPoint = CGPoint(x:0, y:0.5)
    background.position = CGPoint(x:0, y:_screenH / 2.0);
    background.setScale(_scale)
    addChild(background);
    return background;
}

The foreground gets a normal map to react on the light. Don't forget to set lightingBitMask to match the light.

private func addForegroundTile(spriteFile: String, normalsFile: String) -> SKSpriteNode {
    var foreground:SKSpriteNode
    foreground = SKSpriteNode(texture: SKTexture(imageNamed:spriteFile),
    normalMap: SKTexture(imageNamed:normalsFile));
    foreground.lightingBitMask = 1
    foreground.anchorPoint = CGPoint(x:0, y:0.5)
    foreground.position = CGPoint(x:0, y:_screenH / 2.0);
    foreground.setScale(_scale)
    addChild(foreground);
    return foreground;
}

With these 2 function setting the background is easy. Create an initBackground function:`

private func initBackground() {
    _backgroundSprite1 = addBackgroundTile("Sprites/background_01.png");
    _backgroundSprite2 = addBackgroundTile("Sprites/background_01.png");
    _foregroundSprite1 = addForegroundTile("Sprites/foreground_01.png",
    normalsFile:"Sprites/foreground_01_n.png");
    _foregroundSprite2 = addForegroundTile("Sprites/foreground_02.png",
    normalsFile:"Sprites/foreground_02_n.png");
}

Add the variables to hold the sprites to the GameScene class

var _backgroundSprite1: SKSpriteNode?
var _backgroundSprite2: SKSpriteNode?
var _foregroundSprite1: SKSpriteNode?
var _foregroundSprite2: SKSpriteNode?

And call it from didMoveToView before adding the sprite or light objects:

override func didMoveToView(view: SKView) {
    ...
    _ambientColor = UIColor.darkGrayColor()
    initBackground()
    initSprite()
    initLight()
}
SpriteKit Light Tutorial: Dynamic light scene with background

Compile & run - looks nice, eh? It's just not moving...

Notice the big drop in frame rate. This has 2 main reasons:

  • iOS simulator is slow — the emulation with shaders takes a huge amount of performance. This is not only with SpriteKit but also in cocos2d and other frameworks.

The performance on the real hardware is ok - so don't worry too much about this.

  • We use the high-quality images and scale them down at runtime. If you pre-scale the images to match the devices performance will be way better.

Adding some parallax scrolling

Now let's get the scene moving. Simply add the following function:

override func update(currentTime: CFTimeInterval) {
    var y:CGFloat = _screenH / 2.0;
    var backgroundOffset: CGFloat = -CGFloat(Int(currentTime*100) % (1920*2));

    _backgroundSprite1?.position = CGPoint(
        x: _scale*((backgroundOffset < -1920) ? (3840+backgroundOffset) : backgroundOffset),
        y: y)

    _backgroundSprite2?.position = CGPoint(
        x:_scale*(1920+backgroundOffset),
        y:y)

    var foregroundOffset: CGFloat = -CGFloat(Int(currentTime*250) % (1920*2));

    _foregroundSprite1?.position = CGPoint(
        x: _scale*((foregroundOffset < -1920) ? (3840+foregroundOffset) : foregroundOffset),
        y: y)

    _foregroundSprite2?.position = CGPoint(
        x: _scale*(1920+foregroundOffset),
        y: y)
}

The function called update is called before the next frame is rendered. It gets the currentTime as parameter. The function simply uses the time to update the position of the sprites. The speed factor for the foreground is 250 , the factor for the background is 100 — this is why the foreground moves faster than the background — giving the impression of depth.

In case you are wondering what the ?: operator is doing in _backgroundSprite1 and _foregroundSprite1 : It places the sprite before or after the second sprite depending on the scroll position.

Start experimenting!

Now it's up to you to experiment... e.g. using different light presets:

_ambientColor = UIColor.blueColor()
light.lightColor = UIColor.redColor()
SpriteKit Light Tutorial: Red and blue light
_ambientColor = UIColor(red: 0.7, green: 0.4, blue: 0.5, alpha: 1.0)
light.lightColor = UIColor.whiteColor()
SpriteKit Light Tutorial: Sunset
_ambientColor = UIColor(red: 1.0, green: 1.0, blue: 0.8, alpha: 1.0)
light.lightColor = UIColor.whiteColor()
SpriteKit Light Tutorial: Red and blue light

Or what about adding a camp fire? Use some particle effects to create the fire, place a light source in it's middle and animate the position a bit to make it flicker …

... and finally: Create your own normal maps

SpriteIlluminator makes this process easy:

dynamic lighting effects for 2d games - painting a normal map