• Register

Help Patchman rescue the Sheeple from enslavement by the Drone army!

Post feature Report RSS How we (Painfully) Migrated our Game Art Assets into Sprite Sheets

For our first blog entry we'd like to go over one of the technical challenges we faced!

Posted by on

Tech Used: GIMP, TexturePacker, ImageMagick, Tiled, PNG, Bash

About midway through our game we met an obstacle that was hurting on mobile: speed. Specifically, speed due to graphics displayed onscreen. One of the ways to speed this up is a commonly known solution of "sprite sheets", which are large collections of images combined into one big image and then cleverly divided up by texture coordinates. So while this been-there-done-that solution was pursued, there is still a lot of interesting things to go over regarding it, since we had to automate some complex tricks to combine our art.

When we first built our game we just loaded up each PNG image and put it into memory. We have some grids in our world so some of those PNG are grid-like image compilations. But our draw calls were often upwards of 200 per frame, which isn't a ton but was still hurting mobile performance, even though desktop seemed fine. We managed to get this to average 7 draw calls per frame after implementing this solution:

Creating the Ground Tile Sheet

First of all, for our first sheet, the "floor" of our game world is made of 64x32 interlocking isometric tiles. We put these on a typical XCF grid in GIMP and stuffed them into our first sprite sheet. Mostly straightforward, nothing too fancy there.

Creating the Structure Tile Sheet

For our second sheet, our "upright" game world environment consists of 64x64 isometric tiles, usually used for diagonal walls, cubes, and a variety of other physical obstacles. This is where the biggest problem lies, because in addition to these grid tiles, we have a wide variety of characters, items, and effects placed throughout the world tiles, which can be moving, appearing or disappearing between any frame.


The HUD is light on assets so we threw these in too, as the HUD is drawn over top every frame. To reduce draw calls, we put ALL of this stuff onto one sprite sheet! Further compounding the confusion is the fact that OpenGL has a flaw where it can't draw tiles beside each other without introducing texture coordinate artifacts, namely black lines or thin transparent spaces between the tiles. To solve this you have to "extrude" the tiles by cloning a border around them, as you can see done past this red line:

But we also have larger tiles that might be drawn in the game world, tall tiles (64x128) and big tiles (128x128). These are all in grids too. We need to keep these files as-is so they are feasible to edit, and they also have the added benefit of working easily with Tiled Map Editor. But those all need to be aligned perfectly on the sprite sheet, so we have STRUCTURE, TALL, and BIG grids all in that order vertically. First we use 'convert' (ImageMagick) to chop up our structure tiles PNG into 64x64 tiles, which are put into a TexturePacker TPS file that extrudes them to 66x66 and recombines them into a new PNG image. We then take that extruded PNG and use 'convert' to combine the extruded STRUCTURE, TALL, and BIG grids into one perfectly-aligned PNG. That is then loaded into a TPS file (which stays at the top) and all the rest of the art assets are squished in place by TexturePacker and the texture coordinates are exported to a LUA file, as well as a final 2048x2048 PNG sprite sheet. Here is an example of some crinkly stained hand-written notes written during this design:


Finally when we load them into MOAI we put the initial 2048x2048 PNG into a MOAITexture. Then for each of the STRUCTURE, BIG, and TALL tiles we create a MOAITileDeck2D and note the offsets of each from the top of the sheet. STRUCTURE starts at (0,0) and BIG and TALL both are 128 pixels high and so start at the next relevant multiple of (128/2048,0). Individual sprites are put into MOAIGfxQuad2D and animated/sets are loaded into MOAIGfxQuadDeck2D and then run through an algorithm to assign each frame to divided texture coordinates. Most of the important loading code:

