🌻 设计文档小农场 · 野花笔记
← 全部文档/小农场/美术

动效方案对比

现行 #美术#动画#技术选型#性能 docs/小农场/动效方案对比.md

一、先掰清楚术语(这里最容易混)#

"用 sprite 做动画"这句话在中文里指两件完全不同的事:

说法 实际指的东西 一句话
序列帧动画(sprite sheet / 帧动画) 每一帧都是一张完整的图,逐帧播放 像翻页动画书
拆件动画(cutout / 纸片 / 骨骼动画) 角色拆成零件,靠骨骼驱动零件运动 像皮影戏

你说的"拆解出来做动画"是第二种。而"sprite 动画"这个词通常指第一种。 两者经常被拿来对比,但它们其实不在同一个维度上——第三节会说明为什么。

一句话区分

序列帧存的是结果(每一帧长什么样);拆件存的是零件 + 运动规则(怎么动)。

二、三个方案#

A. 序列帧(手绘/美术出图) B. 拆件(骨骼) C. 生图序列帧
存什么 每帧一张完整图 零件各一张 + 骨骼参数 每帧一张完整图
表现力上限 最高(能画出任何形变、透视、特效) 中高(零件是刚体,不能自由形变) 低
体积 帧数 × 每帧大小 零件只存一次 帧数 × 每帧大小
改一个动作 重画所有帧 改参数就行 重新生图
换皮复用 整套重画 换零件,骨架不动 整套重生
关节穿帮 无(本来就是画好的) 有(关节处要藏) 无
运行期开销 低(1 次绘制/个) 高(N 个零件 = N 次变换 + N 次绘制) 低
代表工具/案例 街机、像素游戏、2D 格斗 Spine / DragonBones / Live2D、大部分手游 —

业界关于这两者的取舍讨论很多,可参考 frame-by-frame vs skeleton 的对比帖 与 Spine 还是帧动画的中文讨论。

三、本项目实测#

3.1 方案 C 已经被否决#

指标 实测 结论
帧间位置漂移 48 × 86 px 修不了
帧间形变 鸡冠、翅膀每帧形状都在变 修不了(模型理解"走路循环",不理解"角色必须是同一个")
帧数 6 帧 做不出细节

详见 docs/动效验证-序列帧.md 第一节。

3.2 方案 B 已经在用,但天花板被原图锁住了#

小鸡现在是 4 个零件。量了一下哪些像素能独立运动:

零件 像素 占整体 能不能独立动
head 23145 48.9% 能
body 27791 58.7% 主体,只能整体平移 / 旋转 / 缩放
footL 2813 5.9% 能
footR 1682 3.6% 能

可独立运动的像素合计 58.4%。

剩下 41.6% 是一整块刚体。而且 head 是横切下来的——原图里这只小鸡是"一坨"(头身连在一起), 没有脖子,只能在 52% 高度横着切一刀。

拆件方案的天花板不在代码,在**原图**

这只小鸡能拆出 4 块,是因为它只有 4 个可分离的部分。 换成一只四肢分明的角色(比如小羊),同样的代码能拆出 8~10 块,动画质量完全不是一个量级。

想让动画好,先让美术把角色画成可拆的。

3.3 体积与运行期开销(这是最关键的一组数)#

以 30 只动物同屏为例(docs/核心玩法.md 里那个待解决问题):

素材体积(磁盘) 纹理内存(RGBA) 每帧顶点数
拆件(4 种动物 × 各 4 张全尺寸零件) 698 KB 1.58 MB 24n
烘焙成序列帧(按 300px 高) 410 KB 2.14 MB 6n
烘焙成序列帧(按实际显示尺寸 48px) 10 KB 540 KB 6n

烘焙按实际尺寸出图后,纹理内存小 2.9 倍,磁盘体积小 70 倍(压缩算法对小图更友好)。

最后一行才是重点

序列帧的体积随显示尺寸平方缩放。按 300px 出的图拿去显示 48px,白白浪费 39 倍体积。 按实际尺寸烘焙,4 种动物一共只要 10 KB。

