DEV Community

zheng
zheng

Posted on

GIF to sprite sheet: the pipeline I use for Phaser and Godot, and where it breaks

An animated GIF is the easiest way to share a sprite animation and one of the worst ways to ship
one. It has no alpha channel worth the name, it re-renders every frame from a delta-encoded stream, and
your engine can't index a frame in it. So the first thing I do with any GIF that contains animation I
actually want in a game is convert it into a sprite sheet plus an atlas file.

This is the pipeline I use, the parts that bit me, and the code for both Phaser and Godot.

What a GIF actually gives you

"Just extract the frames" hides three problems:

  1. Frame disposal. Each frame is a delta against the previous one, with a disposal method saying what to do with the previous frame's pixels (keep, restore to background, restore to previous). Extract frames by reading byte offsets naively and you get smeared, partially updated images. You need a real decoder that composites each frame onto the running canvas — the browser gives you this for free via ImageDecoder or by drawing an <img> that points at the GIF and reading frames off a canvas.
  2. 1-bit transparency. GIF pixels are either fully opaque or fully transparent. No partial alpha. So anti-aliased edges that looked fine on a flat background turn into a hard fringe the moment you put the sprite on a different background. If the source was a game animation, check whether the GIF was exported from a PNG sequence — the original PNGs are always better input than the GIF.
  3. Palette quantization. 256 colours per frame, usually with dithering. Your character's smooth gradient is now noise. The sprite may be 2x the size as a sheet but that's a good trade.

Practical consequence: the GIF is fine as a source of frame order and timing, and poor as a source of
colour. If you have the original frames, use those.

Step 1 — get the frames out

Command line, if you're already in a pipeline:

# one PNG per GIF frame, keeping the composited result
ffmpeg -i run.gif -vsync 0 -f image2 frame_%04d.png
Enter fullscreen mode Exit fullscreen mode

-vsync 0 matters: without it ffmpeg duplicates or drops frames to hit a constant rate and your walk
cycle gains or loses a frame.

In the browser, with no upload, the same job is a few lines against a canvas — decode the GIF, draw each
frame, toBlob() it. That's the path I use when the asset isn't mine to send to a server, and it's the
reason the tool I maintain, GIF to sprite sheet,
runs entirely client-side (createImageBitmap per frame, pack, canvas.toBlob, ZIP in memory).

Either way, name the frames after the action, not after the index you happened to extract them in:

idle0001.png  idle0002.png  idle0003.png  idle0004.png
Enter fullscreen mode Exit fullscreen mode

Four digits, zero-padded, action name first. This is not cosmetic — the FNF/Flixel family derives the
animation from the name prefix, and your own importer will thank you when walk and walk_back both
exist.

Step 2 — pack them

Two layouts, and picking wrong costs you either bytes or correctness:

  • Grid — every cell the same size, frame N at (col, row). Simple, and the metadata is tiny. Right for an animation whose frames are all the same canvas (most character animations). Wastes space when frames vary in size.
  • Auto-pack — tight rows around differently sized rectangles. Saves texture space, but frame positions are now irregular, so the atlas metadata stops being optional. If you ever see a sheet in the wild with no JSON next to it, that's a grid.

Two settings that are not optional in practice:

  • Padding — empty pixels around the whole sheet.
  • Spacing — empty pixels between frames.

Both exist to stop texture bleeding: when the GPU samples a sprite with linear filtering or mipmaps at
a non-integer position, it reads a fraction of the neighbouring texel. On a tightly packed sheet that
fraction is the next frame's elbow. A 2 px gutter makes the artifact disappear. If your sprites still
show a one-pixel bright edge after adding spacing, you're probably seeing premultiplied-alpha rounding,
not bleeding — export as PNG-32 and draw at integer coordinates.

One thing worth knowing when you compare tools: rotated frames. Some packers rotate frames 90° to save
space. That's fine for a generic engine but breaks frame-name/atlas conventions that assume unrotated
frames. If the pipeline feeds an engine with a naming convention, keep rotation off.

Step 3 — wire it into the engine

Phaser 3

The atlas JSON is TexturePacker-shaped, so the built-in loader takes it:

function preload() {
  // sheet.png + sheet.json produced from the GIF
  this.load.atlas("hero", "assets/hero.png", "assets/hero.json");
}

function create() {
  // frame names came from the extraction step: idle0001, idle0002, ...
  const frames = this.textures.get("hero").getFrameNames();       // ["idle0001", ...]
  const idle = frames.filter((n) => n.startsWith("idle")).sort();

  this.anims.create({
    key: "idle",
    frames: idle.map((frame) => ({ key: "hero", frame })),
    frameRate: 12,
    repeat: -1
  });

  this.add.sprite(160, 120, "hero").play("idle");
}
Enter fullscreen mode Exit fullscreen mode

The frames array must be sorted explicitly — the loader hands them back in atlas order, which for an
auto-packed sheet is whatever order the packer placed them in, not animation order. This is the single
most common bug I hit: the animation plays, the poses are correct, and they arrive shuffled.

Godot 4

Godot wants a SpriteFrames resource. Either import the atlas with an editor plugin, or build the
resource directly — a .tres with one animation per action:

var sf := SpriteFrames.new()
sf.remove_animation("default")
sf.add_animation("idle")
sf.set_animation_speed("idle", 12.0)
sf.set_animation_loop("idle", true)

var sheet := load("res://assets/hero.png") as Texture2D
for x in range(4):                     # 4 columns of a grid sheet
    var at := AtlasTexture.new()
    at.atlas = sheet
    at.region = Rect2(x * 64, 0, 64, 64)
    sf.add_frame("idle", at)
Enter fullscreen mode Exit fullscreen mode

AtlasTexture.region is exact pixels — no trimming, no rotation. That's why the grid layout is the
friendlier one for hand-written import code.

Unity

Sprite Atlas with multiple sprites, or the texture's Sprite Mode set to Multiple with the atlas JSON
imported as sprite metadata. Unity's convention is {x, y, width, height} while Phaser's frame records
use {x, y, w, h} — a copy-paste of a loader between the two silently produces zero-sized frames
rather than an error.

The gotchas, in one list

  • Sort frames by name before building the animation. Atlas order ≠ animation order.
  • Spacing ≥ 2 px if you use linear filtering or mipmaps; 0 is fine only for nearest-neighbour pixel art drawn at integer positions.
  • Keep rotation off when a naming convention is involved.
  • Watch the sheet size limit. 4096×4096 is the common engine/GPU ceiling; a long GIF will blow past it and you want the packer to tell you, not the driver.
  • GIF in, PNG out. Never ship the GIF itself: no alpha, no mipmaps, no atlas.
  • Triple-check the transparent background. If the GIF was recorded against a checkerboard, that checkerboard is now part of your sprite.

Sanity check before you export

Play the sheet back at the intended frame rate before it reaches the engine. A missing pose, a reversed
frame or a duplicated one is instant to spot in a preview and annoying to debug inside a game loop. Any
decent packer has a preview for exactly this reason; the engine guides
section covers the per-engine import steps in more detail.

If you want to try the pipeline above without installing anything, the GIF → sheet step is a browser tool:
drop the GIF in, it decodes the frames locally, you reorder if needed, and you get a PNG plus JSON or XML
out. Nothing is uploaded, and the free path needs no account.

Top comments (0)