--- /dev/null
+package com.badlogic.gdx.tools.distancefield;
+
+import java.awt.Color;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+
+import javax.imageio.ImageIO;
+
+/**
+ * Generates a signed distance field image from a binary (black/white) source image.
+ *
+ * <p> Signed distance fields are used in Team Fortress 2 by Valve to enable
+ * sharp rendering of bitmap fonts even at high magnifications,
+ * using nothing but alpha testing so at no extra runtime cost.
+ *
+ * <p> The technique is described in the SIGGRAPH 2007 paper
+ * "Improved Alpha-Tested Magnification for Vector Textures and Special Effects" by Chris Green:
+ * <a href="http://www.valvesoftware.com/publications/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf">
+ * http://www.valvesoftware.com/publications/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf
+ * </a>
+ *
+ * @author Thomas ten Cate
+ */
+public class DistanceFieldGenerator {
+
+ private Color color = Color.white;
+ private int downscale = 1;
+ private float spread = 1;
+
+ /** @see #setColor(Color) */
+ public Color getColor() {
+ return color;
+ }
+
+ /**
+ * Sets the color to be used for the output image. Its alpha component is ignored.
+ * Defaults to white, which is convenient for multiplying by a color value at runtime.
+ */
+ public void setColor(Color color) {
+ this.color = color;
+ }
+
+ /** @see #setDownscale(int) */
+ public int getDownscale() {
+ return downscale;
+ }
+
+ /**
+ * Sets the factor by which to downscale the image during processing.
+ * The output image will be smaller than the input image by this factor, rounded downwards.
+ *
+ * <p> For greater accuracy, images to be used as input for a distance field are often
+ * generated at higher resolution.
+ *
+ * @param downscale a positive integer
+ * @throws IllegalArgumentException if downscale is not positive
+ */
+ public void setDownscale(int downscale) {
+ if (downscale <= 0)
+ throw new IllegalArgumentException("downscale must be positive");
+ this.downscale = downscale;
+ }
+
+ /** @see #setSpread(float) */
+ public float getSpread() {
+ return spread;
+ }
+
+ /**
+ * Sets the spread of the distance field. The spread is the maximum distance in pixels
+ * that we'll scan while for a nearby edge. The resulting distance is also normalized
+ * by the spread.
+ *
+ * @param spread a positive number
+ * @throws IllegalArgumentException if spread is not positive
+ */
+ public void setSpread(float spread) {
+ if (spread <= 0)
+ throw new IllegalArgumentException("spread must be positive");
+ this.spread = spread;
+ }
+
+ /**
+ * Caclulate the squared distance between two points
+ *
+ * @param x1 The x coordinate of the first point
+ * @param y1 The y coordiante of the first point
+ * @param x2 The x coordinate of the second point
+ * @param y2 The y coordinate of the second point
+ * @return The squared distance between the two points
+ */
+ private static int squareDist(final int x1, final int y1, final int x2, final int y2)
+ {
+ final int dx = x1 - x2;
+ final int dy = y1 - y2;
+ return dx*dx + dy*dy;
+ }
+
+ /**
+ * Process the image into a distance field.
+ *
+ * The input image should be binary (black/white), but if not, see {@link #isInside(int)}.
+ *
+ * The returned image is a factor of {@code upscale} smaller than {@code inImage}.
+ * Opaque pixels more than {@link #spread} away in the output image from white remain opaque;
+ * transparent pixels more than {@link #spread} away in the output image from black remain transparent.
+ * In between, we get a smooth transition from opaque to transparent, with an alpha value of 0.5
+ * when we are exactly on the edge.
+ *
+ * @param inImage the image to process.
+ * @return the distance field image
+ */
+ public BufferedImage generateDistanceField(BufferedImage inImage)
+ {
+ final int inWidth = inImage.getWidth();
+ final int inHeight = inImage.getHeight();
+ final int outWidth = inWidth / downscale;
+ final int outHeight = inHeight / downscale;
+ final BufferedImage outImage = new BufferedImage(outWidth, outHeight, BufferedImage.TYPE_4BYTE_ABGR);
+
+ // Note: coordinates reversed to mimic storage of BufferedImage, for memory locality
+ final boolean[][] bitmap = new boolean[inHeight][inWidth];
+ for (int y = 0; y < inHeight; ++y) {
+ for (int x = 0; x < inWidth; ++x) {
+ bitmap[y][x] = isInside(inImage.getRGB(x, y));
+ }
+ }
+
+ for (int y = 0; y < outHeight; ++y)
+ {
+ for (int x = 0; x < outWidth; ++x)
+ {
+ int centerX = (x * downscale) + (downscale / 2);
+ int centerY = (y * downscale) + (downscale / 2);
+ float signedDistance = findSignedDistance(centerX, centerY, bitmap);
+ outImage.setRGB(x, y, distanceToRGB(signedDistance));
+ }
+ }
+
+ return outImage;
+ }
+
+ /**
+ * Returns {@code true} if the color is considered as the "inside" of the image,
+ * {@code false} if considered "outside".
+ *
+ * <p> Any color with one of its color channels at least 128
+ * <em>and</em> its alpha channel at least 128 is considered "inside".
+ */
+ private boolean isInside(int rgb) {
+ return (rgb & 0x808080) != 0 && (rgb & 0x80000000) != 0;
+ }
+
+ /**
+ * For a distance as returned by {@link #findSignedDistance}, returns the corresponding "RGB" (really ARGB) color value.
+ *
+ * @param signedDistance the signed distance of a pixel
+ * @return an ARGB color value suitable for {@link BufferedImage#setRGB}.
+ */
+ private int distanceToRGB(float signedDistance) {
+ float alpha = 0.5f + 0.5f * (signedDistance / spread);
+ alpha = Math.min(1, Math.max(0, alpha)); // compensate for rounding errors
+ int alphaByte = (int) (alpha * 0xFF); // no unsigned byte in Java :(
+ return (alphaByte << 24) | (color.getRGB() & 0xFFFFFF);
+ }
+
+ /**
+ * Returns the signed distance for a given point.
+ *
+ * For points "inside", this is the distance to the closest "outside" pixel.
+ * For points "outside", this is the <em>negative</em> distance to the closest "inside" pixel.
+ * If no pixel of different color is found within a radius of {@code spread}, returns
+ * the {@code -spread} or {@code spread}, respectively.
+ *
+ * @param centerX the x coordinate of the center point
+ * @param centerY the y coordinate of the center point
+ * @param bitmap the array representation of an image, {@code true} representing "inside"
+ * @return the signed distance
+ */
+ private float findSignedDistance(final int centerX, final int centerY, boolean[][] bitmap)
+ {
+ final int width = bitmap[0].length;
+ final int height = bitmap.length;
+ final boolean base = bitmap[centerY][centerX];
+
+ final int delta = (int) Math.ceil(spread);
+ final int startX = Math.max(0, centerX - delta);
+ final int endX = Math.min(width - 1, centerX + delta);
+ final int startY = Math.max(0, centerY - delta);
+ final int endY = Math.min(height - 1, centerY + delta);
+
+ int closestSquareDist = delta * delta;
+
+ for (int y = startY; y <= endY; ++y)
+ {
+ for (int x = startX; x <= endX; ++x)
+ {
+ if (base != bitmap[y][x])
+ {
+ final int squareDist = squareDist(centerX, centerY, x, y);
+ if (squareDist < closestSquareDist)
+ {
+ closestSquareDist = squareDist;
+ }
+ }
+ }
+ }
+
+ float closestDist = (float) Math.sqrt(closestSquareDist);
+ return (base ? 1 : -1) * Math.min(closestDist, spread);
+ }
+
+ /** Prints usage information to standard output. */
+ private static void usage() {
+ System.out.println(
+ "Generates a distance field image from a black and white input image.\n" +
+ "The distance field image contains a solid color and stores the distance\n" +
+ "in the alpha channel.\n" +
+ "\n" +
+ "The output file format is inferred from the file name.\n" +
+ "\n" +
+ "Command line arguments: INFILE OUTFILE [OPTION...]\n" +
+ "\n" +
+ "Possible options:\n" +
+ " --color rrggbb color of output image (default: ffffff)\n" +
+ " --downscale n downscale by factor of n (default: 1)\n" +
+ " --spread n edge scan distance (default: 1)\n");
+ }
+
+ /** Thrown when the command line contained nonsense. */
+ private static class CommandLineArgumentException extends IllegalArgumentException {
+ public CommandLineArgumentException(String message) {
+ super(message);
+ }
+ }
+
+ /**
+ * Main function to run the generator as a standalone program.
+ * Run without arguments for usage instructions (or see {@link #usage()}).
+ *
+ * @param args command line arguments
+ */
+ public static void main(String[] args) {
+ try {
+ run(args);
+ } catch (CommandLineArgumentException e) {
+ System.err.println("Error: " + e.getMessage() + "\n");
+ usage();
+ System.exit(1);
+ }
+ }
+
+ /**
+ * Runs the program.
+ * @param args command line arguments
+ * @throws CommandLineArgumentException if the command line contains an error
+ */
+ private static void run(String[] args) {
+ DistanceFieldGenerator generator = new DistanceFieldGenerator();
+ String inputFile = null;
+ String outputFile = null;
+
+ int i = 0;
+ try {
+ for (; i < args.length; ++i) {
+ String arg = args[i];
+ if (arg.startsWith("-")) {
+ if ("--help".equals(arg)) {
+ usage();
+ System.exit(0);
+ } else if ("--color".equals(arg)) {
+ ++i;
+ generator.setColor(new Color(Integer.parseInt(args[i], 16)));
+ } else if ("--downscale".equals(arg)) {
+ ++i;
+ generator.setDownscale(Integer.parseInt(args[i]));
+ } else if ("--spread".equals(arg)) {
+ ++i;
+ generator.setSpread(Float.parseFloat(args[i]));
+ } else {
+ throw new CommandLineArgumentException("unknown option " + arg);
+ }
+ } else {
+ if (inputFile == null) {
+ inputFile = arg;
+ } else if (outputFile == null) {
+ outputFile = arg;
+ } else {
+ throw new CommandLineArgumentException("exactly two file names are expected");
+ }
+ }
+ }
+ } catch (IndexOutOfBoundsException e) {
+ throw new CommandLineArgumentException("option " + args[args.length - 1] + " requires an argument");
+ } catch (NumberFormatException e) {
+ throw new CommandLineArgumentException(args[i] + " is not a number");
+ }
+ if (inputFile == null) {
+ throw new CommandLineArgumentException("no input file specified");
+ }
+ if (outputFile == null) {
+ throw new CommandLineArgumentException("no output file specified");
+ }
+
+ String outputFormat = outputFile.substring(outputFile.lastIndexOf('.') + 1);
+ boolean exists;
+ if (!ImageIO.getImageWritersByFormatName(outputFormat).hasNext()) {
+ throw new RuntimeException("No image writers found that can handle the format '" + outputFormat + "'");
+ }
+
+ BufferedImage input = null;
+ try {
+ input = ImageIO.read(new File(inputFile));
+ } catch (IOException e) {
+ System.err.println("Failed to load image: " + e.getMessage());
+ }
+
+ BufferedImage output = generator.generateDistanceField(input);
+
+ try {
+ ImageIO.write(output, outputFormat, new File(outputFile));
+ } catch (IOException e) {
+ System.err.println("Failed to write output image: " + e.getMessage());
+ }
+ }
+}
import java.util.Iterator;
import java.util.List;
+import com.badlogic.gdx.tools.distancefield.DistanceFieldGenerator;
import com.badlogic.gdx.tools.hiero.unicodefont.Glyph;
import com.badlogic.gdx.tools.hiero.unicodefont.UnicodeFont;
/**
- * A filter to create a distance field from a source image.
+ * A filter to create a distance field. The resulting font can be rendered
+ * with a simple custom shader to draw bitmap fonts that remain crisp
+ * even under high magnification.
*
- * <p> Signed distance fields are used in Team Fortress 2 by Valve to enable
- * sharp rendering of bitmap fonts even at high magnifications,
- * using nothing but alpha testing so at no extra runtime cost.
+ * <p> An example of the use of such a font is included in the libgdx test suite
+ * under the name {@code BitmapFontDistanceFieldTest}.
*
- * <p> The technique is described in the SIGGRAPH 2007 paper
- * "Improved Alpha-Tested Magnification for Vector Textures and Special Effects" by Chris Green:
- * <a href="http://www.valvesoftware.com/publications/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf">
- * http://www.valvesoftware.com/publications/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf
- * </a>
+ * @see DistanceFieldGenerator
*
- * @author Orangy
- * @author ttencate
+ * @author Thomas ten Cate
*/
public class DistanceFieldEffect implements ConfigurableEffect
{
- // See getValues() for descriptions of these
- private Color color = Color.white;
- private int spread = 4;
- private int upscale = 8;
-
- public Color getColor() {
- return color;
- }
-
- public void setColor(Color color) {
- this.color = color;
- }
-
- public int getSpread() {
- return spread;
- }
-
- public void setSpread(int spread) {
- this.spread = Math.max(1, spread);
- }
-
- public int getUpscale() {
- return upscale;
- }
-
- public void setUpscale(int upscale) {
- this.upscale = Math.max(1, upscale);
- }
-
- /**
- * Caclulate the squared distance between two points
- *
- * @param x1 The x coordinate of the first point
- * @param y1 The y coordiante of the first point
- * @param x2 The x coordinate of the second point
- * @param y2 The y coordinate of the second point
- * @return The squared distance between the two points
- */
- private static int squareDist(final int x1, final int y1, final int x2, final int y2)
- {
- final int dx = x1 - x2;
- final int dy = y1 - y2;
- return dx*dx + dy*dy;
- }
-
- /**
- * Process the image into a distance field.
- *
- * The input image should be binary (black/white), but if not, any pixel with a value of over 128
- * in any of its color channels is considered opaque, and transparent otherwise.
- *
- * The returned image is a factor of {@code upscale} smaller than {@code inImage}.
- * Opaque pixels more than {@link #spread} away in the output image from white remain opaque;
- * transparent pixels more than {@link #spread} away in the output image from black remain transparent.
- * In between, we get a smooth transition from opaque to transparent, with an alpha value of 0.5
- * when we are exactly on the edge.
- *
- * @param inImage the image to process.
- * @return the distance field image
- */
- public BufferedImage computeDistanceField(BufferedImage inImage)
- {
- final int inWidth = inImage.getWidth();
- final int inHeight = inImage.getHeight();
- final int outWidth = inWidth / upscale;
- final int outHeight = inHeight / upscale;
- final BufferedImage outImage = new BufferedImage(outWidth, outHeight, BufferedImage.TYPE_4BYTE_ABGR);
-
- // Note: coordinates reversed to mimic storage of BufferedImage, for memory locality
- final boolean[][] bitmap = new boolean[inHeight][inWidth];
- for (int y = 0; y < inHeight; ++y) {
- for (int x = 0; x < inWidth; ++x) {
- // Any colour with one of its channels greater than 128 is considered "inside"
- bitmap[y][x] = (inImage.getRGB(x, y) & 0x808080) != 0;
- }
- }
-
- for (int y = 0; y < outHeight; ++y)
- {
- for (int x = 0; x < outWidth; ++x)
- {
- float signedDistance = findSignedDistance(
- (x * upscale) + (upscale / 2),
- (y * upscale) + (upscale / 2),
- bitmap);
- outImage.setRGB(x, y, distanceToRGB(signedDistance));
- }
- }
-
- return outImage;
- }
-
- /**
- * For a distance as returned by {@link #findSignedDistance}, returns the corresponding "RGB" (really ARGB) color value.
- *
- * @param signedDistance the signed distance of a pixel
- * @return an ARGB color value suitable for {@link BufferedImage#setRGB}.
- */
- private int distanceToRGB(float signedDistance) {
- float alpha = 0.5f + 0.5f * (signedDistance / spread);
- alpha = Math.min(1, Math.max(0, alpha)); // compensate for rounding errors
- int alphaByte = (int) (alpha * 0xFF); // no unsigned byte in Java :(
- return (alphaByte << 24) | (color.getRGB() & 0xFFFFFF);
- }
-
- /**
- * Returns the signed distance for a given point.
- *
- * @param pointX The x coordinate of the point
- * @param pointY The y coordinate of the point
- * @param bitmap The upscaled binary glyph image
- * @return The signed distance, in units of pixels in the <em>output</em> image
- */
- private float findSignedDistance(final int pointX, final int pointY, boolean[][] bitmap)
- {
- final int width = bitmap[0].length;
- final int height = bitmap.length;
- final boolean base = bitmap[pointY][pointX];
-
- int maxDist = upscale * spread;
- final int startX = Math.max(0, pointX - maxDist);
- final int endX = Math.min(width - 1, pointX + maxDist);
- final int startY = Math.max(0, pointY - maxDist);
- final int endY = Math.min(height - 1, pointY + maxDist);
-
- int closestSquareDist = maxDist * maxDist;
-
- for (int y = startY; y <= endY; ++y)
- {
- for (int x = startX; x <= endX; ++x)
- {
- if (base != bitmap[y][x])
- {
- final int squareDist = squareDist(pointX, pointY, x, y);
- if (squareDist < closestSquareDist)
- {
- closestSquareDist = squareDist;
- }
- }
- }
- }
-
- float closestDist = (float) Math.sqrt(closestSquareDist);
- return (base ? 1 : -1) * closestDist / upscale;
- }
+ private Color color = Color.WHITE;
+ private int scale = 1;
+ private float spread = 1;
/**
- * Draws the glyph to the given image, upscaled by a factor of {@link #upscale}.
+ * Draws the glyph to the given image, upscaled by a factor of {@link #scale}.
*
* @param image the image to draw to
* @param glyph the glyph to draw
*/
private void drawGlyph(BufferedImage image, Glyph glyph) {
Graphics2D inputG = (Graphics2D) image.getGraphics();
- inputG.setTransform(AffineTransform.getScaleInstance(upscale, upscale));
+ inputG.setTransform(AffineTransform.getScaleInstance(scale, scale));
// We don't really want anti-aliasing (we'll discard it anyway),
// but accurate positioning might improve the result slightly
inputG.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
@Override
public void draw(BufferedImage image, Graphics2D g, UnicodeFont unicodeFont, Glyph glyph) {
BufferedImage input = new BufferedImage(
- upscale * glyph.getWidth(),
- upscale * glyph.getHeight(),
- BufferedImage.TYPE_BYTE_BINARY);
+ scale * glyph.getWidth(),
+ scale * glyph.getHeight(),
+ BufferedImage.TYPE_BYTE_BINARY);
drawGlyph(input, glyph);
- BufferedImage distanceField = computeDistanceField(input);
+ DistanceFieldGenerator generator = new DistanceFieldGenerator();
+ generator.setColor(color);
+ generator.setDownscale(scale);
+ // We multiply spread by the scale, so that changing scale will only affect accuracy
+ // and not spread in the output image.
+ generator.setSpread(scale * spread);
+ BufferedImage distanceField = generator.generateDistanceField(input);
g.drawImage(distanceField, new AffineTransform(), null);
}
@Override
public List getValues() {
List values = new ArrayList();
- values.add(EffectUtil.colorValue("Color", getColor()));
- values.add(EffectUtil.intValue("Upscale", getUpscale(), "The distance field is computed from an image upscaled by this factor. Set this to a higher value for more accuracy, but slower font generation."));
- values.add(EffectUtil.intValue("Spread", getSpread(), "The maximum distance from edges where the effect of the distance field is seen. Set this to about half the width of lines in your font."));
+ values.add(EffectUtil.colorValue("Color", color));
+ values.add(EffectUtil.intValue("Scale", scale, "The distance field is computed from an image larger than the output glyph by this factor. Set this to a higher value for more accuracy, but slower font generation."));
+ values.add(EffectUtil.floatValue("Spread", spread, 1.0f, Float.MAX_VALUE, "The maximum distance from edges where the effect of the distance field is seen. Set this to about half the width of lines in your output font."));
return values;
}
public void setValues(List values) {
for (Iterator iter = values.iterator(); iter.hasNext();) {
Value value = (Value)iter.next();
- if (value.getName().equals("Color")) {
- setColor((Color)value.getObject());
- } else if (value.getName().equals("Upscale")) {
- setUpscale((Integer)value.getObject());
- } else if (value.getName().equals("Spread")) {
- setSpread((Integer)value.getObject());
+ if ("Color".equals(value.getName())) {
+ color = (Color)value.getObject();
+ } else if ("Scale".equals(value.getName())) {
+ scale = Math.max(1, (Integer)value.getObject());
+ } else if ("Spread".equals(value.getName())) {
+ spread = Math.max(0, (Float)value.getObject());
}
}