JS `requestAnimationFrame` 配合 `setTimeout` 实现精确帧动画控制

各位观众老爷们,掌声在哪里!咳咳,好吧,没人鼓掌,我假装听见了。今天咱们聊点刺激的,关于用requestAnimationFramesetTimeout这对欢喜冤家,一起搞出精确到让像素都哭泣的帧动画控制。准备好了吗?发车!

第一幕:requestAnimationFrame的爱与恨

首先,我们要认识一下requestAnimationFrame这位爷。浏览器亲儿子,性能优化利器,动画界的扛把子之一。它的作用很简单,就是告诉浏览器:“嘿,哥们,我有个动画要搞,你悠着点,在下一次重绘之前帮我执行一下!”

function animate() {
  // 这里写动画相关的逻辑
  console.log("我动了!");
  requestAnimationFrame(animate); // 循环调用
}

requestAnimationFrame(animate); // 启动动画

看起来很美好是不是?但是,理想很丰满,现实很骨感。requestAnimationFrame的回调执行时机,是由浏览器决定的。它会尽量保证每秒60帧(60fps),也就是大约16.67ms执行一次。

问题来了:

  • 不稳定!不稳定!不稳定! 浏览器忙的时候,或者电脑卡的时候,帧率会掉,掉成狗!可能变成30fps,甚至更低。
  • 不精确!不精确!不精确! 就算帧率稳定,每次回调的时间间隔也不一定完全相等。可能16ms,可能17ms,甚至15ms。

这对追求精确控制的强迫症患者来说,简直是噩梦!想象一下,你辛辛苦苦设计的动画,本来应该卡着节拍跳舞,结果跳成了广场舞,节奏全乱了。

第二幕:setTimeout的救赎与缺陷

这个时候,setTimeout老同志站了出来:“年轻人,不要慌!我来帮你!” setTimeout可以设置一个延迟时间,然后执行一个回调函数。

function animate() {
  // 这里写动画相关的逻辑
  console.log("我动了!");
  setTimeout(animate, 16); // 16ms后再次调用
}

setTimeout(animate, 16); // 启动动画

看起来,setTimeout可以完美地控制动画的帧率,比如设置16ms,就能保证每秒60帧。

但是,setTimeout也有自己的问题:

  • 不准时!不准时!不准时! setTimeout的回调函数不是立即执行的,而是要等到当前任务队列中的所有任务都执行完毕后,才会被添加到任务队列中。这意味着,实际的执行时间,可能会比设置的延迟时间更长。
  • 容易丢帧!容易丢帧!容易丢帧! 如果某个回调函数执行时间过长,或者有其他的任务阻塞了任务队列,setTimeout的回调函数可能会被延迟到下一帧执行,导致丢帧。

总之,setTimeout虽然可以控制帧率,但是精度不够,容易丢帧,动画效果可能不够流畅。

第三幕:双剑合璧,天下无敌?

现在,我们有了两个都有缺陷的工具。但是,如果把它们结合起来,能不能取长补短,实现精确的帧动画控制呢?

答案是:可以!

核心思想是:

  1. 使用requestAnimationFrame来保证动画的流畅性。
  2. 使用setTimeout来校正requestAnimationFrame的时间间隔,保证动画的精度。

具体实现如下:

let startTime = null; // 动画开始时间
let frameDuration = 16.67; // 期望的帧间隔(60fps)
let lastFrameTime = null; // 上一帧的时间

function animate(currentTime) {
  if (!startTime) {
    startTime = currentTime;
    lastFrameTime = currentTime;
  }

  const elapsed = currentTime - lastFrameTime; // 实际帧间隔

  // 这里写动画相关的逻辑
  console.log("我动了! 实际间隔:", elapsed.toFixed(2), "ms");

  const delay = frameDuration - elapsed; // 需要延迟的时间

  // 确保延迟时间大于等于0,防止setTimeout立即执行
  const adjustedDelay = Math.max(delay, 0);

  setTimeout(() => {
    lastFrameTime = currentTime + adjustedDelay;
    requestAnimationFrame(animate);
  }, adjustedDelay);
}

