第10章:动画与缓动原理

目标:让初级程序员彻底理解"动画是怎么动起来的"——从帧驱动到时间驱动,从线性插值到贝塞尔缓动,从 CPU 动画到 GPU Shader 动画。

前置章节:第3章 Canvas 2D与坐标系统(建议先掌握 Canvas 基础)

预计学习时间:2天


目录

  1. 帧驱动 vs 时间驱动动画
  2. Delta Time:让动画跑得一样快
  3. 线性插值(Lerp):动画的数学基础
  4. 缓动函数:Ease-In、Ease-Out、Ease-In-Out
  5. 贝塞尔缓动:Cubic-Bezier 曲线
  6. CSS Cubic-Bezier 动画
  7. 精灵动画:纹理图集与帧动画
  8. 顶点着色器动画 vs 片元着色器动画
  9. Animator 类模式:用 async/await 编排动画
  10. 本章 Q&A

1. 帧驱动 vs 时间驱动动画

1.1 生活类比:跑步机的两种速度控制方式

想象你在健身房看到两台跑步机:

  • 帧驱动跑步机:每踩一下踏板,传送带前进固定距离。你踩得快,它就跑得快;你踩得慢,它就跑得慢。如果突然有人按住你的脚,传送带就停了。
  • 时间驱动跑步机:你设定"10分钟后必须跑完1公里",跑步机自动控制速度。不管你中途停下来喝水还是加速冲刺,10分钟结束时它一定刚好跑完1公里。

帧驱动动画就像第一台跑步机——每一帧画面让物体移动固定距离。 时间驱动动画就像第二台——根据"已经过去多少时间"来计算物体应该在哪。

1.2 本质是什么

类型 核心思想 控制变量
帧驱动 (Frame-driven) 每帧移动固定步长 position += speed
时间驱动 (Time-driven) 根据已用时间计算当前位置 position = start + (time / duration) * distance

帧驱动的代码长这样:

// 帧驱动:每帧旋转1度
let rotation = 0;
requestAnimationFrame(function update() {
  rotation++;  // 每帧固定+1
  block.style.transform = `rotate(${rotation}deg)`;
  requestAnimationFrame(update);
});

时间驱动的代码长这样:

// 时间驱动:2秒内从0度旋转到360度
const startAngle = 0;
const T = 2000;  // 总时长2秒
let startTime = null;

function update() {
  startTime = startTime == null ? Date.now() : startTime;
  const p = (Date.now() - startTime) / T;  // 进度比例 0~1
  const angle = startAngle + p * 360;       // 当前角度
  block.style.transform = `rotate(${angle}deg)`;
  requestAnimationFrame(update);
}
update();

1.3 为什么时间驱动更好

场景:你的游戏在高端手机上跑 60fps,在低端手机上跑 30fps。

  • 帧驱动:高端手机 1 秒转 60 度,低端手机 1 秒只转 30 度。同样的操作,不同设备效果不同!
  • 时间驱动:两台手机都是 2 秒转 360 度。只是高端手机画面更流畅(中间帧更多),低端手机有点卡顿,但最终效果一致

帧驱动 = "每步迈多大",时间驱动 = "几点必须到哪"。做游戏必须用时间驱动,否则不同帧率下体验完全不同。

1.4 常见误区

误区 1:"requestAnimationFrame 就是时间驱动"

错!requestAnimationFrame 只是浏览器在下一帧绘制前回调你的函数。你在回调里写 rotation++ 就是帧驱动,写 position = start + (Date.now() - startTime) / duration * distance 才是时间驱动。

误区 2:"帧驱动更简单,先写帧驱动后面再改"

等你后面有 100 个动画对象,每个都依赖帧驱动,改起来就是灾难。一开始就用时间驱动,养成好习惯。

1.5 试一试

打开 akira-graphics/animate/animate_delta.html(帧驱动)和 akira-graphics/animate/animate_time.html(时间驱动),在浏览器控制台执行:

// 模拟低帧率:让 requestAnimationFrame 每 100ms 才执行一次
const originalRAF = window.requestAnimationFrame;
window.requestAnimationFrame = (cb) => setTimeout(cb, 100);

观察两个方块旋转速度的差异。帧驱动的方块明显变慢了,时间驱动的方块仍然按时完成。


2. Delta Time:让动画跑得一样快

2.1 生活类比:用步长估算路程

你和朋友比赛从家走到学校。你腿长,一步迈 80cm;朋友腿短,一步迈 50cm。如果都走 100 步,你走了 80 米,朋友只走了 50 米——这不公平!

