Alpha hit testing in Starling ~ July 8, 2013
In Starling’s display stack, the default behavior for DisplayObject.hitTest
is to use the DisplayObject
’s defined bounds, and only its defined bounds to determine if a touch hits that sprite. My current game has levels with very tightly packed assets, resulting in severely overlapping bounding boxes. Furthermore, on mobile devices, we have tight memory constraints, making it impractical to keep the source BitmapData
for an uploaded texture in memory.
As I mentioned in my initial post, I’m using Flump for level export to the client. So, while this code example is directly related to loading a Flump library, the technique used to generate an alpha mask and use it for hit testing will work in any Starling app.
An explanation of what this class does (click the header link to download the whole thing):
17 18 19 20 21 22 23 24 25 26 | /**
* A flump library loader that hooks into flump loading to allow the Images loaded from this
* library to have an improved hitTest method that uses the alpha from the original texture instead
* of the clip rect of the Image.
*
* In order to save space, only a single bit is stored per non-retina atlas pixel: whether that
* location is fully transparent or not. That is the test used to determine if a given hitTest()
* will pass or fail.
*/
public class AlphaHitTestFlumpLibraryLoader extends FlumpLibraryLoader {
|
The meat of this process; generation of the alpha mask bit array. The idea is to create a ByteArray
that is as compactly arranged as possible to minimize the memory impact of keeping this data around. The result is var alphaBits :ByteArray
, containing a single bit for each pixel in the source BitmapData
. This implementation skips columns and rows at retina resolutions in order to cut down the amount of stored data even further.
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | protected function pngAtlasLoaded (atlas :AtlasMold, image :LoadedImage) :void {
var start :int = getTimer();
var pixels :ByteArray = image.bitmapData.getPixels(new Rectangle(0, 0,
image.bitmapData.width, image.bitmapData.height));
var w :int = image.bitmapData.width;
var h :int = image.bitmapData.height;
pixels.position = 0;
var alphaBits :ByteArray = new ByteArray();
var current :int = 0;
var idx :int = 7;
var row :int = 0;
var rowsSkipped :int = 0;
var col :int = 0;
var colsSkipped :int = 0;
var numColumns :int = 0;
while (pixels.bytesAvailable > 0) {
var pixel :uint = pixels.readUnsignedInt();
if (pixel >> 24 != 0) current = current | (1 << idx);
if (idx == 0) {
alphaBits.writeByte(current);
current = 0;
idx = 7;
} else {
idx--;
}
// skip pixels as necessary to maintain our logical scale factor
// Note: this won't work for scaleFactors < 1, it would need to be smart enough to
// skip multiple columns/rows at a time.
col++;
if (col / (col - colsSkipped) < atlas.scaleFactor) {
var newCol :int = Math.min(col + 1, w);
pixels.position += (newCol - col) * 4;
colsSkipped += newCol - col;
col = newCol;
}
if (col == w) {
if (numColumns != 0 && numColumns != col - colsSkipped) {
log.warning("Column sizes disagree!", "prev", numColumns, "new",
col - colsSkipped);
}
numColumns = col - colsSkipped;
row++;
col = 0;
colsSkipped = 0;
if (row / (row - rowsSkipped) < atlas.scaleFactor && row != h) {
pixels.position += w * 4;
rowsSkipped++;
row++;
}
}
if (row == h && pixels.bytesAvailable > 0) {
log.warning("Covered the full image, but bytes are available",
"size", "(" + w + ", " + h + ")", "avail", pixels.bytesAvailable);
break;
}
}
|
An important result: this implementation is very slow in the flash runtime, particularly on a mobile device. If you need pixel-level hit detection, you need to do something like this, but the algorithm can be ported to a build-time script to generate a binary file with the same data. Then the flash runtime need only load that binary data directly into a ByteArray and continue as normal.
100 101 102 103 104 | // TODO: potential compile time process to precalculate these if this takes too long.
// Update: this is wicked slow, not acceptable for release, will need to precalculate
// for production builds. 5s on desktop, 14s on iPad2
log.info("finished calculating alpha bits", "name", atlas.file, "bitmapSize", pixels.length,
"maskSize", alphaBits.length, "time", getTimer() - start);
|
The AlphaMask
class simply finds the proper location inside the resulting ByteArray
to determine if a given position was transparent or opaque in the original BitmapData
.
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | class AlphaMask {
public var bits :ByteArray;
public var width :int;
public var height :int;
public function AlphaMask (bits :ByteArray, width :int, height :int) {
this.bits = bits;
this.width = width;
this.height = height;
}
public function isOpaque (pos :Point) :Boolean {
var bitIdx :int = Math.floor(pos.y) * width + Math.floor(pos.x);
var byteMask :int = bits[Math.floor(bitIdx / 8)];
bitIdx = 7 - (bitIdx % 8);
return (byteMask & (1 << bitIdx)) > 0;
}
}
|
The final piece is the Image
subclass with improved hitTest()
. Because a Flump library’s images are going to be mostly Quad
s mapped onto a subtexture created via Starling’s Texture.fromTexture
in Flump’s LibraryLoader
, the Image
needs to know its own location on the source atlas.
The baseOffset
is given to this class because we happen to already have that data, and can easily pass in the appropriate Rectangle
for the scale used on our mask array. If we dropped the optimization in pngAtlasLoaded()
that skips columns and rows, the frame
member of Texture
could be used for the same purpose.
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | class AlphaHitTestImage extends Image {
function AlphaHitTestImage (texture :Texture, alphaMask :AlphaMask, baseOffset :Point) {
super(texture);
_alphaMask = alphaMask;
_baseOffset = baseOffset;
}
override public function hitTest (localPoint :Point, forTouch :Boolean = false) :DisplayObject {
var inBounds :Boolean = super.hitTest(localPoint, forTouch) == this;
if (!inBounds) return null;
// super says this is a hit, let's check the alpha map.
return _alphaMask.isOpaque(_baseOffset.add(localPoint)) ? this : null;
}
protected var _alphaMask :AlphaMask;
protected var _baseOffset :Point;
}
|
Using the code here, a reasonable and performant approach to using the alpha channel for hit detection on sprites that are part of a larger texture atlas in a Starling app can be implemented. If you have any questions or comments, feel free to leave them below!
comments powered by Disqus