SpriteKit game scene transitions with shaders

by

The casual game side project I’m working on has recently entered the last 20% phase of development (no doubt where 80% of my time will be invested). Game scene transitions are something I’ve been putting off for a while that I now have the pleasure of grinding through. I wanted to achieve a very “retro” feel with the transitions – the current result is looking like this:

The video shows two transitions (picked at random from a list of many similarly styled transitions) triggered when moving between the in game experience to the world map and vice versa. The transitions are implemented using OpenGL Shaders.

I thought I’d share my approach to SpriteKit scene transition using shaders as it wasn’t as straight forward as I expected.

Transitions using Shaders

SpriteKit scene transition is typically done using one of two SKView’s presentScene methods, one of which takes an SKTransition argument

func presentScene(scene: SKScene?);
func presentScene(scene: SKScene?, transition: SKTransition?);

Unfortunately, out the box SKTransition only offers a limited set of transition styles and no real support to extend these styles using a shader. So the only option available to us if we want to transition between two scenes using a shader is:

  1. Attach and run the shader transition for the current scene
  2. Transition “immediately” to the next scene using presentScene
  3. Optionally attach and run a second shader or animation on the new scene to “smooth” the transition

This approach assumes the hiding of the current scene and display of the new scene happens as two discrete steps i.e. both scenes aren’t partially visible at the same time. If your transition calls for the two scenes to be partially visible in parallel at any point you’ll have some additional work.

I really wanted to maintain SpriteKit’s simplicity for switching scenes by calling just a single function, unfortunately this isn’t possible due to needing to update the elapsed time for the transition shader on each SKScene update(currentTime:). In the end I settled on the following three functions to perform a transition between scenes:

func presentScene(scene: SKScene, 
             shaderName: String, 
     transitionDuration: NSTimeInterval);
func updateShaderTransition(currentTime: CFTimeInterval);
func completeShaderTransition();

These functions are implemented within a Swift class extension on SKScene so that you can easily call them in place of the two SKView’s presentScene methods shown above. We’ll take a look at the implementation of each function in turn in the sections that follow.

Presenting the new scene

To initiate a scene transition, presentScene(scene:shaderName:transitionDuration:) is called passing the name of a shader file & the transition animation duration. The implementation of the function is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/* SKScene+ShaderTransition.swift */

func presentScene(scene: SKScene, 
             shaderName: String, 
     transitionDuration: NSTimeInterval) {
    
    // Create shader and add it to the scene
    var shaderContainer = SKSpriteNode(imageNamed: "dummy")
    shaderContainer.name = kNodeNameTransitionShaderNode
    shaderContainer.position = 
        CGPointMake(size.width / 2, size.height / 2)
    shaderContainer.size = 
        CGSizeMake(size.width, size.height)
    shaderContainer.shader = 
        createShader(shaderName, 
            transitionDuration:transitionDuration)
    shaderContainer.zPosition = 9999 
    self.addChild(shaderContainer)
    
    // remove the shader from the scene after the 
    // transition animation has completed:
    let delayTime = dispatch_time(DISPATCH_TIME_NOW, 
        Int64(transitionDuration * Double(NSEC_PER_SEC)))
    
    dispatch_after(delayTime, dispatch_get_main_queue(), 
        { () -> Void in
            var fadeOverlay = 
                SKShapeNode(rect: 
                    CGRectMake(0, 0, 
                        self.size.width, 
                        self.size.height))
                        
            fadeOverlay.name = kNodeNameFadeColourOverlay
            fadeOverlay.zPosition = shaderContainer.zPosition
            fadeOverlay.fillColor = 
                SKColor(red: 131.0 / 255.0, 
                      green: 149.0 / 255.0, 
                       blue: 255.0 / 255.0, 
                      alpha: 1.0)
                     
            scene!.addChild(fadeOverlay)
            self.view!.presentScene(scene)
        }
    )
    
    // Reset the time presentScene was called so that the 
    // elapsed time from now can be calculated in 
    // updateShaderTransitions(currentTime:)
    presentationStartTime = -1
}

Lines 8-18 create an SKShader, attach it to a dummy SKSpriteNode and then add the node to the view. The dummy image is just a transparent 1x1 pixel png. We give the SKSpriteNode a name so that we can get at it’s shader from within other methods in the extension. The zPosition is set to something suitably large so that it’s guaranteed to be in the foreground.