公平的做法是:记录每一步实际花了多少时间,用"速度 × 时间"来算走了多远。

你的路程 = 1.5米/秒 × 每一步的时间
朋友的路程 = 1.5米/秒 × 每一步的时间

这样不管步频多少,只要时间相同,走的距离就相同。

2.2 本质是什么

Delta Time(dt) = 上一帧到当前帧经过的时间,单位通常是秒或毫秒。

新位置 = 旧位置 + 速度 × dt

为什么要乘 dt?因为:

速度的定义 = 距离 / 时间
所以:距离 = 速度 × 时间

如果速度是"100像素/秒",而两帧间隔了 0.016 秒(60fps),那么这一帧应该移动:

100 像素/秒 × 0.016 秒 = 1.6 像素

如果帧率降到 30fps,dt 变成 0.033 秒:

100 像素/秒 × 0.033 秒 = 3.3 像素

虽然每帧移动距离变了,但每秒移动距离始终是 100 像素!

2.3 代码示例

let lastTime = Date.now();
let position = 0;
const speed = 100; // 100 像素/秒

function update() {
  const now = Date.now();
  const dt = (now - lastTime) / 1000; // 转成秒
  lastTime = now;

  position += speed * dt; // 速度 × 时间 = 距离

  block.style.left = `${position}px`;
  requestAnimationFrame(update);
}
update();

2.4 常见误区

误区:"用了 requestAnimationFrame 就不用管 dt 了"

requestAnimationFrame 不保证 60fps。用户切换标签页时浏览器会节流到 1fps,弹窗出现时也会卡顿。不用 dt 的话,切换回来会发现物体"瞬移"了一大段。

误区:"dt 直接用毫秒,不用转秒"

如果你写 position += speed * dt 但 dt 是毫秒,那速度单位就变成"像素/毫秒"了,数字会小得离谱(100 像素/秒 = 0.1 像素/毫秒)。建议统一用秒作为时间单位,符合物理直觉。

2.5 试一试

修改下面的代码,让方块在不同帧率下移动速度一致:

let position = 0;
const speed = 200; // 200 像素/秒

// TODO: 补充 dt 计算
function update() {
  position += speed; // 错误:没有乘 dt!
  block.style.left = `${position}px`;
  requestAnimationFrame(update);
}
update();

答案:记录上一帧时间,计算 dt = (now - lastTime) / 1000,然后 position += speed * dt


3. 线性插值(Lerp):动画的数学基础

3.1 生活类比:调音量旋钮

你坐在沙发上,音响音量现在是 20%,想调到 80%。你不是直接跳到 80%,而是慢慢转动旋钮,音量从 20 → 21 → 22 → ... → 80 渐变过去。

如果旋钮转了 30% 的行程,音量应该是:

20% + 30% × (80% - 20%) = 20% + 18% = 38%

这就是线性插值——在起点和终点之间,按比例取一个值。

3.2 本质是什么

线性插值(Linear Interpolation,简称 Lerp)的公式:

lerp(start, end, t) = start + t × (end - start)

也可以写成:

lerp(start, end, t) = start × (1 - t) + end × t

其中 t 是 0 到 1 之间的比例:

  • t = 0 时,结果是 start
  • t = 0.5 时,结果是中间值
  • t = 1 时,结果是 end

3.3 公式推导

假设数轴上有两点 A(10) 和 B(30),你想找它们之间 40% 处的点 P。

A --------P------------ B
10        ?            30

P 到 A 的距离 = 40% × (B - A) = 0.4 × 20 = 8
P 的位置 = A + 8 = 18

通用化:

P = A + t × (B - A)      // t = 0.4
  = A × 1 + t × B - t × A
  = A × (1 - t) + B × t   // 两种写法等价

3.4 代码示例

// 基础 lerp 函数
function lerp(start, end, t) {
  return start + (end - start) * t;
  // 等价于:return start * (1 - t) + end * t;
}

// 用 lerp 做动画
const start = 100;   // 起始 left
const end = 400;     // 目标 left
const duration = 3000; // 3秒
let startTime = null;

function update() {
  startTime = startTime || Date.now();
  const t = Math.min((Date.now() - startTime) / duration, 1);

  const left = lerp(start, end, t);
  block.style.left = `${left}px`;

  if (t < 1) requestAnimationFrame(update);
}
update();

3.5 Lerp 不只用于位置

// 颜色插值:从红色渐变到绿色
const fromColor = [1, 0, 0]; // RGB 红
const toColor = [0, 0.5, 0]; // RGB 绿
const color = fromColor.map((c, i) => lerp(c, toColor[i], t));

// 缩放插值:从 1 倍放大到 2 倍
const scale = lerp(1, 2, t);

// 角度插值:从 0 度旋转到 90 度
const angle = lerp(0, 90, t);

3.6 常见误区

误区 1:"t 可以超出 0~1 的范围"

可以,但那就不是"插值"了,而是外推(extrapolation)。比如 t = 2 会得到 end 之外的位置。有些效果(如弹簧过冲)故意让 t > 1,但要清楚自己在做什么。

误区 2:"Lerp 只能用于数字"

Lerp 的本质是"按比例混合"。向量、颜色、甚至旋转角度都可以 lerp,只要你能定义"起点"和"终点"。注意:旋转角度 lerp 时要处理 350° 到 10° 的"近路"问题(应该转 20° 而不是 340°)。

3.7 试一试

打开 akira-graphics/animate/lerp.html,观察方块从 left: 100px 移动到 left: 400px。修改 easing: p => p ** 2easing: p => p,看看纯线性动画是什么感觉——机械、匀速、不自然。这就是为什么真实动画很少用纯线性。


4. 缓动函数:Ease-In、Ease-Out、Ease-In-Out

4.1 生活类比:电梯的启动和停止

坐电梯时,你不会希望它:

  • 门一关就瞬间达到全速(会摔倒)
  • 快到楼层时瞬间停止(会撞墙)

真实的电梯是:

  • Ease-Out(缓出):启动时慢慢加速
  • Ease-In(缓入):停止前慢慢减速
  • Ease-In-Out(缓入缓出):启动和停止都柔和

4.2 本质是什么

缓动函数(Easing Function)是一个映射函数

输入 t(0~1 的线性时间)→ 输出新的 t'(变换后的时间)

动画的真实位置 = lerp(start, end, easing(t))

缓动类型 数学公式 直观感受
Linear f(t) = t 匀速,机械
Ease-In f(t) = t² 先慢后快,像汽车启动
Ease-Out f(t) = 1 - (1-t)² 先快后慢,像刹车
Ease-In-Out f(t) = t < 0.5 ? 2t² : 1 - (-2t+2)²/2 两头慢中间快,最自然

4.3 公式推导

Ease-In(二次方)

f(t) = t²

t = 0 时 f(0) = 0;t = 0.5 时 f(0.5) = 0.25(只走了 25% 的路程,说明前半段很慢);t = 1 时 f(1) = 1。

Ease-Out

把 Ease-In 的图像左右翻转:

f(t) = 1 - (1 - t)²

t = 0.5 时:f(0.5) = 1 - 0.25 = 0.75(前半段走了 75%,说明很快)。

Ease-In-Out

前半段用 Ease-In,后半段用 Ease-Out:

function easeInOutQuad(t) {
  return t < 0.5
    ? 2 * t * t                    // 前半段:Ease-In
    : 1 - Math.pow(-2 * t + 2, 2) / 2; // 后半段:Ease-Out
}

4.4 代码示例

// 各种缓动函数
const easing = {
  linear: t => t,
  easeIn: t => t * t,
  easeOut: t => 1 - (1 - t) * (1 - t),
  easeInOut: t => t < 0.5
    ? 2 * t * t
    : 1 - Math.pow(-2 * t + 2, 2) / 2,
};

// 使用缓动函数做动画
const animator = new Animator({
  duration: 3000,
  easing: easing.easeInOut  // 传入缓动函数
});

animator.animate(
  { el: block, start: 100, end: 400 },
  ({ target: { el, start, end }, timing: { p } }) => {
    const left = start * (1 - p) + end * p;  // lerp
    el.style.left = `${left}px`;
  }
);

4.5 常见误区

误区 1:"缓动函数直接修改位置"

错!缓动函数只修改时间比例 t,位置计算仍然是 lerp(start, end, easedT)。这是两个独立步骤:先缓动时间,再用缓动后的时间做插值。

误区 2:"Ease-In 和 Ease-Out 名字记反了"

容易记混!记住:名字描述的是速度变化的方式

  • Ease-In = "缓入" = 速度慢慢进入(从慢到快)
  • Ease-Out = "缓出" = 速度慢慢退出(从快到慢)

也可以记:CSS 的 transition: all 1s ease-out 表示"结束时变缓"。

4.6 试一试

