Flash (mostly) won the first bitmap test against HTML5 but was then defeated once rotation and scaling entered the mix. Can Flash make a comeback by leveraging hardware acceleration via Stage3D? Today’s test finds out!

Let’s get one thing straight right from the get-go: today’s test will not be a fair fight. Flash is going to use hardware acceleration and HTML5 is not, unless the browser happens to hardware accelerate the normal “2d” canvas context. So why am I comparing apples to oranges? Why am I not using HTML5’s hardware acceleration via WebGL? Well, WebGL is available on Firefox 4+, Safari 6+ (but not mobile), and Chrome 9+ (25+ on mobile). This means that WebGL will work on about 40% of desktop browsers and about 5% of mobile browsers according to these June 2013 stats. On the other hand, Stage3D will work on about 80% of all desktop browsers according to these May 2013 stats. In short, Stage3D is currently a much more practical option than WebGL on desktop browsers where the two are competing. If the leaks turn out to be true, Internet Explorer 11 may support WebGL and change this… whenever it’s released and users actually upgrade (which they ar slow at).

Now for the test. I’ve converted the previous test to draw constantly and try to hold 60 frames per second, which is effectively the maximum. The options are now displayed as is the frame rate, which is updated once per second. The Flash version uses the Stage3DSpritesPacked class from Stage3D Draw Calls: Part 3. Try out the tests to see for yourself and then check out my results below.

Launch the HTML5 version
Launch the Flash version

Here is the source code for the Flash version:

