code, iPhone

Texture Atlases and Core Animation11 May

I have been trying to write a blog post about texture atlases forever now, but I keep getting distracted and then I forget I have a draft up here waiting for me.

SO! I am going to be doing a series of posts about some basic openGL|ES stuff on the iPhone and how to make your cocoa-based core animation application (or game) perform better. Ultimately I will tell you that you should switch to openGL, but for now I am going to keep it simple and talk about core animation layers and texture atlasing. (and for now, we will only be talking about 2d stuff at 1:1 rez, so no mip mapping of the atlases for those of you who are reading ahead, as that gets more complicated than we need right now)

Why do I want to share this information with you? Well, the first game I did on the iPhone: Snowdude was a big ole 2D sprit animation game. I originally did the entire thing in Core Animation and it got about 4 fps, which was not enough :-) So I switched to openGL and could easily get 30 or 40 (though I only needed to do 12 because that was what all the sprite animations were built at), so this is the story of that transition.

The first hurdle we will cross will not require me to get into openGL just yet, but talk about how to optimize your sprite animations, even within CA, so if you only need a teensy bit of performance, you might be able to just get away with this and not actually make a full leap into openGL.

So, lets say I have a sweet animation of a dude face planting that looks like so: (Oh and BTW big mad props to the Lycette Bros makers of the aforementioned Snowdude for letting me use their raw graphics for examples)

faceplant

10 frames, so 10 images.

if I want to turn this into an animation in the easiest, cocoa-tacular fashion I would do something like so:

 
	UIImageView * spritely = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"frame01.png"]];
	spritely.animationImages =  [NSArray arrayWithObjects:[UIImage imageNamed:@"frame01.png"],
[UIImage imageNamed:@"frame02.png"],
[UIImage imageNamed:@"frame03.png"],
[UIImage imageNamed:@"frame04.png"],
[UIImage imageNamed:@"frame05.png"],
[UIImage imageNamed:@"frame06.png"],
[UIImage imageNamed:@"frame07.png"],
[UIImage imageNamed:@"frame08.png"],
[UIImage imageNamed:@"frame09.png"],
[UIImage imageNamed:@"frame10.png"],
nil];
 
	spritely.animationDuration = 10.0/fps;
	[myBigView addSubview:spritely];
	[spritely startAnimating];
 

this is super duper easy. But it is slow. (well, it isnt particularly slow for a few animations, but get a couple dozen of any decent size and your performance will kinda suck)

Well, I dont know exactly what apple is doing on the backend but very likely the CA stuff is built on top of the openGL stuff. So basically what happens every time the frame needs to be redrawn is that you have to bind a new texture then render that texture out to the screen buffer. Binding textures can be slow unless they are already resident in vram, but even then if you can skip binding a texture and just do a draw call every frame then you will have significant gains) (technical note: the CA stuff is actually kinda half built on openGL and half raw core graphics (and half pixie dust) basically the way the tech dudes explained it is that CA goes out to whichever subsystem is best for the task at hand. That said; no matter what the backend is doing, the texture still needs to be bound to the screenbuffer somehow, and that takes time)

So, how do we skip that texture binding step? Well, we have to abandon the UIImage view. (well, you could subclass it and do all this in a uiimage sublclass, but for our purposes we are going to keep it nice and simple and just make a generic uiview subclass)

But first! we need to make an Atlas!

OK, what is a texture atlas? (and why do we care?)

If you want the very detailed explanation then go read the atlasing whitepaper by NVidia. (which is far more detail than I am going to go into)

A simple explanation of an atlas is just this: a big image with lots of little images inside it. Like so:
faceplant

hey! that looks an awful lot like the stuff above! and it is in fact, the same thing, but now I am calling it an atlas instead of an example of 10 images. So that is it, a texture atlas is just a bunch of images in a bigger image. How does this help us? Well it means that the larger image only needs to be loaded as a texture once and then you just draw out different bits of it to the video buffer.

How do we use this new fangled atlas thinger?

First, we need to make a new UIView subclass and add a nice CALayer that we can use to store our images:

 
// our standard frame init method
- (id)initWithFrame:(CGRect)frame
{
	if (self = [super initWithFrame:frame]) {
		// our main layer, holds the main image
		imageLayer = [[CALayer layer] retain];
		imageLayer.frame = self.bounds;
		imageLayer.opaque = YES; // this speeds stuff up if you don't need transparency
		[self.layer addSublayer:imageLayer];
	}
	return self;
}
 
