Sprite Sheet Explained: From an Animated GIF to a Game Animation

A sprite sheet is the same frames as an animated GIF, packed into space instead of time. What the grid, the cell size, the origin and the JSON atlas each decide — and where each one goes wrong.

October 8, 2026

Sprite Sheet Explained: From an Animated GIF to a Game Animation

An animated GIF and a sprite sheet carry identical information. The GIF writes it along the time axis: one rectangle, N frames stored one after another, each replacing the last after a delay measured in hundredths of a second. The sheet writes it along the space axes: one image, N cells arranged in a grid, all visible at once. That is the whole difference, and it is where every downstream complication comes from. If you have ever asked what is a sprite sheet gif to game animation, the short answer is that nothing is added and nothing is lost in the pixels — what changes is that time is no longer stored with the frames, so you have to store it somewhere else. The pixels are the easy part. The bookkeeping is not.

Time Packed Into Space

A GIF is a container with a header, a global color table, and then a sequence of frame blocks. Each block has its own Graphic Control Extension: a delay in centiseconds, a transparent color index, and a disposal method. A decoder walks the blocks in order, composites each one onto a canvas, and waits. The file's structure is sequential. You cannot jump to frame 40 without decoding frames 1 through 39, because frame 40 may be a 12×12 patch that only makes sense painted on top of whatever frame 39 left behind.

A sprite sheet has no decoder and no compositing. It is one PNG with fixed cells and no concept of order, duration, or sequence. Reading frame 40 is arithmetic: x = col * cellWidth. The renderer never learns that the cells form a walk cycle. That knowledge lives in a separate file and in your code.

Property

Animated GIF

Sprite sheet (PNG + JSON)

Frames stored along

Time (sequential blocks)

Space (grid cells)

Per-frame timing

Yes, centiseconds per frame

No, lives in JSON or code

Color depth

8-bit indexed, up to 256 entries

8-bit per channel, RGBA

Alpha

1 bit, one transparent palette index

8-bit per channel in RGBA

Frame dimensions

Variable per frame, with offsets

Fixed by the grid

Frame count

Implicit in the block list

Must be declared

Compositing

Decoder does it per disposal method

None

Random access

Requires decoding the file

Direct index arithmetic

Everything difficult about sprite sheets follows from the bottom half of that table. Time has to be re-authored. Frame count has to be written down instead of counted as you walk a block list. Alpha goes from one bit to eight and back to whatever your pipeline does with it. And because cells are read by arithmetic rather than by a decoder that knows where frames begin and end, any arithmetic error shows up as a visual defect instead of a parse error.

What Is a Sprite Sheet GIF to Game Animation: The Anatomy of a Sheet

A sheet is defined by five numbers and two conventions.

The five numbers are sheet width W, sheet height H, cell width w, cell height h, and the true frame count N. A 256×128 sheet with 32×32 cells gives 8 columns and 4 rows, which is 32 cell positions, which may hold anywhere from 1 to 32 actual frames.

The two conventions are origin and reading order. In a PNG the origin is the top-left corner of the image. Reading order is almost always row-major: left to right, top to bottom, frame 0 at the top-left cell. Both must be written down, because a renderer that assumes bottom-left origin produces a vertically mirrored animation, and one that assumes column-major order produces a sequence that jumps around the sheet instead of playing smoothly.

Padding and the arithmetic it breaks

The moment you add a gutter between cells, the grid stops dividing the sheet evenly. With a 2-pixel gutter and no margin, the row pitch becomes w + 2, not w. With a 2-pixel gutter plus a 2-pixel outer margin, the width is 2 + C*w + (C-1)*2. That number is not a multiple of w. Any code that computes columns as W / w now returns a fractional value, and any code that computes cell x as col * w samples 2 pixels to the left of where the cell actually is by column 1, 4 pixels off by column 2, and so on.

Gutters exist for a reason. If you plan to generate mipmaps or use LinearFilter, a cell's edge texels blend with the neighboring cell's edge texels at small on-screen sizes, and you see the next frame's arm appear at the bottom of the current sprite. A gutter of 1 to 4 pixels per side fixes that. It also means your indexing code must use x = margin + col * (w + gutter) and your metadata must record margin and gutter explicitly. Deriving them from W and C is not possible in general.

Record the pitch, not just the cell size. A sheet with a 2-pixel gutter and a 4-column layout looks identical to a sheet with no gutter until someone writes col * cellWidth and every frame after the first is offset by a growing number of pixels. The bug is invisible in the first column and obvious in the last, which is why it survives code review.

Why the Frame Size Must Divide the Sheet's Dimensions

Position on a sheet is computed in one of two ways. Either the renderer derives the grid from W, H, w, and h, or the metadata hands it explicit rectangles. The first approach is cheap and requires exact divisibility. The second approach costs a few kilobytes of JSON and requires nothing.

The failure mode when divisibility does not hold is shear, and shear has two distinct causes.

Shear from geometry

