·12 min read

Sprite sheet texture atlas: 10.1 MB to 2.7 MB

Packing a 24-frame bat animation for a canvas 2D game in a Capacitor WebView. Trim, dice into strips, a one-pixel bleed, and every frame rebuilt exactly, in a quarter of the memory. Plus the lossy WebP step I should have measured first.

gamedevcapacitorperformanceimages

Contents

Echo Bat is a one-touch cave flyer: tap to flap, ping sonar to see the rocks. It is React and a canvas 2D renderer inside a Capacitor 8 WebView on Android, so every image in it goes through the browser's image pipeline and not a game engine's.

The bat's wingbeat is 24 frames cut from an AI-generated video. I stored them the obvious way, as one sprite sheet: 24 cells of 291×360 in a row, saved as WebP. The file was 250 KB, which looked fine. The decoded image was 10.1 MB, which is the number that matters once the game is running, and it was 10.1 MB for one small animated character.

I knew bigger studios pack animation frames so the GPU holds far fewer pixels, and I wanted the same for this sheet with nothing lost. This post is how it went: a texture atlas built in six steps, the alternatives I measured before picking one, and the check that stops the pack if any frame comes back different. It ends with the part I got wrong.

Sprite sheet vs texture atlas

The two words get used for the same thing, so here is how I use them.

A sprite sheet is frames on a grid. Every cell is the same size, and frame n is at column n. You need nothing but the cell size to find a frame, which is why it is the default. It is also why it wastes space: a cell is sized for the widest wing pose, and every other frame pays for it.

A texture atlas is pieces packed wherever they fit, plus a map saying where each piece came from. Finding a frame now needs the map, and in exchange the empty space is gone.

The sprite vs texture split matters for the numbers. The sprite is what you draw on screen; the texture is the pixels held in memory to draw it from. A WebP is not a format the GPU can sample, so the WebView decodes it to 4 bytes a pixel before it can be drawn. File size is what the store download costs. Decoded size is what the running game costs.

Pixels Decoded (×4 bytes) WebP file
Sprite sheet, 24 × 291×360 2,514,240 10.1 MB 250 KB
Texture atlas 669,460 2.7 MB 221 KB

The decoded figures are pixel counts times four. I computed them. I did not profile the WebView's GPU memory, so read them as what the image costs once decoded, not as a reading off a device.

Packing the sprite sheet into a texture atlas

The packer is one Python script, about 160 lines with NumPy 2.4.4 and Pillow 11.3.0. It runs six steps in order.

  1. Alpha floor. Pixels at most 3/255 opaque are cleared. Removing the video's magenta background left 18,739 of them: invisible, but each one stretched its frame's box.
  2. Trim. Each frame is cut to the box round what is left. Trimming alone takes the sheet from 2,514,240 pixels to 967,036; with the alpha floor first, to 939,592.
  3. Dice. Each trimmed frame is cut into bands 32 px tall, and each band is split wherever more than 8 empty columns run together, such as the gap between a wing and the body. Only the filled runs are kept: 213 strips, between 5 and 13 a frame, 590,596 pixels.
  4. Dedupe. Strips that are identical byte for byte are stored once. This found exactly one. Frames from a video never repeat, so this step earns nothing on this kind of art.
  5. Pack. The strips are laid in shelves, tallest first, and the script tries every atlas width from 256 to 2048 in steps of 8 and keeps the smallest area. It settled on 1969×340.
  6. Bleed. Each strip is stored with a 1 px border of the pixels that really surround it in its frame. More on why below; it is the step I would have left out.

The dicing loop is the core of it:

