Asynchronous Preloading in SpriteKit with Swift
Johan Steen
Posted on April 8, 2022
This is the second and final part of how to handle asynchronous preloading of SpriteKit game assets with Swift and an OperationQueue.
If you haven't already read the previous Asynchronous Operations in Swift article, I'd suggest reading it first to be comfortable with the basics regarding using an OperationQueue in Swift.
Preloading in SpriteKit
We will use a protocol that objects can adopt when requiring preloading of assets which our preloader operation in turn will call. We're keeping this example simple with one static method.
protocol AssetPreloading: class {
static func preloadAssets(withCompletionHandler completionHandler: @escaping () -> Void)
}
When adopting this protocol we will use the preloadAssets() method to load and decode all textures into memory. This would also be a great place to load and compile any pixel fragment shaders that we might be using for the entity.
So let's look at an example how we would use this protocol for an entity in the game.
class Enemy: GKEntity {
static var textures: [SKTexture]?
}
extension Enemy: AssetPreloading {
static func preloadAssets(withCompletionHandler completionHandler: @escaping () -> Void) {
SKTextureAtlas.preloadTextureAtlasesNamed(["Enemy"]) { (error, atlases) in
if let error = error {
fatalError("Texture atlases could not be found: \(error)")
}
// Decode the textures from the sprite atlas here.
// ie, something like...
textures = [
atlases[0].textureNamed("enemy_01"),
atlases[0].textureNamed("enemy_02"),
// ...
]
// Then we must call the completion handler to let the preloader know that this Operation has completed.
completionHandler()
}
}
}
In this example we have a GKEntity defining an Enemy which adopts the AssetPreloading
protocol. We define a static property in the enemy where we will store all the decoded textures. As it is a static property all enemy instances being created will share these textures, keeping the memory footprint to a minimum.
Then in the preloadAssets() method we are doing the actual preloading using the preloadTextureAtlasesNamed() method available in SpriteKit and finally in the completion closure of that method we decode each texture from the atlas into memory.
If we are not using sprite atlases but just plain textures, SKTexture has a preload() method that works similar.
To keep it simple for this article we just decode the textures one by one from the sprite atlas and store in the static array. In a real scenario we would probably follow a naming scheme so we can automatically loop through the assets in the sprite atlas while decoding and assign them identifiers or group them if we are having multiple animations in an atlas.
Anyway, once the decoding of the textures are done we call the completionHandler()
method. This one is passed in by the Operation and let the OperationQueue know that the preloading for this asset has been completed.
This is important, otherwise the OperationQueue will never finish.
Combining with Operation
Now we have an entity with a method containing all the logic to preload and decode assets into memory. Next we are going to need an Operation that can call the preloadAssets() method.
Referring back to the first article I wrote about asynchronous operations in Swift, the Operation class will be pretty much identical to the example shown under the Full Implementation section. So instead duplicating all that code, I'll just show the additions.
class PreloadOperation: Operation {
let preloader: AssetPreloading.Type
init(preloader: AssetPreloading.Type) {
self.preloader = preloader
super.init()
}
override func start() {
// ... Check for cancel and other possible logic...
state = .isExecuting
preloader.preloadAssets { [unowned self] in
self.state = .isFinished
}
}
So what has happened here since the first article is that we now have a constructor. The init(preloader: AssetPreloading.Type) takes a reference to an entity that conforms to the AssetPreloading
protocol so we can later call it once the Operation starts.
Then we have added the actual call to preloadAssets() in the start() method of the operation. Here we also pass in the completionHandler
closure that marks the state of the operation as .isFinished.
Setting up the Operation Queue
With the actual preloading functionality and the Operation class in place, we now need to setup an OperationQueue where we can add the PreloadOperations.
To stay focused on the subject we'll use an array to track the objects that has assets that needs to be preloaded.
let preloaders: [AssetPreloading.Type] = [
Player.self,
Enemy.self,
Turret.self
]
In a bigger project with multiple levels using unique and different assets, we would most likely generate this array from a parser of level data to figure out which assets that the level uses and which of those assets that complies to the AssetPreloading
protocol.
The OperationQueue is multithreaded so we have no way of knowing in which order operations are finished, which in this case doesn't really matter. We need to know when all the operations are finished though, and we will use the dependency functionality of the OperationQueue to track that.
An Operation can depend on one or many other Operations. That boils down to that an Operation won't start until all other Operations it depends on have finished.
We can use that to track when all Operations are done by having one final Operation with the purpose to notify when the OperationQueue is done. This completedOperation
will have every other Operation added as a dependency to guarantee it doesn't run until every previous Operation has finished.
So we will set up the OperationQueue like this.
let operationQueue = OperationQueue()
let completedOperation = BlockOperation {
DispatchQueue.main.async { [unowned self] in
// All preloading done. Game logic can continue to next state...
}
}
for preloader in preloaders {
let preloadOperation = PreloadOperation(preloader: preloader)
completedOperation.addDependency(preloadOperation)
operationQueue.addOperation(preloadOperation)
}
operationQueue.addOperation(completedOperation)
First we setup the completedOperation
. We don't need to subclass Operation for this as it's behavior is simple enough to use the system-defined BlockOperation subclass.
Then we simply loop through all objects that has a preloader and create an Operation for it. We add each new operation as a dependency to the completedOperation
with addDependency(), to ensure the completedOperation
run last.
And finally, when all Operations are added to the queue, we add the completedOperation
to the queue and that my friends, should make us happy campers.
Leveraging the State Machine
We're getting closer to complete the implementation, the missing piece to finish the puzzle is that the completedOperation
that are run last in the OperationQueue needs to progress the game to the next state.
That can be done in multiple ways. Simply just calling a method to progress to next scene could be good enough in many scenarios, using the NotificationCenter
is another option or the solution we will opt for, using GameplayKit's finite GKStateMachine.
We're going to use a state machine, even though we in this example only are using two states, as it leaves room for adding additional states in the future. Additional states could be showing a loading screen, updating the progress on the loading screen as well as a state for purging loaded assets from memory.
In this example we'll just implement the states needed to get preloading of the game assets up and running.
We're going to create an AssetLoader class that we will call whenever we need to preload assets.
class AssetLoader {
lazy var stateMachine = GKStateMachine(states: [
AssetLoaderLoadingState(assetLoader: self),
AssetLoaderReadyState(assetLoader: self)
])
let assetsToLoad: [AssetPreloading.Type]
init(assetsToLoad: [AssetPreloading.Type]) {
self.assetsToLoad = assetsToLoad
stateMachine.enter(AssetLoaderLoadingState.self)
}
}
This class takes an array of objects that implements the AssetPreloading
protocol as well as it sets up the state machine with two possible states.
AssetLoaderLoadingState and AssetLoaderReadyState. When we instance the class we immediately enter the loading state to begin the preloading.
The loading state will run the OperationQueue code from the previous section in the didEnter() method.
class AssetLoaderLoadingState: GKState {
unowned let assetLoader: AssetLoader
init(assetLoader: AssetLoader) {
self.assetLoader = assetLoader
super.init()
}
override func didEnter(from previousState: GKState?) {
// The OperationQueue code from the previous section...
}
}
The completedOperation
BlockOperation in the operation queue will enter the ready state once all preloading is done, so we'll update the completedOperation
to look like this.
let completedOperation = BlockOperation {
DispatchQueue.main.async { [unowned self] in
self.stateMachine?.enter(AssetLoaderReadyState.self)
}
}
And then finally the ready state. Here we will use the didEnter() method to continue the game logic by moving on to the next state, which probably would be to present the next level for the player.
class AssetLoaderReadyState: GKState {
unowned let assetLoader: AssetLoader
init(assetLoader: AssetLoader) {
self.assetLoader = assetLoader
super.init()
}
override func didEnter(from previousState: GKState?) {
// Call code to enter the next state of the game...
}
}
And it's a wrap, we have now successfully implemented a preloader, using multithreading, that can easily be adapted to most games with the need to preload assets.
Conclusion
By combining these different frameworks, SpriteKit, OperationQueue and GameplayKit we end up with a very solid and robust solution that has plenty of room to grow when the game's complexity and number of assets increases.
It is also an implementation that is maintainable and by having the overall logic wrapped in a state machine there will be no hindering to add additional features like a loading progress bar.
Posted on April 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.