package
{
	import flash.display3D.Context3D;
	import flash.events.Event;
	import flash.display.Stage3D;
	import flash.events.MouseEvent;
	import flash.text.TextFormat;
	import flash.utils.getTimer;
	import flash.text.TextFieldAutoSize;
	import flash.display.StageScaleMode;
	import flash.display.StageAlign;
	import flash.text.TextField;
	import flash.display.Bitmap;
	import flash.display.BitmapData;
	import flash.display.Sprite;
 
	public class BitmapScaleRotFlash2 extends Sprite
	{
		[Embed(source="opaque.jpg")]
		private static const OPAQUE:Class;
 
		[Embed(source="alpha.png")]
		private static const ALPHA:Class;
 
		private static const TWO_PI:Number = 2*Math.PI;
 
		private static const REPS:int = 10000;
 
		private static const VIEWPORT_WIDTH:Number = 640;
		private static const VIEWPORT_HEIGHT:Number = 480;
 
		private var opaqueBMD:BitmapData = (new OPAQUE() as Bitmap).bitmapData;
		private var alphaBMD:BitmapData = (new ALPHA() as Bitmap).bitmapData;
		private var lastStatsUpdateTime:uint;
		private var lastFrameTime:uint;
		private var frameCount:uint;
		private var fps:TextField;
		private var context3D:Context3D;
		private var sprites3DPacked:Stage3DSpritesPacked;
		private var sprites3DDataPacked:Vector.<Stage3DSpriteDataPacked> = new <Stage3DSpriteDataPacked>[];
		private var rotating:Boolean = true;
		private var scaling:Boolean = false;
		private var texture:BitmapData;
 
		public function BitmapScaleRotFlash2()
		{
			stage.align = StageAlign.TOP_LEFT;
			stage.scaleMode = StageScaleMode.NO_SCALE;
			stage.frameRate = 60;
 
			fps = new TextField();
			fps.text = "Starting up. Please wait. This could take a while.";
			fps.autoSize = TextFieldAutoSize.LEFT;
			fps.background = true;
			fps.backgroundColor = 0xffffffff;
			addChild(fps);
			stage.addEventListener(Event.ENTER_FRAME, onInitialFrame);
		}
 
		private function onInitialFrame(ev:Event): void
		{
			stage.removeEventListener(Event.ENTER_FRAME, onInitialFrame);
 
			var stage3D:Stage3D = stage.stage3Ds[0];
			stage3D.addEventListener(Event.CONTEXT3D_CREATE, onContextCreated);
			stage3D.requestContext3D();
		}
 
		private function onContextCreated(ev:Event): void
		{
			// Setup context
			var stage3D:Stage3D = stage.stage3Ds[0];
			stage3D.removeEventListener(Event.CONTEXT3D_CREATE, onContextCreated);
			context3D = stage3D.context3D;
			context3D.configureBackBuffer(VIEWPORT_WIDTH, VIEWPORT_HEIGHT, 0, true);
 
			makeButtons(
				"Alpha - Rotation", "Alpha - Scaling", "Alpha - Rotation & Scaling", null,
				"Opaque - Rotation", "Opaque - Scaling", "Opaque - Rotation & Scaling"
			);
 
			sprites3DPacked = new Stage3DSpritesPacked(context3D);
			for (var i:int; i < REPS; ++i)
			{
				sprites3DDataPacked[i] = sprites3DPacked.addSprite(0, 0, 1, 1);
			}
			test(opaqueBMD, true, false);
 
			addEventListener(Event.ENTER_FRAME, onEnterFrame);
		}
 
		private function makeButtons(...labels): Number
		{
			const PAD:Number = 5;
			var curX:Number = PAD;
			var curY:Number = stage.stageHeight - PAD;
			for each (var label:String in labels)
			{
				if (label == null)
				{
					curX = PAD;
					curY -= button.height + PAD;
					continue;
				}
 
				var tf:TextField = new TextField();
				tf.mouseEnabled = false;
				tf.selectable = false;
				tf.defaultTextFormat = new TextFormat("_sans");
				tf.autoSize = TextFieldAutoSize.LEFT;
				tf.text = label;
				tf.name = "lbl";
 
				var button:Sprite = new Sprite();
				button.buttonMode = true;
				button.graphics.beginFill(0xF5F5F5);
				button.graphics.drawRect(0, 0, tf.width+PAD, tf.height+PAD);
				button.graphics.endFill();
				button.graphics.lineStyle(1);
				button.graphics.drawRect(0, 0, tf.width+PAD, tf.height+PAD);
				button.addChild(tf);
				button.addEventListener(MouseEvent.CLICK, onButton);
				if (curX + button.width > stage.stageWidth - PAD)
				{
					curX = PAD;
					curY -= button.height + PAD;
				}
				button.x = curX;
				button.y = curY - button.height;
				addChild(button);
 
				curX += button.width + PAD;
			}
 
			return curY - button.height;
		}
 
		private function onButton(ev:MouseEvent): void
		{
			var mode:String = ev.target.getChildByName("lbl").text;
			switch (mode)
			{
				case "Opaque - Rotation":
					test(opaqueBMD, true, false);
					break;
				case "Opaque - Scaling":
					test(opaqueBMD, false, true);
					break;
				case "Opaque - Rotation & Scaling":
					test(opaqueBMD, true, true);
					break;
				case "Alpha - Rotation":
					test(alphaBMD, true, false);
					break;
				case "Alpha - Scaling":
					test(alphaBMD, false, true);
					break;
				case "Alpha - Rotation & Scaling":
					test(alphaBMD, true, true);
					break;
			}
		}
 
		private function test(bmd:BitmapData, rotation:Boolean, scaling:Boolean): void
		{
			this.texture = bmd;
			this.rotating = rotation;
			this.scaling = scaling;
 
			// Clear old sprites
			context3D.clear(0.5, 0.5, 0.5);
			context3D.present();
 
			sprites3DPacked.bitmapData = bmd;
			var scale:Number = bmd.width / VIEWPORT_WIDTH;
			for (var i:int; i < REPS; ++i)
			{
				var sprDataPacked:Stage3DSpriteDataPacked = sprites3DDataPacked[i];
				sprDataPacked.scaleX = sprDataPacked.scaleY = scale;
			}
 
			// Reset FPS
			frameCount = 0;
			lastFrameTime = 0;
			lastStatsUpdateTime = getTimer();
		}
 
		private function onEnterFrame(ev:Event): void
		{
			var sprDataPacked:Stage3DSpriteDataPacked;
			context3D.clear(0.5, 0.5, 0.5);
			var i:uint;
			for (i = 0; i < REPS; ++i)
			{
				sprDataPacked = sprites3DDataPacked[i];
				sprDataPacked.x = Math.random()*2-1;
				sprDataPacked.y = Math.random()*2-1;
			}
			if (rotating)
			{
				for (i = 0; i < REPS; ++i)
				{
					sprDataPacked = sprites3DDataPacked[i];
					sprDataPacked.rotation = TWO_PI*Math.random();
				}
			}
			if (scaling)
			{
				var baseScale:Number = texture.width / VIEWPORT_WIDTH;
				for (i = 0; i < REPS; ++i)
				{
					sprDataPacked = sprites3DDataPacked[i];
					sprDataPacked.scaleX = baseScale*Math.random();
					sprDataPacked.scaleY = baseScale*Math.random();
				}
			}
			sprites3DPacked.render();
			context3D.present();
 
			// Update stats display
			frameCount++;
			var now:int = getTimer();
			var elapsed:int = now - lastStatsUpdateTime;
			if (elapsed > 1000)
			{
				var framerateValue:Number = 1000 / (elapsed / frameCount);
				fps.text = "Image: " + (texture==opaqueBMD?"Opaque":"Alpha")
					+ ", Rotating: " + rotating
					+ ", Scaling: " + scaling
					+ "\n"
					+ "FPS: " + framerateValue.toFixed(4);
				lastStatsUpdateTime = now;
				frameCount = 0;
			}
			lastFrameTime = now;
		}
	}
}

And here are the images it embeds:

Opaque image
Alpha image

You can get the source code of the HTML5 version by simply opening up the HTML file. Its embedded images are Base64-encoded into the img tags.

Here are the devices tested: (note that the Mac’s video card has changed since last time)

