Phil Hassey - game dev blog
Phil Hassey as Snidely Whiplash
"You can't buy awesomeness.
You're born that way."

Archive for August, 2012

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

Thursday, August 9th, 2012

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);
            }
            
        }

Dynamite Jack – now Universal for iPhone – with special promotion

Wednesday, August 8th, 2012

Ahoy there! I’ve been working all month to get you Dynamite Jack for the iPhone, and it’s a Universal update to the iPad version! You can get it here.


“Like stealth? Like explosions? You’re going to love Dynamite Jack.” – 4.5 stars, TouchArcade

“It’s not often I come across I game that I believe should be on every iPad. Dynamite Jack is one of those games.” – 4.5 stars, 148Apps

“There are some games that you can instantly tell are going to be good, and Dynamite Jack is one of them.” – 4.5 stars, Apple’N’Apps


If that’s not enough to convince you, I’m also running special 48 hour promotion – buy the iOS version and you get the desktop version as a free download! This will also work for owners of the iPad version. If you already have the desktop version, give your bonus code to a friend!

iOS Promo

Anyway, check it out! I hope you enjoy it!

-Phil

P.S. I’m still considering the Android port of this game. If you are interested in an Android version, please post a message in this thread to show your support.