NEW: Get your ticket for Hacking with Swift Live 2019! >>

15 tips to optimize your SpriteKit game

Paul Hudson       @twostraws

SpriteKit is an extraordinarily fast 2D framework, backed by Apple’s own Metal library for raw access to the GPU.

But as your games grow you might occasionally find your frame rates start to drop, and with devices such as the iPad Pro having a 120Hz display you need to work hard to keep your frame updates inside the 8 milliseconds you’re given.

If you’re experiencing low frame rates, choppy animations, or similar performance problems, I’ve put together 15 optimization tips you can work through that will help identify and resolve problems. Even better, where appropriate you’ll find code attached so you can try it yourself!

1. Use texture atlases carefully

Texture atlases place multiple separate assets in the same finished graphic, so that they all get loaded at once. Drawing then happens by effectively rendering only part of an asset at a time, which allows SpriteKit to keep one texture active and merely move the window it’s drawing from.

This often leads to major performance improvements, because changing state – unloading one texture and loading another during rendering – is expensive. However, it’s common to see people add all their images to a single atlas, which actually works against them: Xcode will build your assets into atlases based on its own fitting algorithm, and because it has no idea where assets are actually used you might find entirely unrelated sprites appear in the same atlas.

In practice, what this means is that you might have one sprite from level 2, another from level 8, and another from level 52 all on the same texture atlas. That means Xcode must load two irrelevant sprites into memory just to get access to the one it actually needs to draw, which is extremely inefficient.

So, create multiple texture atlases to fit your actual content: all the animations for a player in one atlas, for example, and all the sprites for a particular world in another.

2. Preload textures as needed

It shouldn’t surprise you when I say that there is a performance cost to loading textures from your app bundle. It might be small if the image is small, but if you try and load a full-screen background picture it might just be enough to push you over your time budget – which means dropped frames.

To fix this, you should look to preload textures in the background, effectively preheating the cache so when you need them they are available immediately. As a result, there’s much less chance of you dropping frames.

To see how this works, it’s important to understand that SKTexture works like UIImage: it doesn’t actually load the data until it’s needed. So, this kind of code is almost instant even for extremely large images:

let texture = SKTexture(imageNamed: "Thrash")

However, as soon as you assign that texture to a sprite node in your game scene it needs to be loaded so it can be drawn. Ideally you want that load to happen before the scene is shown – in a loading screen, perhaps – so you avoid frame problems, so you should preload it like this:

texture.preload {
    print("The texture is ready!")
}

You can also load texture atlases this way, which is particularly useful if you have one texture atlas per game segment.

3. Orient your artwork

When you’re making simple SpriteKit games it makes sense to produce artwork in whatever way is easier for you to understand.

In practice, that usually means making the player’s spaceship point upwards by default, because that’s what many people consider it to be its natural orientation. However, SpriteKit thinks differently: for SpriteKit 90 degrees (3 o’clock) is the natural orientation of things, so you’ll often find yourself adding CGFloat.pi / 2 radians to your calculations in order to make your idea of “straight” match SpriteKit’s.

When performance becomes more critical, it’s time to rethink. Yes, you might think that straight up is the default orientation for objects, but SpriteKit doesn’t, and constantly adding extra arithmetic to convert between the two is an unnecessary overhead.

So: make sure your assets are oriented to the right and are a sensible size for your game scene.

4. Replace, don’t blend

Despite all the calculations that need to take place, drawing pixels to the screen – rendering out your finished product – is still often one of the slowest parts of making a game. This is because it’s complicated: most sprites have irregular shapes and alpha transparency, we usually have multiple layers in your scene, and often we have complex effects that bring things to life.

A lot of this is unavoidable, but there is one easy change you can often make: if you’re drawing a sprite that has no alpha transparency at all (i.e., it’s a solid shape, such as a background image), then you can tell SpriteKit to render it without alpha blending, like this:

yourSprite.blendMode = .replace

In practice, this means drawing happens simply by copying the sprite’s pixels over whatever is already there – SpriteKit doesn’t need to read the existing color value, then blend it with the new color value.

5. Remove nodes the player can't see

SpriteKit does a good job of automatically culling our game scenes so that things that are off screen don’t get drawn. But – and this is important! – they still get factored into physics calculations, and even not drawing something still requires SpriteKit to constantly check whether something is visible or not.

So, if something has genuinely moved off your screen and you don’t need it back in the near future, call removeFromParent() on it. You can add it back later if needed, but in the meantime you’re saving SpriteKit a bunch of extra calculations that just aren’t needed.

6. Restrict your use of crop and effect nodes

Both of these node types let us customize the way other nodes are drawn, either by clipping their drawing or by applying Core Image filters. However, both work using an offscreen render buffer: the contents of the node need to be rendered into a private framebuffer, then copied back into the main scene. This extra pass is slow, so both types should be used sparingly.

