第10章:动画与缓动原理
目标:让初级程序员彻底理解"动画是怎么动起来的"——从帧驱动到时间驱动,从线性插值到贝塞尔缓动,从 CPU 动画到 GPU Shader 动画。
前置章节:第3章 Canvas 2D与坐标系统(建议先掌握 Canvas 基础)
预计学习时间:2天
目录
- 帧驱动 vs 时间驱动动画
- Delta Time:让动画跑得一样快
- 线性插值(Lerp):动画的数学基础
- 缓动函数:Ease-In、Ease-Out、Ease-In-Out
- 贝塞尔缓动:Cubic-Bezier 曲线
- CSS Cubic-Bezier 动画
- 精灵动画:纹理图集与帧动画
- 顶点着色器动画 vs 片元着色器动画
- Animator 类模式:用 async/await 编排动画
- 本章 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时,结果是startt = 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 ** 2 为 easing: 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),性能反而差。只有修改 transform 和 opacity 时,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 的动画调度器:
- 你配置动画参数(时长、缓动、循环次数)
- 调用
animate(target, update)启动动画 - 它内部用
requestAnimationFrame循环调用update - 动画完成后
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,观察四个方块依次旋转的效果。尝试:
- 把
iterations: 1.5改成iterations: 1 - 把
duration: 1000改成duration: 500 - 把
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。性能上两者差不多,关键是都修改 transform 和 opacity,不要触发布局。
Q6:精灵动画的 step-end 是什么意思?
A:step-end 表示"在每一步的末尾瞬间切换",中间不做插值。如果不加,CSS 会在两帧之间渐变,帧动画就变成"变形动画"了。对于逐帧动画,必须用 step-end 或 steps(n)。
Q7:顶点着色器动画和片元着色器动画,用户能看出区别吗?
A:视觉上可能看不出,但交互上能。顶点动画的点击区域会跟着动,片元动画的点击区域不动(因为几何没动)。如果不需要交互,片元动画通常更灵活、代码更简洁。
Q8:Animator 类的 timing.p 为什么用 progress % 1?
A:progress 可能大于 1(比如 iterations = 2 时,progress 从 0 到 2)。% 1 取小数部分,把 progress 映射回 0~1 区间,这样 easing 函数始终收到合法的输入。isFinished 负责判断整体是否完成。
Q9:为什么粒子系统通常用顶点着色器动画?
A:每个粒子是一个顶点(或一个 billboard 四边形)。顶点着色器可以并行处理成千上万个粒子的运动,而片元着色器需要对每个像素都执行一次。10 万个粒子的运动计算在顶点阶段做只需要 10 万次,在片元阶段可能需要几千万次(取决于屏幕分辨率)。
Q10:本章所有概念在实际项目中怎么用?
A:一个典型场景:
- 用
Animator+async/await编排 UI 入场动画序列 - 用
cubic-bezier缓动让动画更自然 - 用
dt保证动画在不同帧率下一致 - 角色行走用精灵动画(纹理图集 + 帧切换)
- 背景特效(旋转星空、波纹)用片元着色器动画
- 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 |
推荐阅读
- MDN: requestAnimationFrame
- MDN: CSS 动画
- easing.net —— 可视化各种缓动函数
- cubic-bezier.com —— 在线调试 cubic-bezier 曲线
下一章预告:第11章《Shader深度入门》—— 我们将从 GLSL 语法开始,深入理解 Uniform、Varying、顶点属性,以及深度测试、模板测试、混合公式等核心概念。