打开 akira-graphics/animate_webgl/lerp_easing.html,它用 smoothstep(0.0, 1.0, p) 做缓动,在片元着色器中实现了颜色渐变。尝试把 smoothstep 换成 p * p(Ease-In)和 1.0 - (1.0 - p) * (1.0 - p)(Ease-Out),观察颜色过渡的变化。


5. 贝塞尔缓动:Cubic-Bezier 曲线

5.1 生活类比:自定义汽车加速曲线

前面讲的 Ease-In/Out 就像汽车的"经济模式"和"运动模式"——预设好的。但如果你想自定义:"起步要猛一点,中段匀速,最后轻轻滑停",怎么办?

贝塞尔缓动就像汽车的自定义驾驶模式:你指定两个"控制点",系统根据这两个点生成一条平滑的加速曲线。

5.2 本质是什么

CSS/Web 动画中常用的 cubic-bezier(x1, y1, x2, y2) 定义了一条三次贝塞尔曲线,用它作为"时间变换函数"。

四个点定义曲线:

  • P0 = (0, 0) —— 固定起点(时间0,进度0)
  • P1 = (x1, y1) —— 第一个控制点
  • P2 = (x2, y2) —— 第二个控制点
  • P3 = (1, 1) —— 固定终点(时间1,进度1)

关键理解:曲线的 x 轴是"输入时间 t",y 轴是"输出进度 p"。给定一个 t,求对应的 y 值,就是缓动后的进度。

5.3 公式推导

三次贝塞尔曲线的参数方程(回顾第5章):

B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3

展开 x 和 y 分量:

x(t) = 3(1-t)²t·x1 + 3(1-t)t²·x2 + t³
y(t) = 3(1-t)²t·y1 + 3(1-t)t²·y2 + t³

动画缓动时,我们已知 x(输入时间),要求 y(输出进度)。这需要解方程 x(t) = 已知值,求出 t,再代入 y(t)。

由于 x(t) 是三次方程,没有简单的求根公式,通常用牛顿迭代法数值求解。

5.4 代码示例

// 使用贝塞尔缓动库
import { Animator } from '../common/lib/animator/index.js';

// BezierEasing(x1, y1, x2, y2)
// 这个例子会产生"过冲"效果:先超过终点,再弹回来
const easing = BezierEasing(0.5, -1.5, 0.5, 2.5);

const animator = new Animator({ duration: 3000, easing });

animator.animate(
  { el: block, start: 100, end: 400 },
  ({ target: { el, start, end }, timing: { p } }) => {
    const left = start * (1 - p) + end * p;
    el.style.left = `${left}px`;
  }
);

BezierEasing(0.5, -1.5, 0.5, 2.5) 的效果:

  • y1 = -1.5:控制点在起点下方,产生"回拉"效果
  • y2 = 2.5:控制点在终点上方,产生"过冲"效果

5.5 常见误区

误区 1:"x1, y1, x2, y2 都必须在 0~1 之间"

x1 和 x2 必须在 [0, 1] 范围内(否则时间不是单调递增的,动画会"倒退")。但 y1 和 y2 可以超出 [0, 1],这就是产生"过冲/回弹"效果的原因。

误区 2:"贝塞尔缓动和 CSS 的 cubic-bezier 不一样"

完全一样!CSS 的 cubic-bezier(0.5, -1.5, 0.5, 2.5) 和 JS 的 BezierEasing(0.5, -1.5, 0.5, 2.5) 是同一个数学函数。

5.6 试一试

打开 akira-graphics/animate/lerp-bezier.html,点击页面观察方块的"弹性过冲"效果。然后修改控制点为 (0.25, 0.1, 0.25, 1.0)(CSS 默认的 ease 曲线),对比感受。


6. CSS Cubic-Bezier 动画

6.1 生活类比:按遥控器选模式

你家空调有"制冷""除湿""送风"等模式,按一下遥控器就切换。CSS 动画也是这样——你写好几行样式,浏览器自动帮你处理所有时间计算和渲染。

6.2 本质是什么

CSS 动画的本质是:浏览器在合成器线程上直接修改元素的变换属性(transform/opacity),不需要重新布局(layout)和绘制(paint),所以性能极好。

.block {
  animation: mymove 3s cubic-bezier(0.5, -1.5, 0.5, 2.5) forwards;
}

@keyframes mymove {
  from { left: 100px; }
  to   { left: 400px; }
}

6.3 关键属性解释

属性 含义
3s 动画持续时间
cubic-bezier(...) 缓动曲线
forwards 动画结束后保持最终状态(不回到起点)
@keyframes 定义关键帧:from(0%) → to(100%)

