Search code examples
javascriptperformancebrowserwebgl

Can requestAnimationFrame be called from input events and still respect refresh rate?


I want to optimize redraws in my WebGL application. A redraw is only needed, when the user modifies the camera position or rotation. Everything else is static. I can keep calling requestAnimationFrame() with the classic js game loop, check for changes to the camera and only redraw if needed.

function animate() {
    requestAnimationFrame(animate);

    if (compare_camera_with_previous_frame()) {
        render();
    }
}

This works and the GPU only does drawing when the a redraw is really needed. However, this still marks my WebApp as animated, requestAnimationFrame() is called 60 times a second, doing essentially nothing except check for a change in the game state.

Another option is to remove requestAnimationFrame(animate); from animate() loop and call requestAnimationFrame(animate); from the touch event. However, this leads to drawing happening at hundreds of FPS, as animate() is being called on each touch event.

In C with DesktopGL and SDL2 or with GLFW I am used to call WaitEvents() instead of PollEvents(). With V-Sync on, this solves everything perfectly. Logic being processed only when an input is performed, whilst not exceeding refresh rate. I want to translate such a behavior into Javascript + WebGL.

How can I solve this? How to only request frames to be drawn when events happen, yet not exceed the refresh rate set by requestAnimationFrame(); being called in a loop?


Solution

  • Stop running your animation loop if there's nothing to animate:

    function animate() {
      // return early if there's nothing to do, halting the animation loop:
      if (!state.stateUpdated) return;
    
      // soft-lock our state so that values will get written to
      // our "pending state" object instead of the real state, to
      // prevent concurrent modification.
      pendingState = {};
    
      // then resolve a frame:
      doSomethingThisFrame();
    
      // and then apply any pending state changes that
      // occurred while we were busy during the frame.
      applyPendingStateChanges();
    }
    
    function doSomethingThisFrame() { 
      // ...do all the things...
    
      // mark the state as handled:
      state.stateUpdated = false;
    
      // and schedule the next frame:
      requestAnimationFrame(animate);
    }
    

    And then you can set that flag to to true and then call animate() as part of updating the state during during input event etc. so that your code will start to run again for as long as there's something to animate. And the moment there isn't, that early return will revert the page to a static page again.

    Of course, you'll want to make sure that you're not concurrently updating state while you're busy work on a frame, so you'll want to make sure to have a "pending state" variable that can both act as soft lock, and that you can write values to while the real state is locked:

    const state = { stateUpdated: false };
    let pendingState = undefined;
    
    function updateState(name, value) {
      // apply this update either directly, or "for later":
      const target = pendingState ?? state;
      target[name] = value;
      target.stateUpdated = true;
      // and if we applied it directly, call animate.
      if (target === state) animate();
    }
    
    function applyPendingStateChanges() {
      if (!pendingState) throw new Error(`apply pending called without pending state`);
      Object.assign(state, pendingState);
      pendingState = undefined;
      if (state.stateUpdated) animate();
    }