Minesweeper (Part 4)

When we last checked in on it, our Minesweeper project was suffering from slow rendering. Today, we find a workaround.

Review

We had been rendering our playfield inside a UIScrollView, drawing 320×416 tiles as necessary. This approach worked, but the time required to render a new tile (between 0.1s and 0.2s) caused intolerable stutter during scrolling. To address this, I was going to look into splitting the rendering and the display of tiles, so as to move the relatively slow render process into a background thread, and perform only a relatively quick “blit” on the foreground/scrolling thread.

Blitting

Asynchronous rendering introduces a host of technical problems, but the most immediate might be the question of how to do a quick “blit” – how to quickly move a pre-calculated image to the screen. As it turns out, one very quick way to do this is to set the contents property of a UIView's layer object. Simply setting this property to a CGImageRef will cause the UIView to bypass its normal rendering and display the supplied image. Relevant code might look like this example:

tv.layer.contents = (id) [[self.contents objectAtIndex:9] CGImage];

(Note the extraction of a CGImageRef from the UIImage, and the coercion to an id.)

Shortcut

In fact, this blit is so fast that it got me thinking about revisiting an earlier approach to rendering, in which we simply tiled the UIScrollView with 32×32 UIViews, each of which corresponded to a single cell. That approach didn’t work because we were relying upon relatively complicated UIView rendering – e.g., each UIView contained a UILabel. What if we increased the cell size to 64×64 (to put fewer on screen, and make the interface more thumb-friendly) and blitted in pre-calculated images, rather than rendering each view on the fly?

As it turns out, that works just fine. We have to give up zooming, but this gives us a workable interface with which we can move forward right now, as opposed to mucking about with a more complex rendering system.

Code

Aside from changing our tile size to 64×64, we need to make only two alterations to the code we’ve seen before. First, we need to add a pre-calculated array of tile graphics (one for each of the 9 possible symbols), and second, we need to assign one of these graphics to each tile after it’s added to the UIScrollView.

Here’s the code to get/generate the pre-calculated array, implemented as a getter method:

- (NSArray*)contents
{
	if (!contents)
	{
		contents = [[NSMutableArray alloc] initWithCapacity:[self.symbols count]];
		NSMutableArray* myContents = (NSMutableArray*) contents;
		
		for (Glyph* symbol in self.symbols)
		{
			// Create and get a pointer to context
			UIGraphicsBeginImageContext(cellSize);
			CGContextRef context = UIGraphicsGetCurrentContext();
			
			// Flop text rightside-up
			CGContextSetTextMatrix(context, CGAffineTransformMakeScale(1, -1));
			
			// Render background
			CGContextAddRect(context, CGRectMake(0, 0, cellSize.width, cellSize.height));
			[[UIColor grayColor] set];
			CGContextDrawPath(context, kCGPathFill);

			// Render box
			for (Glyph* g in self.box)
			{
				[g renderToContext:context];
			}
			
			// Render symbol
			[symbol renderToContext:context];
			
			// Create and store image
			[myContents addObject:UIGraphicsGetImageFromCurrentImageContext()];
			
			// Release image context, and continue
			UIGraphicsEndImageContext();
		}
	}
	return contents;
}

… and here’s the revised scrollViewDidScroll: method:

- (void)scrollViewDidScroll:(UIScrollView*)scrollView
{
	// Tile width and height (in view space)
	CGFloat w = tileSize.width;
	CGFloat h = tileSize.height;
	
	// Legal region (in view space)
	CGRect legal = CGRectMake(0, 0, world.width*scale, world.height*scale);
	
	// Displayed region (in view space)
	CGRect viewport = CGRectIntersection([self.content convertRect:self.scrollView.bounds fromView:self.scrollView], legal);
	
	// For existing tiles
	for (UIView* tv in [self.content subviews])
	{
		// If there's no overlap with viewport, toss the tile
		if (!CGRectIntersectsRect(viewport, tv.frame))
		{
			[tv removeFromSuperview];
			[extraTiles addObject:tv];
		}
	}
	
	NSInteger oldMinCol = tileBox.origin.x;
	NSInteger oldMinRow = tileBox.origin.y;
	NSInteger oldMaxCol = CGRectGetMaxX(tileBox)-1;
	NSInteger oldMaxRow = CGRectGetMaxY(tileBox)-1;
	
	// For new tiles
	tileBox = CGRectIntegral(CGRectApplyAffineTransform(viewport, CGAffineTransformMakeScale(1/w, 1/h)));
	for (int bw = tileBox.size.width, n = bw*tileBox.size.height-1; n >= 0; n--)
	{
		NSInteger c = tileBox.origin.x + n % bw;
		NSInteger r = tileBox.origin.y + n / bw;
		
		//	If missing, create
		if ((c < oldMinCol) || (c > oldMaxCol) || (r < oldMinRow) || (r > oldMaxRow))
		{
			UIView* tv = [self addTileForFrame:CGRectIntersection(CGRectMake(c*w, r*h, w, h), legal)];
			
			UIImage* t;
			if ([puzzle isMinedAtX:c andY:r])
			{
				t = [self.contents objectAtIndex:9];
			}
			else
			{
				t = [self.contents objectAtIndex:[puzzle neighborsAtX:c andY:r]];
			}
			
			tv.layer.contents = (id) [t CGImage];
		}
	}
}

CATileLayer

For future reference, if we want to pursue the idea with which we started this post (i.e. multithreaded, tiled, vector-graphics rendering) it looks like the CATiledLayer class will bear looking into. In fact, this project has highlighted the importance (for me) of a better understanding of Core Animation in general; CA seems to underlie most of the 2-D/UIView stuff on the iPhone, and a better command of it will probably be very useful.

Next

With a workable rendering system addressed, tomorrow we’ll take a look at making our game respond to touches.

Share and Enjoy:
  • Twitter
  • Facebook
  • Digg
  • Reddit
  • HackerNews
  • del.icio.us
  • Google Bookmarks
  • Slashdot
This entry was posted in iPhone, Projects. Bookmark the permalink.

Comments are closed.