OpenFL devlog: scale9Grid

From a designer’s perspective, one of the more powerful features of Flash is the scale9Grid property. Basically, you take a display object’s graphics, and you divide it into nine regions. When you want to scale the display object, the four corner regions do not scale. The top and bottom regions scale horizontally only. The left and right regions scale vertically only. And the center region scales in both directions. This technique to scaling different parts of a shape is sometimes also called 9-slice scaling.

If it’s not clear, hopefully the illustration below may help. The white lines between each region represent the scale9Grid. The arrows indicate the direction(s) that each particular region scales.

Using scale9Grid allows shapes, especially rectangles with rounded corners, to resize without distorting their corners, and minimizing distortion to one direction on each of the edges. The corner radius will remain the same no matter how much larger you scale a Sprite that uses scale9Grid. The edges will preserve the same thickness, but can get longer in the other direction. The center is intended to stretch in both directions.

Without scale9Grid, the same graphics would scale like the next illustration below:

Notice the corner radius has increased, but it has also increased differently in the horizontal and vertical directions. The scaleX value increased more than the scaleY value, in this case.

Obviously, this is a particularly useful feature to include in OpenFL, and my focus for the last month has been on implementing its behavior correctly, so that we can match how Flash’s scale9Grid works. There are actually different possible ways to approach the implementation of scale9Grid, and I learned a lot about how exactly Flash does it in the process. Even though I started using Flash over 20 years ago, it turns out that my mental concept of its scale9Grid behavior was close, but it wasn’t actually completely accurate.

If you’re not familiar, OpenFL is an implementation of the APIs available in Adobe Flash Player (and Adobe AIR) using the Haxe programming language. Projects built with Haxe can be cross-compiled to JavaScript, C++, and many other targets — making it available on the web, desktop apps, mobile apps, game consoles, and more. Lime is a dependency of OpenFL, developed by the same team, and it exposes various native C++ libraries to Haxe (such as SDL, FreeType, and various audio and image format decoders). As I contributor to both projects, I occasionally write these devlogs to share an early look at new features that I’ve implemented, and to spread technical knowledge about how things work.

The quick and dirty approach to scale9Grid

The quickest way to implement scale9Grid would be to treat the display object as if the cacheAsBitmap property were enabled, and then to slice up the BitmapData into nine regions, and render and scale each of those regions as independent bitmaps.

This approach works pretty well when using beginFill() for solid colors, and if you ensure that strokes are used only as a border. However, if you use beginGradientFill() or beginBitmapFill(), if strokes don’t follow right angles in non-corner regions (or if they appear in the center region), or if the display object has children, rendering issues start to creep in.

The following illustration shows how drawRoundRect() with beginGradientFill() behaves with each approach. The shape on the left is the original. The one in the center shows how the gradient is rendered when using the simple bitmap slicing approach. The one on the right is how Flash renders the gradient. You can see that the center one incorrectly distributes the gradient, and the beginning and end colors are too strongly represented. The right one distributes the colors evenly.

The quick and dirty BitmapData slicing technique is better than nothing, of course. I’d rather have it than no ability to use scale9Grid at all, but matching the behavior of Flash will definitely give us better results.

How Flash’s scale9Grid works

The previously described approach basically post-processed a rendered display object after its Graphics drawing commands, such as moveTo(), lineTo(), or curveTo(), were already executed. However, what if the (x, y) positions specified in the Graphics drawing commands could be modified as they are executed instead? This would improve the rendering quality, and it would allow for more natural gradients and things like consistently tiled bitmap fills.

How to do it? Well, it’s best to break it down and calculate the adjusted x and y positions separately. We’ll be able to call the same function for both directions, and the only difference is the arguments that we pass into the function. Here’s a representation of how a lineTo() command is processed by OpenFL when rendering using the HTML Canvas API.

var lineToCommand = data.readLineTo();
var x = lineToCommand.x;
var y = lineToCommand.y;
if (scale9Grid != null)
{
	x = toScale9GridPosition(x, scale9Grid.x, scale9Grid.width, originalWidth, scaleX);
	y = toScale9GridPosition(y, scale9Grid.y, scale9Grid.height, originalHeight, scaleY);
}
context2d.lineTo(x, y);