[!warning] 但"零件多 = draw call 多"是错的 这只在没有合批的时候成立。真实 2D 引擎(Cocos / Laya)会按纹理合批: 同一张图集上的所有四边形被合并成一次 draw call。 所以零件多的真正代价落在顶点数上,不是 draw call 上——第六节有实测。

四、结论:这两个方案不是二选一#

它们不在同一个维度上——一个是制作方式,一个是运行方式:

制作期:拆件(骨骼)
   │  可调、体积小、改一个动作不用重画
   │  策划说"摆幅再大点"→ 改个数字,立刻看效果
   ▼
烘焙(离线把每一帧渲染成图)
   │
   ▼
运行期:按屏幕上同时有几个来定
   ├─ 少数大角色(主界面那只)  → 直接跑骨骼,反正就几个
   └─ 大量小角色(田里 30 只)  → 用烘焙好的序列帧,1 次绘制/只

这才是答案。 拆件负责"好做、好改",序列帧负责"好渲染、体积小"。 两边各拿自己最擅长的那一半。

为什么不用生图来做序列帧(方案 C)

因为方案 C 的失败原因不是"用了序列帧",而是"用生图来出帧"。 序列帧本身没问题——从拆件烘焙出来的序列帧,帧间是绝对一致的 (同一个骨架、同一套零件、同一组参数算出来的),漂移为 0。

五、给美术的硬要求:角色要"画成可拆的"#

这是这一节里唯一需要改变出图方式的地方。

现在这样(不好拆)#

现在这只小鸡:头身连成一体,只能横切三层
现在这只小鸡:头身连成一体,只能横切三层

头身连成"一坨",没有脖子、没有腰,只能在 52% 高度横着切一刀。 切面落在纯色区域才看不出来,所以只能切一层。

下一批应该这样#

要求 为什么
头 / 身体 / 四肢分开出图,不要连成一体 连成一体就只能横切,可动部分不到 60%
每个零件多画 5% 的重叠边 关节转动时不会露出缝(脚就是靠这个藏住的)
关节处别放关键细节(眼睛、花纹、logo) 转起来会被重叠边盖住
零件画在同一个坐标系里,出图带位置信息 rig 才知道每个零件该摆哪;静止姿势才能和原图一致
每个零件的轴心位置标注出来(脖子、肩、髋) 轴心错了,转起来就是错位
零件不许重新生图

角色形象是已经定稿的资产。"再生成一版零件"看着省事,实际是偷偷换了角色—— 实测把原版的正面小鸡(两只眼睛、喙在正中)换成了侧面的(一只眼睛、喙偏在一侧)。

正确做法见 tools/art/cut_chick.py:拿原图切,切完必须能原样拼回去, 和原图做像素差——现在是 0.00%。

六、同屏几十个相同物体:实测#

这一节回答那个具体问题:屏幕上几十只一模一样的动物同时播放动画,哪个方案更快?

"相同"这个词是关键——它意味着所有实例共享同一份素材,于是可以合批、可以实例化。 如果是几十个各不相同的角色,下面的结论全部作废。

7.1 测试条件#

  • 场景:430×764 竖屏(手机尺寸),每只按 48px 高显示,16 帧循环、相位错开
  • 三个数量:30 / 60 / 120 只
  • 每帧结束强制同步一次(readPixels)——不这么做量到的只是"录命令"的时间,不是真正的光栅化
  • 素材:docs/art/anim/ 下的零件图与烘焙序列帧,脚本在 tools/art/bench/(跑法见该目录的 README)

7.2 结果(ms/帧,越低越好)#

渲染方式 30 只 60 只 120 只
Canvas 2D · 拆件(4 次 drawImage/只) 1.55 2.97 6.02
Canvas 2D · 序列帧(1 次/只) 0.32 0.57 1.56
WebGL · 拆件(每零件一次 drawArrays) 3.14 5.58 10.58
WebGL · 序列帧(每只一次 drawArrays) 0.79 1.25 2.09
WebGL · 合批 · 拆件(全部合成 1 次 draw call) 0.62 0.58 0.89
WebGL · 合批 · 序列帧(全部合成 1 次 draw call) 0.43 0.40 0.45

