All Downloads are FREE. Search and download functionalities are using the official Maven repository.

tripleplay.tools.FramePacker.scala Maven / Gradle / Ivy

//
// Triple Play - utilities for use in PlayN-based games
// Copyright (c) 2011-2013, Three Rings Design, Inc. - All rights reserved.
// http://github.com/threerings/tripleplay/blob/master/LICENSE

package tripleplay.tools

import java.io.{BufferedWriter, FileWriter, File, IOException, PrintWriter}

import java.awt.image.BufferedImage
import javax.imageio.ImageIO

import scala.collection.mutable.ArrayBuffer

import pythagoras.i.{Dimension, Rectangle}

import playn.core.json.JsonImpl

/**
* Generates a packed version of a series of image frames along with a JSON file which contains
* information on how to unpack the frames. The results can be used via {@link PackedFrames}.
*
* @param frame the size of a single frame in {@code _source}.
*/
class FramePacker (_source :File, _frame :Dimension) {
  import FramePacker._

  /** The maximum size of the texture atlas image. */
  var maxAtlasSize = 1024

  protected val _image :BufferedImage = ImageIO.read(_source)
  protected val _cols = _image.getWidth / _frame.width
  protected val _rows = _image.getHeight / _frame.height

  // sanity checking and reporting
  {
    if (_image.getWidth % _frame.width != 0) {
      System.err.println("Warning image not even number of frames wide [source=" + _source +
                         ", frameWidth=" + _frame.width +
                         ", imageWidth=" + _image.getWidth() + "]")
    }
    if (_image.getHeight % _frame.height != 0) {
      System.err.println("Warning image not even number of frames tall [source=" + _source +
                         ", frameHeight=" + _frame.height +
                         ", imageHeight=" + _image.getHeight() + "]")
    }
  }

  /** Creates a packer for the supplied source image. The width and height of the frames inside
   * {@code source} will be decoded from the filename, which must be of the form {@code
   * foo_WIDTHxHEIGHT.ext}.
   */
  def this(source :File) = this(source, FramePacker.decodeFrameSize(source.getName))

  /** Creates a packer for the supplied source image.
   *
   * @param frameWidth the width of a single frame in {@code source}.
   * @param frameHeight the height of a single frame in {@code source}.
   */
  def this (source :File, frameWidth :Int, frameHeight :Int) =
    this(source, new Dimension(frameWidth, frameHeight))

