
com.badlogic.gdx.tools.distancefield.DistanceFieldGenerator Maven / Gradle / Ivy
/*******************************************************************************
* Copyright 2011 See AUTHORS file.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
******************************************************************************/
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.
*
*
* 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.
*
*
* The technique is described in the SIGGRAPH 2007 paper "Improved Alpha-Tested Magnification for Vector Textures and Special
* Effects" by Chris Green:
* http://www.valvesoftware.com/publications/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf
*
* @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.
*
*
* 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".
*
*
* Any color with one of its color channels at least 128 and 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
* negative 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());
}
}
}