function Resources:LoadSpriteSheet ( spriteFile, texture )
    local specList = dofile ( spriteFile )
    local infoList = {}
    infoList.indexes = {}
    infoList.frames = {}
    -- count frames and indexes
    local frame_count = 0
    local index_count = 0
    for _,frame in pairs(specList.frames) do
        frame_count = frame_count + 1
        index_count = index_count + 1
    end
    -- create deck and reserve indexes
    local spriteQuad = MOAIGfxQuadDeck2D.new ()
    spriteQuad:reserve( index_count )
    -- load coords into quad
    local frame_count = 0
    local index_count = 0
    for _,frame in pairs(specList.frames) do
        frame_count = frame_count + 1
        local uv = frame.uvRect
        -- bleed the uv?
        if Resources.spriteBleeds[frame.name] then
            local sprite = Resources.spriteBleeds[frame.name]
            -- bleed uv?
            if sprite.bleed then 
                uv.u0 = uv.u0 + sprite.bleed/2048
                uv.u1 = uv.u1 - sprite.bleed/2048
                uv.v0 = uv.v0 + sprite.bleed/2048
                uv.v1 = uv.v1 - sprite.bleed/2048
            end
        end
        local q = {}
        if not frame.textureRotated then
            -- Vertex order is clockwise from upper left (xMin, yMax)
            q.x0, q.y0 = uv.u0, uv.v0
            q.x1, q.y1 = uv.u1, uv.v0
            q.x2, q.y2 = uv.u1, uv.v1
            q.x3, q.y3 = uv.u0, uv.v1
        else
            -- Sprite data is rotated 90 degrees CW on the texture
            -- u0v0 is still the upper-left
            q.x3, q.y3 = uv.u0, uv.v0
            q.x0, q.y0 = uv.u1, uv.v0
            q.x1, q.y1 = uv.u1, uv.v1
            q.x2, q.y2 = uv.u0, uv.v1
        end
        frame.uvQuad = q
        -- convert frame.spriteColorRect and frame.spriteSourceSize
        -- to frame.geomRect.  Origin is at x0,y0 of original sprite
        local d = {}
        local s = frame.spriteSourceSize
        local cr = frame.spriteColorRect
        local r = {}
        local offsetx = 0
        local offsety = 0
        if Resources.sheetOffsets[frame.name] then
            local offset = Resources.sheetOffsets[frame.name]
            offsetx = offset[1]
            offsety = offset[2]
        end
        d.left = cr.x
        d.right = s.width - cr.width - cr.x
        d.top = cr.y
        d.bottom = s.height - cr.height - cr.y
        r.x0 = cr.x/2 - cr.width/2 + offsetx
        r.y0 = cr.y/2 - cr.height/2 + offsety
        r.x1 = cr.x/2 + cr.width/2 + offsetx
        r.y1 = cr.y/2 + cr.height/2 + offsety
        frame.geomRect = r
        -- sprite sheet within a sprite sheet [secondary sheet]
        if Resources.secondarySheets[frame.name] then
            -- secondary sheet
            local sheet = Resources.secondarySheets[frame.name]
            local uwidth = uv.u1 - uv.u0
            local uheight = uv.v1 - uv.v0
            local tiles = MOAIGfxQuadDeck2D.new()
            tiles.width = sheet.width
            tiles.height = sheet.height
            tiles:setTexture(texture)
            tiles:reserve(sheet.tiles[1]*sheet.tiles[2])
            local secondary_index = 0
            for h = 1, sheet.tiles[2] do
                for w = 1, sheet.tiles[1] do
                    secondary_index = secondary_index + 1
                    local sprite_index = (h-1)*sheet.tiles[1]+w
                    local sprite_u0 = (w-1) / sheet.tiles[1]
                    local sprite_u1 = (w) / sheet.tiles[1]
                    local sprite_v0 = (h-1) / sheet.tiles[2]
                    local sprite_v1 = (h) / sheet.tiles[2]
                    local su0 = uv.u0 + sprite_u0 * uwidth
                    local su1 = uv.u0 + sprite_u1 * uwidth
                    local sv0 = uv.v0 + sprite_v0 * uheight
                    local sv1 = uv.v0 + sprite_v1 * uheight
                    -- rect
                    local rect = sheet.rect
                    -- mapping
                    tiles:setUVQuad(secondary_index, su0,sv0, su1,sv0, su1,sv1, su0,sv1)
                    tiles:setRect(secondary_index, rect[1],rect[2],rect[3],rect[4])
                end
            end
            Resources.quadTiles[frame.name] = tiles
            infoList.frames[frame.name] = frame
        end
    end
    return spriteQuad, infoList
end

All of this could be done by hand but is ridiculous to do whenever art is tweaked or added, so we have a cool script that does everything, tho it takes about ten seconds to run. We also kept the ability to "hot-load" sprites and bypass the sheet, for example if an artist doesn't have access to all the materials needed to run the script, or there is a frequently changing asset and it would be too annoying to rebuild the sheet over and over. Some of the PNG chop and join commands here might be useful to someone, so here is our Bash script:

#!/bin/bash

# B chop unbled PNG file
echo 'Chopping unbled PNG file...'
cd xcf-art/sheets
rm 64x64/*
convert xcf-art/tiled/structure-tiles.png -crop 64x64 +repage 64x64/%04d-tile.png

# C - TILES EXTRUDE
echo 'TexturePacker structure-tiles-extrude'
TexturePacker structure-tiles-extrude.tps

# D - COMBINE 
cd xcf-art/
convert sheets/publish/structure-tiles-extrude.png tiled/tall-tiles.png tiled/big-tiles.png -append sheets/publish/structure-tall-big.png

# E - FINAL SHEET
echo 'TexturePacker structure-hud-tiles'
TexturePacker structure-hud-tiles.tps

TexturePacker is a great handy tool (even runs from console!) but has three limitations for us. One, it can't trim tiles while preserving the center of the image (a seemingly easy addition that the creators refuse to add), so it's a big waste of time resizing animated sprite sets - something TexturePacker is *supposed* to help with. Two, it can't put sprite sheets within sprite sheets, admittedly a tougher problem for them to solve. Three, it doesn't support mixing of orderings, so for example you can't tell it to put one image at the top of the sheet and sort the rest by MaxRects, which is critical for our grid-based textures. You just have to hope that the algorithm you picked works the way you want it to, and luckily for us, one of them does. Our final spritesheet, zoomed out:

Hopefully you can sorta see the STRUCTURE grid of 66x66 tiles at the top, the TALL grid of 128x64 trees below it, and then the BIG grid of 128x128 bushes and stuff below that, all of which are aligned to their cell sizes. Below all those you can see the 'randomly' placed sprites and animated frame sets from which we use UV to pull them out.

It took two weeks for one developer to figure all this out, redo the game's resource management and program this. At one point it was so overwhelming and seemed like it would never work, but... it did! Whew! Later we had to expand this to three 2048x2048 spritesheets as our art assets have grown. We try to put the less-commonly-used art on the third sheet. We also tried growing the sprite sheets bigger to 4096x4096 but then our game wouldn't play on our tablet.

Thanks for checking out our game article and hopefully this article is food-for-thought for someone else tinkering with tricky spritesheets.

Post comment Comments
NaturallyIntelligent Author
NaturallyIntelligent - - 28 comments

Linked to reddit discussion:

Reddit.com

Reply Good karma+1 vote
Post a comment

Your comment will be anonymous unless you join the community. Or sign in with your social account: