Core Animation is not only capable of creating advanced, modern animations, but with a little tweaking, it’s possible to create old-school sprite animations akin to the ones you would see on an NES or SNES game console. This post will explore creating a very simple CALayer subclass that is capable of displaying a sprite animation sequence.

For this post, we’ll recreate a video game sprite animation loop like this:

Video game sprite loop animation

There are many ways to accomplish such an animation on iOS, the easiest of which is to use UIImageView’s animationImages property by providing an array of images containing each frame of the animation. But for the purposes of this post, we want to explore a method that makes use of a sprite sheet, which is just a single image that contains multiple sprites packed together.1 The simplest and most naive type of sprite sheet just concatenates every frame of an animation sequence:

Simple sprite sheet

We’ll use the above image for this example.

To start, let’s create a new CALayer subclass for our sprite animation:

class AnimatableSpriteLayer: CALayer {

    var spriteSheetImage: UIImage? {
        didSet {
            setUpSpriteAnimation()
        }
    }

    var spriteFrameSize: CGSize? {
        didSet {
            setUpSpriteAnimation()
        }
    }

    convenience init(spriteSheetImage: UIImage?, spriteFrameSize: CGSize?) {
        self.init()

        self.spriteSheetImage = spriteSheetImage
        self.spriteFrameSize = spriteFrameSize

        setUpSpriteAnimation()
    }

}

This layer takes two parameters: spriteSheetImage, which is a film strip-like image similar to the above sprite sheet, and spriteFrameSize, which is the size of a single frame. For this example, we assume every frame of the animation has the same size and the sprite sheet image has been padded appropriately.2

After we know the sprite sheet image and frame size, we need to configure the layer and create the actual animation:

private func setUpSpriteAnimation() {
    guard let spriteSheetImage = spriteSheetImage,
        let spriteFrameSize = spriteFrameSize else { return }

    minificationFilter = kCAFilterNearest
    magnificationFilter = kCAFilterNearest
    masksToBounds = true
    contentsGravity = kCAGravityLeft

    contents = spriteSheetImage.cgImage
    bounds.size = spriteFrameSize

    let frameCount = Int(spriteSheetImage.size.width / spriteFrameSize.width)
    var frameOffsets = [CGFloat]()

    for frameIndex in 0..<frameCount {
        frameOffsets.append(CGFloat(frameIndex) / CGFloat(frameCount))
    }

    let spriteKeyframeAnimation = CAKeyframeAnimation(keyPath: "contentsRect.origin.x")
    spriteKeyframeAnimation.values = frameOffsets
    spriteKeyframeAnimation.duration = 0.7
    spriteKeyframeAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
    spriteKeyframeAnimation.repeatCount = .greatestFiniteMagnitude
    spriteKeyframeAnimation.calculationMode = kCAAnimationDiscrete

    add(spriteKeyframeAnimation, forKey: "spriteKeyframeAnimation")

    speed = 0.0
}

There’s a lot going on here, so let’s break it down.

minificationFilter = kCAFilterNearest
magnificationFilter = kCAFilterNearest

minificationFilter and magnificationFilter control the resampling behavior of layer contents. In effect, these properties let you control how smooth a layer appears when its resized. kCAFilterNearest means to use the “nearest neighbor” technique, which ensures the layer will remain sharp and pixellated no matter the size.3

masksToBounds = true
contentsGravity = kCAGravityLeft

We mask the layer to its bounds; otherwise, the entire sprite sheet would be visible. Additionally, we use contentsGravity to effectively left align the bitmap so it appears to start at the first frame of the sprite animation.

contents = spriteSheetImage.cgImage
bounds.size = spriteFrameSize

Here we actually provide the layer with the sprite sheet bitmap data and set its size to the size of a single frame of animation.

let frameCount = Int(spriteSheetImage.size.width / spriteFrameSize.width)
var frameOffsets = [CGFloat]()

for frameIndex in 0..<frameCount {
    frameOffsets.append(CGFloat(frameIndex) / CGFloat(frameCount))
}

In order to advance the animation for each frame, we need to know the relative offset of where the frames start as a fraction of the total width of the image.

let spriteKeyframeAnimation = CAKeyframeAnimation(keyPath: "contentsRect.origin.x")

contentsRect is the first key to this technique. Basically, it defines what part of the layer’s contents will actually be used when rendering it.

"contentsRect.origin.x" means this animation is going to affect only the x value of its origin since all we want to do is advance the contents’ origin to the left for each frame of the animation.

spriteKeyframeAnimation.values = frameOffsets

Since this is a keyframe animation, we need to provide it key values. Because contentsRect is a unit rectangle,4 we need to use origin values that are fractions of the total width of the sprite sheet image, which is what we calculated above.

spriteKeyframeAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)

For such a simple sprite animation, we want the animation to pace itself linearly so each frame of the animation is displayed for the same amount of time.

spriteKeyframeAnimation.calculationMode = kCAAnimationDiscrete

calculationMode is the second key to this technique. Normally, keyframe animations are calculated linearly; that is, Core Animation computes in-between values between each key frame using simple linear interpolation. However, what we actually want in this case is for Core Animation to just jump to each offset where a new frame is displayed.

Here’s what it would look like if we didn’t use a discrete calculation mode:

Linearly-interpolated sprite animation

And here’s what we get when we do, which is what we actually want:

Discretely-interpolated sprite animation

add(spriteKeyframeAnimation, forKey: "spriteKeyframeAnimation")

speed = 0.0

The last part of this adds the actual animation and sets the layer’s speed to 0. This effectively pauses the animation. Given this, we can now add two simple functions to control playback:

func play() {
    speed = 1.0
}

func pause() {
    speed = 0.0
}

Now all we have to do is create an instance of AnimatableSpriteLayer:

let rootView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 300.0, height: 300.0))
rootView.backgroundColor = .white

let spriteSheetImage = UIImage(named: "alucard")
let spriteFrameSize = CGSize(width: 176.0, height: 200.0)
let spriteLayer = AnimatableSpriteLayer(spriteSheetImage: spriteSheetImage, spriteFrameSize: spriteFrameSize)
spriteLayer.position = rootView.center

rootView.layer.addSublayer(spriteLayer)

spriteLayer.play()

That’s pretty much all there is to it! Certainly, there are much better options available for actually working with sprites, most notably the eponimous SpriteKit. Still, hopefully this post was useful to demonstrate some interesting features of Core Animation that aren’t as widely known.

Resources

  1. Sprite sheets (or texture atlases) are widely used in video games to more efficiently draw individual—often related—sprites. Instead of constantly incurring the cost of uploading multiple tiny sprite bitmaps to the GPU, a single, larger bitmap that packs a bunch of sprites together is significantly faster. 

  2. More efficient sprite sheet algorithms trim out most of the transparent space of individual frames to make the image smaller and rely on associated metadata for each frame to tell the renderer how to actually pad out each frame. 

  3. To avoid distortions, pixel art should always be scaled by an integer multiplier or divisor. Many video games, especially modern ports of older games, may opt to use linear or trilinear filtering instead of nearest neighbor if they have to arbitrarily scale sprites to match modern display resolutions. This allows screen contents to fit at a variety of display resolutions, but the result is blurry-looking pixel art. 

  4. contentsRect is defined as a unit rectangle, which defaults to ((0, 0), (1, 1)). What this means is that the origin of the contents should be 0 times the width and 0 times the height, and the size of the contents should be 1 times the width and 1 times the height. 

CALayer