While there is no way of reducing the cost of crop nodes (other than avoiding them where possible!), you can reduce the cost of your effect nodes by instructing SpriteKit to cache the resulting framebuffer:

effectNode.shouldRasterize = true

Warning: Rasterizing your effect nodes is a bad idea if they are constantly changing, because you’re constantly storing extra data in RAM then tossing it away. On the other hand, if your effect nodes are changing only once every few seconds or less, rasterizing is a good idea.

7. Be careful with particle systems

Particle systems make for relatively cheap, easy, and impressive effects, but it’s also easy to get carried away – particularly because you can click your way around Xcode’s built in editor for an hour, without feeling the actual performance impact.

The problem is that Xcode’s particle editor doesn’t model real-world game environments – your particular might work great in the test harness, but when you have of them appearing at once it might drag down your frame rate.

A particular culprit is birth rate, which is how fast SpriteKit creates particles. If you’re using a value over 500 it might look impressive, but it’s computationally expensive – try going for half that value and using a larger scale instead, so individual particles take up more space.

8. Disable sibling drawing order

SKView has a Boolean property called ignoresSiblingOrder, which, when set to false, has serious performance implications.

Sibling drawing order refers to the way SpriteKit renders nodes in your game scene. When it’s taken into account, SpriteKit renders nodes according to both their Z position and their position as children of their parent.

This is slow and usually unnecessary because we can just use the Z position to control draw depth and eliminate the extra ordering.

Annoyingly, ignoresSiblingOrder is set to false by default, which means you get the slow drawing behavior. If you can, set it to true by adding this to whatever view controller your scenes are rendered inside:

yourSKView.ignoresSiblingOrder = true

Now make sure you use the zPosition property of your nodes to control where they are drawn.

9. Use shaders for GPU work

Although you can create some interesting effects using textures, crops, and effect nodes, you can do significantly more using fragment shaders – and they are much faster, too.

Fragment shaders are tiny programs written in a dedicated language called GLSL, and they are run on every pixel inside your nodes. They let you create incredible effects such as water ripples, static noise, embossing, interlacing, and more, and modern computers are designed to be extraordinarily efficient at processing them – a modern GPU might have 2000-4000 dedicated shader processors that run simultaneously.

If you want to get a library of advanced effects using shaders, all written by me and tested with SpriteKit, I have just the GitHub repository for you: ShaderKit has 26 different shaders to try out, each of which is comprehensively documented so you can modify them or create your own.

However, for extra performance there are three extra things to do:

  1. Although you can load shaders from a string, it’s better to load them from a shader file in your bundle. This is because even if you create two shader from exactly the same string, SpriteKit considers them to be different and so can’t share them.
  2. You can assign the same shader to multiple different nodes, and doing so is much faster than creating individual shaders.
  3. Try not to adjust your uniforms and attributes often, because it can cause SpriteKit to recompile your shader code.

10. Choose your physics bodies wisely

SpriteKit gives us a collection of physics bodies we can use to represent our nodes in space: circles, rectangles, composite shapes, and even pixel-perfect collision detection. Each have their uses, but broadly speaking you’re going to choose one of three:

  • Pixel-perfect collision detection is the most precise, but also the most costly. This should be used sparingly, such as for your player.
  • Rectangle collision detection is fast and close enough for most purposes, so it’s a sensible default choice.
  • Circle collision detection is by far the fastest of all options, and is about 3-4x faster than even rectangles. The trade off is that it doesn’t fit most sprites so well, but if you can use circles you should do so.

Sometimes – used carefully – you might find that the init(bodies:) initializer can help you overcome particularly troublesome physics. This lets you build a compound body from several other physics bodies, allowing you to join together three circles and a rectangle for example.

Regardless of what you choose, don’t enable the usesPreciseCollisionDetection Boolean unless you absolutely need it. By default SpriteKit evaluates physics every frame to see whether things collide, but if you have small, fast-moving bodies it’s possible that a collision might be missed because too much movement happened between two frames.

In this situation, enabling usesPreciseCollisionDetection helps because it makes SpriteKit use an iterative detection algorithm – rather than move the ball from 100 to 150, for example, it examines the movement between frames to figure out whether a collision happened. This is very slow – use it only when absolutely needed.

11. Use static physics where possible

Objects that have physics bodies must be evaluated every frame to see how they are moving and what they are colliding with. If you set a physics body’s isDynamic property to false it reduces the number of calculates SpriteKit must perform – it will no longer respond to gravity, friction, forces, or impulses you apply to it, and it won’t be moved when another object collides with it.

As a result, it’s worth looking for places where you can use static physics bodies rather than dynamic ones. Static bodies will still act as walls for other things to bounce off, they just won’t themselves move.

12. Choose your bitmasks carefully