requestAnimationFrame(animate);

这段代码的关键在于:

  • 计算实际帧间隔: elapsed = currentTime - lastFrameTime,计算出requestAnimationFrame实际执行的时间间隔。
  • 计算需要延迟的时间: delay = frameDuration - elapsed,计算出为了保证帧率,需要延迟的时间。
  • 使用setTimeout进行校正: setTimeout(() => { ... }, adjustedDelay),使用setTimeout来延迟下一次requestAnimationFrame的调用,保证动画的精度。
  • Math.max(delay, 0): 确保setTimeout的延迟时间永远不会是负数,避免立即执行。

代码解释:

  1. 初始化: startTime 记录动画开始的时间,frameDuration 是我们期望的帧间隔 (60fps 约等于 16.67ms),lastFrameTime 记录上一帧的时间,初始值为 null
  2. animate(currentTime) 函数:
    • currentTimerequestAnimationFrame 传递给回调函数的参数,表示当前时间。
    • 初始化开始时间和上一帧时间: 如果 startTimenull,表示这是第一帧,所以初始化 startTimelastFrameTimecurrentTime
    • 计算实际帧间隔: elapsed = currentTime - lastFrameTime 计算出实际经过的时间,即实际帧间隔。
    • 动画逻辑: 在这里编写你的动画逻辑。console.log 用于输出实际帧间隔,方便调试。
    • 计算延迟时间: delay = frameDuration - elapsed 计算出为了达到期望的帧间隔,需要延迟的时间。如果 elapsed 大于 frameDuration,则 delay 为负数,表示实际帧间隔已经超过了期望值,不需要额外延迟。
    • 调整延迟时间: adjustedDelay = Math.max(delay, 0) 确保延迟时间不小于 0。如果 delay 为负数,则将其设置为 0,避免 setTimeout 立即执行。
    • 使用 setTimeout 延迟下一帧: setTimeout 用于在 adjustedDelay 毫秒后执行一个匿名函数。在这个匿名函数中,更新 lastFrameTimecurrentTime + adjustedDelay,并使用 requestAnimationFrame(animate) 请求下一帧动画。
  3. 启动动画: 使用 requestAnimationFrame(animate) 启动动画。

第四幕:更上一层楼:时间戳的妙用

上面的代码虽然可以提高动画的精度,但是仍然存在一些问题:

  • 漂移问题: 由于setTimeout本身的不精确性,长期运行下来,动画可能会出现漂移,也就是实际时间和期望时间出现偏差。
  • 误差积累: 每次计算延迟时间,都可能存在一定的误差,这些误差会逐渐积累,导致动画的精度下降。

为了解决这些问题,我们可以使用时间戳来进行校正。

let startTime = null; // 动画开始时间
let frameDuration = 16.67; // 期望的帧间隔(60fps)
let expectedNextFrameTime = null; // 期望的下一帧时间

function animate(currentTime) {
  if (!startTime) {
    startTime = currentTime;
    expectedNextFrameTime = currentTime + frameDuration;
  }

  const elapsed = currentTime - startTime; // 动画运行的总时间

  // 这里写动画相关的逻辑
  console.log("我动了! 运行总时间:", elapsed.toFixed(2), "ms");

  const delay = expectedNextFrameTime - currentTime; // 需要延迟的时间
  const adjustedDelay = Math.max(delay, 0);

  setTimeout(() => {
    expectedNextFrameTime += frameDuration; // 更新期望的下一帧时间
    requestAnimationFrame(animate);
  }, adjustedDelay);
}

requestAnimationFrame(animate);

这段代码的关键在于:

  • 使用expectedNextFrameTime记录期望的下一帧时间: 每次回调函数执行完毕后,都会更新expectedNextFrameTime,而不是使用实际的currentTime来计算延迟时间。
  • 基于startTime计算总时间: elapsed = currentTime - startTime,用于在动画逻辑中使用,可以更准确地知道动画运行的总时间。

代码解释:

  1. 初始化: startTime 记录动画开始的时间,frameDuration 是我们期望的帧间隔 (60fps 约等于 16.67ms),expectedNextFrameTime 记录期望的下一帧的时间,初始值为 null
  2. animate(currentTime) 函数:
    • currentTimerequestAnimationFrame 传递给回调函数的参数,表示当前时间。
    • 初始化开始时间和期望的下一帧时间: 如果 startTimenull,表示这是第一帧,所以初始化 startTimecurrentTime,并初始化 expectedNextFrameTimecurrentTime + frameDuration
    • 计算动画运行的总时间: elapsed = currentTime - startTime 计算出动画已经运行的总时间。
    • 动画逻辑: 在这里编写你的动画逻辑。console.log 用于输出运行总时间,方便调试。
    • 计算延迟时间: delay = expectedNextFrameTime - currentTime 计算出为了达到期望的帧间隔,需要延迟的时间。
    • 调整延迟时间: adjustedDelay = Math.max(delay, 0) 确保延迟时间不小于 0。
    • 使用 setTimeout 延迟下一帧: setTimeout 用于在 adjustedDelay 毫秒后执行一个匿名函数。在这个匿名函数中,更新 expectedNextFrameTimeexpectedNextFrameTime + frameDuration,并使用 requestAnimationFrame(animate) 请求下一帧动画。 关键在于,这里更新的是 期望 的下一帧时间,而不是依赖于 实际 发生的时间。
  3. 启动动画: 使用 requestAnimationFrame(animate) 启动动画。

这种方法的优点:

  • 减少漂移: 通过使用时间戳,可以有效地减少动画的漂移,保证动画的长期精度。
  • 减少误差积累: 每次回调函数执行完毕后,都会更新expectedNextFrameTime,而不是使用实际的currentTime来计算延迟时间,从而减少误差积累。

第五幕:高级技巧:帧率补偿

即使使用了时间戳校正,动画的帧率仍然可能不稳定。例如,当浏览器卡顿时,帧率可能会下降到30fps,甚至更低。

为了解决这个问题,我们可以使用帧率补偿。帧率补偿的原理是:根据实际的帧率,调整动画的速度,使动画在不同帧率下看起来速度一致。

let startTime = null; // 动画开始时间
let frameDuration = 16.67; // 期望的帧间隔(60fps)
let expectedNextFrameTime = null; // 期望的下一帧时间

function animate(currentTime) {
  if (!startTime) {
    startTime = currentTime;
    expectedNextFrameTime = currentTime + frameDuration;
  }

  const elapsed = currentTime - startTime; // 动画运行的总时间
  const deltaTime = currentTime - (expectedNextFrameTime - frameDuration); // 实际帧间隔
  const timeScale = deltaTime / frameDuration; // 时间缩放比例

  // 这里写动画相关的逻辑
  // 例如:
  // element.style.left = startLeft + speed * elapsed * timeScale + 'px';

  console.log("我动了! 运行总时间:", elapsed.toFixed(2), "ms", "时间缩放比例:", timeScale.toFixed(2));

  const delay = expectedNextFrameTime - currentTime; // 需要延迟的时间
  const adjustedDelay = Math.max(delay, 0);

  setTimeout(() => {
    expectedNextFrameTime += frameDuration; // 更新期望的下一帧时间
    requestAnimationFrame(animate);
  }, adjustedDelay);
}

requestAnimationFrame(animate);

这段代码的关键在于:

  • 计算deltaTime deltaTime = currentTime - (expectedNextFrameTime - frameDuration),计算出实际的帧间隔。注意,这里不是简单地用 currentTime - lastFrameTime,而是用当前时间减去 期望的 上一帧时间,这样可以更准确地反映实际帧间隔与期望帧间隔的偏差。
  • 计算timeScale timeScale = deltaTime / frameDuration,计算出时间缩放比例。如果timeScale大于1,表示实际帧间隔大于期望帧间隔,动画应该减速;如果timeScale小于1,表示实际帧间隔小于期望帧间隔,动画应该加速。
  • 在动画逻辑中使用timeScale 在动画逻辑中,将动画的速度乘以timeScale,就可以实现帧率补偿。

