VRChat Avatar Flipbook Textures: How to Pack a GIF Properly
A flipbook on an avatar is one texture holding every frame of an animation in a row, plus one float that walks along it. When it stutters, blurs, or seams at the loop, the shader is almost never the cause. This is a vrchat avatar flipbook texture how to pack guide, and it starts with the strip. Pack the strip correctly and most problems vanish before the shader is involved. Pack it wrong and no amount of shader tuning will rescue a texture that was rescaled, re-compressed, or left with an opaque background baked into its edges.
Here is what actually goes wrong, in the order it usually goes wrong.
Why the Shader Is Almost Never the Problem
The flipbook block in Poiyomi Toon is a handful of arithmetic: divide the UV by the column count, add an offset derived from a frame index, sample once. It is deterministic. Feed it a correct strip and a constant speed and it produces a correct animation on every client.
Feed it a broken strip and you get four symptoms that people misdiagnose as shader bugs:
- Softness that grows with distance. Caused by mip generation averaging adjacent frames together, not by filtering settings.
- A one-pixel column of the neighbouring frame at each seam. Caused by bilinear sampling at the exact frame boundary, not by the shader's UV maths.
- A hitch once per loop. Caused by uneven source timings or a duplicated loop frame, not by the speed value.
- A thin bright fringe on soft edges. Caused by keying a background colour in the shader after the texture was already compressed, not by the alpha mode.
All four are fixed on disk, before import. The shader has no knowledge of GIF, no knowledge of frame delays, and no way to recover information the packing step destroyed.
VRChat Avatar Flipbook Texture How to Pack: One Row, Not a Grid
A flipbook shader walks one texture coordinate. It computes U from the frame index and leaves V alone. That is the whole mechanism, and it is why a single horizontal row of frames is the correct layout.
With a grid, the shader needs a second coordinate, and every frame boundary becomes a seam on two axes instead of one. With a single row, V is a constant — for a strip of height H, V sits at 0.5, or at 0.5 with a half-texel inset if you care about the top and bottom edges — and never changes. There is exactly one thing that can go wrong per frame, and it is at the left and right edges.
The practical consequences:
- Compression blocks span four pixels. Every frame boundary that is not a multiple of four pixels wide straddles a block, and the compressor averages the last pixels of one frame with the first pixels of the next. Frame widths that are powers of two are always multiples of four, so this never happens.
- Mip generation on a grid destroys both axes at once. On a single row it destroys one, which at least tells you what you are looking at.
- Sampler wrap mode. Set the texture's wrap to Clamp, not Repeat, and do the frame arithmetic in the shader. With Repeat, an offset that lands a fraction below zero wraps to the right edge of the last frame and blends it into the first.
- Bilinear filtering at a frame boundary samples the last texel of frame i and the first texel of frame i+1. Inset the quad's UVs by half a texel — 0.5 divided by the texture width — at each edge of the frame, or accept a blended column at every seam. On a 4096-wide strip that inset is about 0.00012 in UV space.
A flipbook strip is not a spritesheet. Most GIF-to-sheet tools default to a square grid because that is what a game engine's sprite renderer wants. A Poiyomi flipbook wants one row. Force the exporter to a single row before the file ever reaches Unity.
If a single row would exceed a sane texture width — see the table below — split the animation across two materials driven by an Animator toggle rather than going to a grid. Two strips of eight are easier to debug than one eight-by-two.
VRChat Avatar Flipbook Texture How to Pack: Power-of-Two Width
Power-of-two is not a GPU requirement, and that is exactly why it catches people out. An NPOT texture will often look fine in the editor and wrong in the build, because the failure is not in the sampling — it is in everything that touches the texture before sampling.
Non-power-of-two width means the frame width stops being exact the moment any tool resizes the texture. Unity's texture importer scales a texture to fit inside Max Size while preserving aspect. A 5000 × 500 strip of ten 500-pixel frames at Max Size 2048 becomes 2048 × 205. The shader still reads ten columns. Ten columns of 204.8 pixels do not exist, so the boundaries drift across the strip, and the last frame is visibly offset from the first.
Power-of-two width also guarantees two other things: every frame boundary is a multiple of four pixels, which is what BC, DXT, and ASTC block compression need, and every mip level halves the strip cleanly. A 4096-wide strip of eight frames halves to 2048, 1024, and so on, with each frame staying an exact power of two.
The height should be power-of-two for the same compression reasons, but it does not affect the walking coordinate. Only the width does.
Practical sizes, with the compressed cost of the strip alone:
Frames | Frame size | Strip | DXT5, no mips | DXT5, with mips |
|---|---|---|---|---|
4 | 512 × 512 | 2048 × 512 | 1.0 MB | 1.3 MB |
8 | 256 × 256 | 2048 × 256 | 0.5 MB | 0.7 MB |
8 | 512 × 512 | 4096 × 512 | 2.0 MB | 2.7 MB |
16 | 256 × 256 | 4096 × 256 | 1.0 MB | 1.3 MB |
16 | 512 × 512 | 8192 × 512 | 4.0 MB | 5.3 MB |
32 | 256 × 256 | 8192 × 256 | 2.0 MB | 2.7 MB |
DXT5 is one byte per pixel, so those numbers are just pixel counts. Mipmaps add roughly a third. The last two rows are past the width most people should ship in a single texture; either halve the frame size or split the animation. On a Quest-targeted avatar, treat 2048 as the ceiling rather than 4096 — the build pipeline applies its own texture budget, and a flipbook is usually the single largest texture on an avatar.
Two importer settings matter more than the rest. Turn Mip Maps off, because mip generation averages across frame boundaries — at mip level 3 an eight-frame strip has each frame at an eighth of its width, and every frame is a blend of its neighbours. If the importer shows a Streaming Mip Maps checkbox, turn that off too. And check Max Size: the default is 2048, which silently halves a 4096-wide strip.
If the halo only appears when the camera is far away, it is not your mask. It is mip generation pulling transparent texels into opaque ones. Turn mip maps off before you touch the alpha threshold.
Key the Alpha Before Upload, Not in the Shader
A GIF carries one transparent index and one bit of alpha. Decoders that composite frames onto a background produce edge pixels with that background colour blended into them — half magenta, half sprite. That is the raw material you are working with, and it needs fixing before compression, not after.
A shader colour-key operates on the sampled texel. By the time the shader sees it, bilinear filtering has already averaged the sprite's edge with the background, and the block compressor has already averaged four-by-four pixel groups. A threshold on top of that can only produce two outcomes from a half-magenta pixel: fully opaque magenta, or nothing. The first gives you a magenta ring. The second eats a pixel of the sprite. With alpha blending enabled, the pixel stays half magenta and half transparent, which is the halo.
The fix is to key at the source:
- Decode the GIF and composite every frame onto a fully transparent background, keeping straight (non-premultiplied) alpha.
- Despill: clamp the RGB of near-zero-alpha pixels toward the nearest opaque neighbour, so the colour under transparent pixels is not leftover background. Block compression stores RGB and alpha in the same block, and black under transparent pixels bleeds into soft edges.
- Export as PNG-24 with an alpha channel. Not GIF, not JPEG.
- Enable Alpha Is Transparency in Unity's texture importer. Unity then performs colour dilation on fully transparent pixels during compression, which removes most of the remaining fringe at the edge of a bilinear filter.
Format choice follows the content. Hard-edged pixel art should be keyed to binary alpha and rendered with a Cutout mode and a threshold around 0.5, because cutout discards the blended pixels entirely rather than showing them. Soft glows and smoke need eight-bit alpha and a Transparent mode, and they will show every mistake in the source matte. DXT5 stores alpha as a separate interpolated block, so gradients band slightly; DXT1's one-bit alpha does not do gradients at all.
VRChat Avatar Flipbook Texture How to Pack: Rows, Columns, and Speed
The material wants three numbers: columns, rows, and a speed. With a one-row strip, rows is 1 and columns is the frame count. That is the entire metadata set, and it is where the most common error lives.
Columns set to seven for an eight-frame strip is the classic off-by-one. The shader reads frame 0 at U = 0.5/7 rather than 0.5/8, every frame sits slightly off-centre, and the eighth frame never appears. Rows left at 0 is worse: a shader that computes V as (row + 0.5) / rows divides by zero, the sampler clamps, and the whole strip collapses to a smear. Set rows to 1 even when the strip is one row tall.
Some versions expose a separate total frame count or an Animated UVs toggle. If the toggle is off, the speed value does nothing and the texture renders as a static frame — which is a very common "the shader is broken" report.
Speed is in frames per second in most flipbook blocks. The loop length is columns divided by speed: eight columns at 8 fps is a one-second loop, at 12 fps it is 0.67 seconds. There is no correct value, only a constant one. If the index is driven by an Animator float instead of shader time, set the keyframes to Constant interpolation — with linear interpolation, a float that is supposed to be an integer index spends most of its time between two frames, and the strip shimmers. A locked material property will ignore the animation curve entirely, so check that the float is unlocked.
One more thing: a flipbook driven by shader time starts at scene load and runs locally. Two people looking at your avatar can be on different frames. That is fine for a decorative loop and wrong for anything that has to line up with a sound or a gesture.
Uneven GIF Delays Break a Uniform Frame Step
GIF stores a delay per frame in hundredths of a second. Encoders clamp short delays — a delay of 0 or 1 centisecond is commonly bumped to 10 — so a GIF that plays at what looks like a uniform rate is frequently a mix of 80 ms and 100 ms frames. The delay is per frame and it is not recoverable from the texture once packed.
The shader does not read GIF delays. It advances exactly one frame per uniform step. So the timing you saw in a GIF viewer is not reproduced, and if you packed the strip around the GIF's uneven timings, the uniform step introduces a hitch that was not there before.
The fix is to re-time at the source and pack slots, not source frames. Pick a base rate — 12 fps is a reasonable default for hand-drawn avatar effects — and give every source frame as many consecutive slots as its delay deserves at that rate. A frame the GIF held for 250 ms at a 12 fps base gets three slots in the strip, and the same pixels appear three times in a row.
Count slots, not source frames. Six source frames where two are held for three slots each is a ten-column strip, not a six-column one.
Watch the loop point too. Many GIFs end on a duplicate of frame one so viewers that do not blend between loops still look smooth. Under a shader, that duplicate is one extra slot of hold at the end of every cycle. Delete it; a shader loop is instantaneous.
Finally, export to PNG-24. GIF is limited to 256 colours per frame and frames can carry local palettes. Packing frames with different palettes into one texture quantises them to a shared palette, and colours shift between frames in a way that looks like a shader bug.
Verifying the Strip Before You Upload
Open the packed strip in an image viewer at 1:1 and check four things.
- Dimensions. Width divided by columns must be an integer, and it must be the frame size you authored. Height divided by rows must be an integer too.
- The seams. Zoom into the boundary between frame 0 and frame 1. If there is a blended column, the frame width is not landing on texel centres, or the exporter bled a pixel.
- The loop. Put the strip in a viewer with no frame blending, or rebuild a GIF from the packed frames with uniform delays equal to 1/speed, and watch it cycle. A hitch at the loop is a duplicate frame, not a speed problem.
- The edges. Look at the alpha at 400% on a dark background. Any consistent colour ring is a matte that was keyed too late.
Then in Unity, before the build: Max Size at or above the strip's actual width, Mip Maps off, wrap mode Clamp, Alpha Is Transparency on, and the compression format set to match the content — DXT5 or BC7 for gradients and soft alpha, DXT1 for opaque pixel art, ASTC on Quest. Set columns and rows on the material to match the strip exactly, and check the speed with the Animator preview open rather than with shader time.
Taking the Animation Out of the Flat Texture
A flipbook only draws on the surface it is mapped to. It has no parallax, no silhouette from the side, no shadow of its own, and it is lit as a flat panel. That is fine for a screen, a rune, or a face texture. It is not fine when you want the effect to read as an object.
If you only need to see the animation move — to check timing and loop — the strip is already the animation; convert it back to a sequence with uniform delays and play it. Preview it at the shader's speed, not the original GIF's, or you will tune the wrong number. Agent Sprite Forge handles the conversion in both directions, which is faster than rebuilding a strip by hand every time you change one frame.
When a flat quad genuinely is not enough, the options in order of cost:
- Particle system with sheet animation. The particle renderer steps the sheet itself, so you keep one texture and one material. The same packing rules apply: power-of-two width, frames divided evenly, alpha keyed at export.
- One mesh per frame with visibility animated by an Animator. This gets you real geometry, real lighting, and a real silhouette, and it costs one draw call per frame. VRChat's performance rank counts material slots and polygons, so keep the frame count low and the meshes simple.
- A UV-offset mesh, where the frame is selected by shifting the UVs on a mesh that already has depth. Same strip, same rules, applied to something with a silhouette.
- A texture array sampled by index, if you are writing the shader yourself. It avoids the strip width ceiling entirely, because each frame is its own slice, but it is not a stock material.
The same layout rules apply when you build flipbook materials in Neta Studio's 3D game builder: one row, power-of-two width, alpha keyed before export. A strip packed for an avatar will drop into a world material unchanged.
The Short Version
One row. Columns equal to the frame count, rows equal to one. Width a power of two, frame width a multiple of four. Alpha keyed at the source and despilled before compression. Mip maps off, Max Size above the strip's width, wrap Clamp. Delays re-timed to a uniform rate, holds written as repeated slots, duplicate loop frame deleted. Speed constant, and set to Constant interpolation if an Animator is driving the index.
Everything else is the shader doing exactly what you told it to.
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.