Texture Caching in SpriteKit
“Long story short: rely on Sprite Kit to do the right thing for
you.” -@LearnCocos2D
Here’s the long story, as of iOS 9.
The texture’s bulky image data is not kept directly on the SKTexture
object. (See the SKTexture
class
reference.)
SKTexture
defers loading data until necessary. Creating a
texture, even from a large image file, is quick and consumes little
memory.
Data for a texture is loaded from disk usually when a corresponding
sprite node is created. (Or really, whenever the data is needed,
for example when the size
method is called).
Data for a texture is prepared for rendering during the (first)
render pass.
SpriteKit has some built-in caching for the texture’s bulky image
data. Two features:
The texture’s bulky image data is cached by SpriteKit until
SpriteKit feels like getting rid of it.
According to the SKTexture
class reference: “Once the SKTexture
object is ready for rendering, it stays ready until all strong
references to the texture object are removed.”
In current iOS, it tends to stick around longer, perhaps even
after the whole scene is gone. A StackOverflow
comment quotes an Apple
tech support: “iOS releases the memory cached by
+textureWithImageNamed: or +imageNamed: when it sees fit, for
instance when it detects a low-memory condition.”
In the simulator, running a test project, I was able to see
texture memory reclaimed immediately after a removeFromParent
.
Running on a physical device, though, the memory seemed to
linger; repeatedly rendering and deallocating the texture
resulted in no additional disk accesses.
I wonder: Could the rendering memory be released early in some
memory-critical situations (when the texture is retained but not
currently displayed)?
SpriteKit reuses the cached bulky image data smartly.
In my experiments, it was hard not to reuse it.
Say you’ve got a texture displaying in a sprite node, but rather
than reusing the SKTexture
object, you call [SKTexture
textureWithImageNamed:]
for the same image name. The texture
will not be pointer-identical to the original texture, but it
will share the bulky image data.
The above is true whether the image file is part of an atlas or
not.
The above is true whether the original texture was loaded using
[SKTexture textureWithImageNamed:]
or using [SKTextureAtlas
textureNamed:]
.
Another example: Let’s say you create a texture atlas object
using [SKTextureAtlas atlasNamed:]
. You take one of its
textures using textureNamed:
, and you don’t retain the atlas.
You display the texture in a sprite node (and so the texture is
retained strongly in your app), but you don’t bother tracking
that particular SKTexture
in a cache. Then you do all of that
over again: new texture atlas, new texture, new node. All of
these objects will be freshly allocated, but they are relatively
lightweight. Meanwhile, and importantly: the bulky image data
originally loaded will be transparently shared between instances.
Try this one: You load a monsters atlas by name, and then take
one of its orc textures and render it in an orc sprite node.
Then, the player returns to home screen. You encode the orc node
during application state preservation, and then decode it during
application state restoration. (When it encodes, it doesn’t
encode its binary data; it encodes its name instead.) In the
restored app, you create another orc (with new atlas, texture,
and node). Will this new orc share its bulky orc data with the
decoded orc? Yes. Yes it orcking will.
Pretty much the only way to get a texture not to reuse
texture image data is to initialize it using [SKTexture
textureWithImage:]
. Sure, maybe UIImage
will do its own
internal caching of the image file, but either way, SKTexture
takes charge of the data, and will not reuse the render data
elsewhere.
In short: If you’ve got two of the same sprite showing in your
game at the same time, it’s a fair bet they are using memory
efficiently.
Put those two points together: SpriteKit has a built-in cache that
persists the important bulky image data and reuses it smartly.
In other words, it just works.
No promises. In the simulator running a test app, I can easily prove
that SpriteKit is deleting my texture data from cache before I’m
really done with it.
During prototyping, though, you might be surprised to find reasonably
good behavior from your app even if you never reuse a single atlas or
texture.
SpriteKit Has Atlas Caching Too
SpriteKit has a caching mechanism specifically for texture atlases.
It works like this:
You call [SKTextureAtlas atlasNamed:]
to load a texture atlas.
(As mentioned before, this doesn’t yet load the bulky image data.)
You retain the atlas strongly somewhere in your app.
Later, if you call [SKTextureAtlas atlasNamed:]
with the same
atlas name, the object returned will be pointer-identical to the
retained atlas. Textures extracted from the atlas using
textureNamed:
, then, will be pointer-identical, too. (Update:
The textures will not necessarily be pointer-identical under iOS10.)
Texture objects, it should be mentioned, do not retain their atlases.
You Might Still Want To Build Your Own Cache
So I see you are building your own caching and reuse mechanisms
anyway. Why are you doing it?
Ultimately you have better information about when to keep or purge
certain textures.
You might need complete control over the load timing. For
instance, if you want your textures to appear instantly when first
rendered, you’ll use the preload
methods from SKTexture
and
SKTextureAtlas
. In that case, you should retain the references
to the preloaded textures or atlases, right? Or, will SpriteKit
cache them for you regardless? Unclear. A custom atlas or texture
cache is a good way to keep complete control.
At a certain point of optimization (heaven forbid prematurely!!),
it makes sense to stop creating new SKTexture
and/or
SKTextureAtlas
objects over and over, no matter how lightweight.
Probably you’d build the atlas-reuse mechanism first, since atlases
are less lightweight (they have a dictionary of textures, after
all). Later you might build a separate texture caching mechanism
for reuse of non-atlas SKTexture
objects. Or maybe you’d never
get around to that second one. You are busy, after all, and the
kitchen isn’t cleaning itself, dammit.
All that said, your caching and reuse behavior will probably end up
eerily similar to SpriteKit’s.
How does SpriteKit’s texture caching affect your own texture cache
design? Here are the things (from above) to keep in mind:
You can’t directly control the timing of the release of bulky image
data when using named textures. You release your references, and
SpriteKit releases the memory when it wants to.
You can control the timing of the loading of bulky image data,
using the preload
methods.
If you rely on SpriteKit’s internal caching, then your atlas cache
needs only retain references to the SKTextureAtlas
objects,
not return them. The atlas object will automatically be reused
throughout your app.
Similarly, your texture cache needs only retain references to
the SKTexture
objects, not return them. The bulky image data
will be automatically reused throughout your app. (This one weirds
me out a bit, though; it’s a pain to verify good behavior.)
Given the last two points, consider design alternatives to a
singleton cache object. Instead, you could retain in-use atlases
on your sprite objects or their controllers. For the lifetime of
the controller, then, any calls in your app to atlasNamed:
will
reuse the pointer-identical atlas.
Two pointer-identical SKTexture
objects share the same memory,
yes, but due to SpriteKit caching, the converse isn’t necessarily
true. If you’re debugging memory problems and find two SKTexture
objects that you expected to be pointer-identical, but aren’t, they
still might be sharing their bulky image data.
Testing
I’m a tools novice, so I just measured overall app memory usage on a
release build using the Allocations instrument.
I found that “All Heap & Anonymous VM” would alternate between two
stable values on sequential runs. I ran each test a few times and
used the lowest memory value as the result.
For my testing I’ve got two different atlases with two images each;
call the atlases A and B and the images 1 and 2. The source images
are largish (one 760 KiB, one 950 KiB).
Atlases are loaded using [SKTextureAtlas atlasNamed:]
. Textures are
loaded using [SKTexture textureWithImageNamed:]
. In the table
below, load really means “putting in a sprite node and rendering.”
All Heap
& Anon VM
(MiB) Test
--------- ------------------------------------------------------
106.67 baseline
106.67 preload atlases but no nodes
110.81 load A1
110.81 load A1 and reuse in different two sprite nodes
110.81 load A1 with retained atlas
110.81 load A1,A1
110.81 load A1,A1 with retained atlas
110.81 load A1,A2
110.81 load A1,A2 with retained atlas
110.81 load A1 two different ways*
110.81 load A1 two different ways* with retained atlas
110.81 load A1 or A2 randomly on each tap
110.81 load A1 or A2 randomly on each tap with retained atlas
114.87 load A1,B1
114.87 load A1,A2,B1,B2
114.87 load A1,A2,B1,B2 with preload atlases
* Load A1 two different ways: Once using [SKTexture
textureWithImageNamed:] and once using [SKTextureAtlas
textureNamed:].
Internal Structure
While investigating I discovered some true facts about the internal
structure of texture and atlas objects in SpriteKit.
Interesting? That depends on what kinds of things interest you!
Structure of a Texture From an SKTextureAtlas
When an atlas is loaded by [SKTextureAtlas atlasNamed:]
, inspection
of its textures at runtime shows some reuse.
During Xcode build, a script compiles the atlas from individual
image files into a number of large sprite sheet images (limited by
size, and grouped by @1x @2x @3x resolution). Each texture in the
atlas refers to its sprite sheet image by bundle path, stored in
_imgName
(with _isPath
set true).
Each texture in the atlas is individually identified by its
_subTextureName
, and has a textureRect
inset into its sprite
sheet.
All textures in the atlas that share the same sprite sheet image
will have identical non-nil ivars _originalTexture
and
_textureCache
.
The shared _originalTexture
, itself an SKTexture
object,
presumably represents the whole sprite sheet image. It has no
_subTextureName
of its own, and its textureRect
is (0, 0, 1,
1)
.
If the atlas is released from memory and then reloaded, the new copy
will have different SKTexture
objects, different _originalTexture
objects, and different _textureCache
objects. From what I could
see, only the _imgName
(that is, the actual image file) connects the
new atlas to the old atlas.
Structure of a Texture Not From an SKTextureAtlas
When a texture is loaded using [SKTexture textureWithImageNamed:]
,
it may come from an atlas, but it doesn’t seem to come from an
SKTextureAtlas
.
A texture loaded this way has differences from the above:
It has a short _imgName
, like “giraffe.png”, and _isPath
is set
false.
It has an unset _originalTexture
.
It has (apparently) its own _textureCache
.
Two SKTexture
objects loaded by textureWithImageNamed:
(with the
same image name) have nothing notable in common other than _imgName.
Nevertheless, as thoroughly belabored above, this kind of texture
configuration shares bulky image data with the other kind of texture
configuration. This implies that caching is done close to the actual
image file.