Name CPU GPU OS
MacBook Pro Retina 2.3 GHz Intel Core i7 NVIDIA GeForce GT 650M OS X 10.8.3
Windows Desktop 3.4 GHz Intel Core i7 2600 Nvidia GeForce GTX 550 Ti Windows 7 SP 1
iPad 2 1 GHz ARM Cortex-A9 PowerVR SGX543MP2 iOS 6.1.3
LG Optimus G 1.5 GHz Qualcomm Krait Qualcomm Adreno 320 Android 4.1
HTC Evo V 4G 1.2 GHz Qualcomm Snapdragon Qualcomm Adreno 220 Android 4.0

And here are the results:

Device Opaque Rotating Opaque Scaling Opaque Rotating & Scaling Alpha Rotating Alpha Scaling Alpha Rotating & Scaling
HTC Evo V 4G – Android 4 Browser 0.8 0.8 0.7 0.8 0.8 0.7
HTC Evo V 4G – Google Chrome 27 0.4 0.4 0.3 0.4 0.4 0.4
Apple iPad 2 – Safari 2.8 3 2.7 2.8 3 2.7
LG Optimus G – Android Browser 1 1.2 0.9 1 1.2 0.9
LG Optimus G – Google Chrome 27 0.7 0.7 0.7 0.7 0.7 0.7
Motorola Xoom – Android 4 Browser 1 1 0.7 1 1 0.7
Motorola Xoom – Google Chrome 27 0.7 0.7 0.7 0.7 0.7 0.7
MacBook Pro Retina – Google Chrome 27 11.6 12.2 11.2 11.6 12.3 11.2
MacBook Pro Retina – Firefox 21 9.2 7.7 5.4 8.9 7.2 5.1
MacBook Pro Retina – Safari 6 25.1 26.6 23.8 25.2 27.3 24
MacBook Pro Retina – Flash Player 11.7 60 60 60 60 60 60
Windows Desktop – Google Chrome 27 14.7 19 15.3 17.6 19 14.8
Windows Desktop – Firefox 21 32.9 28.5 17 33.1 28.5 16.1
Windows Desktop – Internet Explorer 10 24 21.5 13.8 24 21.8 13.9
Windows Desktop – Flash Player 11.7 60 60 60 60 60 60

Performance Graph (all)

Performance Graph (Windows, Mac)

Performance Graph (mobile)

From this data we can draw some interesting conclusions:

  • Flash performance always hits the 60 FPS cap and is therefore far and away the best of the bunch.
  • Firefox is best on Windows and manages 30 FPS when just rotating, but quickly falls to 15 FPS when rotating and scaling. A similar falloff occurs on Mac, but ends up at a dismal 5 FPS instead.
  • Internet Explorer and Chrome trail Firefox on Windows with performance in the 10-20 FPS range.
  • Safari is again fastest on Mac and can top 20 FPS regardless of mode.
  • Chrome and Firefox fall to 10 FPS or less on Mac, less than half the performance of Safari.
  • Safari on iOS dominates the mobile competition with nearly 3x their performance numbers.
  • Chrome on Android seems slower than the stock Android browser across the board.
  • All mobile tests are slower than all desktop tests with a maximum of about 3 FPS.
  • No browser will trigger the MacBook Pro to change to its discrete video card. I manually changed it for all tests. Flash, on the other hand, triggers the change automatically.
  • On some devices the Android browser ran so slowly that its UI became unresponsive and required a page reload to change the mode.

Again, this isn’t really a fair fight. Flash uses hardware acceleration to utterly destroy the competition when it competes against them on desktop browsers. But with WebGL effectively not an option for most web games, it’s a realistic assessment of the current state of desktop browsers. On mobile where Flash no longer has a plugin presence, super slow ARM processors simply can’t handle large quantities of bitmap drawing. These chips desperately need hardware acceleration in order to stand a chance at decent frame rates. If you’re going to make an HTML5 game and hope that players will enjoy it on their mobile devices, you’d better scale back your graphical ambitions and keep the game simple. Just imagine the performance on an older single-core device such as the original iPad or low-end Android devices.

HTML5 needs WebGL and the hardware acceleration it brings to compete against Flash’s Stage3D. It’s here for a few browsers, but when will it be a feasible option for reaching the masses? That will likely require Internet Explorer, Safari for iOS, and the Android Browser to add support and then a lag time after that for users to gradually upgrade. At that point Flash may have met its match, but it’s likely quite a ways off.

As a disclaimer with mobile, there are many devices out there with wildly different performance characteristics. Even iOS has five base models of iPad, six of the iPhone, and five of the iPod Touch. There are hundreds to thousands of Android devices. It’s therefore really hard to get a complete picture of mobile performance since very few people have access to so many devices. I certainly don’t. However, I’m guessing many people reading this article have a couple of minutes to point their mobile browsers at the above tests. Want to contribute your own test results? Post a comment with your device name, opaque and alpha times, and if you have them or can look them up, CPU and GPU specs.