playcanvas / engine

JavaScript game engine built on WebGL, WebGPU, WebXR and glTF
https://playcanvas.com
MIT License
9.59k stars 1.34k forks source link

Add 'target framerate' API #4597

Open yaustar opened 2 years ago

yaustar commented 2 years ago

We should give users the ability to try to target a framerate that's independent from the monitor refresh rate.

Having the ability to cap at 60 FPS in the world where more and more monitors are now high refresh rate would really help some developers ensure a consistent experience between users.

Along the same lines, being able to target a lower framerate (eg 30fps) would help with reducing battery drain on mobile devices and also help the experience with users on lower end devices

mvaligursky commented 2 years ago

duplicate of https://github.com/playcanvas/engine/issues/3370

kungfooman commented 2 years ago

Maybe this can be the opposite of @LeXXik's issue: lets say a player/user wants 125 FPS on a 60 Hz screen, is that possible somehow aswell?

Its a field called "trickjumping", that requires special FPS to work out: https://www.google.com/search?q=125+fps+trick+jumping

Probably it doesn't make sense to render 125 FPS on a 60 Hz screen, only the physics/player simulation would require that.

Its just that some engines knit render/physics together, and then I guess it would make sense for the sake of porting such game engines.

yaustar commented 2 years ago

Not sure if that's possible in the browser as we are limited by requestAnimation I believe? I don't think we can a smaller polling step than that @kungfooman

kungfooman commented 2 years ago

Not sure if that's possible in the browser as we are limited by requestAnimation I believe?

I made a little script to test this out. First, using PlayCanvas Extras for pcx.MiniStats:

<script src="http://127.0.0.1/playcanvas-engine/build/playcanvas-extras.js"></script>
<script>
  const miniStats = new pcx.MiniStats(app);
</script>

Two little helper functions:

const originalRequestAnimationFrame = window.requestAnimationFrame;
function setTargetFPS(fps) {
  window.requestAnimationFrame = cb => setTimeout(cb, 1000 / fps);
}
function clearTargetFPS() {
  window.requestAnimationFrame = originalRequestAnimationFrame;
}

My default screen Hz is 60 Hz (1000 / 16.7ms ~= 59.88 FPS):

image

setTargetFPS(10);

image

setTargetFPS(200);

image

11ms is 1000 / 11ms ~= 90.91 FPS, which is the maximum FPS I can set using setTimeout. However, yesterday I made a little test via setInterval (without PlayCanvas "load"), and I calculated that 250 FPS should be possible (4ms minimum delay). So there is at least one way to aim for higher FPS through setTimeout and setInterval could be able to go for higher FPS.

To use requestAnimationFrame again:

clearTargetFPS();
yaustar commented 2 years ago

IIRC, setTimeout/setInterval isn't that precise? It be interesting to try regardless and see if the deltatime is stable.

I was reading this article about how they were managing their tick which what got me to write the ticket: https://blog.flevar.com/the-making-of-flevar?utm_source=pocket_mylist#heading-custom-function

I also wonder how often the input called be polled/if input events can be received faster than the monitor refresh rate.

kungfooman commented 2 years ago

@yaustar Interesting, I just took makeTick and rewrote it a little bit, so it can be pasted into devtools and used with setInterval:

// static data
const _frameEndData = {};
function tickForInterval() {
    const application = app;
    const frame = undefined;
    const {now, math, Debug} = pc;
    const TRACEID_RENDER_FRAME = 'RenderFrame';
    if (!app.graphicsDevice)
        return;
    const currentTime = application._processTimestamp() || now();
    const ms = currentTime - (application._time || currentTime);
    let dt = ms / 1000.0;
    dt = math.clamp(dt, 0, application.maxDeltaTime);
    dt *= application.timeScale;
    application._time = currentTime;
    if (application.graphicsDevice.contextLost)
        return;
    application._fillFrameStatsBasic(currentTime, dt, ms);
    // #if _PROFILER
    application._fillFrameStats();
    // #endif
    application._inFrameUpdate = true;
    application.fire("frameupdate", ms);
    if (frame) {
        application.xr?.update(frame);
        application.graphicsDevice.defaultFramebuffer = frame.session.renderState.baseLayer.framebuffer;
    } else {
        application.graphicsDevice.defaultFramebuffer = null;
    }
    application.update(dt);
    application.fire("framerender");
    Debug.trace(TRACEID_RENDER_FRAME, `--- Frame ${application.frame}`);
    if (application.autoRender || application.renderNextFrame) {
        application.updateCanvasSize();
        application.render();
        application.renderNextFrame = false;
    }
    // set event data
    _frameEndData.timestamp = now();
    _frameEndData.target = application;
    application.fire("frameend", _frameEndData);
    application._inFrameUpdate = false;
    if (application._destroyRequested) {
        application.destroy();
    }
}

It can be called like this (first line is killing the default tick):

app.tick = () => {};
lastId = setInterval(tickForInterval, 100);

Successive calls need to clear the interval id:

clearInterval(id);
lastId = setInterval(tickForInterval, 0.1);

I am still stuck at 90 FPS, but maybe your Web Worker idea could help with a higher FPS (and a precisely timed lower FPS).

I can also imagine that every browser does these things a little bit different implementation-wise.

It is very hard for me to tell the difference between "setTimeout 60 FPS" and "requestAnimationFrame 60 FPS", but anything lower (lets say 50 FPS) feels immediately quite clunky, at least when the game is a bit fast-paced (I am testing in a first-person shooter).

Maksims commented 2 years ago

rAF - ensures to run a callback as close as possible before screen refresh, ensuring least latency from JS to actual screen. Anything related to rendering, should use rAF. Best way to limit framerate reliably, would be by skipping application update and render inside of some rAF callbacks, but not by using timeouts and intervals.