iphoneiosperformancecore-graphicsquartz-2d

Core Graphics Performance on iOS


Summary

I'm working on a fairly straightforward 2D tower defense game for iOS.

So far, I've been using Core Graphics exclusively to handle rendering. There are no image files in the app at all (yet). I've been experiencing some significant performance issues doing relatively simple drawing, and I'm looking for ideas as to how I can fix this, short of moving to OpenGL.

Game Setup

At a high level, I have a Board class, which is a subclass of UIView, to represent the game board. All other objects in the game (towers, creeps, weapons, explosions, etc) are also subclasses of UIView, and are added as subviews to the Board when they are created.

I keep game state totally separate from view properties within the objects, and each object's state is updated in the main game loop (fired by an NSTimer at 60-240 Hz, depending on the game speed setting). The game is totally playable without ever drawing, updating, or animating the views.

I handle view updates using a CADisplayLink timer at the native refresh rate (60 Hz), which calls setNeedsDisplay on the board objects that need to have their view properties updated based on changes in the game state. All the objects on the board override drawRect: to paint some pretty simple 2D shapes within their frame. So when a weapon, for example, is animated, it will redraw itself based on the weapon's new state.

Performance Issues

Testing on an iPhone 5, with about 2 dozen total game objects on the board, the frame rate drops significantly below 60 FPS (the target frame rate), usually into the 10-20 FPS range. With more action on the screen, it goes downhill from here. And on an iPhone 4, things are even worse.

Using Instruments I've determined that only roughly 5% of the CPU time is being spent on actually updating the game state -- the vast majority of it is going towards rendering. Specifically, the CGContextDrawPath function (which from my understanding is where the rasterization of vector paths is done) is taking an enormous amount of CPU time. See the Instruments screenshot at the bottom for more details.

From some research on StackOverflow and other sites, it seems as though Core Graphics just isn't up to the task for what I need. Apparently, stroking vector paths is extremely expensive (especially when drawing things that aren't opaque and have some alpha value < 1.0). I'm almost certain OpenGL would solve my problems, but it's pretty low level and I'm not really excited to have to use it -- it doesn't seem like it should be necessary for what I'm doing here.

The Question

Are there any optimizations I should be looking at to try to get a smooth 60 FPS out of Core Graphics?

Some Ideas...

Someone suggested that I consider drawing all my objects onto a single CALayer instead of having each object on its own CALayer, but I'm not convinced that this would help based on what Instruments is showing.

Personally, I have a theory that using CGAffineTransforms to do my animation (i.e. draw the object's shape(s) in drawRect: once, then do transforms to move/rotate/resize its layer in subsequent frames) would solve my problem, since those are based directly on OpenGL. But I don't think it would be any easier to do that than just use OpenGL outright.

Sample Code

To give you a feel for the level of drawing I'm doing, here's an example of the drawRect: implementation for one of my weapon objects (a "beam" fired from a tower).

Note: this beam can be "retargeted" and it crosses the entire board, so for simplicity its frame is the same dimensions as the board. However most other objects on the board have their frame set to the smallest circumscribed rectangle possible.

- (void)drawRect:(CGRect)rect
{
    CGContextRef c = UIGraphicsGetCurrentContext();

    // Draw beam
    CGContextSetStrokeColorWithColor(c, [UIColor greenColor].CGColor);
    CGContextSetLineWidth(c, self.width);
    CGContextMoveToPoint(c, self.origin.x, self.origin.y);
    CGPoint vector = [TDBoard vectorFromPoint:self.origin toPoint:self.destination];
    double magnitude = sqrt(pow(self.board.frame.size.width, 2) + pow(self.board.frame.size.height, 2));
    CGContextAddLineToPoint(c, self.origin.x+magnitude*vector.x, self.origin.y+magnitude*vector.y);
    CGContextStrokePath(c);

}

Instruments Run

Here's a look at Instruments after letting the game run for a while:

The TDGreenBeam class has the exact drawRect: implementation shown above in the Sample Code section.

Full Size Screenshot Instruments run of the game, with the heaviest stack trace expanded.


Solution

  • Core Graphics work is performed by the CPU. The results are then pushed to the GPU. When you call setNeedsDisplay you indicate that the drawing work needs to occur afresh.

    Assuming that many of your objects retain a consistent shape and merely move around or rotate you should simply call setNeedsLayout on the parent view, then push the latest object positions in that view's layoutSubviews, probably directly to the center property. Merely adjusting positions does not cause a thing to need to be redrawn; the compositor will simply ask the GPU to reproduce the graphic it already has at a different position.

    A more general solution for games might be to ignore center, bounds and frame other than for initial setup. Simply push the affine transforms you want to transform, probably created using some combination of these helpers. That'll allow you aribtrarily to reposition, rotate and scale your objects without CPU intervention — it'll all be GPU work.

    If you want even more control then each view has a CALayer with its own affineTransform but they also have a sublayerTransform that combines with the transforms of sublayers. So if you're so interested in 3d then the easiest way is to load a suitable perspective matrix as the sublayerTransform on the superlayer and then push suitable 3d transforms to the sublayers or subviews.

    There's a single obvious downside to this approach in that if you draw once and then scale up you'll be able to see the pixels. You can adjust your layer's contentsScale in advance to try to ameliorate for that but otherwise you're just going to see the natural consequence of allowing the GPU to proceed with compositing. There's a magnificationFilter property on the layer if you want to switch between linear and nearest filtering; linear is the default.