算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 驱动器与帧:delta-time 与帧率无关 待审核 26 / 47
rAF · delta-time · 帧率无关

驱动器与帧:delta-time 与帧率无关

动画要动,必须有一处「时间在流动」。这就是驱动器(driver):浏览器里它是 requestAnimationFrame,每次屏幕刷新回调一次,并送来一个单调递增的时间戳。引擎其余部分都是纯函数,真正让时间前进的只有这一层。

问题在于刷新间隔不是常数。144 Hz 屏约 7 ms 一帧,60 Hz 约 16.7 ms,掉帧时可能跳到 30 ms 以上。若每帧都按固定一步推进,动画速度就会随帧率漂移。

1 · 两种累加方式

图 1-1 · 固定步进与 delta-time 累加的对照,黑线标出按真实经过时间应到达的位置。可把模拟帧率调到 15 fps,或打开 dt 抖动。

把帧率调到 15 fps,固定步进的球立刻明显落后于参考线,而按 delta-time 累加的球始终贴线——掉帧只是让它每帧走一大步,总距离仍然正确。

pos+=vΔt\text{pos} \mathrel{+}= v \cdot \Delta t

警示 ·「每帧 +1」式的代码在低端设备上跑得更慢、不同设备表现不一致,根因就是把帧数当成了时间。这类问题在开发机上几乎看不出来——开发机往往稳定 60 fps 甚至更高,偏差要到掉帧时才暴露。图 1-1 的 dt 抖动开关模拟的正是这种不稳定:抖动打开后固定步进的偏差会随机游走,而 delta 累加的偏差始终贴 0。

2 · 时间来源收敛之后

delta-time 修正的前提是「同一个 Δt\Delta t 喂给所有运动」。引擎的 Animation.tick(timestamp) 做的正是此事:第一帧只记录 lastTimestamp 不推进,之后每帧算出 delta,再 currentTime += delta × speed

首帧不推进这一条看似是边界处理,实为必须:requestAnimationFrame 的首次回调时间戳与调用时刻之间隔着不确定的一段,若拿它当零点,动画会凭空跳过一小截。

private tick = (timestamp: number): void => {
  if (this.lastTimestamp === null) {
    this.lastTimestamp = timestamp; // 首帧只锚定, 不推进
    return;
  }
  const delta = timestamp - this.lastTimestamp;
  this.lastTimestamp = timestamp;
  this._time += delta * this._speed; // 唯一推进 currentTime 的地方
  this.emit();                       // 纯函数: time → 缓动 → 采样
};

时间来源被收敛到 driver 这一层之后,把 rafDriver 换成 manualDriver、由测试逐帧投喂确定的时间戳,动画就完全可复现、可断言。这是「唯一时间来源」这条设计最实在的回报——不是为了优雅,是为了动画能被写进单元测试。

同一套「唯一时间来源加纯函数求值」也支撑起播放控制:暂停只是停止累加、seek 只是改游标、reverse 只是 speed 取负。当运动没有固定时长、由初速度与物理参数决定时,时间同样按 Δt\Delta t 推进给求解器,见弹簧。掉帧本身的成因与合成层的关系见合成器