显示的精确时间:requestAnimationFrame 的使用和时间线

9
我想要实现的是检测屏幕上出现某种变化的精确时间(主要针对Google Chrome)。例如,我使用$("xelement").show();来显示一个项目或使用$("#xelement").text("sth new");进行更改,然后我想知道在给定屏幕重绘时,变化出现在用户屏幕上时performance.now()的确切值。因此,我完全接受任何解决方案-下面我只是主要参考requestAnimationFrame(rAF),因为它是旨在帮助实现这一点的函数,但似乎没有成功;请见下文。
基本上,正如我所想象的那样,rAF应该在大约0-17毫秒内执行其中的所有内容(每当下一个帧出现在我的标准60 Hz屏幕上时)。此外,时间戳参数应该给出此执行的时间值(该值基于与performance.now()相同的DOMHighResTimeStamp度量)。
现在,这里是我进行的许多测试之一:https://jsfiddle.net/gasparl/k5nx7zvh/31/
function item_display() {
    var before = performance.now();
    requestAnimationFrame(function(timest){
        var r_start = performance.now();
        var r_ts = timest;
        console.log("before:", before);
        console.log("RAF callback start:", r_start);
        console.log("RAF stamp:", r_ts);
        console.log("before vs. RAF callback start:", r_start - before);
        console.log("before vs. RAF stamp:", r_ts - before);
        console.log("")
    });
}
setInterval(item_display, Math.floor(Math.random() * (1000 - 500 + 1)) + 500);

我在Chrome浏览器中看到的是:rAF函数始终在大约0-3毫秒内执行(从它之前立即进行performance.now()计时),最奇妙的是,rAF时间戳与我在rAF内部使用performance.now()获得的时间戳完全不同,通常比rAF之前调用的performance.now()早约0-17毫秒(但有时会晚约0-1毫秒)。
以下是一个典型的示例:
before: 409265.00000001397
RAF callback start: 409266.30000001758
RAF stamp: 409260.832 
before vs. RAF callback start: 1.30000000353902
before vs. RAF stamp: -4.168000013974961 

在Firefox和IE中,情况不同。在Firefox中,“before vs. RAF callback start”的时间要么是大约1-3毫秒,要么是大约16-17毫秒。而“before vs. RAF stamp”总是正数,通常在0-3毫秒左右,但有时会在3-17毫秒之间。在IE中,这两个差异几乎总是在15-18毫秒(正数)左右。这些差异在不同的PC上基本相同。然而,当我在手机的Chrome上运行它时,只有那时它似乎是正确的:“before vs. RAF stamp”随机在0-17之间,“RAF callback start”总是在几毫秒后。
更多背景信息:这是一项在线响应时间实验,用户使用自己的PC(但我通常限制浏览器为Chrome,因此只有Chrome浏览器对我很重要)。我反复显示各种项目,并将响应时间测量为“从元素显示时(当人们看到它时)到按下键的那一刻”,并根据特定项目记录的响应时间计算平均值,然后检查某些项目类型之间的差异。这也意味着,如果记录的时间总是朝一个方向偏斜一点(例如始终比元素实际出现早3毫秒),这并不太重要,只要这种偏斜对于每个显示都是一致的,因为只有差异真正重要。1-2毫秒的精度是理想的,但任何减少随机“刷新率噪声”(0-17毫秒)的东西都会很好。
我还尝试过jQuery.show()回调,但它没有考虑刷新率:https://jsfiddle.net/gasparl/k5nx7zvh/67/
var r_start;
function shown() {
    r_start = performance.now();
}
function item_display() {
    var before = performance.now();
    $("#stim_id").show(complete = shown())
    var after = performance.now();
    var text = "before: " + before + "<br>callback RT: " + r_start + "<br>after: " + after + "<br>before vs. callback: " + (r_start - before) + "<br>before vs. after: " + (after - r_start)
    console.log("")
    console.log(text)
    $("p").html(text);
    setTimeout(function(){ $("#stim_id").hide(); }, 500);
}
setInterval(item_display, Math.floor(Math.random() * (1000 - 500 + 1)) + 800);

使用HTML:
<p><br><br><br><br><br></p>
<span id="stim_id">STIMULUS</span>

根据Kaiido的答案提供的解决方案,包括工作示例:

function monkeyPatchRequestPostAnimationFrame() {
  const channel = new MessageChannel();
  const callbacks = [];
  let timestamp = 0;
  let called = false;
  channel.port2.onmessage = e => {
    called = false;
    const toCall = callbacks.slice();
    callbacks.length = 0;
    toCall.forEach(fn => {
      try {
        fn(timestamp);
      } catch (e) {}
    });
  };
  window.requestPostAnimationFrame = function(callback) {
    if (typeof callback !== 'function') {
      throw new TypeError('Argument 1 is not callable');
    }
    callbacks.push(callback);
    if (!called) {
      requestAnimationFrame((time) => {
        timestamp = time;
        channel.port1.postMessage('');
      });
      called = true;
    }
  };
}

if (typeof requestPostAnimationFrame !== 'function') {
  monkeyPatchRequestPostAnimationFrame();
}

function chromeWorkaroundLoop() {
  if (needed) {
    requestAnimationFrame(chromeWorkaroundLoop);
  }
}

// here is how I display items
// includes a 100 ms "warm-up"
function item_display() {
  window.needed = true;
  chromeWorkaroundLoop();
  setTimeout(function() {
    var before = performance.now();
    $("#stim_id").text("Random new text: " + Math.round(Math.random()*1000) + ".");
    $("#stim_id").show();
    // I ask for display above, and get display time below
    requestPostAnimationFrame(function() {
      var rPAF_now = performance.now();
      console.log("before vs. rPAF now:", rPAF_now - before);
      console.log("");
      needed = false;
    });
  }, 100);
}

// below is just running example instances of displaying stuff
function example_loop(count) {
  $("#stim_id").hide();
  setTimeout(function() {
    item_display();
    if (count > 1) {
      example_loop(--count);
    }
  }, Math.floor(Math.random() * (1000 - 500 + 1)) + 500);
}

example_loop(10);
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.2.3/jquery.min.js"></script>
<div id="stim_id">Any text</div>

编辑:根据实际测量,所有这些中,结果是唯一重要的是rAF循环。rPAF没有真正的影响。


1
阅读关于perfomance.now的内容 - 已经采取措施来缓解* Spectre *问题... 即有些浏览器可能也会稍微随机时间戳 - Jaromanda X
不知道你是否能通过随机化获得那种精度水平。 - Jaromanda X
在 before 和 raf 之间相差 4ms 的情况下,我怀疑你无法达到 1ms 的精度。 - Jaromanda X
但我认为这种不一致性与精度无关。正如我所写的,差异可以高达17毫秒(在Chrome中为负),我几乎不认为这可能是一个巧合,我的屏幕帧间隔大约为17毫秒。现在我也在Firefox和IE中进行了测试,请参见我在原始问题中的补充说明。 - gaspar
是的...Spectre已经破坏了计时函数...你需要多少精度?日期可能尚未随机化,考虑到它的低精度,尽管也可能在不久的将来进行随机化...否则,值得一试的是AudioContext的定时。 - Kaiido
显示剩余4条评论
1个回答

10

你所遇到的是Chrome的一个bug(甚至有两个)。

基本上,当requestAnimationFrame回调池为空时,它们会直接在当前事件循环的末尾调用它,而不像规范要求的那样等待实际的绘画帧。

为了解决这个问题,你可以保持一个不断运行的requestAnimationFrame循环,但要注意这将标记你的文档为“动画”,并会触发页面上的一堆副作用(比如在每次屏幕刷新时强制重绘)。所以我不确定你在做什么,但通常不建议这样做,我更愿意邀请你只在必要时运行此动画循环。

let needed = true; // set to false when you don't need the rAF loop anymore

function item_display() {
  var before = performance.now();
  requestAnimationFrame(function(timest) {
    var r_start = performance.now();
    var r_ts = timest;
    console.log("before:", before);
    console.log("RAF callback start:", r_start);
    console.log("RAF stamp:", r_ts);
    console.log("before vs. RAF callback start:", r_start - before);
    console.log("before vs. RAF stamp:", r_ts - before);
    console.log("")
    setTimeout(item_display, Math.floor(Math.random() * (1000 - 500 + 1)) + 500);
  });
}
chromeWorkaroundLoop();
item_display();

function chromeWorkaroundLoop() {
  if (needed) {
    requestAnimationFrame(chromeWorkaroundLoop);
  }
};

现在,requestAnimationFrame回调在下一次绘制之前(实际上在同一个事件循环中)被触发,TimeStamp参数应该代表当前帧的所有主任务和微任务执行完毕后的时间,在开始“更新渲染”的子任务(第9步这里)之前。
[编辑]: 然而,这并不是浏览器实现的真正情况,请参阅这个Q/A获取更多细节。

所以这不是你可以得到的最精确的方式,你有理由使用performance.now()在这个回调中来获得更接近实际绘制时间。

此外,当Chrome面对另一个错误的bug时,可能与第一个错误相关,当他们把这个rAF时间戳设置为... 我必须承认我不知道什么...也许是上一个绘画帧的时间戳。

(function() {
let raf_id,
  eventLoopReport = {
    id: 0,
    timeStamp: 0,
    now: 0
  },
  report = {
    nb_of_loops_between_call_and_start: -1,
    mouseClick_timeStamp: 0,
    calling_task: {
        eventLoop: null,
      now: 0
    },
    rAF_task: {
        eventLoop: null,
      now: 0,
      timeStamp: 0
    }
  };
  
startEventLoopCounter();
  
btn.onclick = triggerSingleFrame;


// increments eventLoop_id at every event loop
// (or at least every time our postMessage loop fires)
function startEventLoopCounter() {
  const channel = new MessageChannel()
  channel.port2.onmessage = e => {
    eventLoopReport.id ++;
    eventLoopReport.timeStamp = e.timeStamp;
    eventLoopReport.now = performance.now();
    channel.port1.postMessage('*');
  };
  channel.port1.postMessage('*');
}

function triggerSingleFrame(e) {
  // mouseClick Event should be generated at least the previous event loop, so its timeStamp should be in the past
    report.mouseClick_timeStamp = e.timeStamp;
    const report_calling = report.calling_task;
  report_calling.now = performance.now();
  report_calling.eventLoop = Object.assign({}, eventLoopReport);

    cancelAnimationFrame(raf_id);
  
    raf_id = requestAnimationFrame((raf_ts) => {
    const report_rAF = report.rAF_task;
        report_rAF.now = performance.now();
    report_rAF.timeStamp = raf_ts;
    report_rAF.eventLoop = Object.assign({}, eventLoopReport);
    report.nb_of_loops_between_call_and_start = report_rAF.eventLoop.id - report_calling.eventLoop.id;
    // this should always be positive
    report_el.textContent = "rAF.timeStamp - mouse_click.timeStamp: " +
            (report.rAF_task.timeStamp - report.mouseClick_timeStamp) + '\n\n' +
      // verbose
        JSON.stringify(report, null, 2) ;
  });
}
})();
<button id="btn">flash</button>
<div id="out"></div>
<pre id="report_el"></pre>

再次强调,运行一个无限的rAF循环将修复这个奇怪的bug。

所以你可能想要检查的一件事是可能传入的requestPostAnimationFrame方法

你可以在Chrome中访问它,1在你启用了"实验性Web平台功能"后,在chrome:flags中。如果这个方法被HTML标准接受,它将允许我们在绘制操作发生之后立即触发回调函数。

从那里开始,你应该会更接近绘画。

var needed = true;
function item_display() {
  var before = performance.now();
  requestAnimationFrame(function() {
    requestPostAnimationFrame(function() {
      var rPAF_now = performance.now();
      console.log("before vs. rPAF now:", rPAF_now - before);
      console.log("");
      setTimeout(item_display, Math.floor(Math.random() * (1000 - 500 + 1)) + 500);
    });
  });
}

if (typeof requestPostAnimationFrame === 'function') {
  chromeWorkaroundLoop();
  item_display();
} else {
  console.error("Your browser doesn't support 'requestPostAnimationFrame' method, be sure you enabled 'Experimental Web Platform features' in chrome:flags");
}

function chromeWorkaroundLoop() {
  if (needed) {
    requestAnimationFrame(chromeWorkaroundLoop);
  }
};

对于还未实现此提案的浏览器,或者如果该提案从技术规范中被删除,你可以尝试使用任务调度 API或MessageEvent来进行polyfill操作,这应该是下一个事件循环中的第一件事情。

