OSDN Git Service

Extract distance field generation into a standalone class
authorThomas ten Cate <ttencate@gmail.com>
Sun, 4 Nov 2012 17:15:33 +0000 (17:15 +0000)
committerThomas ten Cate <ttencate@gmail.com>
Sun, 4 Nov 2012 17:15:36 +0000 (17:15 +0000)
Also make it runnable as a command line tool

extensions/gdx-tools/src/com/badlogic/gdx/tools/distancefield/DistanceFieldGenerator.java [new file with mode: 0644]
extensions/gdx-tools/src/com/badlogic/gdx/tools/hiero/unicodefont/effects/DistanceFieldEffect.java

diff --git a/extensions/gdx-tools/src/com/badlogic/gdx/tools/distancefield/DistanceFieldGenerator.java b/extensions/gdx-tools/src/com/badlogic/gdx/tools/distancefield/DistanceFieldGenerator.java
new file mode 100644 (file)
index 0000000..37e1d0b
--- /dev/null
@@ -0,0 +1,327 @@
+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());
+               }
+       }
+}
index 3b082e4..c269747 100644 (file)
@@ -9,182 +9,37 @@ import java.util.ArrayList;
 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);
@@ -195,12 +50,18 @@ public class DistanceFieldEffect implements ConfigurableEffect
        @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);
        }
@@ -213,9 +74,9 @@ public class DistanceFieldEffect implements ConfigurableEffect
        @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;
        }
 
@@ -223,12 +84,12 @@ public class DistanceFieldEffect implements ConfigurableEffect
        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());
                        }
                }