Toolkitly

如何把精灵图转成 GIF 动图

精灵图是给引擎看的,GIF 是给其他所有人看的——Discord、issue、商店页、一条推。 两者之间的转换,大部分不是关于你的美术,而是一串关于这个格式限制的决定。 如果这张图当初是带着 JSON 导出的,其中两个决定已经替你做好了。

1. 先把网格弄对

后面每一步都建立在这一刀上。网格要是偏了一个像素, 每一帧都会带上隔壁的一条细边,GIF 就会沿着某一条边闪—— 这在动起来之后,比静态图上明显得多。

动画软件导出的图,列数和行数通常一目了然, 单元格尺寸从整图尺寸里就能算出来。要留意间距: 导出器常在格子之间留一两个像素,防止 GPU 跨帧采样, 而这个留白必须声明出来,否则网格每往右一列就再偏一点。

帧大小不一的紧凑图集则没有网格可找——用自动识别, 它会去找不透明像素连成的独立区域。图比较刁钻的话,精灵图切割工具能覆盖同样的事情,而且控制更细。

如果这张图带着 JSON,上面这些统统不适用。Aseprite 或 TexturePacker 的导出直接写明了每一帧的矩形。 把 .json 和 PNG 一起拖进来,就没有什么可找、可识别、 也没有什么会偏一个像素。这一整步就此消失, 而它正是「某条边在闪」最常见的成因。

2. 把范围收到一段动画

一张图里很少只有一段动画。常见的排布是一行一段—— idle、walk、attack、hurt——整张导出会给你一个把它们全轮一遍的 GIF, 那不是任何人的本意。

把起始帧和结束帧设到你要的那一行。如果这张图有 8 列, 第 2 行就是第 8 到第 15 帧。导出前先播一遍: 在图上看着挺完整的一行,有时末尾其实有两个补位的空格子, 它们导出去就是一段停顿。

Aseprite 的帧标签会替你做这件事。如果这些动画在文档里打过标签,导出的 JSON 会按名字带上那些范围, 从列表里点一下 walk,胜过在图上数格子。 标签还记录了方向,所以乒乓标签会导出成乒乓, 而不是一个到末尾突然跳回去的循环。

3. 逐帧时长——GIF 真正存的其实是这个

这一点值得在下一步之前先弄明白,因为它把通常的假设反了过来。 帧率是对时间的一次平均。GIF 不存帧率—— 它给每一帧各存一个延时。 所以这个格式原生就能表达单个帧率滑块表达不了的东西, 而多数「精灵图转 GIF」把这个能力扔掉了, 因为一张光秃秃的 PNG 网格里根本没有时间信息可留。

Aseprite 的导出有。它 JSON 里的每一帧都带一个以毫秒计的 duration,取自文档自己的时间轴, 而这些时长通常不是均匀的——这正是重点。 走路循环一般会让两个触地帧——脚落地并承重的那两帧—— 比中间的过渡帧停得更久。一次冲击可能让某一帧停到旁边帧的四倍长。 用平均 12 fps 播出来,恰恰是被精心设计过的那部分被抹平了。

所以把这些时长带进 GIF 不是锦上添花, 它是「导出这段动画」和「导出这段动画的帧」之间的区别。精灵动画预览器会按导入的时长播放,并把同样的延时写进 GIF, 所以下载下来的就是你刚才看到的。

下一节讲的取整仍然适用,只不过是逐帧而不是全局: Aseprite 里 100ms 的帧正好是 10cs,而 33ms 会被写成 30ms。 另外,没有 JSON 的图本来就没有时间信息可恢复, 这时单一帧率确实是能拿到的最好答案——靠看,而不是靠算,来选它。

4. 选一个这个格式存得下的帧率

这里是 GIF 让人意外的地方。它不存帧率—— 它给每一帧存一个延时,单位是百分之一秒。 所以它能精确表达的帧率,就是那些能整除 100 的:

