Cuts PNGs into their separate sprites, then packs them into a single power of two spritesheet with a JSON map. No dependencies outside the standard library.
go install ella.to/binpack/cmd/binpack@v0.0.1Two commands, meant to be run in this order:
binpack split -outFolder ./assets/fragments ./assets/*.png ./assets/char/*.png
binpack join -outPNG ./assets/merge.png -outJSON ./assets/merge.json ./assets/fragments/*.pngThe JSON map is keyed by file name, and split can only name fragments by
number. So the workflow has a manual step in the middle, and that step is the
point of splitting separately from joining:
1. Split. Every non transparent region in every input becomes its own file, trimmed of the transparent space around it.
$ binpack split -outFolder ./assets/fragments ./assets/*.png ./assets/char/*.png
split 4 files into 12 fragments, 00000.png to 00011.png
./assets/fragments2. Rename. Look through ./assets/fragments and give each fragment the name
you want to reference it by in code.
$ cd ./assets/fragments
$ mv 00000.png hero-idle.png
$ mv 00001.png hero-walk-1.png
$ mv 00002.png hero-walk-2.png3. Join. Pack the renamed fragments. Each file name without its extension becomes a key in the map.
$ binpack join -outPNG ./assets/merge.png -outJSON ./assets/merge.json \
-padding 0 -allowRect -minSize 128 -maxSize 2048 \
./assets/fragments/*.png ./assets/character/*.png
joined 12 sprites into 256x256 (39.0% used){
"hero-idle": { "x": 0, "y": 0, "w": 67, "h": 67 },
"hero-walk-1": { "x": 68, "y": 0, "w": 40, "h": 36 }
}Skip the rename and you get "00000": {...}, which tells you nothing at the call
site. Rename first and the map reads for itself.
Joining is repeatable: rename another fragment, run join again, and only that
key changes. Coordinates are top left origin, in pixels on the sheet.
binpack split -outFolder <dir> <png paths...>
| flag | meaning |
|---|---|
-outFolder |
directory to write the fragments to, required |
The trailing paths are files, globs, or directories, as many as you like;
directories are walked recursively for .png. Cutting and trimming are always
applied, there is nothing to configure.
A source PNG holding a grid, a strip, or a scattering of sprites yields one
fragment per sprite. Fragments are numbered 00000.png upwards, in input file
order and then reading order within each file, top to bottom then left to right.
Detection finds every group of touching non transparent pixels, using 8 way connectivity, then applies two fixed rules so a sprite does not come apart:
- Regions separated by 2 transparent pixels or fewer are treated as one sprite,
which keeps a dot over an
ior a detached highlight attached to its body. - Regions smaller than 2 pixels on either axis are dropped as antialiasing noise rather than art.
Split into an empty directory. binpack refuses an -outFolder that holds one of
the input files, and warns when the target already contains PNGs: numbering
restarts at 00000 on every run, so a re-run recreates the original names next
to the ones you renamed, and join would then pack both copies.
binpack join -outPNG <file> -outJSON <file> [flags] <png paths...>
| flag | default | meaning |
|---|---|---|
-outPNG |
output spritesheet PNG, required | |
-outJSON |
-outPNG with .json |
output JSON map |
-padding |
0 | transparent pixels to keep between sprites |
-allowRect |
off | also try half height sheets, such as 512x256 |
-minSize |
128 | smallest power of two sheet size to try |
-maxSize |
8192 | largest power of two sheet size to try |
One input file is one sprite, keyed by its file name without the extension, and
each is trimmed to its non transparent bounds so a hand edited fragment does not
carry padding onto the sheet. Files that share a name across directories get a
#2 suffix, reported on stderr.
Join does not re-cut its inputs. A fragment you edited into two disconnected pieces stays one sprite under one key, since resplitting it would throw away the name you just gave it.
The sheet is the smallest power of two square that fits everything, from
-minSize up to -maxSize, doubling each step. -allowRect also tries half
height sheets on the way up, which often wastes less space than the next square.
Sprites are never rotated, so the map needs no rotation flag.
-padding is worth setting to 1 or 2 when the sheet is sampled with linear
filtering or mipmaps, since neighbouring sprites otherwise bleed into each
other. The reported x, y, w, h always describes the sprite itself, never its
padding.
files, err := binpack.FindPNGs([]string{"./assets"})
// split
fragments, err := binpack.Split(files, binpack.DefaultCut(), nil)
err = binpack.DumpFragments("./assets/fragments", fragments)
// join
files, err = binpack.FindPNGs([]string{"./assets/fragments"})
sprites, err := binpack.Load(files, 0, nil)
result, err := binpack.Pack(sprites, binpack.Options{MinSize: 128, MaxSize: 2048})
err = binpack.SavePNG("./assets/merge.png", result.Sheet)
err = binpack.WriteMap("./assets/merge.json", result.Frames)The last argument to Split and Load is an optional Warnf for non fatal
problems such as a skipped transparent file. Packing is MaxRects with the best
short side fit heuristic, largest sprite first.