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:
-
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 viaImageDecoderor by drawing an<img>that points at the GIF and reading frames off a canvas. - 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.
- 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
-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
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");
}
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)
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)