  /** Packs the source and generates the results in {@code target}. A metadata file will be
   * generated along side {@code target} with the file extension changed to {@code .json}.
   */
  def pack (target :File) {
    val bounds = 0 until (_rows*_cols) map(computeTrimmedBounds) filter(_ != null) toSeq
    val frames = bounds.zipWithIndex map(ib => Frame(ib._2, ib._1))
    val sframes = frames.sortWith(_.area > _.area)

    // estimate the optimal atlas size
    val area = frames.map(_.area).sum
    val side = math.sqrt(area).toInt
    var max = new Dimension(frames.map(_.pwidth).max, frames.map(_.pheight).max)

    // search a few increasingly large rectangles for one that fits our textures
    def findPacking (width :Int, height :Int) :Node = {
      if (width > maxAtlasSize || width > maxAtlasSize) {
        throw new RuntimeException("Oops. Need a texture of size " + width + "x" + height +
                                   ", but that exceeds max atlas size (" + maxAtlasSize + ")")
      }
      // use a k-d tree packing algorithm to pack the frames into a single image
      val root :Option[Node] = Some(new Empty(0, 0, width, height))
      (root /: sframes)((n, f) => n.flatMap(_.add(f))) match {
        case Some(n) => n
        case None => if (width < height) findPacking(width+width/5, height)
                     else findPacking(width, height+height/5)
      }
    }
    val node = findPacking(math.max(side, max.width), math.max(side, max.height))

    // writes data to an associated metadata file
    val troot = target.getName.reverse.dropWhile(_ != '.').drop(1).reverse
    def writeTo(suff :String, text :String) = {
      val name = troot + "." + suff
      val file = new File(target.getParentFile, name)
      val out = new PrintWriter(new BufferedWriter(new FileWriter(file)))
      out.println(text)
      out.close
    }

    // generate the atlas image
    val nsize = node.size
    val atlas = new BufferedImage(nsize.width, nsize.height, _image.getType)
    val (srast, drast) = (_image.getRaster, atlas.getRaster)
    node.apply { n =>
      val (f, b) = (n.frame, n.frame.bounds)
      drast.setRect(n.x, n.y, srast.createChild(f.sx, f.sy, b.width, b.height, 0, 0, null))
    }
    ImageIO.write(atlas, "PNG", target)

    // if the source image contains @2x then scale everything by half for iOS fun
    val scaleFactor = if (_source.getName.contains("@2x")) 2f else 1f

    // generate the associated json snippet
    val json = new JsonImpl().newWriter
    json.`object`
    json.value("width", _frame.width/scaleFactor).value("height", _frame.height/scaleFactor)
    json.array("frames")
    node.apply { n =>
      val (f, b) = (n.frame, n.frame.bounds)
      json.`object`
      json.value("idx", f.index)
      json.array("off").value(b.x/scaleFactor).value(b.y/scaleFactor).end
      json.array("src").value(n.x/scaleFactor).value(n.y/scaleFactor).
                        value(b.width/scaleFactor).value(b.height/scaleFactor).end
      json.end
    }
    json.end.end
    writeTo("json", json.write)

    // generate the associated java snippet
    val frags = new ArrayBuffer[(Int,String)]
    node.apply { n =>
      val (f, b) = (n.frame, n.frame.bounds)
      frags += (f.index -> "{ %5.1ff, %5.1ff }, { %5.1ff, %5.1ff, %5.1ff, %5.1ff }".format(
                b.x/scaleFactor, b.y/scaleFactor,
                n.x/scaleFactor, n.y/scaleFactor, b.width/scaleFactor, b.height/scaleFactor))
    }
    val base = "float[][] %s = {\n{ %5.1ff, %5.1ff },\n".format(
      troot.toUpperCase, _frame.width/scaleFactor, _frame.height/scaleFactor)
    writeTo("java", base + frags.sortBy(_._1).map(_._2).mkString(",\n") + "};")
  }

  def computeTrimmedBounds (idx :Int) :Rectangle = {
    val (row, col) = (idx/_cols, idx%_cols)
    val (ox, oy) = (col * _frame.width, row * _frame.height)
    var (firstrow, lastrow, minx, maxx) = (-1, -1, _frame.width, 0)

    for (yy <- 0 until _frame.height) {
      var (firstidx, lastidx) = (-1, -1)
      for (xx <- 0 until _frame.width) {
        val argb = _image.getRGB(ox + xx, oy + yy)
        if ((argb >> 24) != 0) {
          // if we've not yet seen a non-transparent pixel, make a note that this is the first
          // non-transparent pixel in the row
          if (firstidx == -1) firstidx = xx
          // keep track of the last non-transparent pixel we saw
          lastidx = xx
        }
      }

      // if we have not yet seen a pixel on this row...
      if (firstidx != -1) {
        // update our min and maxx
        minx = math.min(firstidx, minx)
        maxx = math.max(lastidx, maxx)
        // keep track of the first and last row on which we see pixels
        if (firstrow == -1) firstrow = yy
        lastrow = yy
      }
    }

    // if we found no non-transparent pixels, return null to indicate so
    if (firstrow == -1) null
    else new Rectangle(minx, firstrow, maxx - minx + 1, lastrow - firstrow + 1)
  }

  case class Frame (index :Int, bounds :Rectangle) {
    // add a one pixel border between images to prevent bleeding
    val (pwidth, pheight) = (bounds.width+1, bounds.height+1)
    val area = pwidth * pheight
    val sx = (index % _cols) * _frame.width + bounds.x
    val sy = (index / _cols) * _frame.height + bounds.y
  }

  abstract class Node {
    def add (frame :Frame) :Option[Node]
    def size :Dimension
    def apply (func :(Leaf => Unit)) :Unit
    def toString (depth :Int) :String = (" " * depth)
    override def toString = toString(0)
  }

