Created
December 29, 2015 20:08
-
-
Save cypherdare/eefa5735a1537c188089 to your computer and use it in GitHub Desktop.
Libgdx texture packing with gifs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/******************************************************************************* | |
* Copyright 2015 Cypher Cove LLC | |
* | |
* 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.cyphercove.dayinspace.desktop; | |
import com.badlogic.gdx.tools.*; | |
import com.badlogic.gdx.tools.texturepacker.*; | |
import com.cyphercove.dayinspace.*; | |
import org.apache.commons.io.*; | |
import org.apache.commons.io.filefilter.*; | |
import java.io.*; | |
import java.util.*; | |
public class AtlasGenerator { | |
static final String SOURCE_DIR = "planning/Texture Atlas"; | |
static final String TARGET_DIR = "core/assets"; | |
public static void main (String[] args) throws Exception { | |
//Delete old pack | |
File oldPackFile = new File(TARGET_DIR + "/" + Assets.MAIN_ATLAS + Assets.ATLAS_EXTENSION); | |
if (oldPackFile.exists()){ | |
System.out.println("Deleting old pack file"); | |
oldPackFile.delete(); | |
} | |
//Delete old font files | |
Collection<File> oldFontFiles = FileUtils.listFiles( | |
new File(TARGET_DIR), | |
new RegexFileFilter(".*\\.fnt"), | |
TrueFileFilter.INSTANCE | |
); | |
for (File file : oldFontFiles){ | |
System.out.println("Copying font file: " + file.getName()); | |
FileUtils.deleteQuietly(file); | |
} | |
//Create PNGs for GIF frames | |
GifProcessor gifProcessor = new GifProcessor(0.015f); | |
ArrayList<FileProcessor.Entry> gifFrames = gifProcessor.process(SOURCE_DIR, SOURCE_DIR); | |
//Pack them | |
TexturePacker.Settings settings = new TexturePacker.Settings(); | |
settings.atlasExtension = Assets.ATLAS_EXTENSION; | |
TexturePacker.process( | |
settings, | |
SOURCE_DIR, | |
TARGET_DIR, | |
Assets.MAIN_ATLAS); | |
//Copy over any fonts | |
Collection<File> fontFiles = FileUtils.listFiles( | |
new File(SOURCE_DIR), | |
new RegexFileFilter(".*\\.fnt"), | |
TrueFileFilter.INSTANCE | |
); | |
File destDir = new File(TARGET_DIR); | |
for (File file : fontFiles){ | |
System.out.println("Copying font file: " + file.getName()); | |
FileUtils.copyFileToDirectory(file, destDir); | |
} | |
//Delete the GIF frames that were generated. | |
for (File file : gifProcessor.getGeneratedFiles()) | |
file.delete(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/******************************************************************************* | |
* Copyright 2015 Cypher Cove LLC | |
* | |
* 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.cyphercove.dayinspace.desktop; | |
import java.util.*; | |
/** | |
* Calculates an approximate greatest common divisor for floating point numbers, given a maximum allowable error. | |
* <p> Based on algorithm described <a href="http://stackoverflow.com/a/479028/506796">here</a>. | |
*/ | |
public class FloatingPointGCD { | |
public static float findFloatingPointGCD(Float[] input, float maxError) { | |
//Sanitize input | |
Set<Float> inputSet = new HashSet<Float>(input.length); | |
for (Float f : input) { | |
if (f!=0) inputSet.add(Math.abs(f)); | |
} | |
if (inputSet.size() <= 1) | |
return Math.abs(input[0]); | |
float lowestValue = input[0]; | |
float highestValue = lowestValue; | |
for (int i=1; i<input.length; i++){ | |
float value = input[i]; | |
if (value<lowestValue) lowestValue = value; | |
if (value>highestValue) highestValue = value; | |
} | |
//Find fast worst case GCD to limit upcoming rational fraction tree search | |
int[] errorScaledInput = new int[input.length]; | |
for (int i = 0; i < input.length; i++) { | |
errorScaledInput[i] = Math.round(input[i] / maxError); | |
} | |
float worstCaseGCD = gcd(errorScaledInput) * maxError; | |
//Express input set as ratios to lowest value. If these ratios can be expressed as | |
//rational fractions that all have the same denominator, then that denominator represents | |
//a potential solution when the lowest value is divided by it. | |
inputSet.remove(lowestValue); | |
List<Float> scaledInput = new ArrayList<Float>(inputSet.size()); | |
for (Float f : inputSet) | |
scaledInput.add(f/lowestValue); | |
//Find set of all possible fractions that might produce better result than worstCaseGCD | |
int maxNumerator = Math.round(highestValue / worstCaseGCD); | |
int maxDenominator = Math.round(lowestValue / worstCaseGCD); | |
Set<Rational> tree = generateSternBrocotTree(maxNumerator, maxDenominator); | |
//Find subset of rational representations of each member of input set that don't violate | |
//max error. | |
List<Set<Rational>> validRationals = new ArrayList<Set<Rational>>(); | |
for (Float f : scaledInput){ | |
float unscaledF = f*lowestValue; | |
Set<Rational> valids = new HashSet<Rational>(); | |
for (Rational rat : tree){ | |
float gcd = lowestValue/rat.den; | |
float error = Math.abs(rat.num*gcd - unscaledF); | |
if (error <= maxError) | |
valids.add(rat); | |
} | |
validRationals.add(valids); | |
} | |
//Find all denominators that are shared among all solutions sets. Score each solution | |
//by summing all the numerators and the denominator. | |
int bestSolutionSum = -1; | |
int bestSolution = -1; | |
for (int den = 1; den < maxDenominator+1; den++) { | |
boolean denIsValidForAll = true; | |
int solutionSum = den; | |
for (Set<Rational> floatsSet : validRationals){ | |
boolean denIsValidForFloat = false; | |
for (Rational rat : floatsSet){ | |
if (rat.den == den) { | |
solutionSum += rat.den; | |
denIsValidForFloat = true; | |
break; | |
} | |
} | |
if (!denIsValidForFloat){ | |
denIsValidForAll = false; | |
break; | |
} | |
} | |
if (denIsValidForAll && (bestSolutionSum <0 || solutionSum<bestSolutionSum)){ | |
bestSolutionSum = solutionSum; | |
bestSolution = den; | |
} | |
} | |
float gcd = bestSolution > 0 ? lowestValue/bestSolution : worstCaseGCD; | |
//Minimize error by calculating linear regression | |
inputSet.add(lowestValue); //was removed earlier | |
Map<Float, Float> points = new HashMap<Float, Float>(inputSet.size()); | |
for (Float f : inputSet){ | |
points.put(f, (float)Math.round(f/gcd)); | |
} | |
gcd = calculateRegressionSlope(points); | |
return gcd; | |
} | |
private static class Rational { | |
public int num, den; | |
public Rational(int num, int den){ | |
this.num = num; this.den = den; | |
} | |
public static Rational mediant(Rational a, Rational b){ | |
return new Rational(a.num + b.num, a.den + b.den); | |
} | |
public boolean equals(Object obj){ | |
if (obj instanceof Rational){ | |
Rational rat = (Rational)obj; | |
return rat.num==num && rat.den==den; | |
} | |
return false; | |
} | |
public String toString(){ | |
return num + ":" + den; | |
} | |
} | |
private static Set<Rational> generateSternBrocotTree(int maxNumerator, int maxDenominator){ | |
Set<Rational> tree = new HashSet<Rational>(); | |
Rational left = new Rational(0,1); | |
Rational right = new Rational(1,0); | |
addSternBrocotBranch(tree, left, right, maxNumerator, maxDenominator); | |
return tree; | |
} | |
private static void addSternBrocotBranch(Set<Rational> tree, Rational left, Rational right, int maxNumerator, int maxDenominator){ | |
Rational mediant = Rational.mediant(left, right); | |
if (mediant.num > maxNumerator || mediant.den > maxDenominator) | |
return; | |
tree.add(mediant); | |
addSternBrocotBranch(tree, left, mediant, maxNumerator, maxDenominator); //add left branch | |
addSternBrocotBranch(tree, mediant, right, maxNumerator, maxDenominator); //add right branch | |
} | |
private static int gcd(int[] in){ | |
if (in.length==1) | |
return in[0]; | |
int gcd = in[0]; | |
for (int i=1; i<in.length; i++){ | |
gcd = gcd(gcd, in[i]); | |
} | |
return gcd; | |
} | |
private static int gcd(int a, int b){ | |
if(a == 0 || b == 0) return a+b; | |
return gcd(b,a%b); | |
} | |
private static float calculateRegressionSlope(Map<Float, Float> points){ | |
float meanX = 0, meanY = 0; | |
int numPoints = points.size(); | |
for(Map.Entry<Float, Float> entry : points.entrySet()){ | |
meanX += entry.getKey(); | |
meanY += entry.getValue(); | |
} | |
meanX /= numPoints; | |
meanY /= numPoints; | |
float stDevX = 0, stDevY = 0, r = 0; | |
for(Map.Entry<Float, Float> entry : points.entrySet()){ | |
float xDiff = entry.getKey()-meanX; | |
float yDiff = entry.getValue()-meanY; | |
stDevX += xDiff*xDiff; | |
stDevY += yDiff*yDiff; | |
r += xDiff*yDiff; | |
} | |
r /= (float)Math.sqrt(stDevX*stDevY); | |
stDevX = (float)Math.sqrt(stDevX/numPoints); | |
stDevY = (float)Math.sqrt(stDevY/numPoints); | |
return r * stDevX / stDevY; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/******************************************************************************* | |
* Copyright 2015 Cypher Cove LLC | |
* | |
* 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.cyphercove.dayinspace.desktop; | |
import com.badlogic.gdx.tools.*; | |
import javax.imageio.*; | |
import javax.imageio.metadata.*; | |
import javax.imageio.stream.*; | |
import java.awt.*; | |
import java.awt.image.*; | |
import java.io.*; | |
import java.util.*; | |
/** | |
* Take all gif files along with potential .alpha counterparts, and write png images | |
* to destination in like directories. | |
*/ | |
public class GifProcessor extends FileProcessor { | |
private static final float GIF_DELAYTIME_TO_SECONDS = 0.01f; | |
private static final Color TRANSPARENT = new Color(0x00000000, true); | |
private float maxAnimationDelayError; | |
private ArrayList<File> generatedFiles = new ArrayList<File>(); | |
public GifProcessor (float maxAnimationDelayError){ | |
this.maxAnimationDelayError = maxAnimationDelayError; | |
addInputSuffix(".gif"); | |
setInputFilter(new FilenameFilter() { | |
@Override | |
public boolean accept(File dir, String name) { | |
return !name.toLowerCase().endsWith(".alpha.gif"); | |
} | |
}); | |
} | |
@Override | |
public ArrayList<Entry> process (File inputFile, File outputRoot) throws Exception { | |
generatedFiles.clear(); | |
return super.process(inputFile, outputRoot); | |
} | |
@Override | |
protected void processFile (Entry entry) throws Exception { | |
File file = entry.inputFile; | |
String fileName = file.getName(); | |
int dotIndex = fileName.lastIndexOf('.'); | |
String name = (dotIndex != -1) ? fileName.substring(0, dotIndex) : fileName; | |
String alphaFileName = name + ".alpha.gif"; | |
File alphaFile = new File(file.getParent(), alphaFileName); | |
if (!alphaFile.exists()){ | |
alphaFileName = name + ".alpha.GIF"; | |
alphaFile = new File(file.getParent(), alphaFileName); | |
} | |
LinkedHashMap<BufferedImage, Float> images = getGifImages(file, true); | |
ArrayList<BufferedImage> alphaImages = null; | |
if (alphaFile.exists()){ | |
LinkedHashMap<BufferedImage, Float> alphaImagesMap = getGifImages(alphaFile, false); | |
alphaImages = new ArrayList<BufferedImage>(alphaImagesMap.size()); | |
for (BufferedImage image : alphaImagesMap.keySet()){ | |
alphaImages.add(image); | |
} | |
} | |
boolean oneFrame = images.size()==1; | |
float delay = 1; | |
if (!oneFrame) { | |
Float[] delays = new Float[images.size()]; | |
delay = FloatingPointGCD.findFloatingPointGCD( | |
images.values().toArray(delays), maxAnimationDelayError); | |
System.out.println("Animation \"" + name + "\" uses delay of " + delay); | |
} | |
int index = -1; | |
int imageNum = 0; | |
for (Map.Entry<BufferedImage, Float> imageEntry : images.entrySet()){ | |
BufferedImage image = imageEntry.getKey(); | |
if (alphaImages!=null){ | |
applyGrayscaleMaskToAlpha(image, alphaImages.get(imageNum)); | |
} | |
int count = oneFrame ? 1 : Math.round(imageEntry.getValue() / delay); | |
for (int i = 0; i < count; i++) { | |
index++; | |
if (!entry.outputDir.exists()) entry.outputDir.mkdir(); | |
File outputFile = new File(entry.outputDir, oneFrame? name + ".png" : name + "_" + index + ".png"); | |
ImageIO.write(image, "png", outputFile); | |
generatedFiles.add(outputFile); | |
} | |
imageNum++; | |
} | |
} | |
public ArrayList<File> getGeneratedFiles() { | |
return generatedFiles; | |
} | |
/** From http://stackoverflow.com/a/8058442/506796 */ | |
public void applyGrayscaleMaskToAlpha(BufferedImage image, BufferedImage mask) | |
{ | |
int width = image.getWidth(); | |
int height = image.getHeight(); | |
int[] imagePixels = image.getRGB(0, 0, width, height, null, 0, width); | |
int[] maskPixels = mask.getRGB(0, 0, width, height, null, 0, width); | |
for (int i = 0; i < imagePixels.length; i++) | |
{ | |
int color = imagePixels[i] & 0x00ffffff; // Mask preexisting alpha | |
int alpha = maskPixels[i] << 24; // Shift green to alpha | |
imagePixels[i] = color | alpha; | |
} | |
image.setRGB(0, 0, width, height, imagePixels, 0, width); | |
} | |
private LinkedHashMap<BufferedImage, Float> getGifImages (File file, boolean withDelayTimes){ | |
ImageReader imageReader = null; | |
LinkedHashMap<BufferedImage, Float> images = new LinkedHashMap<BufferedImage, Float>(); | |
try { | |
ImageInputStream inputStream = ImageIO.createImageInputStream(file); | |
imageReader = ImageIO.getImageReaders(inputStream).next(); | |
imageReader.setInput(inputStream); | |
int numImages = imageReader.getNumImages(true); | |
Image generalImage = ImageIO.read(file); | |
int width = generalImage.getWidth(null); | |
int height = generalImage.getHeight(null); | |
//start with blank image of the complete size, in case first frame isn't full size (possible?) | |
BufferedImage previousImage = | |
new BufferedImage(width, height, BufferedImage.TYPE_4BYTE_ABGR); | |
for (int i = 0; i < numImages; i++) { | |
IIOMetadata imageMetaData = imageReader.getImageMetadata(i); | |
String metaFormatName = imageMetaData.getNativeMetadataFormatName(); | |
IIOMetadataNode root = (IIOMetadataNode) imageMetaData.getAsTree(metaFormatName); | |
IIOMetadataNode imageDescriptorNode = getMetaDataNode(root, "ImageDescriptor"); | |
int top = Integer.parseInt(imageDescriptorNode.getAttribute("imageTopPosition")); | |
int left = Integer.parseInt(imageDescriptorNode.getAttribute("imageLeftPosition")); | |
BufferedImage image = imageReader.read(i); | |
BufferedImage combinedImage = | |
new BufferedImage(width, height, BufferedImage.TYPE_4BYTE_ABGR); | |
Graphics2D graphics = combinedImage.createGraphics(); | |
graphics.drawImage(previousImage, 0, 0, null); | |
graphics.setBackground(TRANSPARENT); | |
graphics.clearRect(left, top, image.getWidth(), image.getHeight()); | |
combinedImage.getGraphics().drawImage(image, left, top, null); | |
float frameDelay = 0; | |
if (withDelayTimes){ | |
IIOMetadataNode graphicsControlExtensionNode = getMetaDataNode(root, "GraphicControlExtension"); | |
frameDelay = Integer.parseInt(graphicsControlExtensionNode.getAttribute("delayTime")) | |
* GIF_DELAYTIME_TO_SECONDS; | |
} | |
images.put(combinedImage, frameDelay); | |
previousImage = combinedImage; | |
} | |
} catch (IOException ex) { | |
throw new RuntimeException("Error reading image: " + file, ex); | |
} finally { | |
if (imageReader != null) | |
imageReader.setInput(null); | |
} | |
return images; | |
} | |
private static IIOMetadataNode getMetaDataNode(IIOMetadataNode rootNode, String nodeName) { | |
int nNodes = rootNode.getLength(); | |
for (int i = 0; i < nNodes; i++) { | |
if (rootNode.item(i).getNodeName().compareToIgnoreCase(nodeName)== 0) { | |
return((IIOMetadataNode) rootNode.item(i)); | |
} | |
} | |
IIOMetadataNode node = new IIOMetadataNode(nodeName); | |
rootNode.appendChild(node); | |
return(node); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment