Search code examples
javascriptsettimeouttimingrequestanimationframeexperimental-design

Replacing setTimeout() with requestAnimationFrame()


I am a PhD student in experimental psychology and due to COVID-19, we have to switch all our experiments online. I also don't know Javascript very well.

The problem is that we usually present stimuli for a short duration (e.g. 200ms) and we need the least amount of variability so we usually sync with the monitor refresh rate.

My limited understanding of Javascript is that setTimeout() is not tied to monitor frames (so a stimulus that should be displayed for 200ms could actually be on the screen longer than this duration), and that requestAnimationFrame() would be more precise. However, I've spent the last few days trying to understand how to use requestAnimationFrame() instead of setTimeout() but to no avail, all the tutorials I found were for displaying animated stimuli. Here's the snippet of code I use right now for handling my experiment's flow.

setTimeout(function() {
    generateTrial(current_trial);
    $fixation.show();
    setTimeout(function() {
        $fixation.toggle();
        $presentation.toggle();
        setTimeout(function() {
            $presentation.toggle();
            $fixation.toggle();
            setTimeout(function() {
                ShowContinuousReport(current_trial);
            }, 995);
        }, 195);
    }, 995);
}, 495);

Would you have an idea on how to convert all these nasty setTimeout() to requestAnimationFrame() (or at least something better than setTimeout())? :)

I use HTML5 canvases ($presentation and $fixation) that are drawn during generateTrial(current_trial).

Thank you for your help!

Best regards, Martin Constant.


Solution

  • setTimeout indeed is not in sync with the frame refresh rate, it will even have some throttling applied on it, and may be delayed by the browser if they decide an other task was more important (e.g, they may prefer to fire an UI event if it happens exactly at the same time the setTimeout was supposed to resolve, and calling it in a recursive loop will always accumulate some drift time.

    So setTimeout is not reliable to animate visual content smoothly.

    On the other hand, requestAnimationFrame will schedule a callback to fire in the next painting frame, generally in sync with the screen refresh rate.

    requestAnimationFrame is the perfect tool to animate visual content smoothly.

    But here you are not animating visual content smoothly.

    The screen refresh rate we are talking about is on the vast majority of devices 60Hz, that is 16.67ms per frame.
    Your timeouts are set to 995ms 195ms, and 495ms. The smallest interval there (195ms) corresponds approximately to a 12Hz frequency, the biggest is almost 1Hz.

    What you are doing is scheduling tasks, and for this, setTimeout is the best.

    If you really need it to be as precise as possible for a long run, then incorporate a drift correction logic in your loop:

    You record the starting time, then at each step, you check how much drift there was, and you adjust the next timeout accordingly:

    Here is a basic example, based on your case, but it might be quite hard to get the usefulness of that drift correction on such a small sample, still pay attention on how the drift corrected version is able to reduce the drift, while in the non-corrected one, it will always add up.

    const delays = [ 495, 995, 195, 995 ];
    
    setTimeout(() => {
    console.log( 'testing with drift correction' );
    const start_time = performance.now();
    let expected_time = start_time;
    setTimeout( () => {
      // do your things here
      const now = performance.now();
      expected_time += delays[ 0 ];
      const drift = now - expected_time;
      setTimeout( () => {
        const now = performance.now();
        expected_time += delays[ 1 ];
        const drift = now - expected_time;
        setTimeout( () => {
          const now = performance.now();
          expected_time += delays[ 2 ];
          const drift = now - expected_time;
          setTimeout( () => {
            const now = performance.now();
            expected_time += delays[ 3 ];
            const drift = now - expected_time;
            console.log( 'last step drift corrected:', drift );
          }, delays[ 3 ] - drift );
          console.log( 'third step drift corrected:', drift );
        }, delays[ 2 ] - drift );
        console.log( 'second step drift corrected:', drift );
      }, delays[ 1 ] - drift );
      console.log( 'first step drift corrected:', drift );
    }, delays[ 0 ] );
    
    }, 100 );
    
    setTimeout( () => {
    
    console.log( 'testing without drift correction' );
    const start_time = performance.now();
    let expected_time = start_time;
    
    setTimeout( () => {
      // do your things here
      const now = performance.now();
      expected_time += delays[ 0 ];
      const drift = now - expected_time;
      setTimeout( () => {
        const now = performance.now();
        expected_time += delays[ 1 ];
        const drift = now - expected_time;
        setTimeout( () => {
          const now = performance.now();
          expected_time += delays[ 2 ];
          const drift = now - expected_time;
          setTimeout( () => {
            const now = performance.now();
            expected_time += delays[ 3 ];
            const drift = now - expected_time;
            console.log( 'last step drift not corrected:', drift );
          }, delays[ 3 ] );
          console.log( 'last step drift not corrected:', drift );
        }, delays[ 2 ] );
        console.log( 'last step drift not corrected:', drift );
      }, delays[ 1 ] );
      console.log( 'last step drift not corrected:', drift );
    }, delays[ 0 ] );
    }, 3000 );