6.4 代码示例

<style>
  .block {
    width: 100px; height: 100px;
    position: absolute; top: 100px; left: 100px;
    background: blue;
  }
  .animate {
    animation: mymove 3s cubic-bezier(0.5, -1.5, 0.5, 2.5) forwards;
  }
  @keyframes mymove {
    from { left: 100px; }
    to   { left: 400px; }
  }
</style>

<div class="block"></div>
<script>
  const block = document.querySelector('.block');
  document.addEventListener('click', () => {
    block.className = 'block animate'; // 添加动画类,触发一次
  }, { once: true });
</script>

6.5 CSS 动画 vs JS 动画

特性 CSS 动画 JS 动画
性能 极好(合成器线程) 好(主线程,但 RAF 已优化)
可控性 有限(播放/暂停/反向) 完全可控(随时修改参数)
动态目标 难(目标值写死在 CSS) 易(运行时计算 end 值)
链式编排 复杂(依赖 animationend 事件) 简单(Promise + async/await)
适用场景 简单的 UI 过渡 复杂的游戏动画、物理模拟

6.6 常见误区

误区:"CSS 动画一定比 JS 动画快"

不一定。如果 CSS 动画触发布局(修改 width/height/left/top),性能反而差。只有修改 transformopacity 时,CSS 动画才是真正的"零成本"。JS 动画用 requestAnimationFrame + transform 同样很快。

6.7 试一试

打开 akira-graphics/animate/lerp-bezier-css.html,对比 CSS 版本和 JS 版本的贝塞尔动画效果。它们看起来应该完全一样,因为底层数学是相同的。


7. 精灵动画:纹理图集与帧动画

7.1 生活类比:翻页小人书

小时候玩过那种快速翻页的小人书吗?每一页画一个动作,快速翻过去,小人就像动起来了一样。精灵动画的原理完全一样——只不过"页"变成了纹理图集里的不同区域。

7.2 本质是什么

精灵动画(Sprite Animation) = 在一张大纹理(纹理图集/Texture Atlas)上,按顺序显示不同的小区域(帧),每帧换一个区域,人眼就看到连续动作。

┌─────────────────────────────┐
│  [帧0]  [帧1]  [帧2]        │  ← 一张纹理图集
│  小鸟展翅  小鸟平飞  小鸟收翅 │
└─────────────────────────────┘

7.3 CSS 帧动画示例