Suppose an exporter writes each cell at 32 pixels wide but pads every row to a 36-pixel stride for alignment, and then reports the sheet as 256 pixels wide because that is what 8 * 32 is. The file on disk is 288 pixels wide. The renderer's vertical mapping is computed against the declared height, so it reads row 1 at a vertical offset that corresponds to 32 file rows, but row 1 actually begins at file row 36. Row 2 is read at 64 while the file has it at 72. The error grows by 4 pixels per row.

Horizontally, the renderer maps a 32-pixel cell across 32/256 of the image width. Since the image is 288 wide, that fraction covers 36 actual pixels, and the last 4 of them belong to the next cell. The result is a sprite whose rows slide progressively sideways as you move down the sheet. That is shear: a linear accumulation of horizontal error with vertical position. The first row looks fine and the eighth is visibly wrong.

Shear from the upload path

The same defect appears even when the file is perfect, if you upload a sub-rectangle of a texture with the wrong row stride. In WebGL, UNPACK_ALIGNMENT defaults to 4 bytes. An RGBA image at 32 pixels wide is 128 bytes per row and is unaffected. An RGB image at 33 pixels wide is 99 bytes per row, which the driver pads to 100. If you then upload a 33×33 sub-rect with UNPACK_ROW_LENGTH unset or set to the cell width rather than the sheet width, each row of the sub-image starts one byte further along than the sampler expects, and the sprite leans by one pixel for every 33 pixels of height.

Fixing this is a matter of setting UNPACK_ALIGNMENT to 1 and UNPACK_ROW_LENGTH to the source image width before the upload, or of never uploading sub-rectangles at all and letting the UV coordinates do the cropping. The second option is almost always simpler and costs nothing on the GPU.

The cheapest guard is a load-time assertion: W % w === 0 && H % h === 0. It catches the exported-with-a-margin case, the odd-stride case, and the case where someone resized the sheet in an image editor to trim two pixels off the right edge. Log a warning and refuse to build the grid rather than rendering a sheet that shears.

The Partial Last Row and the True Frame Count

A 17-frame walk cycle in an 8-column grid occupies three rows. The third row has one frame and seven empty cells. If your loader computes frame count as columns * rows, it gets 24 and animates 7 blank frames.

At 12 frames per second, that is 0.58 seconds of nothing per loop. If the empty cells are actually transparent, you get a visible drop-out. If the artist left anything in those cells — a signature, a color swatch, the bounding box of a guide layer that survived the export — you get that content flashing on screen once per cycle.

The opposite fix is equally wrong. Some exporters pad the final row by duplicating the last frame until the grid is rectangular. Now columns * rows is correct, but the animation holds on the final pose for eight frames instead of one. The walk cycle hitches once per loop. The derived count is right and the animation is still broken, because the metadata cannot distinguish "this cell is a copy" from "this cell is the next step."

Frame count is data. Write it down. If your format supports frame lists rather than ranges, write the indices down too, because they let you express a hold (frame 3 listed twice), a reordered sequence, or a one-off mirror without duplicating pixels.

Any time a loader derives a quantity that a human already knows, that derivation is a place for a silent bug. frameCount: 17 is one line of JSON. Diagnosing a single-frame flash that only appears on one character, in one animation, at one frame rate, is an afternoon.

Transparency and the GIF Palette's One-Bit Alpha

A GIF stores pixels as indices into a palette of at most 256 colors, and exactly one index can be marked transparent in the frame's Graphic Control Extension. Alpha is therefore one bit. A pixel is either fully opaque or fully invisible. There is no 50% anything.

Two things follow. First, the antialiased edge of a character rendered against a white background becomes a ring of pixels that are a blend of the character's color and white, and they are opaque. Composite that character onto a dark cave floor and you get a pale halo one pixel wide. Second, if the source was quantized with dithering to fit the palette, that halo becomes a checkerboard of two colors rather than a smooth ring, and any chroma-key removal leaves speckled holes in the outline.

The other trap is the disposal method. Frames in a GIF are not necessarily full canvases. A frame can be a 40×40 patch with an offset, and the disposal method tells the decoder whether to keep, clear, or restore what was underneath. A decoder that returns raw frame blocks without compositing gives you a folder of 40×40 patches that will not line up in a grid. You want the composited result: play the GIF into a canvas frame by frame and capture the full canvas each time.

Once you have RGBA PNGs, you have 8-bit alpha and the halo problem becomes a choice rather than a constraint. Options, in order of quality:

  • Re-render the source at the target resolution with a real alpha channel. This is the only fix that produces clean edges.
  • Never downscale a GIF to make a sheet. Downscaling blends neighboring background pixels into the edge, which is exactly what you were trying to avoid.
  • Keep the original matte color and use a matte in the game that matches it.
  • Store premultiplied alpha if your renderer supports it, which removes the dark fringe that unpremultiplied textures produce at partially transparent edges.

Note that PNG-8 with a tRNS chunk is also one-bit alpha. Saving as "PNG" is not sufficient. Save as RGBA, 8 bits per channel.

The JSON Atlas and What It Is For

