4、动画基础概念:帧率(FPS)、时间步长(Delta Time)、动画循环(Game Loop)模式

做动画,说白了就是让画面动起来。但怎么动得流畅、动得自然,这里头有不少门道。我刚开始写动画的时候,以为只要在循环里不断重绘就行,结果跑出来的效果要么卡成幻灯片,要么快得像开了倍速。后来才明白,核心就三个东西:帧率、时间步长、动画循环。

这一节,咱们就把这三个概念彻底搞清楚。你想想看,搞懂了它们,后面做字体动画、刷新联动,才能心里有底。

4.1 帧率(FPS)—— 动画的“心跳”

帧率,全称 Frames Per Second,每秒显示的画面数。单位是 fps。60fps 就是一秒闪过 60 张图。

人眼有个特点:当画面切换速度超过每秒 10-12 帧,就会觉得是连续运动。电影是 24fps,电视是 30fps,电脑显示器主流是 60fps。为什么是 60?因为 60fps 的刷新间隔约 16.67ms,人眼基本感觉不到闪烁了。

关键认知: 帧率不是越高越好,而是越稳定越好。30fps 稳定,体验远好于 60fps 忽高忽低。

我在项目中遇到过一个问题:一个粒子动画在开发机上跑 120fps,非常丝滑。结果部署到用户的老笔记本上,直接掉到 15fps,动画像在爬。这就是典型的“帧率敏感”问题。所以,永远不要假设用户的设备跟你一样快。

帧率 每帧间隔 典型场景 体验评价
24 fps 41.67 ms 电影 可接受,有轻微抖动
30 fps 33.33 ms 电视、低配游戏 流畅,适合交互
60 fps 16.67 ms 显示器、手机 非常流畅,标准目标
120 fps 8.33 ms 高端电竞屏 极致丝滑,但功耗高

我的习惯: 开发时我会在屏幕上显示一个实时 FPS 计数器。一旦发现 FPS 低于 50,我就知道该优化了。别等到用户投诉才去查。

4.2 时间步长(Delta Time)—— 动画的“尺子”

光有帧率还不够。假设你让一个小球每帧移动 10 像素。60fps 时,小球一秒移动 600 像素。但如果掉到 30fps,一秒只移动 300 像素。同样的代码,在不同设备上速度不一样,这显然不合理。

解决办法就是引入 Delta Time(通常写作 dt)。它记录的是上一帧到这一帧实际经过的时间,单位是秒。

有了 dt,移动逻辑就变成:位置 += 速度 × dt。这样不管帧率怎么变,小球一秒移动的距离都是固定的。

// 错误写法:依赖帧率
x += 10;  // 60fps 时一秒 600px,30fps 时一秒 300px

// 正确写法:依赖时间
x += 10 * dt;  // 无论帧率多少,一秒都是 600px

嗯,这里要注意:dt 的单位要统一。我见过有人用毫秒做单位,结果动画快了 1000 倍,画面直接飞出去。建议统一用秒。

我曾经踩过的坑: 在页面切后台再切回来时,dt 会变得非常大(比如切后台 10 秒,dt = 10)。如果不做限制,动画会瞬间跳到一个离谱的位置。解决方案是给 dt 设一个上限,比如最大 0.1 秒。

4.3 动画循环(Game Loop)—— 动画的“引擎”

有了帧率和 dt,还需要一个循环来驱动它们。这就是动画循环,也叫 Game Loop。它负责三件事:输入 → 更新 → 渲染。

在浏览器里,最标准的动画循环接口是 requestAnimationFrame(简称 rAF)。它告诉浏览器:“下次重绘之前,请调用我这个函数。” 浏览器会尽量保持 60fps 的节奏,而且页面切到后台时会自动暂停,省电又省资源。

function gameLoop(timestamp) {
  // 1. 计算 dt
  const dt = timestamp - lastTimestamp;
  lastTimestamp = timestamp;

  // 2. 更新逻辑(位置、状态、碰撞等)
  update(dt);

  // 3. 渲染画面
  render();

  // 4. 请求下一帧
  requestAnimationFrame(gameLoop);
}

let lastTimestamp = 0;
requestAnimationFrame(gameLoop);

这里有三种常见的循环模式,我整理了一张表,方便你对比:

模式 核心思路 优点 缺点 适用场景
固定时间步长 每次更新固定 dt(如 16.67ms),渲染按实际帧率 物理模拟稳定,不依赖帧率 实现稍复杂,可能多次更新才渲染一次 物理引擎、游戏
可变时间步长 每次更新使用实际 dt 实现简单,直接 dt 波动大时物理不稳定 UI 动画、简单效果
半固定时间步长 固定 dt 但允许累积,超过上限则丢弃 兼顾稳定性和性能 需要处理累积逻辑 大多数实战项目

我个人习惯用半固定时间步长。说白了,就是给 dt 设个上限,防止切后台回来时动画“瞬移”。同时内部用固定步长做物理更新,保证碰撞检测不出错。

4.4 知识体系总览

下面这张图把三个概念的关系串起来了。你可以看到,帧率是节奏,dt 是标尺,循环是引擎。三者缺一不可。

动画基础概念知识体系 动画基础概念知识体系 帧率 (FPS) 每秒画面数,决定流畅度 时间步长 (dt) 每帧实际耗时,统一速度 动画循环 驱动更新与渲染的引擎 固定时间步长 物理稳定,实现复杂 可变时间步长 实现简单,dt 波动大 半固定时间步长 兼顾稳定与性能 核心公式 位置变化 = 速度 × dt dt = 当前帧时间戳 - 上一帧时间戳 建议:dt 上限设为 0.1s,防止切后台瞬移

4.5 实战建议:如何选择循环模式

你可能会问:那我到底用哪种?

我的建议很简单:

  • 做 UI 动画、字体渐变、简单位移 → 用可变时间步长。代码少,够用。
  • 做物理模拟、碰撞检测、游戏 → 用固定时间步长。物理稳定比什么都重要。
  • 做复杂项目,不确定用户设备 → 用半固定时间步长。这是最稳妥的选择。

一个小技巧: 在开发阶段,我会在循环里加一个 console.log(dt),观察 dt 的波动。如果波动超过 5ms,说明当前帧有性能瓶颈,该去查查是哪段代码拖慢了。

好了,帧率、时间步长、动画循环这三个概念,到这里就讲透了。你想想看,有了 dt 这把“尺子”,动画就不再依赖设备帧率;有了 rAF 这个“引擎”,动画就能跟浏览器刷新同步。后面做字体显示与动画刷新联动,这些就是地基。