for f in frames:
    padded = np.pad(f, ((bleed, bleed), (bleed, bleed), (0, 0)))
    ys, xs = np.nonzero(f[..., 3])
    x0, y0, x1, y1 = xs.min(), ys.min(), xs.max() + 1, ys.max() + 1
    runs = []
    for by in range(y0, y1, band_h):
        band = f[by:min(by + band_h, y1)]
        for a, b in runs_of(band[..., 3].any(0)):     # split at gaps > GAP
            h = band.shape[0]
            piece = padded[by:by + h + 2 * bleed, a:b + 2 * bleed]
            key = hashlib.sha1(piece.tobytes() + str(piece.shape).encode()).hexdigest()
            if key not in by_hash:
                by_hash[key] = len(pieces)
                pieces.append(piece)
            runs.append([by_hash[key], a - x0, by - y0])
    layout.append({'x': x0, 'y': y0, 'w': x1 - x0, 'h': y1 - y0, 'runs': runs})

The output is the atlas WebP and a 6 KB JSON map: for every frame, its trimmed box and a list of (strip, x, y) triples.

What I measured before choosing 32 px strips

Smaller pieces fit the outline more tightly, so I expected the smallest dice to win. It won on pixels and lost on everything else.

Square cells of 8 px brought the sheet to 560,384 pixels, about the same as strips, but needed 8,756 cells: 365 per frame, against roughly 9 for 32 px strips. Each piece is one drawImage call when the frame is rebuilt, so that was out.

Between strip heights and bleed widths, all with the same lossy WebP setting:

Band Bleed Atlas pixels Decoded File Edge error
16 px 1 px 620,874 2.5 MB 235 KB 4.47
16 px 2 px 716,760 2.9 MB 248 KB 4.18
24 px 1 px 661,024 2.6 MB 228 KB 4.53
32 px 1 px 669,460 2.7 MB 221 KB 4.35
32 px 2 px 720,324 2.9 MB 229 KB 4.09

Edge error is the mean difference, out of 255, between the source frames and the decoded atlas, measured only on pixels at a strip's edge. The old sheet scored 4.17 on those same pixels.

Thinner strips saved 0.2 MB of memory and made a bigger file with twice the pieces. A second pixel of bleed bought a fraction of a point of edge error for 0.2 MB. I took 32 px and 1 px.

How to prevent texture bleeding when drawImage draws multiple images from a sprite sheet or texture atlas

Texture bleeding is the usual reason an atlas has padding: draw a piece scaled, and the filter samples the pixel next to it in the atlas, which belongs to some other piece. That is the case most advice about atlas padding is written for, and it is a real problem.

It was not my problem, because my strips are never scaled. They are copied into a scratch canvas at 1:1, on whole-pixel coordinates, with smoothing off, so no sample ever lands outside the strip. The rebuilt frame gets a clear pixel round it, and only then is it scaled to the screen.

My problem was the encoder. My first pack had no bleed at all, and with lossy WebP at quality 88 the error at strip edges was 7.00 against 4.25 in the middle of the strips: about 65% worse. With one pixel of the real surroundings stored next to each strip, the final 32 px pack came in at 4.35 on its edges, against 4.17 for the same pixels in the old sheet. Only the strip itself is ever drawn; the border is there for the compressor.

The explanation I worked from, which the numbers fit but which I did not test on its own: a lossy encoder works on blocks, and a strip butted against an unrelated strip is a hard edge that was never in the picture. With the real neighbours stored beside it, the encoder sees the image carry on.

Padding is standard atlas advice. Here it earned its place for a different reason than the usual one, and only measuring the edges showed which.

Rebuilding a frame in the game

A frame is rebuilt the first time it is needed and kept:

const KEPT_FRAMES = 4;

export function batFrame(index, look = 'night') {
  const { picture, layout } = batSprite(look);
  const atlas = image(picture);
  if (!atlas) return null;
  const key = `${look}:${index}`;
  if (rebuilt.has(key)) return rebuilt.get(key);

  const [x, y, w, h, runs] = layout.frames[index];
  const canvas = freeCanvas();               // one of 4, reused oldest first
  const ctx = canvas.getContext('2d');
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.imageSmoothingEnabled = false;
  for (let i = 0; i < runs.length; i += 3) {
    const [px, py, pw, ph] = layout.pieces[runs[i]];
    ctx.drawImage(atlas, px, py, pw, ph, 1 + runs[i + 1], 1 + runs[i + 2], pw, ph);
  }
  const frame = { canvas, x: x - 1, y: y - 1, width: w + 2, height: h + 2 };
  rebuilt.set(key, frame);
  return frame;
}