// set our big atlas image
-(void)setAtlasImage:(UIImage*)myAtlasImage
{
        // this is basically binding the big image to our layer
        imageLayer.contents = (id)myAtlasImage.CGImage;
	imageLayer.masksToBounds = YES;
       // this is the magic code here, this tells the layer to only display a
       // subset of the larger image.
	imageLayer.contentsRect = [self rectForFrameIndex:0];
}
 

OK, so we build a simple view with a single sublayer. When we set the atlas image for our new animated view, we set the contents to the entire image.

Now if we just did that, you would see the entire atlas (smashed into whatever frame you had defined when you alloced the view). This is not what you want, so we add the contentsRect definition, which allows us to tell the layer to just display one sub rectangle of the entire contents.

The contentsRect is the basis of the whole idea of the texture atlas. So how do we figure that out?

The contentsRect uses normalized coordinates. If you havent done much with contentsRect or the few other CA properties that inexplicable require normalized coords, then it can be kinda confusing at first, but really it is very easy (and a nice way to handle things).

So, lets have a look at our image and see how to generate some normalized rectangle coordinates for our frames:

The sample image above is 480 x 236 pixels (which is a strange size, i know, more on that later, but the upshot is that this is a much smaller version of the raw graphics (to discourage people from stealing them :-) so the example frame sizes are a bit odd... you will get over it I am sure)

A single frame is 121 x 80 (ish) pixels.

OK, quick tangent: this image is a smaller version of the images that the animator built for snow dude. Keen readers will notice some nice hot pink crop marks, those are not part of the animation, but are in there so that I can feed that image into an atlas generator program that I built and it strips out the proper frames for each animation based on the crop marks. For today, we are going to pretend those are part of the animations, and I will talk about optimizing your atlases in another post)

OK, where were we? Oh yes, contentsRect. So if frame 1 is in the upper left, then it is bounded by:

 
// remember that CALayers have 0,0 at the lower left
CGRect frame1 = CGRectMake( 0.0, 156.0, 121.0, 80.0);
 

Great! almost there, now we just need to normalize that rectangle. That means that all the values need to be between 0 and 1. so we divide all the widths by 480 (the total width of the image) and all the heights by 236 (to total height of the image). So something like this:

 
// remember that CALayers have 0,0 at the lower left
-(CGrect)rectForFrameIndex:(NSInteger)frameIndex
{
	NSInteger column = frameIndex % 4;
	NSInteger row = frameIndex / 4;
	row += 1;
 
	CGFloat x = frameIndex * column;
	CGFloat y = atlasHeight - (row * frameHeight);
	CGFloat normalizedX = x/atlasWidth;
	CGFloat normalizedY = y/atlasHeight;
	CGFloat normalizedWidth = frameWidth/atlasWidth;
	CGFloat normalizedHeight = frameHeight/atlasHeight;
	CGRect frameRect = CGRectMake( normalizedX, normalizedY, normalizedWidth, normalizedHeight);
}
 

so, our frame 1 will look something like this rect:

 
// remember that CALayers have 0,0 at the lower left
CGRect frame1 = CGRectMake( 0.0, 0.6610, 0.2520, 0.3389);
 

OK, that was pretty easy, but why do we need normalized coordinates? Well, the short answer is: you need them cuz the methods require them. The long(er) answer is that if you start to use openGL or even doing mipmapping then they become super handy. (in otherwords that normalized frame would work whether i loaded the example atlas that is 480x236 or the original atlas which is 3 times bigger, and you wouldnt have to recalculate the contentsRect. and later, when we start talking about texture binding and UV coordinate space, it will all make so much more sense)

OK! Now all we need is a timer that switches frames for us and we are all set!