Lines 25-44 dispatch an asynchronous block to be run when the transition animation completes. This block is responsible for adding an SKShapeNode that covers the new SKScene with an opaque overlay and then immediately presenting it. The overlay covers the entirety of the screen and has the same fill colour that the SKShader transition animation ends up filling the screen with. The new scene can now call completeShaderTransition() when it wants to complete the transition and gracefully fade in.

Updating the shader transition

The shader itself has several uniform variables one of which is u_elapsed_time – this variable indicates the total time that has elapsed since the start of the transition. Updating this uniform variable is the responsibility of the SKScene extension’s updateShaderTransition(currentTime:) method:

/* SKScene+ShaderTransition.swift */

func updateShaderTransition(currentTime: CFTimeInterval) {
    if let shader = self.transitionShader {
        let elapsedTime = shader.uniformNamed("u_elapsed_time")!
        if (presentationStartTime < 0) {
            presentationStartTime = currentTime
        }
        elapsedTime.floatValue = 
            Float(currentTime - presentationStartTime)
    }
}

presentationStartTime is set to -1 whenever presentScene is called, it then gets set to the currentTime next time updateShaderTransition is called and from then on we have a reference point as to when the transition animation began.

The above needs to be called from every SKScene update(currentTime:) method
that wants to support shader transitions:

override func update(currentTime: NSTimeInterval) {
    updateShaderTransitions(currentTime)
    // do your usual other updates
}

Completing the transition

Once the presented scene is ready to be revealed (perhaps immediately, or perhaps after loading all required assets, etc) it calls completeShaderTransition(). This function retrieves the SKShapeNode overlay and fades it out to gracefully reveal the new scene and complete the transition:

/* SKScene+ShaderTransition.swift */

func completeShaderTransition() {
    if let fadeOverlay = 
            self.childNodeWithName(kNodeNameFadeColourOverlay) {
        fadeOverlay.runAction(
            SKAction.sequence(
                [SKAction.fadeAlphaTo(0, duration: 0.3), 
                 SKAction.removeFromParent()]
            )
        )
    }
}

Example retro transition shader

The shader itself has several uniform variables (in addition to those that SpriteKit provides)

  • u_total_animation_duration - The total duration of the transition animation
  • u_elapsed_time - The time that has elapsed since calling presentScene. It’s updated every frame in the updateShaderTransition(currentTime:) method
  • u_fill_colour - The individual diamond tile fill colour
  • u_border_colour - The individual diamond tile stroke colour
/* retro_transition_fade_from_top.fsh */

int NUM_COLUMNS = 15;

void main( void ) {
    float tileSize = u_size.x / float(NUM_COLUMNS);
    int NUM_ROWS = int(ceil(u_size.y / tileSize));
    
    int column = int(floor(gl_FragCoord.x / tileSize));
    int row = int(floor(gl_FragCoord.y / tileSize));
    
    vec2 pos = mod(gl_FragCoord.xy, 
        vec2(tileSize)) - vec2(tileSize / 2.0);
    float singleTileAnimDuration = 
        u_total_animation_duration / 3.0;
    float animStartOffset = 
        (float(NUM_ROWS - row) / float(NUM_ROWS)) * 
        (u_total_animation_duration - singleTileAnimDuration);
    float elapsedTileAnimTime = 
        min(
            max(0.0, u_elapsed_time - animStartOffset), 
            singleTileAnimDuration);
    float strokeSize = 3.0;
    float tileRadius = 
        (elapsedTileAnimTime / singleTileAnimDuration) * 
        (tileSize + strokeSize);
  
    if (abs(pos.x) + abs(pos.y) < tileRadius - strokeSize) {
        gl_FragColor = u_fill_colour;
    } else if (abs(pos.x) + abs(pos.y) < tileRadius) {
        gl_FragColor = u_border_colour;
    } else {
        gl_FragColor = SKDefaultShading();
    }
}

This shader creates the fade down from the top of the screen effect that can be seen as the first transition in the video at the top of the page. The shader itself divides the screen into NUM_COLUMNS columns and a dynamic number of rows. Within each tileSizextileSize space in the grid a diamond shape is drawn. The diamond starts small (it’s initial scale is 0) and gradually fills it’s tile space over singleTileAnimDuration seconds. Each row of tiles has it’s individual tile animation start time delayed/offset by animStartOffset based on the rows distance from from the top – the top row of tiles has animStartOffset = 0, the bottom row animStartOffset = u_total_animation_duration - singleTileAnimDuration and everything else has something in between the two.

Source code

Full source code in the form of an example app can be found on GitHub