package com.badlogic.gdx.tools.hiero.unicodefont.effects;
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 Magni�cation 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());
}
}
}