Touchable transparent Starling textures without breaking the memory bank

This post began as a response to twitter user @umibose - thanks for the question!

The Starling framework handles touch/mouse events well, but out of the box it only knows whether a the event overlaps with a given displayObject. If you’ve given that object a partially transparent texture, usually you want to ignore touch events that hit the transparent part, but Starling has no way to do this. (This is because Starling has already sent the texture off to the GPU, and doesn’t retain a copy it can check the transparency of). The straightforward solution is to extend Starling’s Image class to make it save a copy of the texture, and change the hit-test logic to know about transparency, but this means storing all the textures in ActionScript memory, which can add up fast. In this post I’ll go into a simple way to get what we want with a greatly reduced memory cost.

First, we extend the starling.display.Image class so as to retain a copy of the texture that was passed in:

package {
import flash.display.Bitmap;
import flash.display.BitmapData;
import starling.display.Image;
import starling.textures.Texture;

public class CachingImage extends Image {

private var cachedTexture:BitmapData;

public function CachingImage(_bitmap:Bitmap) {
// store the texture for later
cachedTexture = _bitmap.bitmapData;
// fall back to implementation of parent class
super(Texture.fromBitmap(_bitmap));
}
}
}

The next task is to add the logic to check this cached texture before generating touch events. Starling makes this quite easy by exposing a hitTest() method for just this purpose. This method returns null to mean that no hit took place, and the target of the touch event otherwise. So we simply check if the alpha value of the pixel where the event took place is above some predefined threshold:

public var alphaCutoff:int = 20;

// do a hit test for a touch event
public override function hitTest(localPoint:Point, forTouch:Boolean=false):DisplayObject {
// test fails if the object is invisible or not touchable
if (forTouch && (!visible || !touchable)) { return null; }

// likewise if touch is outside bounds of the object
if (! getBounds(this).containsPoint(localPoint)) { return null; }

// call a Starling helper function to find the alpha value at the event x,y
var color:uint = cachedTexture.getPixel32(localPoint.x,localPoint.y);
if (Color.getAlpha(color) > alphaCutoff) {
return this;// a hit occurred!
} else {
return null;
}
}

Now we have the functionality we want, but at the cost of storing all the textures in ActionScript memory, which could get expensive fast. But when you think about it, it’s usually not important to have pixel-perfect hit tests - especially for touch, where hit areas must be large to begin with. So we often don’t need to keep the texture at full size - we might as well scale it down before storing. (You can often scale a texture down like this by a factor of 10:1 without any noticeable difference in the hit testing.)

*Actual texture:*
*Texture stored in memory:*
*Hit test takes place at a lower, but acceptable, resolution:*

And since this is Flash, the scaling operation can easily be done in a line or two of code, by invoking the renderer with the bitmapData.draw() method. With this optimization in place, our finished class looks like this:

package {

import flash.display.Bitmap;
import flash.display.BitmapData;
import flash.geom.*;
import starling.display.*;
import starling.textures.Texture;
import starling.utils.Color;

public class SmartImage extends Image {

// scaling factor for cached texture (0.01-1)
private var scaling:Number;

// cached texture
private var bitmapCache:BitmapData;

// threshold alpha value for touch events to occur (0-255)
public var alphaCutoff:int = 32;

public function SmartImage(_bitmap:Bitmap, _scaling:Number=0.5) {
scaling = Math.max(_scaling, .01);
scaling = Math.min(scaling, 1);
cacheData(_bitmap);
super( Texture.fromBitmap(_bitmap) );
}

// scale down and cache the texture data
private function cacheData(bmp:Bitmap) {
// prepare the cached texture
var w:int = Math.ceil(bmp.width * scaling);
var h:int = Math.ceil(bmp.height * scaling);
bitmapCache = new BitmapData(w,h,true,0);

// call .draw() uses Flash's renderer for the scaling
var scaleMatrix:Matrix = new Matrix();
scaleMatrix.scale(scaling,scaling);
bitmapCache.draw(bmp, scaleMatrix);
}

// do a hit test for a touch event
public override function hitTest(localPoint:Point, forTouch:Boolean=false):DisplayObject {
// test fails if the object is invisible or not touchable
if (forTouch && (!visible || !touchable)) { return null; }

// likewise if touch is outside bounds of the object
if (! getBounds(this).containsPoint(localPoint)) { return null; }

// call a Starling helper function to find the alpha value at the event x,y
var color:uint = bitmapCache.getPixel32(localPoint.x*scaling, localPoint.y*scaling);
if (Color.getAlpha(color) > alphaCutoff) {
return this; // a hit occurred!
} else {
return null;
}
}
}
}

A simple test project using this SmartImage class can be found here: Starling hitTest project
(The project contains a FLA file in CS6 format. To use with FlashBuilder just add the logic from Document.as to a fresh project.)