// polyfills requestPostAnimationFrame
// requestPostAnimationFrame polyfill
if (typeof requestPostAnimationFrame !== "function") {
  // Either use the Task Scheduling API if available,
  // or fallback to a MessageChannel
  const postTask = (cb) => {
    if (globalThis.scheduler?.postTask) {
      return scheduler.postTask(cb, { priority: "user-blocking" });
    }
    return new Promise((resolve, reject) => {
      const { port1, port2 } = postTask.channel ??= new MessageChannel();
      port1.addEventListener("message", () => {
        try {
          resolve(cb());
        }
        catch(err) {
          reject(err);
        }
      }, { once: true });
      port1.start();
      port2.postMessage("");
    });
  }
  const callbacks = [];
  let timestamp = 0;
  let called = false;
  let scheduled = false; // to make it work from rAF
  let inRAF = false; // to make it work from rAF

  const afterFrame = () => {
    called = false;
    const toCall = callbacks.slice();
    callbacks.length = 0;
    toCall.forEach(fn => {
      try {
        fn(timestamp);
      } catch (err) {
        reportError(err);
      }
    });
  }
  // We need to overwrite rAF to let us know we are inside an rAF callback
  // as to avoid scheduling yet an other rAF, which would be one painting frame late
  // We could have hooked an infinite loop on rAF, but this means
  // forcing the document to be animated all the time
  // which is bad for perfs
  const rAF = globalThis.requestAnimationFrame;
  globalThis.requestAnimationFrame = function(...args) {
    if (!scheduled) {
      scheduled = true;
      rAF.call(globalThis, (time) => inRAF = time);
      globalThis.requestPostAnimationFrame(() => {
        scheduled = false;
        inRAF = false;
      });
    }
    rAF.apply(globalThis, args);
  };
  globalThis.requestPostAnimationFrame = function(callback) {
    if (typeof callback !== "function") {
      throw new TypeError("Argument 1 is not callable");
    }
    callbacks.push(callback);
    if (!called) {
      if (inRAF) {
        timestamp = inRAF;
        postTask(afterFrame);
      } else {
        requestAnimationFrame((time) => {
          timestamp = time;
          postTask(afterFrame);
        });
      }
      called = true;
    }
  };
}

var needed = true;

function item_display() {
  var before = performance.now();
  requestPostAnimationFrame(function() {
    var rPAF_now = performance.now();
    console.log("before vs. rPAF now:", rPAF_now - before);
    console.log("");
    setTimeout(item_display, Math.floor(Math.random() * (1000 - 500 + 1)) + 500);
  });
}


chromeWorkaroundLoop();
item_display();

function chromeWorkaroundLoop() {
  if (needed) {
    requestAnimationFrame(chromeWorkaroundLoop);
  }
};

原来这个功能似乎已经从Chrome实验中删除了。查看实现问题,我找不到为什么、何时以及他们是否计划继续处理它。

非常感谢@Kaiido,这是一个很棒的答案,如果我没有更好的答案(例如,如果可以无需解决方法获取时间),您将获得悬赏。只有一件相当令人困扰的事情:在我的Chrome中使用"before vs. RAF callback start:"和"before vs. RAF stamp:"时,我看到的是逐渐减少的数字,例如14毫秒,12,8,5,2,然后再次是15,13等。由于间隔是随机的,这应该也是随机的,对吧?您有任何解释吗?或者您是否有任何明确证明这些数字真正反映了重绘时间的方法? - gaspar
还有两个小问题,虽然它们并不是很重要。首先,我非常怀疑你提到的“抖动”是否有任何影响:正如我在另一条评论中提到的那样,对于 (i = 0; i < 19; i++) { console.log(performance.now()); },连续的数字都具有亚毫秒精度,这似乎证明 performance.now() 至少具有毫秒级别的精度。其次,我不能使用本地修改,因为重新绘制的目的是用于在线心理实验,在该实验中,每个用户都有自己的 PC。(尽管我通常不允许使用除 Chrome 之外的任何浏览器。) - gaspar
@gaspar 在抖动方面,你是完全正确的,这让我意识到在早期调用时rAF timeStamp完全错误。另外,我猜你最好至少使用答案末尾可用的requestPostAnimationFrame的monkey-patch。 然而,在退化方面,这有点正常,我们不是在运行随机间隔,每次迭代时间隔虽然设置为随机值,但是相同,所以线性变化是可以预期的。我已经在我的回答中为你修复了它。 - Kaiido
至于剩下的部分,非常棒,我已经接受了这个答案。不过,您能否请看一眼我提供的解决方案(在问题中),如果我从您的倒数第二个代码片段中正确地实现了您的函数?基本上,我只是在每个显示开始时以100毫秒的“预热”chromeWorkaroundLoop(),并在显示后通过needed = false关闭它 - 这样循环rAF就不会干扰其余脚本。 - gaspar
1
@gaspar 是的,那确实是正确的方式。rPAF_now 应该是我们可以得到最接近绘画操作的时间。你的预热实现很好,并且在需要时正确地降低了标志。干得好。(现在,如果您想知道绘画大约需要多长时间,您需要在 rAF 回调中包装您的调用方法:https://jsfiddle.net/wx17byL6/,但我不确定您是否感兴趣,如果您感兴趣,那么您可能希望让用户从其浏览器的开发工具中运行性能记录。) - Kaiido
显示剩余4条评论

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