gkjohnson / js-framerate-optimizer

Library for tracking and iteratively improving page framerate over time
https://gkjohnson.github.io/js-framerate-optimizer/example/
MIT License
32 stars 4 forks source link

Throttled Request Animation Frame #38

Open jteppinette opened 7 months ago

jteppinette commented 7 months ago

I am wondering if you have had any issues with throttled requestAnimationFrame loops. iOS Safari will throttled the RAF loop to 30fps when in low power mode or cross origin iframes pre-interaction.

I found out about this the hard way when all of my optimizations were applied attempting to reach 60fps when 30fps was the hard limit.

I attempted to remedy this by moving from optimizer.update() to optimizer.begin(); composer.render(), optimizer(), but of course the composer.render() turned out to not actually block until render which I should have known about...

Anyways, just wondering if you had any thoughts on a solution. Perhaps we could add something to the README here.

Possible Solutions

gkjohnson commented 7 months ago

Hey! When I made this it was designed for deskop application browsers that were capped at 60 - now we have laptops running at 120 fps, as well 😅. And yeah a lot of devices will cap framerates based on battery state, too.

Unfortunately the browser doesn't provide a way to detect its current running rate (it would be really nice if it did) which means you can't easily switch to use the appropriate target framerate for the page. My first thought is that you could run the rAF loop for some set of frames (ie for 1 second) with low load to detect how long the frame time is when running at top speeds, and then round to the nearest standard framerate (30, 60, 120).

This would mean you can't be responsive to sudden changes in max browser framerate - ie sudden changes to low framerate due to battery drain (or being plugged in) - but at least the optimization steps can run with an appropriate target framerate before settling.

jteppinette commented 7 months ago

@gkjohnson My thought on this is to have a separate loop that is always tracking rAF deltas, and to pump up the target framerate as I see faster rAF deltas (start at 30fps / cap at 60fps). Similar to your idea, just keeping it running all the time.

gkjohnson commented 7 months ago

Unfortunately just adding a second rAF loop won't work because JS is single threaded. rAF will only fire as fast as the browser is drawing so if rendering is blocked by the GPU or other rAF loop the second wont fire and you'll see the same slowed framerate.

It does seem that rAF is callable in a WebWorker, though, which may not be affected by the main thread framerate. If it's not then you could calculate the framerate in a worker and pass it to the main thread. However it may be the case that the browser tries to align the rate at which all rAF functions get called so this may not help, so it's worth checking. If you want to give it a try with the main thread under heavy load please let me know how it works.

Here's a quick example of setting it up. I don't know what the browser support for rAF in a worker is like, either, tho.

const blob = new Blob( [ /* js */`
  let lastTime = performance.now();
  iterate();
  function iterate() {

    requestAnimationFrame( iterate );

    const delta = performance.now() - lastTime;
    lastTime = performance.now();
    console.log( 'FPS', 1000 / delta );

  }
` ] );

const url = URL.createObjectURL( blob );
const worker = new Worker( url );
jteppinette commented 7 months ago

That's a great idea. I'll play with it.

The solution is working currently even with the sync nature of JS just because my application quickly pauses and during that pause period (with no updates) - it can use that time to calculate the max rAF.

gkjohnson commented 7 months ago

Sound good - it would be nice to have a general solution, too, and if it works it might be nice to add as a utility to the project or at least recommend in the documentation.

jteppinette commented 7 months ago

This is what I am using right now. I am using gsap to manage the runtime here. It will work even during constant rendering as long as it gets over 5% 30fps.

The most important part is the continuallyRefine is required. Also, you'll notice that I bump up all the interval/wait/etc.. my framerate is so all over the place even on a really good phone that these are necessary.

I will try the worker route and let you know.

import { Optimizer } from 'framerate-optimizer'
import { gsap } from 'gsap'

const TARGET_FRAMERATE = 60

gsap.ticker.fps(TARGET_FRAMERATE)

// Create a rAF callback which continuously checks
// for the max framerate. Increase the optimizer target
// until we hit 60FPS. This is the only way to support
// iOS low battery mode and pre-interaction cross origin
// iframes.

const SAMPLES = 30

// You have to start the target framerate at 30, since some devices
// will never support higher than 30fps rAF loop.
const optimizer = new Optimizer({
  waitMillis: 250,
  interval: 1000,
  targetFramerate: 30,
  continuallyRefine: true,
  margin: 0.2
})

let sum = 0
let frames = 0

function check(_, dt) {
  sum += dt
  frames++

  if (frames < SAMPLES) return

  const avg = sum / frames
  if (
    optimizer.options.targetMillis - avg <
    optimizer.options.targetMillis * 0.05
  ) {
    sum = 0
    frames = 0
    return
  }

  optimizer.options.targetFramerate = TARGET_FRAMERATE
  gsap.ticker.remove(check)
}

gsap.ticker.add(check)

export { optimizer }