Something like this will do the trick:

 
-(void)timerFire:(id)sender
{
	// we want this to be immediate
	[CATransaction begin];
	[CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
 
	imageLayer.contentsRect = thisFrame;
 
	[CATransaction commit];
	frameIndex ++;
}
 

What is all the CATransaction stuff? well, for better or worse, whenever you do something in core animation that CAN be animated it will be, unless you tell it not to. since we want the next frame to show up immediately, we do not want to animate the contentsRect transition (otherwise your new frame will slide in and the old one will slide out). So we use the CATransaction system to shut off the animations.

AndFinally something to call this animation timer routine:

 
[NSTimer scheduledTimerWithTimeInterval:1.0/fps target:self selector:@selector(timerFire) userInfo:nil repeats:YES];
 

And that is it. Super basic texture atlasing.
The pros of this approach: better than loading individual images into your CALayer and animating them that way (or via a UIImageView)
This also makes memory management a bit simple (since you only have to worry about a single image instead of 10)
And the best thing is, this way is halfway to openGL, which is where we want to be.

This particular implementation has lots of drawbacks, like all your frames need to be the same size, and they need to be packed into the atlas in the right way and the right order. Also we are calculating the frame rect over and over again for each frame (this is very quick, but if you have lots of sprites then it seems like a waste when you could just cache them) And because each frame has to be the same size, then we are actually wasting quite a bit of memory with all that whitespace. (turns out transparent pixels take up just as much memory as colored pixels! who knew?!?)

That is OK, we will talk about all this in the next post when i get into openGL.

Cheers!
-B

No TweetBacks yet. (Be the first to Tweet this post)

One Response to “Texture Atlases and Core Animation”

  1. logix812 11 July 2009 at 2:59 am #

    This is exactly what I was looking for! I wanted a more performant way to get animation into my application without the use of UIImageView.animationImages. However, I did not want OpenGL, as mixing UI components with OpenGL is a no no for performance.

    Given that you know what the atlases dimensions are when you apply it, I suppose you could easily calculate all the frames in advance prior to it’s first run, which would save you the repeated calculations on animations that loop. Or have it calculate on the first pass, and cache the values for subsequent passes.

    Anyway, thanks again!

  2. Ben 11 July 2009 at 8:32 am #

    Hey Logix,

    Yes you are absolutely correct. In fact I would definitely suggest pre-calculating your atlas positions prior to your animation.

    I have a little program that builds the atlases for me from individual images (if I ever have free time, i might even do a post about it).

    In any case I generate a plist meta file with all the frame positions in the atlas and then load that meta data at the same time I load the image (usually sometime before the animation is required)

    This is also handy because you can then be very clever about your image usage. For instance, the last four or five frames of the example animation are really only about half the height of the first few, that is a pretty big waste of space (and memory). Instead I pack them into the atlas with the minimum amount of space around them and just record a center offset so that I can (in core animation or OpenGL) move it to the right position in the animation cell.

    This way you can pack your atlases much much tighter and save tons of space on the iPhone :-)

    Anyway, it is early here so hopefully that makes some amount of sense.

    Cheers!
    -B

  3. logix812 13 July 2009 at 3:33 am #

    Ben, I would LOVE to get my hands on the atlas program of yours, I started writing my own, but hey, whatever saves time =). I also played with pixen (http://opensword.org/Pixen/) but I could not get the transparency to output for the PNG sequence. Also it would make the atlases greater than 1024, which would still mean I need to edit them for openGL usage.

    Basically I vote for you doing a post on your program =)

Leave a Reply

You must be logged in to post a comment.

About

meMy full name is Ben Britten Smith.

I go by Ben Britten because Ben Smith is a bit too common and using my full name is a mouthful.

I live in Melbourne, Australia and service clients all over the globe.

Contact

Have some questions?

Feel free to contact me directly at support@benbritten.com with any questions you might have about any of the applications I support.

Thanks!

