Hotdry.

Article

帧级动画时序优化:requestAnimationFrame 与屏幕刷新率的精确同步

深入解析 requestAnimationFrame 与显示器 VSync 的同步机制,提供固定时间步长、掉帧补偿与平滑插值的工程化实现方案。

2026-06-13web-performance

从 setTimeout 到 rAF:为什么精度 matters

早期 Web 动画依赖 setTimeoutsetInterval 驱动,但这两个 API 与浏览器渲染流水线完全脱节。它们既不知道显示器何时刷新,也不关心页面是否处于可视状态。结果是:动画可能在两次屏幕刷新之间更新多次(浪费 CPU),也可能错过刷新窗口导致画面撕裂(jank)。

requestAnimationFrame(rAF)的设计初衷就是解决这个断层。浏览器将 rAF 回调安排在下一次重绘之前执行,天然与显示器的 VSync 信号对齐。在 60Hz 显示器上,rAF 约每 16.67ms 触发一次;在 120Hz 或 144Hz 高刷设备上,它会自动适配到 8.33ms 或 6.94ms 的周期。

更关键的是,现代浏览器会在标签页隐藏时自动暂停 rAF 回调,切回后再恢复,这为后台资源管理提供了原生支持。

固定时间步长:物理更新与渲染分离

直接用 rAF 驱动动画逻辑会遇到一个根本问题:帧时间不稳定。即使显示器固定 60Hz,主线程可能被阻塞,导致两次 rAF 回调间隔从 16ms 跳到 34ms 甚至更长。如果按实际帧时间推进动画,速度就会出现肉眼可见的波动。

游戏引擎的经典解决方案是 ** 固定时间步长(Fixed Timestep)** 模式:

  1. 设定一个固定的逻辑更新间隔(如 16.667ms 对应 60Hz)
  2. 使用累加器(accumulator)累积实际经过的时间
  3. 每满一个固定步长,执行一次物理 / 逻辑更新
  4. 剩余不足一个步长的时间用于插值渲染

这种分离保证了物理模拟的确定性和可重复性,同时让渲染保持流畅。

掉帧补偿:Clamp 与多步更新

当主线程繁忙或设备性能不足时,单帧实际耗时可能远超固定步长。此时简单的累加会导致时间螺旋:一次更新太多步,进一步拖慢渲染,形成恶性循环。

工程上的防御策略是Clamp 最大帧时间

const MAX_FRAME_TIME = 250; // ms,超过视为异常,截断处理
const frameTime = Math.min(now - lastTime, MAX_FRAME_TIME);

同时,累加器可能累积多个步长,需要循环执行更新直到累加器小于步长:

while (accumulator >= STEP) {
  updatePhysics(currentState, STEP);
  accumulator -= STEP;
}

如果一次跳过了 5 个步长,物理会连续更新 5 次,确保逻辑不丢失。视觉上的平滑则由接下来的插值环节保证。

平滑插值:Alpha 混合与双状态管理

固定步长更新后,累加器中通常剩余不足一个步长的时间(如 0.3 个步长)。此时如果直接渲染当前状态,物体会出现 "跳跃" 感 —— 它实际上应该处于两个逻辑状态之间的某个位置。

解决方案是维护双状态previousStatecurrentState。每次固定步长更新后,将当前状态复制到前一状态,再推进当前状态。渲染时计算插值系数:

const alpha = accumulator / STEP; // 0.0 ~ 1.0
const renderState = interpolate(previousState, currentState, alpha);
render(renderState);

线性插值适用于位置、缩放、透明度等连续属性。但对于角度,需要处理环绕(wrap-around)问题,例如从 350° 插值到 10° 应该顺时针短路径过渡,而非逆时针绕一大圈。

可落地的代码骨架

以下是一个完整的动画循环骨架,可直接嵌入 Canvas 或 WebGL 项目:

const STEP = 1000 / 60;           // 16.667ms
const MAX_FRAME_TIME = 250;       // 防跳变阈值

let lastTime = performance.now();
let accumulator = 0;
let previousState = createState();
let currentState = createState();

function loop(now) {
  // 1. 计算帧时间并 Clamp
  const frameTime = Math.min(now - lastTime, MAX_FRAME_TIME);
  lastTime = now;
  accumulator += frameTime;

  // 2. 固定步长更新(可能多次)
  while (accumulator >= STEP) {
    previousState = clone(currentState);
    updatePhysics(currentState, STEP / 1000); // 转为秒
    accumulator -= STEP;
  }

  // 3. 插值渲染
  const alpha = accumulator / STEP;
  const renderState = interpolate(previousState, currentState, alpha);
  draw(renderState);

  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

边界情况与监控点

标签页切换:rAF 在后台暂停,返回前台后 performance.now() 的差值可能累积数秒。Clamp 机制在此处发挥作用,防止物体瞬移。

高刷显示器适配:代码中的 STEP 固定为 60Hz,但在 120Hz 设备上 rAF 会以更高频率调用。由于累加器每次只增加 8ms 左右,while 循环通常只执行 0 或 1 次,渲染插值让画面依然平滑。如需利用高刷优势,可将 STEP 缩小到 8.33ms。

性能监控:建议在生产环境埋点追踪 frameTime 的 P99 值,超过 33ms(2 帧)的占比应控制在 1% 以内。

资料来源

  • MDN Web Docs: Window.requestAnimationFrame () —— 浏览器刷新同步机制官方说明
  • "Fix Your Timestep!" —— Gaffer on Games 经典游戏循环模式

web-performance

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com