requestAnimationFrame [现在] 与 performance.now() 时间差异

15

假设:rAF的now时间是在所有回调被触发时计算的。因此,在该帧的第一个回调被调用之前发生的任何阻塞都不会影响rAF的now,并且至少对于该第一个回调而言,它是准确的。

在触发rAF集之前进行的任何performance.now()测量都应早于rAF的now

测试:记录before(在任何事情发生之前的基线时间)。设置下一个rAF。将rAF的now和实际的performance.now()before进行比较,以查看它们之间的差异。

预期结果:

var before = performance.now(), frames = ["with blocking", "with no blocking"], calls = 0;
requestAnimationFrame(function frame(rAFnow) {
  var actual = performance.now();
  console.log("frame " + (calls + 1) + " " + frames[calls] + ":");
  console.log("before frame -> rAF now: " + (rAFnow - before));
  console.log("before frame -> rAF actual: " + (actual - before));

  if (++calls < frames.length) { before = actual; requestAnimationFrame(frame); }
});

// blocking
for (var i = 0, l = 0; i < 10000000; i++) {
    l += i;
}

观察结果:当帧开始前存在阻塞时,rAF的now时间有时会错误,甚至对于第一帧也是如此。有时第一帧的now实际上比记录的before时间还要早。

无论是否存在帧之前的阻塞,每隔一段时间,在帧内时间rAFnow会比预先帧时间before更早--即使我在进行第一次测量之后才设置rAF。尽管这很少见,但这种情况也可能发生,而没有任何阻塞。

(大多数情况下,第一个阻塞帧会出现时间错误。在其他情况下,也会偶尔遇到问题,如果您尝试运行几次,则会发生问题。)

通过更广泛的测试,我发现在回调之前阻塞的时间错误率为100帧中的1%,没有阻塞的时间错误率为约400帧中的0.21645021645021645%,似乎是由用户打开窗口或其他潜在的CPU密集型操作引起的。

因此,这种情况相当罕见,但问题是根本不应该发生。如果您想使用它们进行有用的操作,例如模拟时间、动画等,则需要这些时间来使其有意义。

我已经考虑了人们所说的话,但也许我仍然不明白事情的运作方式。如果这都是按规范的,我希望能得到一些伪代码以巩固我的理解。

更重要的是,如果有人有任何建议,可以让我避免这些问题,那就太棒了。唯一想到的办法是在每帧中测量自己的performance.now()并使用该值--但这似乎有些浪费,因为它实际上在每帧中运行两次,另外还有任何触发的事件等。


我在你的片段中添加了一个停止状态。随意回滚,但这样代码实际上会在某个时刻结束 :)。 - Heretic Monkey
不,那很酷。谢谢,@MikeMcCaughan。 - Whothehellisthat
4个回答

10
请查看以下更详细的答案,以了解计时器值来自何处的分析。
传递给 `requestAnimationFrame()` 回调的时间戳是动画帧开始的时间。在同一帧中调用多个回调函数都会收到相同的时间戳。因此,如果 `performance.now()` 返回一个早于参数值的时间,那么这将非常奇怪,但如果它返回一个晚于参数值的时间,则并不奇怪。

这里是相关规范:

当用户代理以时间戳 now 运行文档 Document 的动画帧回调时,它必须执行以下步骤:

  1. 如果文档对象的 hidden 属性返回的值为 true,请中止这些步骤。[PAGE-VISIBILITY]

  2. 让 callbacks 成为文档列表中添加到列表中的条目的顺序列表。

  3. 将文档的动画帧回调列表设置为空列表。

  4. 对于 callbacks 中的每个条目,按顺序调用 Web IDL 回调函数,仅传递 now 作为参数,如果抛出异常,则报告异常。

所以你已经注册了一个回调函数(比方说只有一个),用于下一帧动画。滴答滴答滴答,BOOM,该进行那一帧动画了:

  1. JavaScript运行时记录时间,并标记为now
  2. 运行时复制已注册动画帧回调的列表,并清除实际列表(以便如果时间过长导致下一帧动画出现则不会意外调用)。
  3. 该列表中仅有一项:您的回调函数。系统使用now作为参数调用它。
  4. 您的回调函数开始运行。也许它以前从未运行过,因此JavaScript优化器可能需要做一些工作。或者操作系统切换到其他系统进程的线程,例如启动磁盘缓冲区刷新或处理某些网络流量,或者其他几十种情况。
  5. 哦,对了,您的回调函数。浏览器再次获得CPU,您的回调代码开始运行。
  6. 您的代码调用performance.now()并将其与传入的now值进行比较。
由于步骤1和步骤6之间可能会经过一个简短但不可忽视的时间量,performance.now()的返回值可能表明已经过去了几微秒甚至更多。这是完全正常的行为。

你是说_任何_回调函数,即使不是rAF的,都会有相同的performance.now()值吗?这似乎并不是事实。如果你是指多个rAF回调函数,我只使用了一个rAF。 - Whothehellisthat
我已经进行了编辑,以更好地解释正在发生的事情。(我不确定您是否会收到通知?) - Whothehellisthat
根据系统上正在发生的事情(其他进程、操作系统工作等),在动画帧内部收集帧开始时间戳和回调代码运行的时间之间可能会有一些微秒的时间差。特别地,系统不会将调用performance.now()的结果传递给每个回调函数。因此,即使只有一个已注册的回调函数,它的参数比performance.now()返回的数字小也并不奇怪。 - Pointy
@Whothehellisthat的回答稍微扩展了一下。 - Pointy
1
你说过“如果 performance.now() 返回的时间早于参数值,那就太奇怪了”,但问题的关键在于初始的“before”比由 requestAnimationFrame 传递的“rAFnow”要大,这是因为 requestAnimationFrame 传递的“timestamp”并不是人们认为它应该是的时间。 - Changdae Park
显示剩余8条评论

4
我在chrome上遇到了同样的问题,调用 performance.now() 返回的值比随后由 window.requestAnimationFrame() 调用的回调函数中传递的 now 值要高。
我的解决方法是在第一个 window.requestAnimationFrame() 的回调函数中使用传递给它的 now 来设置 before,而不是使用 performance.now()。似乎只使用这两个函数中的一个来测量时间可以保证值得到逐步增加。
希望这能帮到其他遇到这个bug的人。

3

一般描述

我进行了深入调查,发现requestAnimationFrame可以在先前调用performance.now()时报告时间,即使使用高精度定时器(启用COOP/COEP)。例如,当您的应用程序处于重负载状态时,以下代码:

let prev = 0
function onFrame(t) {
    if (t < prev) {
        console.log("FAIL!", t - prev)
    }
    prev = performance.now() 
    window.requestAnimationFrame(onFrame)
}
window.requestAnimationFrame(onFrame)

可以报告:

FAIL! -1.4840001144402777
FAIL! -0.6779997711182659
FAIL! -1.3689998474110325
FAIL! -0.5829999618545116
FAIL! -0.4240000762947602
FAIL! -1.018999790190719
FAIL! -0.056999980926775606
FAIL! -5.993999904632801
FAIL! -0.26899996185238706
FAIL! -0.9029996757490153
FAIL! -1.6019999237068987
....

看起来,这是正确的行为,至少在Chrome中是如此。事件处理、动画帧和任务调度是每个浏览器的实现细节。我们对Chrome如何处理这些事情进行了深入分析。其他浏览器的行为可能会有所不同。

Chrome中的事件调度

以下图表显示了一帧内任务的示例顺序,假设所有帧任务都可以在该帧内执行,并且应用程序以本机FPS运行。

             ║    Frame 1     ║    Frame 2     ║    Frame 3     ║ ...
 Idle time   ║█            ███║██         █████║████           █║ ...
 Other tasks ║ █          █   ║  █       █     ║    █        █  ║ ...
 I/O Events  ║  ██   █████    ║      ████      ║     █   ████   ║ ...
 RAF         ║    ███         ║   ███          ║      ███       ║ ...
 GPU         ║      ████      ║     ███        ║        ██████  ║ ...


这里有几点需要注意:
  • 帧开始空闲时间。 几乎每一帧的开始都会有一个空闲时间,长达几毫秒(我们观察到的空闲时间在0毫秒至4毫秒之间)。这个时间可能是由于帧与屏幕刷新率同步,而任务是由Chrome任务调度器执行的,该调度器在同步点不会被触发。要了解更多信息,请参见this link。目前尚不清楚Chrome是否认为这是一个错误,或者他们是否会修复它。

  • 其他任务。 这些是Chrome内部任务。例如,在每个动画帧之前,Chrome会执行一些准备工作,这在大多数情况下非常短暂(我们观察到的时间在20微秒至40微秒之间)。在帧结束时,Chrome执行预绘制、布局、分层和提交作业,对于像我们这样的WebGL应用程序(当舞台上没有HTML元素时),这些作业也很短,范围在0.1毫秒至0.5毫秒之间。

  • I/O事件。 诸如鼠标按下/松开/移动等事件可以在RAF回调之前或之后进行调度。其中一些事件可能起源于上一帧,并且它们的处理可能会被Chrome安排在下一帧中。

  • RAF。 RAF(请求动画帧)回调,在WebGL应用程序的情况下,将触发每帧函数,例如动画、显示对象布局重新计算、WebGL缓冲区更新和调用WebGL绘制调用。

  • GPU。 与RAF回调、I/O事件和Chrome执行的其他任务并行进行的GPU相关工作始于RAF回调结束时,即第一个WebGL调用发出时。

如果所有每帧任务的总时间超过了本机刷新率的帧持续时间,那么某些帧可能会被跳过,并且可以观察到非常有趣的时间报告行为。让我们考虑以下事件图:

             ║ Real frame 1 work  ┆          Real frame 2 work         ┆        ...
             ║    Frame 1     ║   ┆Frame 2     ║ Frame 3 (skip) ║    Frame 4     ║  ...
 Idle time   ║███             ║   ┆            ║                ║      ┆         ║  ...
 Other tasks ║   █            ║  █┆            ║                ║     █┆         ║  ...
 RAF         ║    ██████████  ║   ┆  ██████████████████████████████    ┆ ██████████ ...
 I/O Events  ║              █████ ┆██          ║                ║  ██  ┆█        ║  ...
 GPU         ║              ███   ┆            ║                ║     █┆         ║  ...

这里需要注意几点:

  • 帧开始空闲时间。 如前所述,我们可以观察到第一帧开始时有一个空闲时间。Chrome任务调度器及时渲染了上一帧,因此没有压力立即启动下一个任务。然而,在接下来的帧中没有空闲时间,因为它们的计算时间超过了本机帧持续时间。

  • 帧时间。 即使实际计算时间可能超过使用本机刷新率发出的几个帧的时间,但帧始终以本机刷新率时间安排。因此,像上面示例中的第3帧一样,其中的一些帧可能会被跳过。

  • RAF开始时间。 当调用RAF回调时,它会提供帧开始时间。但是,这个时间可能小于performance.now()报告的时间。例如,在上面的图表中,第三次调用RAF回调将报告第4帧的开始时间,即使它是在第4帧中间执行的。如果我们在第二个RAF回调结束时使用performance.now()测量时间,则该时间将大于第三个RAF回调报告的时间。


1
这是非常有用的信息。感谢您的努力。 - Pointy

2
假设:rAF中的“now”时间是在触发所有回调函数时计算的。
这就是问题所在。即使是规范要求的内容,浏览器也不会这样做。在支持VSync的系统中,"now"将是显示器发送其最后一个VSync开放信号的时间。它与CPU或JS执行没有关联。
浏览器将假定直到它收到新的VSync信号之前,它仍有时间向合成器发送新数据,这实际上将在下一帧将其发送到显示器。基本上,它有一个帧缓冲区1。
因此,在长帧的情况下(其中您会阻止JS线程),它将尝试快速生成下一帧,以便两个帧可以及时传递给显示器。
例如,如果我们采用以下图表,
VSync open signals      :  |-----------------|-----------------|-----------------|----------
JS callbacks            :  |long-frame------------|short-frame |next-frame----   |...
Presentation to monitor :  [previous frame  ][long frame      ][short frame     ][next frame  

我们可以看到,短帧仍然有时间在自己的呈现帧中呈现给监视器,而下一帧甚至已经赶上了延迟。但是,在短帧中传递的now确实会报告在长帧结束之前的时间。

如果短帧在自己的呈现帧中渲染时间过长,它将占据下一帧的位置,并且自己的呈现帧将被跳过。

1. 有一些API可以绕过合成器和因此绕过这个帧缓冲区,例如canvas2D具有desynchronized选项,但这仅适用于某些系统。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接