A flap can run through all 24 frames in under 0.4 s, and making a canvas per frame would churn memory, so there are four, each sized to the largest trimmed frame. Four lets a held pose and the two faint smear copies drawn behind the wings on a hard stroke come from the cache without rebuilding every screen frame.

Those four canvases are memory too. Sized to the night bat's largest frame, 281×259, they hold about 1.2 MB between them, so the honest total is near 3.9 MB against 10.1, not 2.7. Still a saving of more than half, and worth stating the right way round.

The draw code places each trimmed frame where it sat in its original cell, so the bat's position on screen did not move.

How I checked nothing was lost

Three checks, at three levels.

In the packer. After packing, every frame is rebuilt from the atlas and the map, in memory, before encoding, and compared with the frame it was cut from:

assert (rebuilt == f).all(), 'a frame did not rebuild exactly'

If one pixel differs, the pack stops and nothing is written. The packing is exact by construction, and this line is what makes "by construction" more than a claim.

In the tests. A test reads the JSON map and checks that every strip it names exists, that strips stay inside their frame and frames inside their cell, and that the body's anchor point falls inside every frame. The full suite was 323 tests, all passing.

On the emulator. I built the app, installed it on a Pixel emulator at 1280×2856, and took full-resolution screenshots in three poses: level, mid-downstroke and the bottom of the stroke. Zoomed to 2×, there were no seams and no missing pieces.

What "same as before" was hiding

My summary at the end of that day said the atlas used lossy WebP at quality 88, the same setting as the old sheet, and the measured error was the same as before. Both true. I took that as proof the packing had cost nothing, and it was. What I had not asked was whether "before" was any good.

The next day two other bats, ghost and frost, flickered and seemed to boil on the zoomed home screen. I tried four patches on the motion (dimmer pings, the upstroke timing, steadying the body, freezing the face) and none of them fixed it. Then I measured every step of the art pipeline against the source video, on the solid pixels of the frost bat:

Step Mean error, out of 255
Removing the magenta background 0.28
Despill, taking the pink fringe off the wings 0.11
Lossy WebP at quality 88 2.46

The encoder was almost nine times the background removal and more than six times the first two steps together. The packing this post is about added nothing to it. The cost was in my own table the day before: the old sheet was already 4.35 out of 255 away from its source inside the strips, and I read that as a baseline to match rather than as a cost to question.

The fix for those bats changed four things at once: lossless WebP, frames cut at 540 px instead of 360, one uncut wingbeat at the video's own 24 fps instead of 24 picked frames, and the extra motion the game added on the home screen switched off. The flicker was gone and home held 59 fps on the emulator. Because all four changed together, I cannot tell you how much of the flicker was the encoder alone, only that it was the largest error the measurement found.

Lossless costs file size, not decoded memory; the taller 540 px frames are what raised that. Frost's file went from 0.16 MB to about 0.9 MB. The gold bat, reworked the same way, packs into a 1.3 MB atlas that decodes to 4.4 MB, against 18.1 MB for its 540 px sheet. The trim, the strips and the exact rebuild carried over unchanged; the packer now writes lossless.

What I would keep, and what I would check first

  • Measure the decoded size, not the file. The 250 KB on disk told me nothing about the 10.1 MB in memory.
  • Pick the dice by measuring a few sizes. The smallest pieces were the worst choice here.
  • Put the exact-rebuild assertion in the packer from the first version. It is one line and it turns "lossless packing" from a description into a guarantee.
  • When something says "same as before", check what before was. My baseline was the lossy encode I later threw away.

I did not try an off-the-shelf sprite sheet texture packer such as TexturePacker for this, so I cannot compare its trim and padding with mine. My art pipeline was already Python, from cutting the video to keying the background, and the packer went on the end of it.

More posts

New writing when there is something worth saying. By email or by RSS, whichever you prefer.