25 fps → 4cs · 20 fps → 5cs · 12.5 fps → 8cs · 10 fps → 10cs · 5 fps → 20cs

其他的都会被取整。你要 12 fps,拿到的是 8cs,播出来是 12.5。 你要 24,拿到 4cs,播出来是 25。这个差别通常看不出来, 但如果你要把 GIF 和一段视频或者一个已知帧率的游戏对上, 那就从上面这张表里挑一个,把这个问题绕过去。

还有两条硬限制:GIF 根本快不过 50 fps; 而且很多查看器会把 0 或 1 厘秒的延时当成 10, 这意味着一个「100 fps」的 GIF 实际上按 10 fps 播。 大约 25 fps 以上,就已经超出这个格式当初被设计出来要干的事了。

5. 搞清楚 GIF 会对你的颜色做什么

一个 GIF 帧最多能用 256 个调色板条目, 而如果这段动画有透明,其中一个还得拿去当透明色。 留给画面的就是 255 种颜色。

对像素画来说这不成问题—— 用 32 色配色画的精灵能一个字节不差地通过。 对渲染图或照片来说就不是了:编码器必须做量化, 挑出 255 种代表色,把其余的映射到最近的一种, 在渐变上表现出来就是色带。

透明只有一位。每个像素要么完全透明要么完全不透明; GIF 没有半透明。硬边缘的精灵不受影响。 带柔光、投影或抗锯齿边缘的精灵, 会在 PNG 本来是渐隐的地方出现一条硬边界—— 而如果那些半透明像素被当成不透明, 你会看到一圈颜色取自它下面那层的光晕。

这两条里有一条对你是问题,那 GIF 就是错的容器。 动画 WebP 和 APNG 都带完整的透明通道和完整的色彩, 当前所有浏览器都能播;GIF 活到今天, 是因为 Discord、Slack 和老论坛程序还默认它。

6. 放大要在导出时做,不是导出之后

一个 32×32 的精灵在现代显示器上就是一张邮票。 按 4 倍或 8 倍导出,让它一眼能看清—— 但要用邻近采样和整数倍来放大, 否则像素不是变得不匀就是发糊,你等于把美术给毁了。

放大在文件体积上的代价还出人意料地小。 GIF 压缩的是连续相同的像素,而 4 倍放大把每个像素变成一个 4×4 的同色方块—— 那正是压缩器最擅长的东西。 8 倍的 GIF 通常远小于 1 倍那个的 64 倍。

放大要作为导出的一部分来做,而不是事后在图像软件里再来一遍。 对一个已经做好的 GIF 再走一遍, 意味着对一个已经量化过的调色板再量化一次,损失是会叠加的。

常见故障

某条边上有一条闪动的细线

网格偏了一个像素,或者这张图有没被声明的间距。 导出前放大检查那一刀——或者导入这张图的 JSON, 它直接写明了矩形,而不是留给你去算。

动画每循环一轮就停顿一下

行尾用来补位的空格子被当成帧导出去了。收窄帧范围,或者打开跳过空白帧。

GIF 播得比预览慢或快

帧率取整。挑一个能整除 100 的——10、20 或 25 fps。

动作比在 Aseprite 里显得机械

每一帧被停留了同样长的时间。文档自己的时长并不均匀; 导入 JSON,让它们活到 GIF 里。

某个很短的帧播得比画的时候慢

GIF 表达不了低于 20ms 的延时,短于它的一律被抬上去。 Aseprite 里 10ms 的帧会变成 20ms,它周围的时间也跟着挪。 这在格式内部无解——大约 25 fps 以上,你已经在 GIF 的设计范围之外了。

精灵周围有一圈带颜色的光晕

半透明的边缘像素被 GIF 的一位透明强行变成了不透明。 去掉柔和边缘,或者改用 WebP、APNG。

文件大得离谱

帧太多、颜色太多,或者两者都是。先削帧范围; 减少调色板的帮助远不如去掉几帧。

现在就做一个 GIF