SpriteKit physics bodies have three bitmasks:

  1. The category bitmask decides what kind of object this is, and we can define up to 32 of these.
  2. The collision bitmask decides what objects this thing bounces off in the physics world.
  3. The contact test bitmask decides what collisions we care about.

Separating 2 and 3 is an important performance optimization, and also allows extra functionality. For example, we might say that the player and the power up don’t collide but they do have contact, which means the player won’t bounce backwards when they touch a power up, but we are told about the touch so we can respond to it.

Similarly, we might make a player and a wall collide but not have a contact, which means they player can’t walk through walls but we don’t keep getting callbacks saying they touched a wall.

So, to optimize your physics:

  • Reduce the number of things you assign in your collision bitmasks, so SpriteKit has to simulate fewer bounces.
  • Drastically reduce the number of things you assign in your contact test bitmask, so SpriteKit calls our code as infrequently as possible.

13. Keep tabs on your node and draw counts

By default, SpriteKit will show you how many nodes are currently on screen – it should be there in the bottom-right corner of your game scene. You’ll also see how many frames per second you have, and you should be achieving 60fps on most iOS devices or 120fps on ProMotion devices.

However, if you have performance problems there’s another useful diagnostic value you can show there: draw count. To enable this, add showsDrawCount = true to your SKView configuration code in your view controller, then run the game again.

“Draw count” is the number of drawing passes it takes to render one frame of your game. It’s not possible for SpriteKit to draw everything simultaneously, because there are lots of layers, lots of effects, and more all taking place. So, instead it draws game scenes in multiple passes. As I said above, crop and effect nodes need their own passes because of their private framebuffers, which means they incur performance hits.

Once you enable drawing passes you have another useful value for profiling in your app: if your frame rate is stuttering and you find you have 20 or more drawing passes, see if you can get that number down as a matter of priority.

Tip: One crop node with one sprite inside it will cost you two draw passes.

14: Avoid adding logic to update()

The update() method (as well as other frame-cycle events such as didEvaluatelActions() and didSimulatePhysics() are called once every 16ms on most devices, or once every 8ms on ProMotion devices. That’s an extremely short amount of time, which means you need to actively limit how much work you’re doing in there.

So, instead of pro-actively checking things, can you re-actively check them? For example, rather than checking whether all enemies are destroyed in update(), keep an array of active enemies and trigger functionality when you remove the last one. This kind of change removes an expensive branch operation from a time-sensitive method.

15: Always test on the oldest hardware you support

Different iOS devices run at dramatically different speeds, so the only way to be sure if your game is fast enough is to test it on the oldest, slowest device supported by your iOS deployment target.

If you deploy to iPad, the oldest device is likely to be an original iPad Air or iPad Air 2; on iPhone it’s likely to be an iPhone 5s or 6. Either way, if your game works smoothly on those old devices you’ll have no problem on newer ones!

Bonus tip: separate out your frame-cycle events

This one won’t help your performance, but it will help you organize your game code better. It’s common to see folks dump lots of functionality right into their SKScene subclass, but it’s really not necessary – scenes have a delegate property that can handle frame-cycle events automatically if you want to.

To try it out, first make a new class that conforms to SKSceneDelegate, then create an instance of that in your main view and assign it to the delegate property. Now move into any of the frame-cycle events you want the delegate to handle:

  • update()
  • didEvaluateActions()
  • didSimulatePhysics()
  • didApplyConstraints()
  • didFinishUpdate()

The end result is a smarter, simpler distribution of your code.

Where next?

I have a whole book on SpriteKit, and it has a unique feature I’ve never seen in other tutorial books: you choose how the games should work.

If you ever read “Choose your own adventure” books when you were younger, you’ll know exactly how it works: each project we make offers you choices that affect the rest of the game, meaning that are hundreds of possible outcomes you can make. To find out more about Dive Into SpriteKit, click here.

I also recommend you watch a video from WWDC14 called Best Practices for Building SpriteKit Games – it's a few years old now, but the principles haven't changed.

UPGRADE YOUR SWIFT Hacking with Swift Live is a new iOS conference taking place in the UK this July, with all profits going to charity. Come and learn the major new APIs announced at WWDC19 with sessions and hands-on tutorials – click here to learn more!

 

MASTER SWIFT NOW
Buy Testing Swift Buy Practical iOS 12 Buy Pro Swift Buy Swift Design Patterns Buy Swift Coding Challenges Buy Server-Side Swift (Vapor Edition) Buy Server-Side Swift (Kitura Edition) Buy Hacking with macOS Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with Swift Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

About the author

Paul Hudson is the creator of Hacking with Swift, the most comprehensive series of Swift books in the world. He's also the editor of Swift Developer News, the maintainer of the Swift Knowledge Base, and Mario Kart world champion. OK, so that last part isn't true. If you're curious you can learn more here.

Was this page useful? Let me know!

Click here to visit the Hacking with Swift store >>