Phil Hassey - game dev blog
Phil Hassey as Wolverine
"What kind of
arrogant jerk
has a website like this?"

How I got Dynamite Jack from 62MB down to 46MB

Hey,

So – I went Universal with Dynamite Jack just today! Yay! This involved a lot of “blah blah” messing with resizing all the menus for iPhone users, which wasn’t very interesting, though it came out really well. The interesting bit was when I realized that “going Universal” meant that my retina iPad assets were going to be put on everyone’s phones. And that meant that the 50 MB OTA limit was going to hit me. Quite a few devs advised me against going over the limit, so I took their warning.

I was at 62 MB. I had to take the size down AT LEAST 12 MB to hit the 50MB limit. However, I also know that when distributing your game iTunesConnect pads things a bit, so adding 2MB to that is a good idea. So my target is 48MB.

What was taking up all that room??

– Video (IVF): 10 MB
– Sound effects (WAV): 7 MB
– Music (AAC): 24 MB
– Images (PNG): 20 MB
– Maps, etc: 1 MB

Okay, so I’m at a good place, I know exactly who is eating my space. But now what to do? Let’s check it out!

Video

So I decided not to do anything about the video. I include two IVF files, one for the retina iPad and one for everything else. They are as lossy as I want them to be, so further altering would start to eat up the quality. I could probably get away with re-encoding them a few % lighter and save 1 or 2 MB if I really had to.

Sound Effects

Stereo 16-bit WAV files sound great but they are huge. I took some advice and converted them to IMA4 files which are 25% as large as WAV files. This saved me about 5 MB. However, I found when testing the game out on my decent computer speakers that the IMA4 format really adds a lot of noise to the sound, this was not acceptable to me. I decided to re-encode them using the AAC encoder at 96k which resulted in perfect sounding sound effects and saved me 6MB!

iMac$ afconvert -d aac -f caff -b 98304 in.wav -o out.caf

I am using CocosDenshion for my audio engine, and it seems to handle this just fine.

Music

My music was already encoded as AAC files at 128k. I tried a variety lower bit rates to see if I could save a few bytes. I found at 64k it was really obvious that I was cutting corners. I found at 80k I couldn’t tell any difference, so I decided to go with 96k since that would give me a bit of a margin above that just so I could be sure the music sounded perfect. I used the same command line as converting the sound effects. Going from 128k to 96k saved me 5MB!

So far I had saved 11MB, but I was 14MB over and I knew I needed to trim a bit more fat to make this work.

Images

Previously I had tried a ton of variations on 16-bit “4444” dithered style images. These, unfortunately, looked horrible in Dynamite Jack. So I wasn’t able to use that trick.

I did find out about ImageOptim which takes forever to pack PNGs but it did manage to pull me back 2MB getting me down to 13MB total saved, which was really going to cut things close. I decided to investigate one other option.

I had heard that Amazing Breaker had used JPGs for the RGB component of images and a PNG file for the alpha component. I really require high-quality images in my game, so I found that at 98% quality and 1×1 sampling I was able to get really great looking images.

Ubuntu$ convert -quality 98 -sampling-factor 1x1 tmp-rgb.bmp tmp-rgb.jpg

I pre-blitted them (which gave me premultiplication) onto a black background to get the JPG. Then I made a grayscale PNG file of the alpha channel. I created my own mini format “.cuz” to combine these into single files and loaded them in my game. I found that the game looked perfect! This saved me 6MB!

Afterwards, I found that some of my pre-baked font images got larger using my format, so I left those as straight PNGs.

Finally …

So all said and done, I had saved 6 + 5 + 6 = 17MB! This got my IPA down to around 46MB, which is a nice distance below the 50MB limit 🙂 I’m quite pleased with the results. Some bonus tips:

– The AAC sound effect trick will only work iOS 3.0 and higher. Which shouldn’t be a problem now-a-days. I hear that this won’t work out-of-the-box with OpenAL, so maybe check out CocosDenshion.

– Definitely check your own music to find what bit rate starts to degrade the sounds. Playing on your iPhone or iPad speaker isn’t enough. Playing on earphones isn’t either (unless they are really nice). I recommend playing on your computer speakers so you can be sure the sound IS really good before deciding.

– The JPG+PNG image trick can get great results – but definitely keep the quality high. I found that I was able to go down to 98% and found no artifacts in my game. Be sure to experiment and find the sweet spot for your game images. Also be sure to test on all device resolutions you have to check for artifacts. I tested on all 4 iOS screen resolutions to be sure things were perfect.

Anyway, I hope you find this helpful in your quest for saving bytes! And don’t compromise on quality! Nobody wants to hear or see compression artifacts.

-Phil

P.S. If you need to cut your App in half after that, here’s what you could do:

– Change all SFX+music from stereo to mono
– Set JPG quality to 95% or something even less

That would probably cut mine back another 15MB or so, and very few people would notice. I would notice a tiny bit, and in my case, I don’t need to compromise any more, since I’m already under 50MB.


Bonus: a bit more detail on my image file format

First, I used a python script to figure out which was the best way to compress the image. I do a variety of conversions and see which one is the smallest:

– Using JPG-RGB + PNG-A
– Using PNG premultiplied Alpha
– Using original PNG

So, for example, fonts ended up working best as “original PNGs” and most everything else ended up being JPG-RGB + PNG-A. There should be a 4th option of just JPG-RGB with no alpha, but I didn’t bother, since I don’t have any fully opaque textures.