7.3 三个结论#

① 合批与否,差了一个数量级

同样 120 只:

WebGL 不合批 · 拆件   10.58 ms   ← 每只每个零件都单独一次 drawArrays
WebGL 合批  · 拆件    0.89 ms   ← 全部四边形拼成一个顶点缓冲,只发一次
                      ↑ 差 12 倍

"零件多 = draw call 多"这个直觉只在没有合批的时候成立。 真实 2D 引擎(Cocos / Laya / Egret)都会按纹理合批,所以这条基本不成立。

② 合批之后,拆件只比序列帧慢 1.4 ~ 2 倍

同屏 合批 · 拆件 合批 · 序列帧 差距
30 0.62 ms 0.43 ms 1.46×
60 0.58 ms 0.40 ms 1.44×
120 0.89 ms 0.45 ms 1.99×

差距来自顶点数(拆件 24n 个顶点 vs 序列帧 6n),不是 draw call。 在 60fps 的 16.7ms 预算里,这点差距完全不是瓶颈。

③ 所以真正的决定性因素不是帧率,是内存

纹理内存 磁盘体积
拆件(4 种动物全尺寸零件) 1.58 MB 698 KB
序列帧(按 48px 烘焙) 540 KB 10 KB

同一套动作,按 48px 烘焙出来长这样(左上角标了实际像素尺寸,右边是放大 6 倍的样子):

烘焙 48px · 180×192 整张精灵表只有 5 KB
帧 1 / 16

放大 6 倍看细节还在不在:

同一张图放大 6 倍(田里显示尺寸就是左边那么小)
帧 1 / 16
一句话结论

同屏几十个相同物体,序列帧(烘焙)更好——但好处主要来自内存和包体,不是帧率。

帧率上合批后只差 1.4~2 倍(30 只时 0.62ms vs 0.43ms,都远在预算内); 内存上差 2.9 倍,包体上差 70 倍——而包体对小游戏是硬指标。

另外序列帧还多一个拆件没有的优势:所有实例完全相同 → 可以用 GPU 实例化, 一个 draw call 画完 30 只(实测 30 只时 0.75ms vs 序列帧普通绘制 0.79ms, 在软件渲染下差别不大,但在真实 GPU 上实例化的收益会更明显)。

7.4 但如果是 Canvas 2D,序列帧是压倒性的#

微信小游戏两种后端都支持。Canvas 2D 下没有任何合批,每次 drawImage 都是独立开销:

同屏 拆件 序列帧 差距
30 1.55 ms 0.32 ms 4.8×
120 6.02 ms 1.56 ms 3.9×

120 只时拆件要吃掉 6ms,是 16.7ms 预算的 36%——这时候就必须上烘焙了。

碰到这种情况的通用解法

同屏同类物体越多,就越应该把动画"烘焙"成序列帧。 这不只适用于角色:粒子、飘字、金币特效,凡是"同一个东西重复几十次"的,都是这个规律。

七、同屏上千上万:瓶颈会转移#

几十个和几万个是两个完全不同的问题。数量上去以后,第三节和第六节讨论的东西全部不再是瓶颈。

8.1 结论先行#

数量级 瓶颈是什么 关键手段
几十只 draw call 数量、包体大小 合批 / 烘焙
几百只 还是 draw call 实例化
上千~上万 像素填充率(fillrate) 缩小尺寸、剔除、降帧率
限制你的是**像素数**,不是物体数

实测:同样画 2.88 M 像素, 20000 只 × 12px = 486 ms,而 1250 只 × 48px 只要 72 ms。

同样多的像素、同样一次 draw call,差 6.8 倍——所以"物体数"本身也是成本, 但像素数的系数大得多。优化要往"让每个东西占更少像素"上使劲。

8.2 业界在用的方案#

