It has been quite a while since I wrote the last article in the Core Animation Games series. If you remember, in it I complained bitterly about the CA scrolling classes, and how none of them filled the ticket. To recapitulate, these are the 3 things I needed:
- A window into a coordinate system plane populated by “objects”…
- that smoothly moves through it by remapping how its borders map to that underlying plane…
- and loads the objects to be displayed as they are needed, while keeping memory requirements in check.
So let’s explain how to do that with Core Animation, and as a bonus we will do parallax scrolling, talk about some graphic optimizations, and show you flying cupcakes.
Recycling
Point 3 above requires some explanation. The last thing you want to do in the middle of your game loop is allocate memory, since it will totally kill performance. We also need to use the minimum amount of memory possible, since iOS devices don’t have much. To do just that we initialize the tiles that make up our background at the beginning of the game. We will use a separate CALayer for each tile and put them in an array. How many do you need?
Example of scrolling to the right.
In this image the red line is the view in the screen, slowly moving over a grid of tiles making up the world (grey lines). Only the blue and green tiles are in memory at any time though. We used as many as needed to cover the whole screen, plus a 1-tile wide border around the whole thing so we always have tiles under our view during the scroll (depending on the scrolling speed you may need more than 1).
When the view scrolls to a point where it needs to show more tiles to continue, the farthest tiles (in green) are moved to the required new position, and their contents recalculated by looking up in the world map what graphic tile goes with that position (or similar). If you have ever used UITableView
s and dequeueReusableCellWithIdentifier:
, is the same concept but in two dimensions instead of just one.
No Core Animation
If our game was an infinite scroller, say something like Jetpack Joyride, we could use Core Animation to create a CABasicAnimation
at the beginning of the game, set a speed, and let it run indefinitely. The problem is that, as I explained in the latest article, getting notifications when the tiles need to be refreshed is not easy. But usually you will need more control over the scrolling anyway, modifying its direction or speed according to user interaction, events during play like the player dying or being stuck on an obstacle, difficulty increasing with time, etc. What we need is a more traditional game loop, in which at every frame we can modify the view position in the world.
{
if (displayLink == nil)
displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(update:)];
[displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
}
- (void)update:(CADisplayLink*)sender;
{
static const CFTimeInterval maxElapsedTime = 1 / 20.0f;
static CFTimeInterval lastTimestamp = 0;
const CFTimeInterval currentTime = CACurrentMediaTime();
CFTimeInterval elapsedTime = currentTime - lastTimestamp;
lastTimestamp = currentTime;
if (elapsedTime > maxElapsedTime)
elapsedTime = maxElapsedTime;
[CATransaction begin];
[CATransaction setDisableActions:YES];
// Use elapsedTime to update stuff here, calculate how much we need
// to scroll, update tiles, etc.
[CATransaction commit];
}
That’s a basic CADisplayLink
loop, except for the CATransaction
part. Since we don’t want any of those pesky implicit animations when we change the properties of the CALayer
s, we wrap everything into a transaction and disable them. This has two side-effects though.
First the positive one. When we need to read the current state of a layer, say its position, we usually do the following to get what’s on the screen (instead of what an animation started or will end with):
CGPoint currentPos = [[tile presentationLayer] position];
However, since we are skipping animations the changes to properties are way faster, so after the transaction is committed we will have the same value in the layer properties than what we see on the screen. Ergo, we can do this instead.
Why is this any good? Every time you call presentationLayer
a new copy of the layer is created. Every time you call that method, a new object is allocated1 and returned. And as we said before, allocating memory mid-loop is a Very Bad Thing™. That’s why in other cases, when you really need to use the presentation layer, it is probably better you do this…
CGRect theBoundsRect = currentTile.bounds;
CGRect theContentsRect = currentTile.contentsRect;
rather than this:
CGRect theContentsRect = [[tile presentationLayer] contentsRect];
The second side-effect is not that good. When Core Animation is in charge it runs its calculations and updates properties on a different thread, then it shows the results on the screen at the next screen refresh. This, unless you run lots of simultaneous animations or other calculations, gives you a smooth 60 Hz refresh rate. But by making changes to layer properties by hand we are basically bypassing all that. The CADisplayLayer
will call your code at a multiple of the refresh rate on the main thread (again, unless you are limited by other calculations). Then you will update the properties of the layers, but is not until the [CATransaction commit]
that those numbers actually change in the presentation layer or the private render-tree. The next time around the runloop, whenever that happens, the new values should be visible, but they still may need to wait for the screen to refresh. When before we had a smooth 60Hz animation now we are limited by the CADisplayLink
’s frameInterval
, and by whatever else we are doing on the main thread. Performance is going to suffer because we are fighting, rather than using, Core Animation. Why use Core Animation at all then? I guess I will have to talk about that in the next article in the series.
Apple Land of Flying Cupcakes
Now that we know what we are doing, why we are doing it, and what are the consequences, let‘s jump into some sample code. We are going to be modeling the following:
Don’t know what I had for breakfast the day I drew this :/
We have, from front to back:
- A floor of dirt with grass.
- Some yellow blocks, in a repeating pattern for the example.
- Some kind of robot head.
- Apple-shaped hills.
- Clouds.
- A flying cupcake.
- And the sky.
Of all those only the apples, blocks, and grass are going to be separate tiled layers moving at different speeds. The sky is just a fixed background, and since they are more like individual elements, we will deal with the clouds, cupcake, and robot head later.
We are going to create a CALayer
subclass to take care of all our tiling needs. You can check the MCParallaxLayer
class in the sample code at the end of the article. As part of its initialization we pass the size (in points) of the tiles (they need to be all the same size). Then we can calculate how many tiles we need like this:
So each tile (the ones in memory, the blue ones in the diagram), is a separate CALayer
that we save in an array. We need enough tiles to cover the whole bounds
of our MCParallaxLayer
plus one more at each side. If we make this layer the size of our view, we would be covering the whole thing (what you will do for the terrain of an RPG, for example). But if we make it smaller that our view we can restrict the drawing area and save some memory. For example, instead of needing this many tiles for our yellow blocks…
…we only need this many doing it this way. Notice how, since we are never going to scroll vertically (horizontal == YES
), we are not creating extra rows of tiles on top and bottom.
Once we have allocated memory for our array of tiles we need to initialize them:
{
tiles[i] = [[CALayer layer] retain];
tiles[i].anchorPoint = CGPointZero;
tiles[i].bounds = CGRectMake(0, 0, tileSize.width, tileSize.height);
// img is a CGImageRef to our atlas
tiles[i].contents = (id)img;
[self addSublayer:tiles[i]];
}
We just allocate and initialize some CALayers
, set their size, the anchor point to make them easier to position, and assign the image atlas. Then we add them as a child of our MCParallaxLayer
. Notice there are two things missing there: we are not setting the correct position
of each tile, and we are not setting the contentsRect
(that would identify what part of the atlas to show).
Scrolling and Blurry Pixels
There are a couple things I added to MCParallaxLayer
to make things a bit easier. The first one is the TileCoords
type, to measure position in tiles (integers) instead of points (floats). A couple methods convert between CGPoints and TileCoords and back using the tileSize
.
int x;
int y;
} TileCoords;
- (CGPoint)tileCoordsToPoint:(TileCoords)coords;
{
return CGPointMake(
(float)coords.x * tileSize.width,
(float)coords.y * tileSize.height);
}
- (TileCoords)pointToTileCoords:(CGPoint)pt;
{
return (TileCoords){
(int)floorf(0.5f + pt.x/tileSize.width),
(int)floorf(0.5f + pt.y/tileSize.height) };
}
The second is a provider, that works as kind of a delegate supplying what section of the atlas to draw for a specific tile coordinate. Usually it will be an object that has access to the level’s layout, and can answer questions like ‘what do I draw in tile (13, 24)?’. In our sample code, we are just using the view controller as the provider for all our layers.
- (CGImageRef)atlasFor:(MCParallaxLayer*)sender;
- (CGRect)tileAt:(TileCoords)pos for:(MCParallaxLayer*)sender;
@end
So with that said, this is how we calculate the position
and contentsRect
of the initial tiles.
prevOriginTileCoords.x -= 1;
if (!horizontal)
prevOriginTileCoords.y -= 1;
for (int i = 0; i < numTiles; ++i) {
const TileCoords coords = (TileCoords){
prevOriginTileCoords.x + i % tilesWide,
prevOriginTileCoords.y + i / tilesWide };
tiles[i].position = [self tileCoordsToPoint:coords];
tiles[i].contentsRect = [provider tileAt:coords for:self];
}
How do we scroll then? The same way CAScrollLayer
does, by changing the bounds of the MCParallaxLayer
. We have to do it carefully though.
{
//scrollLeftover is an CGPoint ivar
scrollLeftover.x += diff.x;
scrollLeftover.y += diff.y;
CGRect currentBounds = self.bounds;
self.bounds = CGRectMake(
floorf(currentBounds.origin.x + scrollLeftover.x),
floorf(currentBounds.origin.y + scrollLeftover.y),
currentBounds.size.width,
currentBounds.size.height);
scrollLeftover.x -= floorf(scrollLeftover.x);
scrollLeftover.y -= floorf(scrollLeftover.y);
}
What’s with all thosee floorf()
and scrollLeftover
s? The thing is if we just add any decimal amount to the bounds position, the tiles will not be rendered aligned to pixels. They will be antialiased, and performance will suffer (there is a reason the Core Animation Instrument has an option to warn you about ‘Misaligned Images’). So the floorf()
helps with that. But what happens if, for example, we scroll 0.6 points every frame. If we don’t keep track of the remainder we will be scrolling 0 pixels every frame! So that’s why we do, so 2 frames moving 0.6 actually scrolls 1 point.
You may have noticed that I keep talking about points, not pixels. This method could actually be improved to take into account double resolution devices like the iPhone 4, since those would actually accept scrolling in half points (half a point * 2 point per pixel = 1 pixel). The truth is those are some really small pixels and, at least for me, the difference is minimal.
Recycling Implemented
This is already running really long, so let’s get to the meat of the scrolling, the implementation of the recycling method shown in the first figure. Almost the most important question is, where do we implement this? What gets called every time we change the layer’s bounds
even when needsDisplayOnBoundsChange
is NO
? Since we have all our tiles as sublayers, layoutSublayers
does.
{
const TileCoords pos = [self pointToTileCoords:self.bounds.origin];
TileCoords topLeftTile = { pos.x - 1, pos.y - 1 };
if (horizontal)
topLeftTile.y = pos.y;
// 1 = new col to the right; 0 = no change; -1 = new col to the left
int xDir = topLeftTile.x - prevOriginTileCoords.x;
// 1 = new row at bottom; 0 = no change; -1 = new row at top
int yDir = topLeftTile.y - prevOriginTileCoords.y;
NSAssert(xDir <= 1 && xDir >= -1 && yDir <= 1 && yDir >= -1,
@"Scrolling too fast");
if (xDir == 0 && yDir == 0)
return;
prevOriginTileCoords = topLeftTile;
int affectedColumn, affectedRow;
float xDiff, yDiff;
xDiff = (float)xDir * tilesWide * tileSize.width;
affectedColumn = (xDir == 1) ?
topLeftTile.x - 1 :
topLeftTile.x + tilesWide;
yDiff = (float)yDir * tilesHigh * tileSize.height;
affectedRow = (yDir == 1) ?
topLeftTile.y - 1 :
topLeftTile.y + tilesHigh;
TileCoords tilePos;
for (int i = 0; i < numTiles; ++i)
{
BOOL changed = NO;
tilePos = [self pointToTileCoords:tiles[i].position];
if (xDir && tilePos.x == affectedColumn)
{
tiles[i].position = CGPointMake(
tiles[i].position.x + xDiff,
tiles[i].position.y);
changed = YES;
}
if (yDir && tilePos.y == affectedRow)
{
tiles[i].position = CGPointMake(
tiles[i].position.x,
tiles[i].position.y + yDiff);
changed = YES;
}
if (changed)
tiles[i].contentsRect = [provider tileAt:tilePos for:self];
}
}
The code is pretty self explanatory. We figure out what are the tile coordinates of our most upper left tile by checking the bounds origin
and if we are only scrolling horizontally. Then we compare it with the previous value, that we had stored in the prevOriginTileCoords
ivar. If the coordinates are the same, we don’t need to recycle anything yet, so we return.
If the coordinates are not the same, we find what row and/or column is going to be recycled. Then we loop through all our tiles (since at this point they can be in the array in any order), and we move each one that matches an affected column and/or row to the other side of grid; horizontally for matching columns, vertically for matching rows. Then we ask our provider for the new section of our atlas (contentsRect
) for the new tile coordinate. That’s it. Tiled layer with infinite scrolling without spending extra memory.
Odds and Ends
So what do we need to extend this and make it into a parallax scrolling example? Not much. Basically we can create multiple MCParallaxLayer
s with different tile sizes and images, add them all to the same view, and then scroll them at different speeds. To make things easier, I just added a multiplier
ivar to use in the first lines of the scrollTiles:
method.
scrollLeftover.y += diff.y * scrollMultiplier;
So what about the clouds, and the rest? The three clouds are not the same size, and there is lots of blank space in-between, so they don’t really fit our MCParallaxLayer
. The thing is our class is still a normal CALayer
, so we can add individual sublayers to them and they will scroll along with the rest when we change its bounds. Check the sample code to see how we use that for the clouds, the robot head, and the flying cupcake.
Opaque or not Opaque
The moment you start to pile layers one on top of another, performance is going to take a hit, specially so if they have transparent or translucent pixels that need to be combined. That’s why UIView
s and CALayer
s have the opaque
property, so you can signal UIKit that it is OK to forget about what’s underneath, because you are covering that whole section of the screen.
What I didn’t knew though, is that the opaque
property is completely ignored if the image being rendered has an alpha channel (like, you know, if you have that Transparency checkbox checked when exporting PNGs from Photoshop), even if all the pixels inside the layer are actually solid. That’s the reason the sample code uses two different atlases, one with an alpha channel for clouds, the robot head or the grass, the other one without for the sky or the apples. This improves performance a tad, so remember to use solid images whenever possible.
Parallax Scrolling with Core Animation
As always the code is not a drag and drop black box to use as is, but for you to check out and play with.
Have fun experimenting!
1 To be honest, I don’t know if it is allocated or it comes from a pool or what happens behind the scenes. A new object is initialized and returned each time, so the advice still stands.
没有评论:
发表评论