# -*- coding: utf-8 -*-
import glob
import os
import pygame
from pygame.locals import *
from PIL import Image
import numpy

SRC = "../data-ios"
DST = "../data-ios"

def do_cmd(cmd):
    print cmd
    os.system(cmd)

def png_fix(fname):
    img = pygame.image.load(fname)
    img = img.convert_alpha()
    pygame.image.save(img,fname)
    
def premult(finput, foutput):
    im = Image.open(finput)
    
    print "premultiplying matrix..."
    a = numpy.fromstring(im.tostring(), dtype=numpy.uint8)
    alphaLayer = a[3::4] / 255.0
    a[::4]  *= alphaLayer
    a[1::4] *= alphaLayer
    a[2::4] *= alphaLayer
    res = Image.fromstring("RGBA", im.size, a.tostring())

    res.save(foutput)
    png_fix(foutput)

def main():
    s = pygame.display.set_mode((256,256),0,32)
    for fname in glob.glob(SRC+"/*.png"):
        print fname
        img = pygame.image.load(fname).convert_alpha()
        
        img2 = img.convert_alpha()
        img2.fill((0,0,0,255))
        img2.blit(img,(0,0))
        pygame.image.save(img2,"tmp-rgb.bmp")
        
        cmd = "convert -quality 98 -sampling-factor 1x1 tmp-rgb.bmp tmp-rgb.jpg"
        do_cmd(cmd)
            
        img.fill((255,255,255),None,BLEND_RGB_MAX)
        img2 = img.convert_alpha()
        img2.fill((0,0,0,255))
        img2.blit(img,(0,0))
        pygame.image.save(img,"tmp-a.bmp")
        
        cmd = "convert tmp-a.bmp -define png:bit-depth=8 -define png:color-type=0 tmp-a.png"
        do_cmd(cmd)
        
        dst = fname 
        dst = dst.replace(".png",".cuz")
        f = open(dst,"wb")
        
        # 4 byte magic
        f.write("CZCO")
        # 4 byte version / whatever
        f.write("I\x00\x00\x01")
        
        s1 = open("tmp-rgb.jpg","rb").read()
        s2 = open("tmp-a.png","rb").read()
        t = 3 #JPG + PNG
        s3 = open(fname,"rb").read()
        
        # add a check for non-alpha images, store as JPGs.
        # wouldn't save much room since the full alpha PNG itself will only
        # be like 100 bytes.  not a high priority item!
        
        if (len(s3) < (len(s1)+len(s2))): 
            # we have failed, fall back to just wrapping a PNG
            # but first, premultiply it
            
            premult(fname,"tmp-pre.png")
            s4 = open("tmp-pre.png","rb").read()
            
            if len(s3) < len(s4):
                t = 1 # PNG - original
                s1 = s3
                s2 = ''
            else:
                t = 2 # PNG - premult
                s1 = s4
                s2 = ''
        
        s1 += "\x00"*(4-len(s1)%4)
        s2 += "\x00"*(4-len(s2)%4)
        
        # 24 byte info
        s = "%d %d %d"%(t,len(s1),len(s2))
        s += "\x00"*(24-len(s))
        f.write(s)
        
        # data
        f.write(s1)
        f.write(s2)
        
s = f = open("%s/data.json"%(SRC)).read()
s = s.replace(".png",".cuz")
f = open("%s/data.json"%(SRC),"wb")
f.write(s)
f.close()

main()

In my game, I use stb_image to load my images. But I used a little bit of C code to read my header and decide how to decode them.

        unsigned char cuz_head[256];
        FILE *f = fopen(fname,"rb");
        fread(cuz_head,1,256,f);
        int tp=0,s1=0,s2=0;
        sscanf((char*)cuz_head+8,"%d %d %d",&tp,&s1,&s2);
        fprintf(stderr,"is_cuz: %d %d %d\n",tp,s1,s2);
        fseek(f,32,SEEK_SET);
        
        // load our first image!
        data = stbi_load_from_file(f,&width,&height,&bpp,4);

        if (tp == 1) {
            // do nothing, it's like we loaded a normal image
        }
        
        if (tp == 2) {
            // this image is premultiplied
            is_premult = 1;
        }
        
        if (tp == 3) // separate ALPHA image
        if (data) {
            // this image is premultiplied
            is_premult = 1; 
        
            unsigned char *alpha;
            fseek(f,32+s1,SEEK_SET);
            int _width,_height,_bpp;
            alpha = stbi_load_from_file(f,&_width,&_height,&_bpp,1);
            
            if (!alpha) {
                fprintf(stderr,"(cuzi:alpha) stbi_load failed %s - %s\n",fname,stbi_failure_reason());
            }
            
            if (alpha) {
                fprintf(stderr,"(cuzi:alpha) OK\n");
            
                unsigned int *pix = (unsigned int *)data;
                unsigned char *pa = alpha;
                for (int i=0; i<width*height; i++) {
                    unsigned char *p = (unsigned char*)pix;
                    p[3] = *pa;
                    pa ++;
                    pix ++;
                }
                stbi_image_free(alpha);
            }
            
        }

2 Responses to “How I got Dynamite Jack from 62MB down to 46MB”

  1. Denilson Says:

    For compressing PNG images, don’t forget to use optipng, a command-line tool that tries to compress PNG images using the best (smallest) possible settings, while retaining the exact same pixel values (i.e. lossless).

  2. philhassey Says:

    “ImageOptim” that I mentioned actually uses optipng and about 3 or 4 other lossless PNG compressors, and then it picks the best result.