方案 做法 适用 资料
实例化(GPU Instancing) 一份几何 + 每实例属性,1 次 draw call 画完 上万个相同物体 Khronos WebGL 技术分享
单次批绘制(Single-draw Batching) 所有四边形拼进一个顶点缓冲 数量中等,几何简单 VectoriumEngine:60fps @ 60 万实体
顶点着色器驱动动画 帧号由 uTime 在顶点着色器里算,buffer 一次上传不再改 本文采用 —
顶点动画纹理(VAT) 把每帧顶点位置烘进贴图,顶点着色器采样 3D / 有骨骼形变的角色 Babylon.js VAT 文档、Unity GPU 动画教程
GPU 精灵层 引擎层封装:一块 buffer 装几万个精灵 引擎已内置,直接用 Phaser 4 SpriteGPULayer

对2D 精灵来说,VAT 是多余的——精灵本来就是一张四边形,把 16 帧打在图集里、 用实例化 + 顶点着色器算 UV 偏移,就等价于 VAT 干的事,而且更简单。

8.3 实测:成本模型#

在 1024×768 视口下测了 12 组(数量 × 尺寸),线性拟合:

每帧耗时(ms) ≈ 0.0221 × 实例数  +  14.8 × 百万像素

残差最大 4.5 ms / 486 ms,拟合得很准:

场景 像素数 实测 拟合
20000 只 × 12px 2.88 M 486.6 ms 485.0
5000 只 × 24px 2.88 M 152.2 ms 153.2
1250 只 × 48px 2.88 M 72.0 ms 70.3
10000 只 × 24px 5.76 M 306.8 ms 306.4
20000 只 × 24px 11.52 M 611.6 ms 612.9
这两个系数的可信度不一样

14.8 ms/百万像素 是填充率成本——真实 GPU 上同样存在,只是常数小得多 (手机 GPU 参考填充率 14 Gpixel/s,11.5 M px 约 311 ms)。

0.0221 ms/实例 是每实例的图元装配开销——测试机没有 GPU,数字来自 SwiftShader 软件光栅化,这个系数被极度放大,真实 GPU 上小几个数量级。 结论方向可信,绝对值不可信。 真机数字必须自己在目标设备上重测。

8.4 怎么实现:帧号交给顶点着色器算#

这是上万规模下最有用的一招。要点是每帧只更新一个 uniform,不动顶点缓冲:

// 每实例属性:iD = (x, y, phase, scale),iUVStep = (1/列数, 1/行数)
attribute vec2 aPos;      // 四边形角点 0..1
attribute vec4 iD;
attribute vec2 iUVStep;

uniform float uTime;      // 秒
uniform float uFPS;
uniform float uFrames;    // 总帧数
uniform vec2  uGrid;      // (列数, 行数)

varying vec2 vUV;

void main() {
  // ★ 帧号在 GPU 上算,CPU 每帧只需要 gl.uniform1f(uTime, t)
  float fi = floor(mod(uTime * uFPS + iD.z, uFrames));
  float cx = mod(fi, uGrid.x);
  float cy = floor(fi / uGrid.x);

  vUV = vec2(cx, cy) * iUVStep + aPos * iUVStep;
  vec2 world = iD.xy + aPos * iD.w;         // iD.z 是相位,让每只错开
  gl_Position = vec4(world / uResolution * 2.0 - 1.0, 0.0, 1.0);
}

实例缓冲的构造(只做一次):

const d = new Float32Array(n * 6);
for (let i = 0; i < n; i++) {
  const o = i * 6;
  d[o]     = x[i];          // 位置
  d[o + 1] = y[i];
  d[o + 2] = (i * 3) % 16;  // 相位:错开播放,否则上万个动作完全同步会很假
  d[o + 3] = size;          // 尺寸
  d[o + 4] = 1 / cols;      // 每帧在 UV 上的步进
  d[o + 5] = 1 / rows;
}
gl.bufferData(gl.ARRAY_BUFFER, d, gl.STATIC_DRAW);   // ← 一次
// 之后每帧只有:
gl.uniform1f(uTimeLoc, t);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, n);
相位错开是免费的,而且必须做

