How much does a sprite atlas actually save you?
On disk, 1.17x — almost nothing. In texture memory, 1.54x against sprites padded to a power of two, and 16x against a naive fixed-cell grid. Measured over 340 generated sprites with three packing algorithms, four sort orders and four padding widths. Shelf packing matched MaxRects at 1/1500th of the
On disk, almost nothing: one atlas PNG was 1.17x smaller than the 340 individual PNGs it replaced. The saving that matters is elsewhere — 1.54x in uncompressed texture memory against sprites padded to a power of two, 16x against a naive fixed-cell grid, and 3.6x faster to decode. And the packing algorithm everyone reaches for is not the one worth implementing: shelf packing filled a 1024-wide atlas to 94.3% in 0.10 ms, where MaxRects reached 93.7% in 149 ms — 1,500 times the CPU for slightly worse.
Hardware: Apple M3, 16 GB, macOS 26.4.1. Python 3.14.6, Pillow 12.3.0, no engine, no GPU, no network. Timings are medians of five runs on one laptop under normal desktop load — indicative, not a lab benchmark. The dimensions, occupancies and byte counts are exact and reproduce from the seed.
The sprite set is 340 generated sprites from random.Random(1234): 120 characters (24–80 x 32–96 px, a rounded body, an ellipse head and two arms drawn on a transparent canvas), 160 tiles (square, 16/32/48/64 px, fully opaque with per-pixel colour noise so they do not compress to nothing) and 60 UI elements (40–160 x 16–48 px, rounded translucent rectangles). Total 854,735 sprite pixels. Real art will differ in how much transparent margin it carries — see the trimming section — but the packing geometry is the same problem.
The short answer
- A sprite atlas barely helps your download. 340 PNGs totalled 258,405 bytes; the single 1024x1024 atlas PNG was 220,321 — 1.17x. Per-file PNG overhead was only 45 bytes (5.9% of the set), not the fat people assume.
- Shelf packing is the algorithm to implement. Sorted by height it hit 94.3% occupancy in 0.10 ms; MaxRects best-short-side-fit hit 93.7% in 149 ms. Both landed on the same 1024x1024 power-of-two atlas, so the difference was worth exactly zero bytes of texture memory.
- Sorting matters more than the algorithm. Unsorted, shelf packing needed a 2048x2048 atlas (20.4% occupancy, 16 MiB); sorted by height it fitted into 1024x1024 (81.5%, 4 MiB). One
sorted()call, 4x the texture memory. - Padding is not free and it is not linear. 1 px of padding cost 5.2% of atlas height, 2 px cost 8.5% — but 4 px pushed the atlas past 1024 rows and doubled the texture to 8 MiB.
- A naive fixed-cell grid needs a 4096x4096 atlas for the same sprites: 5.1% occupancy, 64 MiB of texture against the shelf atlas's 4 MiB.
Which packing algorithm is worth implementing?
Three packers, all in plain Python, all deterministic, no rotation. The naive grid uses one cell sized to the largest sprite. Shelf packing (first-fit decreasing height) opens a row as tall as its first sprite and fills it left-to-right. MaxRects keeps a list of maximal free rectangles and places each sprite by best-short-side-fit, the variant that usually wins in Jylänki's 2010 survey.
Sprites sorted by height descending, no padding. "POT atlas" is the smallest square power-of-two that fits everything; "1024-wide" is the used height when the width is fixed at 1024 and the height is free.
| Algorithm | POT atlas | Occupancy | 1024-wide | Occupancy | Pack time |
|---|---|---|---|---|---|
| Naive grid | 4096x4096 | 5.1% | 1024x5392 | 15.5% | 0.03 ms |
| Shelf (FFDH) | 1024x1024 | 81.5% | 1024x885 | 94.3% | 0.10 ms |
| MaxRects (BSSF) | 1024x1024 | 81.5% | 1024x891 | 93.7% | 149 ms |
MaxRects lost, and that is the finding. It is the algorithm every atlas tool advertises, it is roughly 60 lines against shelf packing's 12, it ran 1,500x slower, and on this sprite set it produced a taller atlas. The reason is structural: 160 of the 340 sprites are squares of four sizes, so height-sorted shelves come out nearly perfectly filled, and MaxRects' greedy short-side fit spends its cleverness carving holes that later sprites do not exactly fit. MaxRects earns its keep on wildly irregular sets — a few huge backgrounds among small icons — not on a normal game's sprite mix.
The naive grid is the one to be genuinely afraid of. One cell sized to the largest sprite (156x96) means a 16x16 tile occupies 14,976 pixels of atlas. At 5.1% occupancy it needs a 4096x4096 texture, which is 64 MiB of RGBA8 — above the maximum texture size on some mobile GPU profiles, for 3.26 MiB of actual sprite. If your atlas is generated by a script someone wrote in an afternoon, this is probably what it does.
Does sorting the sprites first help?
More than choosing the algorithm does. Same three packers, four orders:
| Sort | Grid POT | Shelf POT | Shelf 1024-wide | MaxRects POT | MaxRects 1024-wide |
|---|---|---|---|---|---|
| Unsorted | 4096 (5.1%) | 2048 (20.4%) | 1024x1250 (66.8%) | 1024 (81.5%) | 1024x960 (86.9%) |
| Height desc | 4096 (5.1%) | 1024 (81.5%) | 1024x885 (94.3%) | 1024 (81.5%) | 1024x891 (93.7%) |
| Area desc | 4096 (5.1%) | 2048 (20.4%) | 1024x1368 (61.0%) | 1024 (81.5%) | 1024x1006 (83.0%) |
| Max side desc | 4096 (5.1%) | 2048 (20.4%) | 1024x1031 (81.0%) | 1024 (81.5%) | 1024x986 (84.7%) |
Height descending wins for both real packers, and for shelf packing it is the difference between a 4 MiB texture and a 16 MiB one. That is not surprising once stated — a shelf's wasted space is the gap between its tallest sprite and everything else in it, and sorting by height makes each row uniform. What is worth noticing is that sorting by area, the intuitive choice, is one of the worst options: area conflates a tall thin sprite with a short wide one, which is exactly the distinction a shelf cares about. Sorting by max side, the default in several published packers, is also beaten by plain height here.
MaxRects is far less sensitive to order (81.5% at every POT size, 83.0–93.7% tight) which is a real argument in its favour if your pipeline cannot guarantee input order. But you can guarantee input order. It is one line.
What does padding cost you?
You need a gap between sprites or the GPU's bilinear filter samples the neighbour at non-integer scales and you get a bright seam — texture bleeding. The usual advice is 2 px, or 4 px if you use mipmaps. Shelf packing, height sorted, 1024 wide:
| Padding | Used height | Occupancy | POT atlas | Texture memory |
|---|---|---|---|---|
| 0 px | 885 | 94.3% | 1024x1024 | 4.00 MiB |
| 1 px | 931 | 89.7% | 1024x1024 | 4.00 MiB |
| 2 px | 960 | 86.9% | 1024x1024 | 4.00 MiB |
| 4 px | 1035 | 80.6% | 1024x2048 | 8.00 MiB |
The occupancy tax is real but modest — 4.6 points for 1 px, 7.4 for 2 px. The cliff is elsewhere. At 4 px the atlas needs 1,035 rows, eleven rows past 1024, and a power-of-two pipeline rounds that to 2048 — the padding decision doubled the texture. Eleven rows. This is the failure mode to watch for: padding and POT rounding interact, and the cost of one more pixel of padding is either zero or 100%, never something in between.
If you are near a boundary, the fix is not to cut padding. It is to move a few sprites to a second page, or to use extrude (repeat the edge pixel into the padding) instead of transparent padding, which lets you get away with 1 px.
What does the atlas actually save on disk?
Less than the folklore says.
| Bytes | vs atlas | |
|---|---|---|
| 340 individual PNGs | 258,405 | 1.17x |
| One 1024x1024 atlas PNG | 220,321 | — |
| 340 individual lossless WebPs | 97,134 | 0.78x |
| One 1024x1024 atlas WebP (lossless) | 124,172 | — |
| One atlas WebP, quality 90 | 146,568 | — |
Two things here contradict the usual claim. First, the per-file PNG overhead is small: walking every chunk in all 340 files, non-IDAT bytes came to 15,300 in total — 45 bytes per file, 5.9% of the set. A PNG's fixed cost is a signature, an IHDR and an IEND; the "hundreds of headers" argument is worth about 15 KB here, not the bulk of the difference.
Second, the individual WebPs were smaller than the atlas WebP — 97,134 against 124,172 bytes, a 0.78x ratio the wrong way. Combining sprites hands the encoder a large image full of unrelated neighbours and 18.5% empty space, and WebP's lossless entropy coding does better on many small coherent images than on one incoherent large one. If your only goal is download size on a web build, packing an atlas can cost you bytes.
Where the single file does pay: 339 fewer HTTP requests, and decode. Reading and decoding all 340 PNGs through Pillow with a warm page cache took 14.7 ms; the one atlas took 4.1 ms — 3.6x. On a mobile web build with per-request latency, the request count alone is usually the argument.
What does it save in texture memory?
This is the number people mean when they say atlases save memory, and it is a calculation from measured dimensions, not a GPU measurement: an RGBA8 texture costs width x height x 4 bytes on the GPU regardless of how well its PNG compressed.
| What is uploaded | Calculation | Memory |
|---|---|---|
| 340 sprites at exact size | sum of w x h x 4 | 3.26 MiB |
| 340 sprites each padded to POT | sum of npot(w) x npot(h) x 4 | 6.16 MiB |
| One shelf atlas, 1024x931 tight | 1024 x 931 x 4 | 3.64 MiB |
| One shelf atlas, 1024x1024 POT | 1024 x 1024 x 4 | 4.00 MiB |
| One naive-grid atlas, 4096x4096 | 4096 x 4096 x 4 | 64.00 MiB |
Read the first and fourth rows together: if your pipeline uploads each sprite at its exact size, the atlas costs 0.82x — it uses more memory than the loose sprites, not less. The atlas only wins when something rounds textures up, and then it wins 1.54x. It wins enormously against a bad packer. Nobody publishes that first row because it is not the sales pitch, but it is the honest one: the atlas is not a compression trick, it is a way of paying the rounding tax once instead of 340 times.
The other saving — fewer draw calls, because sprites sharing a texture can share a batch — is the reason atlases exist, and we did not measure it. That needs a renderer and a frame profiler, not Pillow, and the number depends entirely on your engine's batching rules. Treat everything above as the memory and bytes half of the decision.
Is alpha trimming worth it too?
Trimming each sprite to its non-transparent bounding box before packing removed 7.2% of the total sprite area (854,735 → 793,467 px) and pulled the 1024-wide atlas from 931 rows to 855. After power-of-two rounding both are 1024x1024: zero memory saved. 77.3% of the untrimmed area was genuinely non-transparent, so these generated sprites are tightly cropped already. Hand-authored art exported from an animation tool usually sits on a generous shared canvas, where trimming routinely removes 30–50% — if that is your pipeline, trim first and re-measure, because it will move more than any packer choice here.
For the same style of measurement applied to other cheap wins that turn out not to be, see quantising embeddings — 3.96x smaller, no faster — and gzip or brotli for a JSON API, where the default setting is the whole problem. Our sprite generator at animator.cstsolution.com emits frames as loose PNGs on purpose, for exactly the reason in the WebP table: the packing decision belongs to your engine's pipeline, not to the tool that drew the frames.
Check it yourself
One file, no assets, nothing written to disk. pip install pillow and run it — it takes about six seconds and reproduces every table above.
#!/usr/bin/env python3
"""Sprite atlas packing, measured. Python 3 + Pillow."""
import io, random, time
from statistics import median
from PIL import Image, ImageDraw
def make_sprites(seed=1234):
rng = random.Random(seed)
pal = lambda: (rng.randrange(40,230), rng.randrange(40,230), rng.randrange(40,230), 255)
out = []
for i in range(120): # characters
w, h = rng.randrange(24, 81), rng.randrange(32, 97)
im = Image.new("RGBA", (w, h), (0,0,0,0)); d = ImageDraw.Draw(im)
body, skin = pal(), pal()
d.rounded_rectangle([w*.25,h*.35,w*.75,h*.92], radius=max(2,w//8), fill=body)
d.ellipse([w*.28,h*.05,w*.72,h*.42], fill=skin)
d.rectangle([w*.05,h*.40,w*.28,h*.70], fill=body)
d.rectangle([w*.72,h*.40,w*.95,h*.70], fill=body)
for _ in range(6):
d.point((rng.randrange(w), rng.randrange(int(h*.35), h)), fill=pal())
out.append((f"char_{i:03d}", im))
for i in range(160): # tiles
s = rng.choice([16,16,32,32,32,48,64])
im = Image.new("RGBA", (s, s), pal()); d = ImageDraw.Draw(im)
b = im.getpixel((0, 0))
for _ in range(s*s//6):
x, y, j = rng.randrange(s), rng.randrange(s), rng.randrange(-30, 31)
d.point((x,y), fill=tuple(max(0,min(255,b[k]+j)) for k in range(3)) + (255,))
out.append((f"tile_{i:03d}", im))
for i in range(60): # UI
w, h = rng.randrange(40, 161), rng.randrange(16, 49)
im = Image.new("RGBA", (w, h), (0,0,0,0)); d = ImageDraw.Draw(im)
c = pal()
d.rounded_rectangle([0,0,w-1,h-1], radius=min(8,h//2), fill=c[:3]+(210,),
outline=(255,255,255,255), width=1)
d.line([w*.15,h*.5,w*.85,h*.5], fill=(255,255,255,160), width=max(1,h//8))
out.append((f"ui_{i:03d}", im))
return out
def pack_grid(ss, W, H, pad=0): # one cell, sized to the biggest
cw, ch = max(s[1] for s in ss)+pad, max(s[2] for s in ss)+pad
cols, rows = (W+pad)//cw, (H+pad)//ch
if cols*rows < len(ss): return None
return [(n, (i%cols)*cw, (i//cols)*ch, w, h) for i,(n,w,h) in enumerate(ss)]
def pack_shelf(ss, W, H, pad=0): # rows as tall as their tallest
shelves, out, ytop = [], [], 0
for n, w, h in ss:
pw, ph = w+pad, h+pad
for sh in shelves:
if sh[1] >= ph and sh[2]+pw <= W:
out.append((n, sh[2], sh[0], w, h)); sh[2] += pw; break
else:
if ytop+ph > H or pw > W: return None
shelves.append([ytop, ph, pw]); out.append((n, 0, ytop, w, h)); ytop += ph
return out
def _split(r, u):
rx, ry, rw, rh = r; ux, uy, uw, uh = u
if ux >= rx+rw or ux+uw <= rx or uy >= ry+rh or uy+uh <= ry: return [r]
res = []
if uy > ry: res.append((rx, ry, rw, uy-ry))
if uy+uh < ry+rh: res.append((rx, uy+uh, rw, ry+rh-uy-uh))
if ux > rx: res.append((rx, ry, ux-rx, rh))
if ux+uw < rx+rw: res.append((ux+uw, ry, rx+rw-ux-uw, rh))
return [x for x in res if x[2] > 0 and x[3] > 0]
def pack_maxrects(ss, W, H, pad=0): # best short side fit, no rotation
free, out = [(0, 0, W, H)], []
for n, w, h in ss:
pw, ph = w+pad, h+pad
best = None
for fx, fy, fw, fh in free:
if fw >= pw and fh >= ph:
lx, ly = fw-pw, fh-ph
key = (min(lx, ly), max(lx, ly))
if best is None or key < best[0]: best = (key, fx, fy)
if best is None: return None
_, px, py = best
out.append((n, px, py, w, h))
nf = []
for r in free: nf.extend(_split(r, (px, py, pw, ph)))
nf.sort(key=lambda r: -(r[2]*r[3]))
free = [r for i, r in enumerate(nf) if not any(
r[0] >= k[0] and r[1] >= k[1] and r[0]+r[2] <= k[0]+k[2] and r[1]+r[3] <= k[1]+k[3]
for k in nf[:i])]
return out
PACKERS = {"grid": pack_grid, "shelf": pack_shelf, "maxrects": pack_maxrects}
POT = [128, 256, 512, 1024, 2048, 4096, 8192]
def npot(v):
p = 1
while p < v: p *= 2
return p
def order(ss, how):
return {"none": lambda: list(ss),
"height": lambda: sorted(ss, key=lambda s: (-s[2], -s[1])),
"area": lambda: sorted(ss, key=lambda s: -(s[1]*s[2])),
"maxside": lambda: sorted(ss, key=lambda s: (-max(s[1],s[2]), -min(s[1],s[2])))}[how]()
def ms(fn, *a):
return median([(lambda s: (fn(*a), (time.perf_counter()-s)*1000)[1])(time.perf_counter())
for _ in range(5)])
imgs = make_sprites()
sprites = [(n, im.width, im.height) for n, im in imgs]
by_name = dict(imgs)
AREA = sum(w*h for _, w, h in sprites)
print(f"{len(sprites)} sprites, {AREA} sprite pixels\n")
print("algorithm sort POT atlas occ% 1024-wide occ% pack ms")
for a in ("grid", "shelf", "maxrects"):
for so in ("none", "height", "area", "maxside"):
o = order(sprites, so)
s = next((p for p in POT if PACKERS[a](o, p, p)), None)
r = PACKERS[a](o, 1024, 8192)
hgt = max(y+sh for _, _, y, _, sh in r) if r else 0
print(f"{a:10} {so:8} {f'{s}x{s}':>9} {AREA/(s*s)*100:6.1f}% "
f"{f'1024x{hgt}':>11} {AREA/(1024*hgt)*100:6.1f}% {ms(PACKERS[a], o, s, s, 0):10.2f}")
print("\npadding used height occupancy POT atlas texture MiB")
o = order(sprites, "height")
for pad in (0, 1, 2, 4):
r = pack_shelf(o, 1024, 8192, pad)
hgt = max(y+sh for _, _, y, _, sh in r)+pad
p = npot(hgt)
print(f"{pad:5} {hgt:11} {AREA/(1024*hgt)*100:8.1f}% {f'1024x{p}':>9} {1024*p*4/2**20:11.2f}")
r = pack_shelf(o, 1024, 8192, 1)
atlas = Image.new("RGBA", (1024, 1024), (0, 0, 0, 0))
for n, x, y, w, h in r: atlas.paste(by_name[n], (x, y))
def nbytes(im, **kw):
b = io.BytesIO(); im.save(b, **kw); return b.tell()
ind_png = sum(nbytes(im, format="PNG", optimize=True) for _, im in imgs)
ind_webp = sum(nbytes(im, format="WEBP", lossless=True, quality=100) for _, im in imgs)
at_png = nbytes(atlas, format="PNG", optimize=True)
at_webp = nbytes(atlas, format="WEBP", lossless=True, quality=100)
print(f"\n{len(imgs)} PNG files {ind_png} B -> 1 atlas PNG {at_png} B ({ind_png/at_png:.2f}x)")
print(f"{len(imgs)} WebP files {ind_webp} B -> 1 atlas WebP {at_webp} B ({ind_webp/at_webp:.2f}x)")
exact = sum(w*h*4 for _, w, h in sprites)
potpad = sum(npot(w)*npot(h)*4 for _, w, h in sprites)
print(f"\nCALCULATED texture memory (w*h*4 RGBA8, not a GPU measurement):")
print(f" sprites at exact size : {exact/2**20:6.2f} MiB")
print(f" sprites padded to POT : {potpad/2**20:6.2f} MiB")
print(f" one 1024x1024 atlas : {1024*1024*4/2**20:6.2f} MiB "
f"({potpad/(1024*1024*4):.2f}x smaller than POT-padded sprites)")
On the M3 laptop above that prints:
340 sprites, 854735 sprite pixels
algorithm sort POT atlas occ% 1024-wide occ% pack ms
grid none 4096x4096 5.1% 1024x5420 15.4% 0.03
grid height 4096x4096 5.1% 1024x5392 15.5% 0.03
grid area 4096x4096 5.1% 1024x5392 15.5% 0.03
grid maxside 4096x4096 5.1% 1024x5392 15.5% 0.03
shelf none 2048x2048 20.4% 1024x1250 66.8% 0.06
shelf height 1024x1024 81.5% 1024x885 94.3% 0.10
shelf area 2048x2048 20.4% 1024x1368 61.0% 0.07
shelf maxside 2048x2048 20.4% 1024x1031 81.0% 0.07
maxrects none 1024x1024 81.5% 1024x960 86.9% 151.87
maxrects height 1024x1024 81.5% 1024x891 93.7% 149.30
maxrects area 1024x1024 81.5% 1024x1006 83.0% 187.60
maxrects maxside 1024x1024 81.5% 1024x986 84.7% 178.67
padding used height occupancy POT atlas texture MiB
0 885 94.3% 1024x1024 4.00
1 931 89.7% 1024x1024 4.00
2 960 86.9% 1024x1024 4.00
4 1035 80.6% 1024x2048 8.00
340 PNG files 258405 B -> 1 atlas PNG 220321 B (1.17x)
340 WebP files 97134 B -> 1 atlas WebP 124172 B (0.78x)
CALCULATED texture memory (w*h*4 RGBA8, not a GPU measurement):
sprites at exact size : 3.26 MiB
sprites padded to POT : 6.16 MiB
one 1024x1024 atlas : 4.00 MiB (1.54x smaller than POT-padded sprites)
Swap make_sprites for your own directory of PNGs and read two rows: the shelf row for your sort order, and the padding row that crosses a power of two. Those are the only two decisions in the file that change what the GPU is asked to hold.