package org.newdawn.slick.font.effects; import java.awt.Color; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import org.newdawn.slick.UnicodeFont; import org.newdawn.slick.font.Glyph; /** * A filter to create a distance field from a 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 Orangy * @author ttencate */ 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; } /** * Draws the glyph to the given image, upscaled by a factor of {@link #upscale}. * * @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)); // 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); inputG.setColor(Color.WHITE); inputG.fill(glyph.getShape()); } @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); drawGlyph(input, glyph); BufferedImage distanceField = computeDistanceField(input); g.drawImage(distanceField, new AffineTransform(), null); } @Override public String toString() { return "Distance field"; } @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.")); return values; } @Override 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()); } } } }