上万个物体如果帧号完全同步,看起来像一块巨大的抽搐的色块。 iD.z 存一个每实例不同的相位,在着色器里加进去——零额外成本。

8.5 CPU 侧根本不用担心#

单独量了纯 JS 侧(硬件无关):

同屏数量 每帧重建整个 buffer 只改帧号字段 上传量/帧
1000 0.048 ms 0.016 ms 23 KB
10000 0.094 ms 0.034 ms 234 KB
100000 0.810 ms 0.240 ms 2.34 MB

十万个物体重建整个 buffer 也只要 0.81 ms。 所以"每帧重建 buffer 太贵"是个伪命题—— 真正该省的是别的地方。不过既然静态 buffer 更简单(少写代码),就用静态的。

8.6 优化清单(按收益排序)#

优先级 手段 收益 代价
★★★ 缩小显示尺寸 / LOD 填充率 ∝ 尺寸²,减半就是省 4 倍 画面变小
★★★ 视口剔除 屏幕外的一个像素都不画 要维护空间索引
★★☆ 实例化 draw call 降到 1 需要 WebGL2 或 ANGLE_instanced_arrays
★★☆ 降帧率 背景物体 6~12fps 就够,成本按比例降 动画略顿
★☆☆ 静态 buffer + 着色器算帧 省掉每帧上传 实现稍复杂
★☆☆ 图集 / 贴图数组 避免纹理切换 打包工序
微信小游戏的限制

微信小游戏的渲染层支持 WebGL2 (官方说明), 但为了兼容老设备通常还是按 WebGL1 写,实例化走 ANGLE_instanced_arrays 扩展。 drawArraysInstanced 在 WebGL1 下要写成 ext.drawArraysInstancedANGLE—— 本文的测试脚本两种都做了兼容。

8.7 这套用在小农场上意味着什么#

我们的场景是"田里几百株作物 + 几十只动物",远没到上万的量级。 第六节的结论不变(烘焙序列帧)。但有两个可以现在就做的:

  • 作物如果要做几百株摇摆,用实例化 + 着色器算相位,成本几乎为零 (摇摆不需要美术,sin(uTime + phase) 直接改顶点就行)。
  • 飘字 / 金币 / 粒子这类"同一张图重复几百次"的东西,直接按本章的实例化写法做。

八、本项目的决定#

场合 用什么 理由
所有角色动画的制作 拆件(tools/art/waddle.py 这类 rig) 可调、好迭代,改一个数字就能看效果
主界面那只(点它加钱的那只) 直接跑骨骼 屏幕上就 1~2 只,开销无所谓,而且要能实时响应点击
田里 / 街上几十只 从拆件烘焙成序列帧,--bake 48 纹理内存省 2.9 倍、包体省 70 倍;Canvas 2D 下还快 4 倍
金色小鸡出现 / 大丰收这种"演出时刻" 可以上序列帧或加特效 出现频率低、时间短,值得多花一点体积换表现力
交互反馈 / 飘字 / 粒子 / 待机摆动 代码做,零素材 见 docs/动效验证-序列帧.md 第四节

下一步:

  • 把四个动物从原图切零件(小兔的长耳朵、小猪的卷尾巴会有更明显的次级运动)。
  • 给 waddle.py 加一个 --bake-size 参数 —— 已完成,用法是 --bake 48。
  • 下一批角色出图时,按第五节那张表分层出图,不要再画成"一坨"。
  • 用同一套基准脚本,量一下真机(微信开发者工具 / 真机)上的数字。 测试机没有 GPU,第七节那些绝对值来自软件光栅化,只能看趋势。
  • 给作物摇摆做一版实例化 + 着色器算相位的实现(几百株的成本几乎为零)。

相关文档
  • docs/动效验证-序列帧.md —— 三种做法的实测过程与数据
  • docs/美术风格.md —— 画风基准与可执行验收标准