Optimal scrolling for 2D games or UIs

I recently stumbled upon an old bit of code which demonstrates a technique I created for an efficient scrolling data grid on the YouView TV platform. I call it ‘Blit Scrolling’. Below is a demo of the concept.

How it works

I use a small viewport on 1 larger bitmap, onto which content is blitted. I handle scrolling off the buffer area by shifting the bitmap’s pixels to counter the offset. This results in even fewer draw calls and less CPU-bound logic than the common 4+ tile approach: 2 for shifting pixels (if using a double buffer) and just 1 for the viewport render. It also avoids having to draw items renderers (think data grid items or game sprites) multiple times, if they sit across a tile boundary, for example.

Further optimisations could be made (such as only filling the newly exposed region, ‘locking’ the large bitmap and only updating from the viewed region), but you get the idea.

Here’s the code:

package 
{
	import flash.display.Bitmap;
	import flash.display.BitmapData;
	import flash.display.Shape;
	import flash.display.Sprite;
	import flash.events.Event;
	import flash.events.KeyboardEvent;
	import flash.geom.Rectangle;
	import flash.ui.Keyboard;
	
	/**
	 * Blit scrolling example
	 * @author Liam O'Donnell
	 */
	[SWF(frameRate="60", width="474", height="237", backgroundColor="#000000")]
	public class Main extends Sprite
	{
		private const CANVAS_WIDTH :int = 474;
		private const CANVAS_HEIGHT :int = CANVAS_WIDTH;
		private const VIEWPORT_WIDTH :int = CANVAS_WIDTH / 2;
		private const VIEWPORT_HEIGHT :int = CANVAS_HEIGHT / 2;
		
		private var viewportBorder :Shape;
		private var viewportBitmap :Bitmap;
		private var viewportData :BitmapData;
		private var drawRect :Rectangle = new Rectangle(VIEWPORT_WIDTH / 2, VIEWPORT_HEIGHT / 2, 4, 4);
		private var fillRect :Rectangle = new Rectangle();
		
		private var preview :Sprite;
		private var previewOverlay :Shape;
		private var previewBitmap :Bitmap;
		
		private var key_fx :Number = 0.0;
		private var key_fy :Number = 0.0;
		private var key_vx :Number = 0.0;
		private var key_vy :Number = 0.0;
		private var key_speed :Number = 3.0;
		private var key_friction :Number = 0.9;
		
		public function Main():void
		{
			if (stage) 
				init();
			else 
				addEventListener(Event.ADDED_TO_STAGE, init);
		}
		
		private function init(e:Event = null):void
		{
			removeEventListener(Event.ADDED_TO_STAGE, init);
			
			viewportData = new BitmapData(CANVAS_WIDTH, CANVAS_HEIGHT, true, 0xFFCCCCCC);
			viewportBitmap = new Bitmap(viewportData);
			addChild(viewportBitmap);
			
			viewportBorder = new Shape();
			viewportBorder.graphics.lineStyle(2, 0xff0000);
			viewportBorder.graphics.beginFill(0xff0000, .1);
			viewportBorder.graphics.drawRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
			viewportBorder.graphics.endFill();
			viewportBorder.cacheAsBitmap = true;
			addChild(viewportBorder);
			
			preview = new Sprite();
			preview.scaleX = preview.scaleY = 0.5;
			preview.x = VIEWPORT_WIDTH;
			addChild(preview);
			
			viewportBitmap.scrollRect = new Rectangle(VIEWPORT_WIDTH / 2, VIEWPORT_HEIGHT / 2, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
			drawTargets(viewportBitmap.scrollRect);
			
			addEventListener(Event.ENTER_FRAME, update, false, 0, true);
			stage.addEventListener(KeyboardEvent.KEY_DOWN, onKeyDown);
			stage.addEventListener(KeyboardEvent.KEY_UP, onKeyUp);
			
			previewBitmap = new Bitmap(viewportData);
			preview.addChild(previewBitmap);
			
			previewOverlay = new Shape();
			previewOverlay.graphics.lineStyle(2, 0xff0000);
			previewOverlay.graphics.beginFill(0xff0000, .1);
			previewOverlay.graphics.drawRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
			previewOverlay.graphics.endFill();
			previewOverlay.cacheAsBitmap = true;
			preview.addChild(previewOverlay);
		}
		
		private function onKeyDown(e:KeyboardEvent):void
		{
			if (e.keyCode == Keyboard.RIGHT) key_fx = 1;
			if (e.keyCode == Keyboard.LEFT) key_fx = -1;
			if (e.keyCode == Keyboard.UP) key_fy = -1;
			if (e.keyCode == Keyboard.DOWN) key_fy = 1;
		}
		
		private function onKeyUp(e:KeyboardEvent):void
		{
			if (e.keyCode == Keyboard.RIGHT) key_fx = 0;
			if (e.keyCode == Keyboard.LEFT) key_fx = 0;
			if (e.keyCode == Keyboard.UP) key_fy = 0;
			if (e.keyCode == Keyboard.DOWN) key_fy = 0;
		}
		
		private function update(e:Event):void
		{
			var viewport:Rectangle = viewportBitmap.scrollRect;
			
			applyMovementTo(viewport);
			drawTracker(viewport);
			recentreViewport(viewport);
			doIdleRedraw(viewport);
			drawPreview(viewport);
			
			viewportBitmap.scrollRect = viewport;
		}
		
		private function doIdleRedraw(viewport:Rectangle):void
		{
			if (key_fx != 0 || key_fy != 0) return;
			if (key_vx == 0 && key_vy == 0) return;
			
			if (Math.abs(key_vx) < .1 && Math.abs(key_vy) < .1) { key_vx = key_vy = 0; recentreViewport(viewport, true); } } private function drawPreview(viewport:Rectangle):void { previewOverlay.x = viewport.x; previewOverlay.y = viewport.y; } private function applyMovementTo(viewport:Rectangle):void { key_vx += key_fx; key_vy += key_fy; key_vx *= key_friction; key_vy *= key_friction; viewport.x += key_vx; viewport.y += key_vy; } private function drawTracker(viewport:Rectangle):void { var offset:Number = 10; drawRect.x = viewport.x + (VIEWPORT_WIDTH / 2) + Math.random() * offset; drawRect.y = viewport.y + (VIEWPORT_HEIGHT / 2) + Math.random() * offset; viewportData.fillRect(drawRect, Math.random() * 0xffffffff); } private function recentreViewport(viewport:Rectangle, forceRedraw:Boolean = false):void { var centreX:int = int(VIEWPORT_WIDTH / 2); var centreY:int = int(VIEWPORT_HEIGHT / 2); var offsetX:int = viewport.x - centreX; var offsetY:int = viewport.y - centreY; var fillRect:Rectangle = this.fillRect; var scrolledX:Boolean = false; var scrolledY:Boolean = false; if (Math.abs(offsetX) > centreX || forceRedraw) 
			{
				viewportData.scroll( -offsetX, 0);
				repairCanvasLeft(viewportData, fillRect, offsetX);
				repairCanvasRight(viewportData, fillRect, offsetX);
				viewport.x = centreX;
				scrolledX = true;
			}
			
			if (Math.abs(offsetY) > centreY || forceRedraw) 
			{
				viewportData.scroll(0, -offsetY);
				repairCanvasTop(viewportData, fillRect, offsetY);
				repairCanvasBottom(viewportData, fillRect, offsetY);
				viewport.y = centreY;
				scrolledY = true;
			}
		}
		
		private function repairCanvasLeft(canvas:BitmapData, fillRect:Rectangle, offsetX:int):void
		{
			if (offsetX >= 0) return;
			var colour:uint = 0xff9999cc;
			fillRect.x = 0;
			fillRect.y = 0;
			fillRect.width = -offsetX;
			fillRect.height = CANVAS_HEIGHT;
			markRedrawRegion(canvas, fillRect, colour);
		}
		
		private function repairCanvasRight(canvas:BitmapData, fillRect:Rectangle, offsetX:int):void
		{
			if (offsetX <= 0) return; var colour:uint = 0xff99cc99; fillRect.x = CANVAS_WIDTH - offsetX; fillRect.y = 0; fillRect.width = offsetX; fillRect.height = CANVAS_HEIGHT; markRedrawRegion(canvas, fillRect, colour); } private function repairCanvasTop(canvas:BitmapData, fillRect:Rectangle, offsetY:int):void { if (offsetY >= 0) return;
			var colour:uint = 0xffcc99cc;
			fillRect.x = 0;
			fillRect.y = 0;
			fillRect.width = CANVAS_WIDTH;
			fillRect.height = -offsetY;
			markRedrawRegion(canvas, fillRect, colour);
		}
		
		private function repairCanvasBottom(canvas:BitmapData, fillRect:Rectangle, offsetY:int):void
		{
			if (offsetY <= 0) return;
			var colour:uint = 0xff99cccc;
			fillRect.x = 0;
			fillRect.y = CANVAS_HEIGHT - offsetY;
			fillRect.width = CANVAS_WIDTH;
			fillRect.height = offsetY;
			markRedrawRegion(canvas, fillRect, colour);
		}
		
		private function markRedrawRegion(canvas:BitmapData, region:Rectangle, colour:uint):void
		{
			canvas.fillRect(region, Math.random() * 0xffffffff);
		}
		
		private function drawTargets(clipRect:Rectangle = null):void
		{
			var increment:int = 25;
			for (var i:int = 0; i < CANVAS_WIDTH; i += increment)
				for (var j:int = 0; j < CANVAS_HEIGHT; j += increment)
					viewportData.setPixel(i, j, 0xff000000);
		}
	}
}

Leave a Reply