The sheet stores pixels. The JSON stores intent. Intent is the part that cannot be derived: which cells form the walk cycle, how fast it plays, whether it loops, where the character's feet are relative to the cell, and which frame spawns the hitbox.

A minimal atlas carries the image name, the cell size or explicit rectangles, the column count, the true frame count, and a map of animation names to frame ranges with timing:

{ "image": "hero.png", "cell": [32, 32], "columns": 8, "frameCount": 17, "animations": { "idle": { "frames": [0, 1, 2, 1], "fps": 8, "loop": true }, "walk": { "frames": [3, 10], "fps": 12, "loop": true }, "attack": { "frames": [11, 16], "fps": 18, "loop": false, "events": [{ "frame": 13, "name": "hitboxOn" }] } } }

Three things are worth calling out. ${"frames": [0, 1, 2, 1]} is an explicit list, not a range, because the idle animation holds frame 1 twice and that is cheaper than duplicating the pixel data. The events array fires on frame entry rather than on a wall clock, so the hitbox opens on the same rendered frame regardless of frame rate. And the pivot, if you use trimmed rectangles, must be stored along with the original cell size, or a sprite trimmed to its bounding box will shift its own feet every frame it moves.

The JSON is also diffable. Changing a clip from 12 to 16 frames per second is a one-line change in version control with a readable history. The same change made by re-exporting a GIF is a binary blob with no history at all.

In Neta Studio, Agent Sprite Forge emits the sheet and its JSON together from the same source data, so the rectangles and the pixels cannot disagree. The 3D game builder reads the pair when you drop them in: the PNG becomes a texture, the JSON becomes a set of animation clips. The playable worlds built on that path — Mini World, The Cyclops' Island — use the same loader as any sheet you bring yourself.

What Is a Sprite Sheet GIF to Game Animation in the Engine

In Three.js, the entire sheet is one texture and the animation is a moving UV window.

Texture settings

Set texture.magFilter = THREE.NearestFilter and texture.minFilter = THREE.NearestFilter, and set texture.generateMipmaps = false. Mipmaps average neighboring texels across the whole image, which means at distance the sampler blends cell boundaries and you get fragments of the adjacent frame at the edges of your sprite. Set texture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping so that a floating-point overshoot at the last column does not wrap to the opposite side of the sheet. Set texture.colorSpace = THREE.SRGBColorSpace unless you have already linearized the art, or your colors will shift.

If you must use LinearFilter for a hand-painted sheet, inset each rectangle by half a texel: u += 0.5 / textureWidth on the left and top, and subtract it on the right and bottom. Without the inset, the sampler at the outer edge of a cell reaches into the next one.

Frame math

Three.js flips images vertically on upload by default, so texture v = 0 is the bottom of the source image and v = 1 is the top. For a cell at column c, row r, with cols columns and rows rows counted from the top-left:

texture.repeat.set(1 / cols, 1 / rows); texture.offset.set(c / cols, 1 - (r + 1) / rows);

That is the entire per-frame update. Do not rebuild geometry or re-upload the image. Change two Vector2 values and the sprite advances.

One gotcha: offset and repeat live on the Texture, not the Material. Two sprites sharing one Texture instance will always show the same frame. Call texture.clone() per sprite instance; the clone shares the underlying image data but carries its own offset and repeat.

If your sprite art is pixel art and you draw it at 1:1, snap the sprite's world position so its screen position lands on whole pixels. A sprite sampled at a half-pixel offset with NearestFilter picks a different texel set every time the camera moves by a fraction, and it shimmers. Integer scale factors — 2×, 3× — remove the problem entirely.

Timing and events

Advance frames with a subtractive accumulator rather than by multiplying total elapsed time by frame rate. while (acc >= 1 / fps) { acc -= 1 / fps; frame++; } does not drift, and it handles a variable frame budget without skipping. If your metadata carries per-frame durations, subtract the current frame's duration instead of a constant, and compute the next index before subtracting so a long frame does not consume two steps of a short animation.

Fire events on frame entry, not on a timer started when the animation began. A 18-fps attack with a hitbox on frame 13 opens the hitbox 0.72 seconds in if nothing else has changed, but if you started the clip mid-swing or blended from another state, the timer and the sprite will disagree.

The Difference Between a PNG and an Animation Is That One of Them Moves

A single PNG is a picture. A sprite sheet is also a picture — a still life of every pose the character will ever take, laid out on a grid like a sheet of stamps. Neither one moves. The animation is not in the file. It is in the JSON, the loader, and the clock.

That is why converting a GIF into a sheet is not a file-format conversion, it is a relocation. You are moving frames off the time axis and onto the space axes, and the timing does not come with them. Everything afterward is restoring what the move discarded: an integer grid so the arithmetic is exact, a declared frame count so the last row is not guessed, an alpha decision so the edges survive a background change, and an atlas so the engine knows that cells 3 through 10 are a walk and that cell 13 is when the sword connects.

The pixels were never the hard part.


Keep going


Make the world it walks in

A sprite is a character with nowhere to stand. Every world on the 3D game builder was made from one written description and published as a page you can play.

Open the 3D Game Builder →