Along with Flash Player 11’s new Stage3D class have come hardware-accelerated 2D rendering engines. Impressive results have already been demonstrated by advanced engines like Starling and ND2D. Today’s article shows a simple Stage3D-based sprite class to help learn more about how these engines are implemented and provides a simplified alternative to the more complex 2D engines that still delivers hardware-accelerated performance.

If you haven’t read my “Introduction to AGAL” series (part one, part two, part three), you should start by reading those to familiarize yourself with 3D shaders since they are at the core of this simple sprite class. Once you’ve read those and have a good grasp of how data is flowing to the shaders and how it’s used, have a look at the Stage3DSprite class:

package
{
	import flash.geom.*;
	import flash.utils.*;
	import flash.display.*;
	import flash.display3D.*;
	import flash.display3D.textures.*;
 
	import com.adobe.utils.*;
 
	/**
	*   A Stage3D-based 2D sprite
	*   @author Jackson Dunstan, www.JacksonDunstan.com
	*/
	public class Stage3DSprite
	{
		/** Cached static lookup of Context3DVertexBufferFormat.FLOAT_2 */
		private static const FLOAT2_FORMAT:String = Context3DVertexBufferFormat.FLOAT_2;
 
		/** Cached static lookup of Context3DVertexBufferFormat.FLOAT_3 */
		private static const FLOAT3_FORMAT:String = Context3DVertexBufferFormat.FLOAT_3;
 
		/** Cached static lookup of Context3DProgramType.VERTEX */
		private static const VERTEX_PROGRAM:String = Context3DProgramType.VERTEX;
 
		/** Cached static lookup of Vector3D.Z_AXIS */
		private static const Z_AXIS:Vector3D = Vector3D.Z_AXIS;
 
		/** Temporary AGAL assembler to avoid allocation */
		private static const tempAssembler:AGALMiniAssembler = new AGALMiniAssembler();
 
		/** Temporary rectangle to avoid allocation */
		private static const tempRect:Rectangle = new Rectangle();
 
		/** Temporary point to avoid allocation */
		private static const tempPoint:Point = new Point();
 
		/** Temporary matrix to avoid allocation */
		private static const tempMatrix:Matrix = new Matrix();
 
		/** Temporary 3D matrix to avoid allocation */
		private static const tempMatrix3D:Matrix3D = new Matrix3D();
 
		/** Cache of positions Program3D per Context3D */
		private static const programsCache:Dictionary = new Dictionary(true);
 
		/** Cache of positions and texture coordinates VertexBuffer3D per Context3D */
		private static const posUVCache:Dictionary = new Dictionary(true);
 
		/** Cache of triangles IndexBuffer3D per Context3D */
		private static const trisCache:Dictionary = new Dictionary(true);
 
		/** Vertex shader program AGAL bytecode */
		private static var vertexProgram:ByteArray;
 
		/** Fragment shader program AGAL bytecode */
		private static var fragmentProgram:ByteArray;
 
		/** 3D context to use for drawing */
		public var ctx:Context3D;
 
		/** 3D texture to use for drawing */
		public var texture:Texture;
 
		/** Width of the created texture */
		public var textureWidth:uint;
 
		/** Height of the created texture */
		public var textureHeight:uint;
 
		/** X position of the sprite */
		public var x:Number = 0;
 
		/** Y position of the sprite */
		public var y:Number = 0;
 
		/** Rotation of the sprite in degrees */
		public var rotation:Number = 0;
 
		/** Scale in the X direction */
		public var scaleX:Number = 1;
 
		/** Scale in the Y direction */
		public var scaleY:Number = 1;
 
		/** Fragment shader constants: U scale, V scale, {unused}, {unused} */
		private var fragConsts:Vector.<Number> = new <Number>[1, 1, 1, 1];
 
		// Static initializer to create vertex and fragment programs
		{
			tempAssembler.assemble(
				Context3DProgramType.VERTEX,
				// Apply draw matrix (object -> clip space)
				"m44 op, va0, vc0\n" +
 
				// Scale texture coordinate and copy to varying
				"mov vt0, va1\n" +
				"div vt0.xy, vt0.xy, vc4.xy\n" +
				"mov v0, vt0\n"
			);
			vertexProgram = tempAssembler.agalcode;
 
			tempAssembler.assemble(
				Context3DProgramType.FRAGMENT,
				"tex oc, v0, fs0 <2d,linear,mipnone,clamp>"
			);
			fragmentProgram = tempAssembler.agalcode;
		}
 
		/**
		*   Make the sprite
		*   @param ctx 3D context to use for drawing
		*/
		public function Stage3DSprite(ctx:Context3D): void
		{
			this.ctx = ctx;
			if (!(ctx in trisCache))
			{
				// Create the shader program
				var program:Program3D = ctx.createProgram();
				program.upload(vertexProgram, fragmentProgram);
				programsCache[ctx] = program;
 
				// Create the positions and texture coordinates vertex buffer
				var posUV:VertexBuffer3D = ctx.createVertexBuffer(4, 5);
				posUV.uploadFromVector(
					new <Number>[
						// X,  Y,  Z, U, V
						-1,   -1, 0, 0, 1,
						-1,    1, 0, 0, 0,
						 1,    1, 0, 1, 0,
						 1,   -1, 0, 1, 1
					], 0, 4
				);
				posUVCache[ctx] = posUV;
 
				// Create the triangles index buffer
				var tris:IndexBuffer3D = ctx.createIndexBuffer(6);
				tris.uploadFromVector(
					new <uint>[
						0, 1, 2,
						2, 3, 0
					], 0, 6
				);
				trisCache[ctx] = tris;
			}
		}
 
		/**
		*   Set a BitmapData to use as a texture
		*   @param bmd BitmapData to use as a texture
		*/
		public function set bitmapData(bmd:BitmapData): void
		{
			var width:uint = bmd.width;
			var height:uint = bmd.height;
 
			// Create a new texture if we need to
			if (createTexture(width, height))
			{
				// If the new texture doesn't match the BitmapData's dimensions
				if (width != textureWidth || height != textureHeight)
				{
					// Create a BitmapData with the required dimensions
					var powOfTwoBMD:BitmapData = new BitmapData(
						textureWidth,
						textureHeight,
						bmd.transparent
					);
 
					// Copy the given BitmapData to the newly-created BitmapData
					tempRect.width = width;
					tempRect.height = height;
					powOfTwoBMD.copyPixels(bmd, tempRect, tempPoint);
 
					// Upload the newly-created BitmapData instead
					bmd = powOfTwoBMD;
 
					// Scale the UV to the sub-texture
					fragConsts[0] = textureWidth / width;
					fragConsts[1] = textureHeight / height;
				}
				else
				{
					// Reset UV scaling
					fragConsts[0] = 1;
					fragConsts[1] = 1;
				}
			}
 
			// Upload new BitmapData to the texture
			texture.uploadFromBitmapData(bmd);
		}
 
		/**
		*   Create the texture to fit the given dimensions
		*   @param width Width to fit
		*   @param height Height to fit
		*   @return If a new texture had to be created
		*/
		protected function createTexture(width:uint, height:uint): Boolean
		{
			width = nextPowerOfTwo(width);
			height = nextPowerOfTwo(height);
 
			if (!texture || textureWidth != width || textureHeight != height)
			{
				texture = ctx.createTexture(
					width,
					height,
					Context3DTextureFormat.BGRA,
					false
				);
				textureWidth = width;
				textureHeight = height;
				return true;
			}
			return false;
		}
 
		/**
		*   Render the sprite to the 3D context
		*/
		public function render(): void
		{
			tempMatrix3D.identity();
			tempMatrix3D.appendRotation(-rotation, Z_AXIS);
			tempMatrix3D.appendScale(scaleX, scaleY, 1);
			tempMatrix3D.appendTranslation(x, y, 0);
 
			ctx.setProgram(programsCache[ctx]);
			ctx.setTextureAt(0, texture);
			ctx.setProgramConstantsFromMatrix(VERTEX_PROGRAM, 0, tempMatrix3D, true);
			ctx.setProgramConstantsFromVector(VERTEX_PROGRAM, 4, fragConsts);
			ctx.setVertexBufferAt(0, posUVCache[ctx], 0, FLOAT3_FORMAT);
			ctx.setVertexBufferAt(1, posUVCache[ctx], 3, FLOAT2_FORMAT);
			ctx.drawTriangles(trisCache[ctx]);
		}
 
		/**
		*   Get the next-highest power of two
		*   @param v Value to get the next-highest power of two from
		*   @return The next-highest power of two from the given value
		*/
		public static function nextPowerOfTwo(v:uint): uint
		{
			v--;
			v |= v >> 1;
			v |= v >> 2;
			v |= v >> 4;
			v |= v >> 8;
			v |= v >> 16;
			v++;
			return v;
		}
	}
}