<style>
  .bird {
    position: absolute;
    left: 100px; top: 100px;
    width: 86px; height: 60px;
    background-image: url(https://p.ssl.qhimg.com/t01f265b6b6479fffc4.png);
    /* 三帧动画,每帧切换 background-position */
    animation: flappy .5s step-end infinite;
  }

  @keyframes flappy {
    0%   { background-position: -178px -2px; }  /* 帧2 */
    33%  { background-position: -90px  -2px; }  /* 帧1 */
    66%  { background-position: -2px   -2px; }  /* 帧0 */
  }
</style>
<div class="bird"></div>

注意 step-end:表示"不插值,直接跳到下一帧"。如果不用 step-end,CSS 会在两帧之间做渐变插值,帧动画就变成"变形动画"了。

7.4 WebGL 帧动画示例

// 片元着色器:根据 frameIndex 选择纹理区域
uniform sampler2D tMap;      // 纹理图集
uniform float fWidth;        // 图集总宽度
uniform vec2 vFrames[3];     // 三帧的 [startX, endX]
uniform int frameIndex;      // 当前帧索引

void main() {
  vec2 uv = vUv;
  for (int i = 0; i < 3; i++) {
    // 根据当前帧索引,选择对应的 UV 范围
    uv.x = mix(vFrames[i].x, vFrames[i].y, vUv.x) / fWidth;
    if (float(i) == mod(float(frameIndex), 3.0)) break;
  }
  gl_FragColor = texture2D(tMap, uv);
}
// JS 端:每 200ms 切换一帧
setInterval(() => {
  renderer.uniforms.frameIndex++;
}, 200);

7.5 常见误区

误区 1:"帧动画的帧率越高越好"

人眼能分辨的流畅动画大约 1224fps。游戏角色动画通常 812fps 就够了。帧率太高反而浪费内存(更多帧 = 更大图集)。

误区 2:"每帧用一张独立图片"

千万不要!每加载一张图片就是一个 HTTP 请求,而且 WebGL 切换纹理有开销。正确的做法是把所有帧打包成一张纹理图集(Texture Atlas),只绑定一次纹理。

7.6 试一试

打开 akira-graphics/animate/frame_animate.html(CSS 版本)和 akira-graphics/animate_webgl/frame_animate.html(WebGL 版本),观察同一个 Flappy Bird 精灵动画的两种实现方式。注意 WebGL 版本中 vFrames 数组如何存储每帧的 UV 范围。


8. 顶点着色器动画 vs 片元着色器动画

8.1 生活类比:舞台布景 vs 投影画面

想象一个舞台:

  • 顶点着色器动画 = 演员在舞台上真的走动、转身。你移动的是"实体"。
  • 片元着色器动画 = 演员站着不动,但背后投影仪播放的画面在转。你移动的是"像素颜色"。

两种看起来可能一样,但本质完全不同。

8.2 本质是什么

类型 操作对象 影响范围 性能特点
顶点着色器动画 顶点位置 改变几何形状 顶点数少时极快
片元着色器动画 像素颜色 改变外观,不改变形状 依赖屏幕分辨率

8.3 顶点着色器动画:旋转一个方块

// 顶点着色器:用矩阵旋转变换顶点位置
uniform float rotation;

void main() {
  float c = cos(rotation);
  float s = sin(rotation);

  // 2D 旋转矩阵
  mat3 transformMatrix = mat3(
    c,  s, 0,
    -s, c, 0,
    0,  0, 1
  );

  vec3 pos = transformMatrix * vec3(a_vertexPosition, 1);
  gl_Position = vec4(pos, 1);
}
// JS 端:每帧更新 rotation uniform
requestAnimationFrame(function update() {
  renderer.uniforms.rotation += 0.05;
  requestAnimationFrame(update);
});

这个方块真的在旋转——如果你用鼠标去"点"它,点击区域也会跟着转。

8.4 片元着色器动画:旋转一个"画出来的"方块

// 片元着色器:在像素级别做旋转
uniform float rotation;

void main() {
  // 将 UV 从 [0,1] 映射到 [-1,1]
  vec2 st = 2.0 * (vUv - vec2(0.5));

  float c = cos(rotation);
  float s = sin(rotation);
  mat3 transformMatrix = mat3(
    c,  s, 0,
    -s, c, 0,
    0,  0, 1
  );

  // 旋转的是"像素坐标",不是顶点
  vec3 pos = transformMatrix * vec3(st, 1.0);

  // 用 SDF 画一个方块(在旋转后的坐标系中)
  float d1 = 1.0 - smoothstep(0.5, 0.505, abs(pos.x));
  float d2 = 1.0 - smoothstep(0.5, 0.505, abs(pos.y));

  gl_FragColor = d1 * d2 * color; // 只有方块区域内显示颜色
}

这个"方块"其实是画在一张全屏四边形上的图案。四边形的顶点根本没动,动的只是每个像素的颜色计算。

8.5 什么时候用哪种

场景 推荐方式 原因
真实的 3D 模型旋转 顶点着色器 需要正确的光照、阴影、碰撞检测
UI 元素的移动/缩放 顶点着色器 需要响应点击事件,要真实几何
2D 特效(旋转光晕、波纹) 片元着色器 不需要交互,效果丰富
图案填充、纹理动画 片元着色器 UV 操作更灵活
大量粒子的独立运动 顶点着色器 + Instancing GPU 并行处理顶点

8.6 常见误区

误区 1:"片元着色器动画不能交互"

确实不能直接交互(因为几何没动),但可以用**拾取(Picking)**技术:在片元着色器里同时渲染一张"ID 图",用鼠标坐标去读 ID 图上的颜色,就知道点到了哪个"虚拟物体"。

误区 2:"顶点动画一定更快"

不一定。如果顶点数很少(比如一个全屏四边形只有 4 个顶点),顶点着色器几乎不干活,所有计算都在片元着色器里。这时候片元着色器的开销占主导。但如果顶点数很多(比如 10 万个粒子的 mesh),顶点着色器的开销就不可忽视。

8.7 试一试

打开以下文件对比:

  • akira-graphics/animate_webgl/vertex_rotation.html —— 顶点旋转
  • akira-graphics/animate_webgl/fragment_rotation_animator.html —— 片元旋转

两者看起来都是红色方块在转,但前者是真正的几何旋转,后者是"画出来"的旋转。试着把片元版本中的 gl_Position = vec4(a_vertexPosition, 1, 1) 改成缩小一点的顶点范围,你会发现"旋转的方块"大小没变——因为它根本不依赖顶点位置!


9. Animator 类模式:用 async/await 编排动画

9.1 生活类比:导演喊"Action"和"Cut"

拍电影时,导演不会一直盯着演员。他会说:"这段戏 3 分钟,演完叫我。"然后去做别的事。演员演完后喊"Cut",导演再安排下一段。

Animator 类就像这个导演——你告诉它"动画持续多久、用什么缓动",然后 await 它完成,再继续下一幕。

9.2 本质是什么

Animator 是一个基于 Promise 的动画调度器

  1. 你配置动画参数(时长、缓动、循环次数)
  2. 调用 animate(target, update) 启动动画
  3. 它内部用 requestAnimationFrame 循环调用 update
  4. 动画完成后 resolve,你可以 await

9.3 源码解析

// common/lib/animator/index.js
export class Animator {
  constructor({ duration, iterations, easing }) {
    this.timing = { duration, iterations, easing };
  }

  animate(target, update) {
    let frameIndex = 0;
    const timing = new Timing(this.timing);

    return new Promise((resolve) => {
      function next() {
        // update 返回 false 或动画完成时停止
        if (update({ target, frameIndex, timing }) !== false && !timing.isFinished) {
          requestAnimationFrame(next);
        } else {
          resolve(timing); // 动画完成,resolve Promise
        }
        frameIndex++;
      }
      next();
    });
  }
}
// common/lib/animator/timing.js
export class Timing {
  constructor({ duration, iterations = 1, easing = p => p } = {}) {
    this.startTime = Date.now();
    this.duration = duration;
    this.iterations = iterations;
    this.easing = easing;
  }

  get time() {
    return Date.now() - this.startTime;
  }

  get p() {
    // progress: 0 ~ iterations
    const progress = Math.min(this.time / this.duration, this.iterations);
    // 如果已完成返回 1,否则取小数部分并应用缓动
    return this.isFinished ? 1 : this.easing(progress % 1);
  }

  get isFinished() {
    return this.time / this.duration >= this.iterations;
  }
}

9.4 基础用法

import { Animator } from '../common/lib/animator/index.js';

const block = document.querySelector('.block');
const animator = new Animator({ duration: 3000, easing: p => p ** 2 });

// 点击启动动画
document.addEventListener('click', () => {
  animator.animate(
    { el: block, start: 100, end: 400 },
    ({ target: { el, start, end }, timing: { p } }) => {
      const left = start * (1 - p) + end * p;
      el.style.left = `${left}px`;
    }
  );
});

9.5 高级用法:async/await 编排动画序列

import { Animator } from '../common/lib/animator/index.js';

const blocks = document.querySelectorAll('.block');
const animator = new Animator({ duration: 1000, iterations: 1.5 });

(async function () {
  let i = 0;
  while (true) {
    // 等待当前动画完成,再启动下一个
    await animator.animate(blocks[i++ % 4], ({ target, timing }) => {
      target.style.transform = `rotate(${timing.p * 360}deg)`;
    });
  }
}());

这段代码的效果:4 个方块依次旋转,每个转 1.5 圈(540 度),转完一个再转下一个,无限循环。

如果没有 await,四个动画会同时启动,变成一起转。

9.6 与 WebGL 结合

const animator = new Animator({ duration: 2000, iterations: Infinity });

// 对 WebGL renderer 做动画
animator.animate(renderer, ({ target, timing }) => {
  target.uniforms.rotation = timing.p * 2 * Math.PI; // 0 ~ 2π
});

Animator 不 care target 是什么——可以是 DOM 元素、WebGL renderer、或者任何你自定义的对象。

9.7 常见误区

误区 1:"await animator.animate() 会阻塞主线程"

不会!await 只是让代码"看起来"是顺序执行的,实际上每次 requestAnimationFrame 之间浏览器都在正常处理事件循环。UI 不会卡顿。

误区 2:"iterations: Infinity 时 Promise 永远不会 resolve"

对!无限循环的动画永远不会 resolve,所以不要用 await 等它。如果要中途停止,可以让 update 回调返回 false

let shouldStop = false;
animator.animate(renderer, ({ timing }) => {
  if (shouldStop) return false; // 返回 false 停止动画
  renderer.uniforms.rotation = timing.p * 2 * Math.PI;
});

9.8 试一试

打开 akira-graphics/animate/animator.html,观察四个方块依次旋转的效果。尝试:

  1. iterations: 1.5 改成 iterations: 1
  2. duration: 1000 改成 duration: 500
  3. await 删掉,看看会发生什么(四个方块同时转)

10. 本章 Q&A

Q1:为什么游戏必须用时间驱动动画,不能用帧驱动?

A:不同设备的帧率不同(60fps、30fps、甚至 120fps)。帧驱动下,同样的代码在不同设备上速度完全不同。时间驱动保证"无论帧率多少,3 秒后一定完成",体验一致。

Q2:Lerp 的两种写法 start + t * (end - start)start * (1-t) + end * t 有什么区别?

A:数学上完全等价。第一种更直观(起点 + 偏移量),第二种更利于硬件优化(两次乘加,适合 SIMD)。实际用哪种都行,看团队规范。

Q3:Ease-In 和 Ease-Out 到底哪个是"先慢后快"?

A:Ease-In = 缓入 = 速度慢慢进入 = 先慢后快(像汽车起步)。Ease-Out = 缓出 = 速度慢慢退出 = 先快后慢(像刹车)。记法:名字描述的是速度如何变化,不是时间如何变化。

Q4:贝塞尔缓动的控制点 y 值可以小于 0 或大于 1 吗?

A:可以!y 值超出 [0, 1] 会产生"过冲"(overshoot)和"回弹"(bounce)效果。但 x 值必须在 [0, 1] 内,否则时间不是单调的,动画会"倒退"。

Q5:CSS 动画和 JS 动画怎么选?

A:简单的 UI 过渡(hover 效果、页面切换)用 CSS;需要动态计算目标值、链式编排、物理模拟的用 JS。性能上两者差不多,关键是都修改 transformopacity,不要触发布局。

Q6:精灵动画的 step-end 是什么意思?

Astep-end 表示"在每一步的末尾瞬间切换",中间不做插值。如果不加,CSS 会在两帧之间渐变,帧动画就变成"变形动画"了。对于逐帧动画,必须用 step-endsteps(n)

Q7:顶点着色器动画和片元着色器动画,用户能看出区别吗?

A:视觉上可能看不出,但交互上能。顶点动画的点击区域会跟着动,片元动画的点击区域不动(因为几何没动)。如果不需要交互,片元动画通常更灵活、代码更简洁。

Q8:Animator 类的 timing.p 为什么用 progress % 1

Aprogress 可能大于 1(比如 iterations = 2 时,progress 从 0 到 2)。% 1 取小数部分,把 progress 映射回 0~1 区间,这样 easing 函数始终收到合法的输入。isFinished 负责判断整体是否完成。

Q9:为什么粒子系统通常用顶点着色器动画?

A:每个粒子是一个顶点(或一个 billboard 四边形)。顶点着色器可以并行处理成千上万个粒子的运动,而片元着色器需要对每个像素都执行一次。10 万个粒子的运动计算在顶点阶段做只需要 10 万次,在片元阶段可能需要几千万次(取决于屏幕分辨率)。

Q10:本章所有概念在实际项目中怎么用?

A:一个典型场景:

  1. Animator + async/await 编排 UI 入场动画序列
  2. cubic-bezier 缓动让动画更自然
  3. dt 保证动画在不同帧率下一致
  4. 角色行走用精灵动画(纹理图集 + 帧切换)
  5. 背景特效(旋转星空、波纹)用片元着色器动画
  6. 3D 模型旋转用顶点着色器动画

本章小结

概念 核心要点 对应 Demo
帧驱动 vs 时间驱动 帧驱动 = 每帧固定步长;时间驱动 = 按已用时间计算位置 animate_delta.html / animate_time.html
Delta Time position += speed * dt,保证不同帧率下速度一致 所有动画都应使用
Lerp start + t * (end - start),动画的数学基础 lerp.html
缓动函数 先变换时间 t,再做 lerp lerp_easing.html
贝塞尔缓动 cubic-bezier(x1,y1,x2,y2),自定义加速曲线 lerp-bezier.html / bezier_easing.html
CSS 动画 animation + @keyframes,合成器线程高性能 lerp-bezier-css.html
精灵动画 纹理图集 + 逐帧切换,用 step-end frame_animate.html (x2)
顶点 vs 片元动画 顶点动几何,片元动像素 vertex_rotation.html / fragment_rotation_animator.html
Animator 模式 Promise + RAF,async/await 编排动画序列 animator.html / vertex_rotation_animator.html

推荐阅读


下一章预告:第11章《Shader深度入门》—— 我们将从 GLSL 语法开始,深入理解 Uniform、Varying、顶点属性,以及深度测试、模板测试、混合公式等核心概念。