代码解释:

  1. 初始化: 与之前类似,初始化 startTime, frameDuration, expectedNextFrameTime
  2. animate(currentTime) 函数:
    • currentTimerequestAnimationFrame 传递给回调函数的参数,表示当前时间。
    • 初始化: 如果 startTimenull,则初始化 startTimeexpectedNextFrameTime
    • 计算动画运行的总时间: elapsed = currentTime - startTime
    • 计算实际帧间隔: deltaTime = currentTime - (expectedNextFrameTime - frameDuration)。 这是关键一步,它计算了 实际经过的时间期望经过的时间 之间的差异。
    • 计算时间缩放比例: timeScale = deltaTime / frameDurationtimeScale 表示实际帧间隔与期望帧间隔的比率。
    • 动画逻辑: 在这里编写你的动画逻辑。 重要的是,在更新动画状态时,要将 timeScale 考虑进去。例如,如果你的动画涉及位置变化,你应该将位置变化量乘以 timeScaleelement.style.left = startLeft + speed * elapsed * timeScale + 'px'; 这个例子展示了如何将 timeScale 应用于位置变化。speed 是每毫秒移动的像素数。
    • 延迟下一帧: 与之前类似,计算延迟时间并使用 setTimeout 延迟下一帧。
    • 更新期望的下一帧时间: expectedNextFrameTime += frameDuration
  3. 启动动画: 使用 requestAnimationFrame(animate) 启动动画。

示例:

假设有一个元素要从左向右移动,速度为每秒100像素。

let startLeft = 0; // 元素的初始位置
let speed = 100 / 1000; // 元素每毫秒移动的像素数

在动画逻辑中,可以这样更新元素的位置:

element.style.left = startLeft + speed * elapsed * timeScale + 'px';

这样,无论帧率如何变化,元素都会以每秒100像素的速度移动。

第六幕:总结与展望

今天,我们学习了如何使用requestAnimationFramesetTimeout这对欢喜冤家,一起实现精确的帧动画控制。

  • requestAnimationFrame 保证动画的流畅性。
  • setTimeout 校正requestAnimationFrame的时间间隔,保证动画的精度。
  • 时间戳: 减少漂移和误差积累。
  • 帧率补偿: 保证动画在不同帧率下速度一致。

通过这些技巧,我们可以制作出更加流畅、精确的动画,提升用户体验。

当然,动画的世界远不止这些。还有各种各样的优化技巧、动画算法、特效等等,等待我们去探索。希望今天的讲座能给大家带来一些启发,让大家在动画的道路上越走越远。

最后,记住一句至理名言:没有最好的动画,只有不断优化的动画!

感谢大家的观看,下次再见! (谢幕)

表格总结:

技术点 优点 缺点 适用场景
requestAnimationFrame 浏览器优化,性能好,流畅 时间间隔不稳定,不精确 对精度要求不高的动画,追求流畅性
setTimeout 可以控制帧率 不准时,容易丢帧,精度差 简单的定时任务,对动画效果要求不高
requestAnimationFrame + setTimeout (无时间戳) 结合了requestAnimationFrame的流畅性和setTimeout的帧率控制 依然存在漂移和误差积累,精度有限 对精度有一定要求的动画,但不需要长期运行
requestAnimationFrame + setTimeout (有时间戳) 在上一方案的基础上,减少了漂移和误差积累,提高了长期精度 setTimeout本身的不精确性依然存在 对精度有较高要求的动画,需要长期运行
requestAnimationFrame + setTimeout (有时间戳 + 帧率补偿) 在上一方案的基础上,实现了帧率补偿,保证动画在不同帧率下速度一致,抗卡顿能力强 实现较为复杂 对精度和稳定性要求极高的动画,需要适应不同的运行环境

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注