The Stage3DSprite class creates and controls a 2D rectangle on the Z=0 plane in 3D space. It allows you to specify a BitmapData for its texture and use the familiar DisplayObject-style properties (x/y, scaleX/scaleY, rotation) to control its placement in the scene. This is done on the CPU side via a Matrix3D and on the GPU side in the vertex shader by applying the Matrix3D. Since vertex buffers (position and texture coordinates), index buffers (triangles), and the shader program are shared among all sprites, only one of each is created (and then cached with weak keys) per Context3D it is created for.

Now for a test app to show off the 3D sprite. The following app allows you to create sprites based on either a power-of-two (128×128) opaque bitmap or a non-power-of-two (300×300) transparent bitmap. You can then scale, rotate, and position the sprite in the scene before adding another to repeat the process.

package
{
	import flash.display3D.*;
	import flash.display.*;
	import flash.filters.*;
	import flash.events.*;
	import flash.text.*;
	import flash.geom.*;
	import flash.utils.*;
 
	public class Stage3DSpriteTest extends Sprite 
	{
		[Embed(source="flash_logo.png")]
		private static const TEXTURE:Class;
 
		[Embed(source="flash_logo_alpha.png")]
		private static const TEXTURE_ALPHA:Class;
 
		private var context3D:Context3D;
		private var sprites:Vector.<Stage3DSprite>;
		private var selection:Stage3DSprite;
 
		private var fps:TextField = new TextField();
		private var lastFPSUpdateTime:uint;
		private var lastFrameTime:uint;
		private var frameCount:uint;
		private var driver:TextField = new TextField();
 
		public function Stage3DSpriteTest()
		{
			stage.align = StageAlign.TOP_LEFT;
			stage.scaleMode = StageScaleMode.NO_SCALE;
			stage.frameRate = 60;
 
			sprites = new <Stage3DSprite>[];
 
			var stage3D:Stage3D = stage.stage3Ds[0];
			stage3D.addEventListener(Event.CONTEXT3D_CREATE, onContextCreated);
			stage3D.requestContext3D(Context3DRenderMode.AUTO);
		}
 
		protected function onContextCreated(ev:Event): void
		{
			// Setup context
			var stage3D:Stage3D = stage.stage3Ds[0];
			stage3D.removeEventListener(Event.CONTEXT3D_CREATE, onContextCreated);
			context3D = stage3D.context3D;			
			context3D.configureBackBuffer(
				stage.stageWidth,
				stage.stageHeight,
				0,
				true
			);
			context3D.enableErrorChecking = true;
 
			// Setup UI
			fps.background = true;
			fps.backgroundColor = 0xffffffff;
			fps.autoSize = TextFieldAutoSize.LEFT;
			fps.text = "Getting FPS...";
			addChild(fps);
 
			driver.background = true;
			driver.backgroundColor = 0xffffffff;
			driver.text = "Driver: " + context3D.driverInfo;
			driver.autoSize = TextFieldAutoSize.LEFT;
			driver.y = fps.height;
			addChild(driver);
 
			makeButtons(
				"Add Opaque Bitmap", "Add Transparent Bitmap", "Remove Sprite", null,
				"Rotate Clockwise", "Rotate Counter-clockwise", null,
				"Move Right", "Move Left", "Move Up", "Move Down", null,
				"Scale Up", "Scale Down"
			);
 
			// Start the simulation
			addEventListener(Event.ENTER_FRAME, onEnterFrame);
		}
 
		private function makeButtons(...labels): void
		{
			const PAD:Number = 5;
 
			var curX:Number = PAD;
			var curY:Number = stage.stageHeight - PAD;
			for each (var label:String in labels)
			{
				if (!label)
				{
					curX = PAD;
					curY -= button.height + PAD;
					continue;
				}
 
				var tf:TextField = new TextField();
				tf.mouseEnabled = false;
				tf.selectable = false;
				tf.defaultTextFormat = new TextFormat("_sans", 16, 0x0071BB);
				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;
			}
		}
 
		private function onButton(ev:MouseEvent): void
		{
			var mode:String = ev.target.getChildByName("lbl").text;
			var spr:Stage3DSprite;
			switch (mode)
			{
				case "Add Opaque Bitmap":
					spr = new Stage3DSprite(context3D);
					spr.bitmapData = (new TEXTURE()).bitmapData;
					sprites.push(spr);
					selection = spr;
					break;
				case "Add Transparent Bitmap":
					spr = new Stage3DSprite(context3D);
					spr.bitmapData = (new TEXTURE_ALPHA()).bitmapData;
					sprites.push(spr);
					selection = spr;
					break;
				case "Remove Sprite":
					if (selection)
					{
						sprites.splice(sprites.indexOf(selection), 1);
						selection = null;
					}
					break;
				case "Rotate Clockwise":
					if (selection)
					{
						selection.rotation += 10;
					}
					break;
				case "Rotate Counter-clockwise":
					if (selection)
					{
						selection.rotation -= 10;
					}
					break;
				case "Move Right":
					if (selection)
					{
						selection.x += 0.1;
					}
					break;
				case "Move Left":
					if (selection)
					{
						selection.x -= 0.1;
					}
					break;
				case "Move Up":
					if (selection)
					{
						selection.y += 0.1;
					}
					break;
				case "Move Down":
					if (selection)
					{
						selection.y -= 0.1;
					}
					break;
				case "Scale Up":
					if (selection)
					{
						selection.scaleX += 0.1;
						selection.scaleY += 0.1;
					}
					break;
				case "Scale Down":
					if (selection)
					{
						selection.scaleX -= 0.1;
						selection.scaleY -= 0.1;
					}
					break;
			}
		}
 
		private function onEnterFrame(ev:Event): void
		{
			// Render the scene
			context3D.clear(0.5, 0.5, 0.5);
			for each (var sprite:Stage3DSprite in sprites)
			{
				sprite.render();
			}
			context3D.present();
 
			// Update frame rate display
			frameCount++;
			var now:int = getTimer();
			var dTime:int = now - lastFrameTime;
			var elapsed:int = now - lastFPSUpdateTime;
			if (elapsed > 1000)
			{
				var framerateValue:Number = 1000 / (elapsed / frameCount);
				fps.text = "FPS: " + framerateValue.toFixed(4);
				lastFPSUpdateTime = now;
				frameCount = 0;
			}
			lastFrameTime = now;
		}
	}
}

Launch Test App

Here’s a screenshot of the test app in action:

Screenshot of the Stage3DSpriteTest app

As you can see from the test app when sprites are added at full-stage size, this system of sizing is based on a percentage of the whole stage and not pixels. This shares some advantages with auto-scaling Flash movies as Stag3D graphics are also a form of vector graphics. You can therefore inherit many of the same advantages.

I hope you’ve found this educational and/or useful. If you spot a bug or have a suggestion, feel free to post a comment!