PHVsPjxsaT48c3Ryb25nPndvb19hYm91dDwvc3Ryb25nPiAtIGFib3V0LXdpZGdldDwvbGk+PGxpPjxzdHJvbmc+d29vX2FkX2JlbG93X2ltYWdlPC9zdHJvbmc+IC0gaHR0cDovL2JlbmJyaXR0ZW4uY29tL3dwLWNvbnRlbnQvdGhlbWVzL3ZpYnJhbnRjbXMvaW1hZ2VzL2FkNDY4LmpwZzwvbGk+PGxpPjxzdHJvbmc+d29vX2FkX2JlbG93X3VybDwvc3Ryb25nPiAtIGh0dHA6Ly93d3cud29vdGhlbWVzLmNvbTwvbGk+PGxpPjxzdHJvbmc+d29vX2FsdF9zdHlsZXNoZWV0PC9zdHJvbmc+IC0gYmVuYnJpdHRlbi5jc3M8L2xpPjxsaT48c3Ryb25nPndvb19ibG9ja19pbWFnZTwvc3Ryb25nPiAtIGh0dHA6Ly9iZW5icml0dGVuLmNvbS93cC1jb250ZW50L3RoZW1lcy92aWJyYW50Y21zL2ltYWdlcy9hZDMzNi5qcGc8L2xpPjxsaT48c3Ryb25nPndvb19ibG9ja191cmw8L3N0cm9uZz4gLSBodHRwOi8vd3d3Lndvb3RoZW1lcy5jb208L2xpPjxsaT48c3Ryb25nPndvb19ibG9nPC9zdHJvbmc+IC0gdHJ1ZTwvbGk+PGxpPjxzdHJvbmc+d29vX2Jsb2djYXQ8L3N0cm9uZz4gLSAvY2F0ZWdvcnkvYmxvZy88L2xpPjxsaT48c3Ryb25nPndvb19jYXRfbWVudTwvc3Ryb25nPiAtIGZhbHNlPC9saT48bGk+PHN0cm9uZz53b29fY29udGFjdDwvc3Ryb25nPiAtIGNvbnRhY3Q8L2xpPjxsaT48c3Ryb25nPndvb19jdXN0b21fY3NzPC9zdHJvbmc+IC0gPC9saT48bGk+PHN0cm9uZz53b29fY3VzdG9tX2Zhdmljb248L3N0cm9uZz4gLSBodHRwOi8vYmVuYnJpdHRlbi5jb20vZmF2aWNvbi5pY288L2xpPjxsaT48c3Ryb25nPndvb19mZWF0cGFnZXM8L3N0cm9uZz4gLSA1NDk8L2xpPjxsaT48c3Ryb25nPndvb19mZWVkYnVybmVyX3VybDwvc3Ryb25nPiAtIDwvbGk+PGxpPjxzdHJvbmc+d29vX2dvb2dsZV9hbmFseXRpY3M8L3N0cm9uZz4gLSA8L2xpPjxsaT48c3Ryb25nPndvb19ncmF2YXRhcjwvc3Ryb25nPiAtIHRydWU8L2xpPjxsaT48c3Ryb25nPndvb19sYXlvdXQ8L3N0cm9uZz4gLSBkZWZhdWx0LnBocDwvbGk+PGxpPjxzdHJvbmc+d29vX2xvZ288L3N0cm9uZz4gLSA8L2xpPjxsaT48c3Ryb25nPndvb19tYW51YWw8L3N0cm9uZz4gLSBodHRwOi8vd3d3Lndvb3RoZW1lcy5jb20vc3VwcG9ydC90aGVtZS1kb2N1bWVudGF0aW9uL3ZpYnJhbnRjbXMvPC9saT48bGk+PHN0cm9uZz53b29fbmF2X2V4Y2x1ZGU8L3N0cm9uZz4gLSAyLDgyLDU0OSw1NTMsNTY3LDUzMiw1MzQsNTM3LDgzMjwvbGk+PGxpPjxzdHJvbmc+d29vX3Nob3J0bmFtZTwvc3Ryb25nPiAtIHdvbzwvbGk+PGxpPjxzdHJvbmc+d29vX3Nob3dfYWQ8L3N0cm9uZz4gLSBmYWxzZTwvbGk+PGxpPjxzdHJvbmc+d29vX3Nob3dfbXB1PC9zdHJvbmc+IC0gZmFsc2U8L2xpPjxsaT48c3Ryb25nPndvb19zdGVwczwvc3Ryb25nPiAtIDEuLCAyLiwgMy48L2xpPjxsaT48c3Ryb25nPndvb190YWJiZXI8L3N0cm9uZz4gLSBmYWxzZTwvbGk+PGxpPjxzdHJvbmc+d29vX3RoZW1lbmFtZTwvc3Ryb25nPiAtIFZpYnJhbnRDTVM8L2xpPjwvdWw+