Search code examples
javascriptrequestanimationframe

requestAnimationFrame seems to update variables after cancelAnimationFrame is called


I'm trying to animate a camera rotation using MapBoxGL, while providing the option to pause the rotation and restart the rotation with a checkbox callback. The 'pause'/'stop' rotation works fine, but the 'restart' seems to pick up the animation where the it should have been if it was never paused, as opposed to picking up where the animation stopped.

var animation;

function rotateCamera(timestamp) {
    map.rotateTo((timestamp / 600) % 360, {duration: 0});
    animation = requestAnimationFrame(rotateCamera);
}

When the map loads, the animation is called with:

animation = rotateCamera(0);

The callback looks like this:

d3.selectAll("input[name='camerarotation-selection']").on("change", function() {
    if (d3.select("input[name='selection']").property("checked")) {
        rotateCamera(map.getBearing());
    } else {
        cancelAnimationFrame(animation);
    }
});

If I console.log the timestamp var inside the rotateCamera function, I can see that despite the cancelAnimationFrame call, it continues to be incremented. I have tried declaring animation to be undefined upon a restart, and that doesn't seem to work either. I'm stumped! Thanks for your help.


Solution

  • The timestamp passed to the callback of requestAnimationFrame is an DOMHighResTimestamp, similar to the one returned by performance.now(). This timestamp indicates the number of milliseconds that elapsed since the beginning of the current document's lifetime (well it can be a bit more complicated) when the callbacks execution started.

    So even when no requestAnimationFrame loop is running, this timestamp indeed increments, just like Date.now() also does.

    let animation = 0;
    inp.onchange = e => {
      if (inp.checked) start();
      else {
        cancelAnimationFrame(animation);
      }
    };
    
    function start(timestamp) {
      _log.textContent = timestamp;
      animation = requestAnimationFrame(start);
    }
    <input type="checkbox" id="inp">
    <pre id="_log"></pre>

    In your code, you will probably want to only keep the time that elapsed since last frame. To do so, you can simply save timestamp in a variable that is globally available, and then in your callback do

    var elapsed = timestamp - last_frame;
    last_frame = timestamp;
    

    And remember to also take care of the resume case, where timestamp will be undefined and elapsed should be reset.



    Now, I'd like to point out that your description of the problem could also indicate an other problem entirely: you could have more than a single loop running simultaneously.

    Since you are using a single animation variable to hold the frame_id (used by cancelAnimationFrame), if you do call rotateCamera while a loop is already running, the first frame_ids will get lost, and their rAF loop will indeed continue.

    let common_id = 0; // OP's `animation` variable
    let first_id = 0;
    let second_id = 0;
    
    function loop1(timestamp) {
      common_id = first_id = requestAnimationFrame(loop1);
      log_1.textContent = "loop 1: " + timestamp;
    }
    
    function loop2(timestamp) {
      common_id = second_id = requestAnimationFrame(loop2);
      log_2.textContent = "loop 2: " + timestamp;
    }
    
    btn.onclick = e => {
      console.log("first loop's id", first_id);
      console.log("second loop's id", second_id);
      console.log('clearing common_id', common_id);
      cancelAnimationFrame(common_id);
    }
    
    loop1();
    loop2();
    <button id="btn">stop the loop</button>
    
    <pre id="log_1"></pre>
    <pre id="log_2"></pre>

    I think it is possible in your code, since input[name='camerarotation-selection'] could change multiple times, when input[name='selection'] had not chnaged, or even since input[name='camerarotation-selection'] could be multiple elements.

    To avoid that, you could keep a semaphore variable allowing you to know if the loop is running or not, and to only start it when it's not. Or you could even get rid entirely of cancelAnimationFrame by using only one semaphore, and exiting early in the rAF callback:

    let stopped = true;
    
    function loop(timestamp) {
      if (stopped) return; // exit early
      requestAnimationFrame(loop);
      log.textContent = timestamp;
    }
    // you can click it several times
    btn_start.onclick = e => {
      if (stopped === true) { // only if not running yet
        stopped = false;
        requestAnimationFrame(loop);
      }
    }
    btn_stop.onclick = e => {
      stopped = true; // deal only with the semaphore
    };
    
    btn_switch.onclick = e => {
      stopped = !stopped;
      if (stopped === false) { // we were paused
        // start again
        requestAnimationFrame(loop);
      }
    }
    <button id="btn_start">start the loop</button>
    <button id="btn_stop">stop the loop</button>
    <button id="btn_switch">switch the loop</button>
    
    <pre id="log"></pre>