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.

Overlapping child bounds

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):

AlphaHitTestFlumpLibraryLoader.as
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.

AlphaHitTestFlumpLibraryLoader.as
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.

AlphaHitTestFlumpLibraryLoader.as
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.

AlphaHitTestFlumpLibraryLoader.as
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 Quads 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.

AlphaHitTestFlumpLibraryLoader.as
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