4、动画循环设计:游戏循环模式、状态管理、时间增量(delta time)计算
动画循环,说白了就是让画面动起来的那个「心脏」。你想想看,不管是游戏还是网页动画,本质上都是一帧一帧地刷新画面。但怎么刷、什么时候刷、刷多快,这里面的门道可不少。
我刚开始做动画的时候,觉得用 setInterval 就完事了。后来发现,这玩意儿坑太多。用户切换标签页、手机降频、系统卡顿……各种情况都会让动画变得一塌糊涂。今天我们就来聊聊,怎么设计一个靠谱的动画循环。
4.1 游戏循环模式:三种主流方案
动画循环的核心,就是「更新状态 → 渲染画面」这个死循环。但具体怎么实现,业内主要有三种模式。
4.1.1 固定时间步长(Fixed Timestep)
这种模式,我习惯叫它「铁饭碗」模式。不管实际帧率是多少,逻辑更新都按固定的时间间隔走。
const FIXED_DT = 1000 / 60; // 16.67ms
let accumulator = 0;
let lastTime = 0;
function gameLoop(currentTime) {
let deltaTime = currentTime - lastTime;
lastTime = currentTime;
// 防止时间增量过大(比如切标签页回来)
if (deltaTime > 100) deltaTime = 100;
accumulator += deltaTime;
// 按固定步长更新逻辑
while (accumulator >= FIXED_DT) {
update(FIXED_DT); // 物理、碰撞检测等
accumulator -= FIXED_DT;
}
// 渲染可以插值,也可以直接渲染
render(accumulator / FIXED_DT);
requestAnimationFrame(gameLoop);
}
我在项目中遇到过一个问题:用固定步长做物理模拟,结果低端手机上卡成幻灯片。后来发现是 while 循环里积压了太多帧。嗯,这里要注意,一定要加一个最大帧数限制,否则就是灾难。
4.1.2 可变时间步长(Variable Timestep)
这种模式更「随性」。每帧的实际时间差是多少,就用多少来更新逻辑。代码简单,但问题也明显。
let lastTime = 0;
function gameLoop(currentTime) {
let deltaTime = (currentTime - lastTime) / 1000; // 转成秒
lastTime = currentTime;
// 直接用实际时间差更新
update(deltaTime);
render();
requestAnimationFrame(gameLoop);
}
说白了,这种模式就是「有啥吃啥」。帧率高,更新就快;帧率低,更新就慢。好处是代码简单,坏处是物理模拟会变得不稳定。比如一个跳跳球,60帧时跳得正常,30帧时可能就穿模了。
4.1.3 半固定时间步长(Semi-Fixed Timestep)
这是我个人最推荐的模式。它结合了前两者的优点:逻辑更新用固定步长,渲染用可变步长。
const TICK_RATE = 1000 / 60;
let lastTime = 0;
let accumulator = 0;
let alpha = 0;
function gameLoop(currentTime) {
let deltaTime = currentTime - lastTime;
lastTime = currentTime;
if (deltaTime > 100) deltaTime = 100;
accumulator += deltaTime;
while (accumulator >= TICK_RATE) {
fixedUpdate(TICK_RATE);
accumulator -= TICK_RATE;
}
alpha = accumulator / TICK_RATE;
render(alpha); // 渲染时做插值
requestAnimationFrame(gameLoop);
}
为什么推荐这个?因为物理模拟需要稳定的时间步长,而渲染需要平滑的视觉效果。半固定模式两者兼顾,我在做 Canvas 游戏引擎时一直用这个方案。
4.2 状态管理:动画的「大脑」
动画循环跑起来了,但状态怎么管?我见过不少新手把状态变量散落在全局,最后自己都搞不清哪个变量是干嘛的。
4.2.1 状态机的设计
一个典型的动画状态机,至少包含这几个状态:
| 状态 | 说明 | 触发条件 |
|---|---|---|
| IDLE | 空闲状态 | 动画未开始或已结束 |
| RUNNING | 运行中 | 调用 start() |
| PAUSED | 暂停 | 调用 pause() |
| STOPPED | 停止 | 调用 stop() 或自然结束 |
class AnimationStateMachine {
constructor() {
this.state = 'IDLE';
this.transitions = {
'IDLE': ['RUNNING'],
'RUNNING': ['PAUSED', 'STOPPED'],
'PAUSED': ['RUNNING', 'STOPPED'],
'STOPPED': ['IDLE']
};
}
transition(newState) {
if (this.transitions[this.state].includes(newState)) {
this.state = newState;
console.log(`状态切换: ${this.state} → ${newState}`);
} else {
console.warn(`非法状态转换: ${this.state} → ${newState}`);
}
}
}
4.2.2 状态数据的组织
我建议把动画状态数据封装成一个对象,不要散落在各处。
class AnimationState {
constructor() {
this.position = { x: 0, y: 0 };
this.rotation = 0;
this.scale = { x: 1, y: 1 };
this.opacity = 1;
this.velocity = { x: 0, y: 0 };
this.acceleration = { x: 0, y: 0 };
this.elapsed = 0;
this.duration = 1000;
this.easing = 'easeInOut';
}
reset() {
this.position = { x: 0, y: 0 };
this.rotation = 0;
this.scale = { x: 1, y: 1 };
this.opacity = 1;
this.velocity = { x: 0, y: 0 };
this.acceleration = { x: 0, y: 0 };
this.elapsed = 0;
}
}
4.3 时间增量(delta time)计算
delta time 是动画循环里最重要的概念之一。它决定了动画的「速度」是否与帧率无关。
4.3.1 为什么需要 delta time?
你想想看,60帧的显示器,每帧间隔约16.67ms。但如果用户用的是144Hz的显示器,每帧间隔只有6.94ms。如果不做 delta time 处理,同样的动画在144Hz上会快一倍多。
说白了,delta time 就是让动画速度与帧率解耦。不管你是60帧还是144帧,动画播放的「实际时间」是一样的。
4.3.2 正确的 delta time 计算方式
let lastTimestamp = 0;
let deltaTime = 0;
function animationLoop(timestamp) {
// 第一帧时,lastTimestamp 为0,需要特殊处理
if (lastTimestamp === 0) {
lastTimestamp = timestamp;
deltaTime = 16.67; // 默认值
} else {
deltaTime = timestamp - lastTimestamp;
lastTimestamp = timestamp;
}
// 限制 deltaTime 的最大值,防止切标签页后跳帧
if (deltaTime > 100) {
deltaTime = 100;
}
// 使用 deltaTime 更新动画
update(deltaTime);
render();
requestAnimationFrame(animationLoop);
}
4.3.3 常见坑点与避坑指南
- 第一帧问题:
requestAnimationFrame的第一帧 timestamp 是随机的,不能直接用来计算 delta time。我习惯在第一帧时跳过计算,或者给一个默认值。 - 标签页切换: 用户切到其他标签页再回来,delta time 可能非常大(几秒甚至几分钟)。一定要做上限限制,我一般设 100ms。
- 精度问题:
performance.now()的精度是微秒级的,但requestAnimationFrame的 timestamp 是毫秒级的。如果需要高精度,建议用performance.now()自己算。
4.4 实战:一个完整的动画循环框架
最后,我把上面讲的东西整合成一个完整的框架。这个框架我用了好几年,改了好几版,现在算是比较成熟了。
class AnimationEngine {
constructor() {
this.isRunning = false;
this.lastTime = 0;
this.accumulator = 0;
this.fixedDt = 1000 / 60;
this.maxFrameSkip = 5;
this.state = new AnimationStateMachine();
this.listeners = [];
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.state.transition('RUNNING');
this.lastTime = performance.now();
this.loop(this.lastTime);
}
loop(currentTime) {
if (!this.isRunning) return;
let deltaTime = currentTime - this.lastTime;
this.lastTime = currentTime;
// 防止切标签页后跳帧
if (deltaTime > 100) deltaTime = 100;
this.accumulator += deltaTime;
// 固定步长更新,最多处理 maxFrameSkip 帧
let frameCount = 0;
while (this.accumulator >= this.fixedDt && frameCount < this.maxFrameSkip) {
this.fixedUpdate(this.fixedDt);
this.accumulator -= this.fixedDt;
frameCount++;
}
// 渲染插值
let alpha = this.accumulator / this.fixedDt;
this.render(alpha);
requestAnimationFrame((time) => this.loop(time));
}
fixedUpdate(dt) {
// 物理、碰撞检测等逻辑
this.listeners.forEach(fn => fn(dt));
}
render(alpha) {
// 渲染逻辑,alpha 用于插值
}
pause() {
this.isRunning = false;
this.state.transition('PAUSED');
}
resume() {
this.start();
this.state.transition('RUNNING');
}
stop() {
this.isRunning = false;
this.accumulator = 0;
this.state.transition('STOPPED');
}
addUpdateListener(fn) {
this.listeners.push(fn);
}
}
fixedUpdate 和 render 分离。逻辑更新用固定步长,渲染用插值。这样既保证了物理模拟的稳定性,又保证了画面的平滑度。
好了,关于动画循环设计,今天就聊这么多。记住一句话:好的动画循环,是让用户感觉「流畅」,而不是让开发者感觉「简单」。下一章我们聊聊缓动函数,那才是让动画「活起来」的关键。