gdx-studio

#libgdx#java#desktop

git clone https://git.pyrossh.dev/gdx-studio

An IDE for creating Games using libgdx and Java supported on all platforms Android, iOS, Desktop


src_libs/com/badlogic/gdx/tools/hiero/unicodefont/effects/DistanceFieldGenerator.java
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());
		}
	}
}