It’s a little more complicated in the real code because scale9Grid is ignored in cases where the object is rotated or skewed, but this is pretty close to how OpenFL handles it.

The magic happens in that toScale9GridPosition() function that we use above to adjust both the x and y positions. A simplified representation of that function appears below.

function toScale9Position(pos:Float, gridStart:Float, gridCenter:Float, unscaledSize:Float, scale:Float):Float
{
	// scale9Grid stores two of the three regions in each direction,
	// so we need to calculate the third region
	var gridEnd = unscaledSize - gridCenter - gridStart;

	// what's the new total width or height after accounting for
	// scaleX or scaleY?
	var scaledSize = unscaledSize * scale;

	// if the start and end regions are unscaled, what's the new
	// size of the center region?
	var scaledCenter = scaledSize - gridStart - gridEnd;

First, we need to calculate a few values based on our inputs.

scale9Grid represents a 3×3 grid, but the openfl.geom.Rectangle type stores only the data for a 2×2 grid. We need to calculate the final region in each direction based on the original, unscaled width or height of the graphics.

Then, we need to calculate the rendered width or height by multiplying the original, unscaled width or height of the graphics either by scaleX or scaleY.

Finally, we need to determine the rendered width or height of the center region, which is based on the scaled width or height, but subtracting the first and third grid regions. These regions represent the corners, which we don’t want to scale.

From there, we can figure out where the pos parameter falls within the three regions. The first region is easy. Just check if pos is less than the value of scale9Grid.x or scale9Grid.y (represented here as gridStart).

if (pos <= gridStart)
{
	return pos;
}

Then, we’ll check if pos falls within the third region.

if (pos >= (gridStart + gridCenter))
{
	return gridStart + scaledCenter + (pos - gridStart - gridCenter);
}

After scaling, the third region starts at (gridStart + scaledCenter). To determine where pos falls within the third region, we need to subtract both gridStart and gridCenter from pos.

Finally, if pos does not fall within the first or third region, we need to figure out where it falls within the center region.

return gridStart + scaledCenter * (pos - gridStart) / gridCenter;

The center region starts at gridStart. To determine where pos falls within the original, unscaled center region, we need to subtract gridStart from pos. However, since the center region is scaled, we need to divide by either scale9Grid.width or scale9Grid.height (represented here as gridCenter) and then multiply by scaledCenter.

I left out some edge cases, but that’s basically how to adjust the points to account for scale9Grid. The first edge case is when scaleX or scaleY is zero or a negative value. In that case, the graphics simply aren’t rendered, and the function returns 0.0. The second edge case is when the graphics are scaled down so small that the center region size would be a negative value. In that case, the center region size is treated as 0.0, and then, the corners need to be scaled down! Normally, we don’t scale the corners at all, but when the size is too small, we have no choice (otherwise, they would overlap). In that case, we introduce the value (gridStart + gridEnd + scaledCenter) / (gridStart + gridEnd) as an additional scale multiplier for the first and third regions.

Handling fills with scale9Grid

Adjusting the positions solves rendering strokes and simple usage of beginFill(). However, more complex fills, like gradients and bitmaps, need to be adjusted too.

For gradients, you typically use the createGradientBox() method on openfl.geom.Matrix to specify the region of the graphics that the gradient should fill. That box is based on the original, unscaled width and height of the graphics. For a simple linear gradient, we use two openfl.geom.Point instances to represent where the gradient starts and ends. After applying that gradient box Matrix, the x and y properties of the Point instances basically map to the same coordinate space you would use for moveTo() or lineTo(). Maybe you’re seeing where this is going already….

point = new Point(-819.2, 0);
point2 = new Point(819.2, 0);
point = matrix.transformPoint(point);
point2 = matrix.transformPoint(point2);
if (scale9Grid != null)
{
	point.x = toScale9Position(point.x, scale9Grid.x, scale9Grid.width, originalWidth, scaleX);
	point.y = toScale9Position(point.y, scale9Grid.y, scale9Grid.height, originalHeight, scaleY);
	point2.x = toScale9Position(point2.x, scale9Grid.x, scale9Grid.width, originalWidth, scaleX);
	point2.y = toScale9Position(point2.y, scale9Grid.y, scale9Grid.height, originalHeight, scaleY);
}

For gradients, we can use that same toScale9Position() function described above to adjust the x and y properties of those Point instances! Now, we can draw an evenly distributed gradient between those points, instead of slicing through the gradient in the middle somewhere.

Side note: Are you wondering what’s up with the -819.2 and 819.2 values? I’m not completely sure either, but I have a some ideas about where it came from. We don’t really use them much in OpenFL, but Flash has a unit of measure called a twip. In Flash, a twip is defined as 1/20th of a pixel. If we multiply 819.2 by 20, we get 16384. That’s an interesting value because, if you know your powers of 2, 16384 is the result of 2^14. Computers handle everything in binary, so this value may have been chosen as an optimization based on how something (I’m not sure what exactly) is stored in memory. Anyway, let’s get back to scale9Grid….

Bitmap fills are a little more complicated. Gradient fills always have a defined start and end point, and adjusting the gradient is based on which regions of the scale9Grid that those points fall into. With a bitmap fill, we mainly have the width and height, which provides an ending position, but not really a starting position, so we can’t use toScale9Position() in the same way that we did with the gradient box Matrix. The bitmap can be drawn with a Matrix too, but it’s purely a transformation, and it doesn’t define a box to work with, like you’d get with a gradient’s Matrix.

To better understand how scaling the bitmap works, I created a little demo app in Flash. I used beginBitmapFill() (with tiling) and drawRect() to test how scaling works when a rectangle is fully contained within the top-left corner, the left region, the bottom region, or the center region. Then, I drew some more rectangles that spanned two or more regions. I included one spanning the top region and the top-right corner, another spanning the top region and the center region, and one spanning the right region and the center region. One final rectangle spanned four regions around the bottom-left corner, including the left region, the bottom region, and the center region. See the screenshot below for what I mean:

Going in, I knew from experience that if your bitmap fill was fully contained within a corner, it wouldn’t be scaled. I also knew that each of the four sides scales in one direction only. Finally, the center scales in both directions. Basically, it’s normal scale9Grid rules, as long as you keep the bitmap fill inside a single region.

However, I wasn’t quite sure that I understood what would happen when a bitmap fill spanned multiple regions. I remembered that if I spanned all nine regions with a single bitmap fill, it would scale in both directions too. I just wasn’t sure by how much. Would it inherit the scale factor of the center region? Would it take some kind of average of each region that it spanned? Something else?

Looking at the screenshot above, it’s easy to rule out the center region’s scale factor taking precedence. The fill in the bottom-left corner that spans four regions includes the center region, but it is clearly scaled differently than the fill that is fully contained within the center region. The boxes in the grid would be the same size in both fills, if they were scaled the same.

Is it some kind of average? No, not that either. I figured it out by playing with my demo so that the Sprite resized based on MouseEvent.MOUSE_MOVE. I watched how the grid boxes would grow and shrink. The number of grid boxes in either the horizontal or vertical direction never changed in Flash. If, at the original width and height of the Sprite, my bitmap fill had four boxes in the horizontal direction and three boxes in the vertical direction, it would always have four boxes and three boxes no matter how big or small I resized the Sprite. Only the boxes would scale, but the same number of boxes would always be displayed (including the same portion of partial boxes too).

With that in mind, we can conclude that the starting and ending points of the fill are actually based on the points of the drawing commands, like moveTo() and lineTo(). In other words, we’ve already used toScale9Position() when we were figuring out those positions. We don’t need to call it again for the bitmap fill. Instead, we need to use the results of those existing calculations to somehow figure out the scale of the fill.

Basically, what I ended up doing was keeping track of the minimum and maximum x and y positions used by all drawing commands between beginBitmapFill() and endFill() (with one additional check at the end, in case a final call to endFill() was omitted, which is allowed). I saved the minimum and maximum of both the original x and y values, and the scaled x and y values returned by toScale9Position().

Then, the bitmap scale is basically calculated like this:

var fillScaleX = (scaledMaxX - scaledMinX) / (unscaledMaxX - unscaledMinX);
var fillScaleY = (scaledMaxY - scaledMinY) / (unscaledMaxY - unscaledMinY);

Note: If maximum and minimum values are equal, we need to avoid dividing by zero, so it returns 1.0 when that special case is encountered.

Scaling isn’t quite enough to match Flash’s behavior in OpenFL. We also need to translate the position of the bitmap fill just a little so that the fill starts relative to scaledMinX and scaledMinY instead of unscaledMinX and unscaledMinY.

var fillTx = ((scaledMinX % (bitmapData.width * fillScaleX)) / fillScaleX)
	- (unscaledMinX % bitmapData.width);
var fillTy = ((scaledMinY % (bitmapData.height * fillScaleY)) / fillScaleY)
	- (unscaledMinY % bitmapData.height);

That’s basically the gist of how scale9Grid works in Flash, and now, OpenFL. I didn’t cover shape drawing commands, like drawRect(), drawCircle(), and others like that mainly because it ends up being almost exactly the same as basic commands like moveTo() and lineTo(). Shapes simply have more points, and each of those points is adjusted through toScale9Position() the same way.

Where can I find the code?

If you want to try out the new scale9Grid implementation in OpenFL, you’ll need to check out OpenFL’s 9.5.0-dev branch on Github, or you can download the openfl-haxelib artifact from a successful Github Actions OpenFL 9.5.0-dev nightly build. Of course, it’s important to remember that nightly builds are not necessarily ready for a stable release on Haxelib yet, so use at your own risk in production. You may encounter some bugs, but we’d love any feedback that you can give. Thanks, and happy coding!

P.S. If you love reading my OpenFL and Lime devlogs, or if you benefit from any of my contributions to OpenFL in general (coding, documentation, answering forum/Discord questions), please consider either a one-time donation or a monthly recurring donation as a token of appreciation through my GitHub Sponsors or Liberapay pages. Thank you!

Introducing hunter, a CLI file watcher for Haxe

Having your development tools rebuild your project automatically when a source file changes can be very convenient. Especially when you’re trying to ensure that everything looks just right, and you’re making a lot of tiny little tweaks, which require a short feedback loop.

Today, I’m releasing the first version a new CLI utility for Haxe developers that I’m calling hunter. This tool runs in Haxe’s interpreter using the haxelib run hunter command, and it simply watches one or more directories for changes. When a change gets detected, it automatically runs any command line tool that you specify. For instance, you might watch your src directory for changes to .hx code, and then compile your project in response.

Here’s an example command that will rebuild a Haxe project when files in the src directory change:

haxelib run hunter "haxe compile.hxml" src

The first argument passed to hunter is the command to run (be sure to wrap it in quotes or escape the spaces if you need to include arguments), and it is followed by a separate argument for each directory to watch for file changes. There are a few optional arguments available too, like --ignoreDotFiles and --interval, which you can learn about in the README.

Or maybe you’re using OpenFL instead. Luckily, any CLI command is supported by hunter:

haxelib run hunter "lime build html5" src

The hunter utility is built using the file system watchers implemented in the excellent haxe-files library.

My reason for building hunter is to help transition Lime and OpenFL away from requiring Node.js for certain functionality, like the -watch option. Ideally, any Haxe developer (not only those using OpenFL and Lime) should be able to use haxelib run for common development tasks like this, without requiring a separate runtime like Node.js, Python, etc. to be installed on a their system. It’s the same reason why I created Snake Server, which provides a simple HTTP server for testing JS and HTML content locally. I’m hoping that the next major version of Lime will be able to switch to these new CLI utilities that run in the Haxe interpreter instead of Node.js. It will result in a smaller Lime bundle to download, and it will enrich the Haxe ecosystem with new tools that everyone can use.

To install hunter, run the following command:

haxelib install hunter

The hunter source code is available on GitHub. PRs with bug fixes are certainly welcome!

I hope that the Haxe community finds hunter to be useful for rebuilding projects when files change. If you like hunter (or my other utilities, like snake-server), please consider a monthly donation towards my open source contributions on my GitHub Sponsors page or my Liberapay page. Thank you!

OpenFL Devlog: The importance of text metrics

In my previous OpenFL and Lime devlogs, I’ve mostly talked about the technical details of implementing new features in OpenFL. Not this time… but don’t worry! There’s more of that coming soon. I have a couple new features currently in development that I’m planning to write about once I get them committed to the Git repository. However, today, I thought that I would share my journey fixing some text issues that popped up recently in Lime 8.2.

If you’re not familiar, OpenFL is an implementation of the APIs available in Adobe Flash Player (and Adobe AIR) using the Haxe programming language. Projects built with Haxe can be cross-compiled to JavaScript, C++, and many other targets — making it available on the web, desktop apps, mobile apps, game consoles, and more. Lime is a dependency of OpenFL, developed by the same team, and it exposes various native C++ libraries to Haxe (such as SDL, FreeType, and various audio and image format decoders).

Some fonts render incorrectly in Lime 8.2.0

Soon after the release of Lime 8.2.0, some users reported that certain fonts weren’t rendering correctly. Many fonts were fine, though, which is how this got missed in testing. The fonts that didn’t render correctly were resulting in lines that were very close together, with almost zero gap in between them.

My initial investigation showed that the broken fonts had different ascent and descent values compared to Lime 8.1.3 (I’ll go into detail about what those terms mean in a little bit).

Interestingly, I also discovered that when compiling with Lime’s flash/air targets, the same fonts (when embedded into the SWF generated by Lime) were getting rendered incorrectly in Flash Player or AIR. Obviously, we couldn’t have replaced Flash’s C++ text rendering code with our own (even accidentally), so that’s pretty strange. When Lime embeds a font for Flash, it manually writes the binary SWF tags that tell Flash how to render each of the font’s characters, and how lines should be laid out relative to each other. That code in Lime that writes binary SWF tags shares something in common with OpenFL’s text rendering code: Both use the open source FreeType library to parse and read the font files.

We had updated FreeType to version 2.10.0 in Lime 8.2.0. Simply put: the new FreeType was the obvious culprit for this bug. Rolling back to an older version of FreeType fixed the issue, and that’s what we did in Lime 8.2.1. That resulted in a different letter spacing issue, though, and it could have been fixed by downgrading FreeType even further. I didn’t want to do that.

It would be nice to be able to update FreeType from time to time to get the latest bug fixes and enhancements. I wanted to know why updating FreeType changed the line spacing, and if there was something I could do to restore the behavior from the older FreeType version with that newer FreeType version so that OpenFL text rendering would more closely match Flash Player and AIR, which behaves more like the older FreeType. So my investigation continued, and I fell down quite the rabbit hole on the subject of text rendering.

A bit of terminology about text line metrics

To get everyone on the same page, let’s define some terms used when measuring and laying out text, including the ones that I promised to define earlier. The image below is from the Adobe Flash documentation, and was licensed CC-BY-SA 3.0 by Adobe.

First, let’s talk about the baseline. The baseline is where the bottom of most characters will align when you position them next to each other. In the picture below, the bottom of the W, the bottom of the circular part of the q, and at the bottom of the x are all resting on the baseline (drawn horizontally in red from the far left side of the image to the far right side).

The ascent of a text line is the distance from the baseline to the highest point of the line. In the picture above, the ascent is drawn vertically in green to the right of the x character. It starts at the baseline and goes a bit above the height of the W.

Some characters are partially rendered below the baseline, such as lower case g, y, or j. The descent is the distance from the baseline to the lowest point of the line. In the picture above, the descent is drawn vertically in green a bit below and to the right of the ascent. It starts at the baseline and extends a bit below the q.

Leading or line gap is the spacing between lines. In the picture above, the leading is drawn vertically in green a bit below and to the right of the decent. It starts at the descent of the first line and extends to the top of the ascent of the second line.

Line height is distance from the baseline of one line to the baseline of the next. This is a value that may be customized by the font designer based on aesthetic taste. In the picture above, it is drawn vertically in green on the left of the first line. In OpenFL, and Adobe Flash before it, the line height is typically expected to be equal the sum of the ascent, descent, and leading/gap.

How do font files store text line metrics?

So it might be a little bit weird, but values like ascent and descent may be stored in three (possibly even more, for all I know…) different places in a font file. Even weirder, each of those different places may store a completely different value than the others. What?!

Strange, but true. For ascent, there’s hheaAscender from the hhea OpenType table, typoAscender from the OS/2 OpenType table, and winAscent also from the OS/2 OpenType table. Descent and line gap have similar values in those tables.

So, if there are duplicates with different values, how do you know which one is correct? Well… that’s where things get tricky. Not everyone agrees about which one should be considered correct. WHAT?!

That’s right! Programs like Microsoft Word and Adobe Photoshop don’t necessarily render the same string of text with the same text metrics when using the exact same font file. In other words, Word might select one set of ascent and descent values (such as winAscent and winDescent), while Photoshop selects a different set (such as typoAscender and typoDescender). Even stranger: Word and Photoshop might actually choose the same set of metrics for certain font files.

Basically, Word and Photoshop each have different algorithms for determining which of the metrics from the font file should be selected. These algorithms might depend on whether some of the metrics are missing or not, or if certain flags have been set elsewhere in the font data to indicate that the designer preferred certain metrics over others. Part of why there are multiple sets of metrics is backwards compatibility with older, legacy programs and operating systems that can’t be updated to handle new font format improvements. It’s quite the complex beast!

Customizing FreeType’s algorithm

Upgrading FreeType in Lime 8.2.0 drastically changed the text line metrics passed to OpenFL (and also used by Lime for generating embedded SWF fonts). This is because FreeType changed its algorithm for determining how to choose the correct metrics between the 2.9.1 version and the 2.10.0 version.

As a result, when using FreeType 2.10.0, it’s likely that ascent and descent values will be noticeably smaller than when using FreeType 2.9.1. This isn’t necessarily bad, but OpenFL’s text layout algorithm makes some assumptions about the relationship among line height, line gap, ascent and descent. Those assumptions were safe to make with FreeType 2.9.1’s algorithm, but not with FreeType 2.10.0’s new algorithm. In the future, we might consider upgrading OpenFL’s text rendering code to support both algorithms without issues. However, it’s a non-trivial change, and it may risk introducing new bugs. For the time being, it’s better if we could upgrade FreeType to 2.10.0 or newer, but keep FreeType 2.9.1’s algorithm.

Luckily, FreeType’s algorithm for choosing text line metrics is not set in stone. Developers are welcome to provide their own custom algorithm for choosing metrics, if they desire. With that in mind, it is actually pretty easy to copy the algorithm from FreeType 2.9.1 and use it with FreeType 2.10.0, so that’s exactly what I did in Lime 8.2.2.

For reference, here are links to the original C code used for choosing text line metrics in both versions of FreeType, if you want to see how they’re different.

https://github.com/freetype/freetype/blob/VER-2-9-1/src/sfnt/sfobjs.c#L1633-L1688
https://github.com/freetype/freetype/blob/VER-2-10-0/src/sfnt/sfobjs.c#L1644-L1713

Each snippet includes a comment that explains why they chose to implement the algorithm the way that they did.

And here’s Lime’s new implementation, based on FreeType 2.9.1 (which also includes fallback to FreeType 2.10.0’s implementation, if any metric is missing, for some reason):

https://github.com/openfl/lime/blob/8.2.2/project/src/text/Font.cpp#L575-L622

That’s it! Lime 8.2.2 was released at the end of 2024, so if you’re still in Lime 8.2.0 or 8.2.1, you should probably update Lime to get this fix.

If you’re interested into diving deeper into the topic of font files and their various metrics, I highly recommend reading Glyphsapp Tutorial: Vertical Metrics. In addition to the FreeType documentation, the Glyphsapp tutorial was definitely my most referenced resource while trying to understand how this all worked. It has the right amount of detail, and it carefully guides you through a journey of understanding it all, and I was really impressed.