  class Empty (val x :Int, val y :Int, width :Int, height :Int) extends Node {
    override def add (frame :Frame) = (width - frame.pwidth, height - frame.pheight) match {
      case (0, 0) =>
        Some(new Leaf(x, y, frame))
      case (dw, dh) if (dw < 0 || dh < 0) =>
        None
      case (dw, dh) if (dw > dh) =>
        Some(new Branch(new Empty(x, y, frame.pwidth, height).add(frame).get,
                        new Empty(x + frame.pwidth, y, dw, height)))
      case (dw, dh) =>
        Some(new Branch(new Empty(x, y, width, frame.pheight).add(frame).get,
                        new Empty(x, y + frame.pheight, width, dh)))
    }
    override def size = new Dimension(0, 0)
    override def apply (func :(Leaf => Unit)) = ()
    override def toString (depth :Int) =
      super.toString(depth) + "Empty(" + width + "x" + height + "+" + x + "+" + y + ")"
  }

  class Branch (left :Node, right :Node) extends Node {
    override def add (frame :Frame) = (left.add(frame), right.add(frame)) match {
      case (Some(nl), _) => Some(new Branch(nl, right))
      case (_, Some(nr)) => Some(new Branch(left, nr))
      case (_, _) => None
    }
    override def size = {
      val (ls, rs) = (left.size, right.size)
      new Dimension(math.max(ls.width, rs.width), math.max(ls.height, rs.height))
    }
    override def apply (func :(Leaf => Unit)) = {
      left.apply(func)
      right.apply(func)
    }
    override def toString (depth :Int) =
      super.toString(depth) + "Branch\n" + left.toString(depth+1) + "\n" + right.toString(depth+1)
  }

  class Leaf (val x :Int, val y :Int, val frame :Frame) extends Node {
    override def add (frame :Frame) = None
    override def size = new Dimension(x + frame.bounds.width, y + frame.bounds.height)
    override def apply (func :(Leaf => Unit)) = func.apply(this)
    override def toString (depth :Int) =
      super.toString(depth) + " Leaf(" + x + ", " + y + ", " + frame + ")"
  }
}

object FramePacker {
  val usage = """Usage: FramePacker (source target) or (srcdir tgtdir)
  |""" stripMargin('|') dropRight(1) // drop final \n
  // |  -Djpga=quality system property causes packer to emit JPEGs with the
  // |                 specified quality (0-100) along with an image_alpha.png
  // |                 8-bit alpha mask image

  def main (args :Array[String]) {
    if (args.length != 2) {
      System.err.println(usage)
      System.exit(255)
    }
    val (src, dst) = (new File(args(0)), new File(args(1)))
    if (src.isDirectory) {
      val size = decodeFrameSize(src.getName)
      src.listFiles.foreach { f => new FramePacker(f, size).pack(new File(dst, f.getName)) }
    } else if (dst.isDirectory) {
      // strip the framesize info from the source and use that file name in the specified
      // destination directory as the destination
      new FramePacker(src).pack(new File(dst, stripFrameSize(src.getName)))
    } else {
      new FramePacker(src).pack(dst)
    }
  }

  def decodeFrameSize (name :String) :Dimension = {
    val didx = name.lastIndexOf(".")
    val bname = if (didx == -1) name else name.substring(0, didx)
    val uidx = bname.lastIndexOf("_")
    if (uidx == -1) throw new IllegalArgumentException(ERR_INVALID_FILENAME + name)
    val bits = bname.substring(uidx+1).split("x")
    if (bits.length != 2) throw new IllegalArgumentException(ERR_INVALID_FILENAME + name)
    try {
      new Dimension(Integer.parseInt(bits(0)), Integer.parseInt(bits(1)))
    } catch {
      case nfe :NumberFormatException =>
        throw new IllegalArgumentException(ERR_INVALID_FILENAME + name)
    }
  }

  def stripFrameSize (name :String) = {
    val didx = name.lastIndexOf(".")
    if (didx == -1) throw new IllegalArgumentException(ERR_INVALID_FILENAME + name)
    val uidx = name.lastIndexOf("_")
    if (uidx == -1) throw new IllegalArgumentException(ERR_INVALID_FILENAME + name)
    name.substring(0, uidx) + name.substring(didx)
  }

  final val ERR_INVALID_FILENAME = "File name must be of the form 'foo_WIDTHxHEIGHT.ext': "
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy