My texture atlas tool

10 Aug 2012

I’m not a fan of XML. Even less a fan of dealing with it in a static, compiled language. And when it comes to load or run time in a game, the thought of spending the time to parse XML abhors me.

However, all the good texture packing tools output XML, so I have to deal with it at some point. To that end, I wrote a small script to compile the “Generic XML” output from TexturePacker into a binary format that I can quickly load in game without having to deal with parsing any plain text.

The structure of the file is very simple. I haven’t had a need yet for any of the fancier features of TexturePacker like rotation, trimming, etc, so I don’t try and support them. It’s simply a short header, followed by N AtlasSprite instances:

/* The .atlas file format is simply a Header structure, followed by numFrames
 * frame structures.
 *
 * All multibyte values are defined to be little endian.
 */
struct AtlasHeader {
    uint32_t magic; // (1635019891, or 'atls')
    uint16_t textureWidth;
    uint16_t textureHeight;
    uint16_t numFrames;
    char     textureName[64];
};

struct AtlasFrame {
    char     name[64];
    uint16_t width, height;
    float    u1, v1;
    float    u2, v2;
};

The script that parses the files is a short Python script:

#!/usr/bin/env python
#
# Compiles texture atlases exported from TexturePacker (in the Generic XML format)
# into a binary format for fast/easy loading in game code. At the moment this doesn't
# support trimming or rotation, 'cause I don't really need 'em.
#
# The file format (all multibyte values are little endian):
# struct AtlasHeader {
#     uint32_t magic; // (1635019891, or 'atls')
#     uint16_t textureWidth;
#     uint16_t textureHeight;
#     uint16_t numFrames;
#     char     textureName[64];
# };
# sizeof(struct AtlasHeader) == 74;
#
# struct AtlasFrame {
#     char     name[64];
#     uint16_t width, height;
#     float    u1, v1;
#     float    u2, v2;
# };
# sizeof(struct AtlasFrame) == 84;

import struct
import xml.sax
from optparse import OptionParser

class Texture:
    def __init__(self, xml_attrs):
        self.name = xml_attrs.getValue('imagePath')
        self.width = int(xml_attrs.getValue('width'))
        self.height = int(xml_attrs.getValue('height'))


class Frame:
    def __init__(self, xml_attrs):
        self.name = xml_attrs.getValue('n')
        self.x = float(xml_attrs.getValue('x'))
        self.y = float(xml_attrs.getValue('y'))
        self.width = float(xml_attrs.getValue('w'))
        self.height = float(xml_attrs.getValue('h'))
        self.u1 = 0
        self.v1 = 0
        self.u2 = 0
        self.v2 = 0

    def make_bytes(self):
        """
        Returns the byte-array representation of the frame.
        """
        frame_fmt = '<64s2H4f'
        return struct.pack(frame_fmt, str(self.name), int(self.width), int(self.height), self.u1, self.v1, self.u2, self.v2)

class AtlasHandler(xml.sax.ContentHandler):
    def __init__(self):
        xml.sax.ContentHandler.__init__(self)
        self.texture = None
        self.frames = []

    def startElement(self, name, attrs):
        if name == 'TextureAtlas':
            self.texture = Texture(attrs)
        elif name == 'sprite':
            self.frames.append(Frame(attrs))

    def endElement(self, name):
        if name == 'TextureAtlas':
            for frame in self.frames:
                frame.u1 = frame.x / self.texture.width
                frame.v1 = frame.y / self.texture.height
                frame.u2 = (frame.x + frame.width) / self.texture.width
                frame.v2 = (frame.y + frame.height) / self.texture.height

    def dumpDescription(self):
        if self.texture:
            print("Texture: %s (%s, %s)" % (self.texture.name, self.texture.width, self.texture.height))
        else:
            print("No texture!")

        for frame in self.frames:
            print("  Frame: %s" % frame.name)
            print("         x: %s" % frame.x)
            print("         y: %s" % frame.y)
            print("     width: %s" % frame.width)
            print("    height: %s" % frame.height)
            print("        u1: %s" % frame.u1)
            print("        v1: %s" % frame.v1)
            print("        u2: %s" % frame.u2)
            print("        v2: %s" % frame.v2)

    def make_header(self):
        """
        Returns a byte array representation of the binary file header.
        """
        header_fmt = "<ccccHHH64s"
        return struct.pack(header_fmt, 'a', 't', 'l', 's', self.texture.width, self.texture.height, len(self.frames), str(self.texture.name))

    def write_file(self, f):
        """
        Writes out the byte array representation of the texture atlas to the given file.
        """
        f.write(self.make_header())
        for frame in self.frames:
            f.write(frame.make_bytes())


def parse_file(filename):
    source = open(filename)
    handler = AtlasHandler()
    xml.sax.parse(source, handler)
    return handler

USAGE="""
Usage: atlasc.py -o <output-filename> <input-filename>
"""

def handle_commandline():
    """
    Handles command-line options and arguments and does the right things.
    """
    parser = OptionParser(usage=USAGE)
    parser.add_option('-o', '--output', dest='output', default='out.atlas')

    (options, args) = parser.parse_args()

    if len(args) == 0:
        print(USAGE)
        return -1

    atlas = parse_file(args[0])

    out = open(options.output, 'w')
    atlas.write_file(out)
    out.close()
    return 0

if __name__ == '__main__':
    return handle_commandline()

And sample Objective-C code to read in the files is included in the same gist on Github.