Limiting framerate in Unity WebGL builds that use emscripten

548 views
Skip to first unread message

Dominik Klooz

unread,
Aug 28, 2023, 10:57:38 AM8/28/23
to emscripten-discuss
Hello,

I hope this is deemed on-topic, as I don't have experience with emscripten directly, but a problem in WebGL applications made with Unity (which uses emscripten) has brought me here. I have already submitted a bug report on the Unity side which has since been accepted, but as there is no ETA for a fix from their side and I have WebGL content online that is affected, I'm trying to see if a workaround can be found / I can speed up that process. Since my WebGL builds have been online and untouched for a few months, but only recently started to show different behavior, I assume it's not simply a regression on the Unity side and thus something that could possibly be fixed in the code Unity emits for a WebGL build (as opposed to in Unity's internal code).

The "problem" is simple: I want to cap the frame rate at 30 frames per second, so as to not put unnecessary pressure on the GPU. This used to work fine, but for some time now old and new builds instead try to match the screen refresh rate (which often makes especially laptop fans spin up audibly). This doesn't give a good impression, especially since my content would look perfectly fine at 30 fps and my target audience may not have gaming-grade hardware.

I tried to look through the emscripten code Unity produces when building a WebGL app and added logging to isolate sections that might be of interest, but I'll link to the complete un-minified framework code Unity emits as well.

Here are the functions setMainLoop and _emscripten_set_main_loop_timing, which I suspect are relevant. I added comments in bold and red highlighting the order and content of the console log messages: 
  1. function _emscripten_set_main_loop_timing(mode, value) {
  2.     console.log("_emscripten_set_main_loop_timing called with " + mode + ", " + value); // 2: _emscripten_set_main_loop_timing called with 0, 33.333333333333336
  3.     Browser.mainLoop.timingMode = mode;
  4.     Browser.mainLoop.timingValue = value;
  5.     if (!Browser.mainLoop.func) {
  6.         return 1;
  7.     }
  8.     if (!Browser.mainLoop.running) {
  9.         Browser.mainLoop.running = true;
  10.     }
  11.     if (mode == 0) {
  12.         console.log("setting timeout"); // 3: called next
  13.         Browser.mainLoop.scheduler = function Browser_mainLoop_scheduler_setTimeout() {
  14.             var timeUntilNextTick = Math.max(0, Browser.mainLoop.tickStartTime + value - _emscripten_get_now()) | 0;
  15.             console.log("timeUntilNextTick: " + timeUntilNextTick); // 4: called regularly with timeUntilNextTick always around 32
  16.             setTimeout(Browser.mainLoop.runner, timeUntilNextTick);
  17.         };
  18.         Browser.mainLoop.method = "timeout";
  19.     } else if (mode == 1) {
  20.         Browser.mainLoop.scheduler = function Browser_mainLoop_scheduler_rAF() {
  21.             Browser.requestAnimationFrame(Browser.mainLoop.runner);
  22.         };
  23.         Browser.mainLoop.method = "rAF";
  24.     } else if (mode == 2) {
  25.         if (typeof setImmediate === "undefined") {
  26.             var setImmediates = [];
  27.             var emscriptenMainLoopMessageId = "setimmediate";
  28.             var Browser_setImmediate_messageHandler = function (event) {
  29.                 if (event.data === emscriptenMainLoopMessageId || event.data.target === emscriptenMainLoopMessageId) {
  30.                     event.stopPropagation();
  31.                     setImmediates.shift()();
  32.                 }
  33.             };
  34.             addEventListener("message", Browser_setImmediate_messageHandler, true);
  35.             setImmediate = function Browser_emulated_setImmediate(func) {
  36.                 setImmediates.push(func);
  37.                 if (ENVIRONMENT_IS_WORKER) {
  38.                     if (Module["setImmediates"] === undefined) Module["setImmediates"] = [];
  39.                     Module["setImmediates"].push(func);
  40.                     postMessage({ target: emscriptenMainLoopMessageId });
  41.                 } else postMessage(emscriptenMainLoopMessageId, "*");
  42.             };
  43.         }
  44.         Browser.mainLoop.scheduler = function Browser_mainLoop_scheduler_setImmediate() {
  45.             setImmediate(Browser.mainLoop.runner);
  46.         };
  47.         Browser.mainLoop.method = "immediate";
  48.     }
  49.     return 0;
  50. }
  51.  
  52. function setMainLoop(browserIterationFunc, fps, simulateInfiniteLoop, arg, noSetTiming) {
  53.     assert(!Browser.mainLoop.func, "emscripten_set_main_loop: there can only be one main loop function at once: call emscripten_cancel_main_loop to cancel the previous one before setting a new one with different parameters.");
  54.     Browser.mainLoop.func = browserIterationFunc;
  55.     Browser.mainLoop.arg = arg;
  56.     var thisMainLoopId = Browser.mainLoop.currentlyRunningMainloop;
  57.     function checkIsRunning() {
  58.         if (thisMainLoopId < Browser.mainLoop.currentlyRunningMainloop) {
  59.             maybeExit();
  60.             return false;
  61.         }
  62.         return true;
  63.     }
  64.     Browser.mainLoop.running = false;
  65.     Browser.mainLoop.runner = function Browser_mainLoop_runner() {
  66.         if (ABORT) return;
  67.         if (Browser.mainLoop.queue.length > 0) {
  68.             var start = Date.now();
  69.             var blocker = Browser.mainLoop.queue.shift();
  70.             blocker.func(blocker.arg);
  71.             if (Browser.mainLoop.remainingBlockers) {
  72.                 var remaining = Browser.mainLoop.remainingBlockers;
  73.                 var next = remaining % 1 == 0 ? remaining - 1 : Math.floor(remaining);
  74.                 if (blocker.counted) {
  75.                     Browser.mainLoop.remainingBlockers = next;
  76.                 } else {
  77.                     next = next + 0.5;
  78.                     Browser.mainLoop.remainingBlockers = (8 * remaining + next) / 9;
  79.                 }
  80.             }
  81.             console.log('main loop blocker "' + blocker.name + '" took ' + (Date.now() - start) + " ms");
  82.             Browser.mainLoop.updateStatus();
  83.             if (!checkIsRunning()) return;
  84.             console.log("Setting timeout to 0");
  85.             setTimeout(Browser.mainLoop.runner, 0);
  86.             return;
  87.         }
  88.         if (!checkIsRunning()) return;
  89.         Browser.mainLoop.currentFrameNumber = (Browser.mainLoop.currentFrameNumber + 1) | 0;
  90.         if (Browser.mainLoop.timingMode == 1 && Browser.mainLoop.timingValue > 1 && Browser.mainLoop.currentFrameNumber % Browser.mainLoop.timingValue != 0) {
  91.             console.log("Browser.mainLoop.timingMode == 1");
  92.             Browser.mainLoop.scheduler();
  93.             return;
  94.         } else if (Browser.mainLoop.timingMode == 0) {
  95.             console.log("Browser.mainLoop.timingMode == 0");
  96.             Browser.mainLoop.tickStartTime = _emscripten_get_now();
  97.         }
  98.         GL.newRenderingFrameStarted();
  99.         Browser.mainLoop.runIter(browserIterationFunc);
  100.         if (!checkIsRunning()) return;
  101.         if (typeof SDL === "object" && SDL.audio && SDL.audio.queueNewAudioData) SDL.audio.queueNewAudioData();
  102.         Browser.mainLoop.scheduler();
  103.     };
  104.     if (!noSetTiming) {
  105.         console.log("fps: " + fps); // 1: called once with fps: 0
  106.         fps = 30; // manually added in case that was the problem, it's not
  107.         if (fps && fps > 0) _emscripten_set_main_loop_timing(0, 1e3 / fps);
  108.         else _emscripten_set_main_loop_timing(1, 1);
  109.         Browser.mainLoop.scheduler();
  110.     }
  111.     if (simulateInfiniteLoop) {
  112.         throw "unwind";
  113.     }
  114. }
Here's a link to a pastebin with those functions, in case the formatting is bad: https://pastebin.com/FdeQPacv
And the whole framework code (minus some unrelated obfuscated stuff to stay below pastebin's 512KB limit): https://pastebin.com/0t4acuka

The values set for Browser.mainLoop.timingMode and Browser.mainLoop.timingValue and the resulting timeUntilNextTick all look right to me.

Is it possible that browser updates or maybe some dynamically loaded code have changed in the last months to make something here no longer work as it used to? The behavior is the same in Edge, Brave and Firefox.
My hope is that someone with emscripten / browser shenanigans experience has an idea what changed or how I could get back to a limited frame rate. I appreciate any insights!


Thank you for your time,

Dominik

Alon Zakai

unread,
Aug 28, 2023, 2:44:39 PM8/28/23
to emscripte...@googlegroups.com
I'm not sure what could have changed to affect this, but you can probably "monkey-patch" to work around it if you want. Specifically if you replace the default setTimeout and requestAnimationFrame methods (probably just the latter is needed) with ones that cap to 30 fps, you should be ok. That is, something like

var real = requestAnimationFrame;
requestAnimationFrame = (callback) => {
  var delay = ..compute the right delay to slow down to 30fps.
  setTimeout(() => { real(callback) }, delay);
};

--
You received this message because you are subscribed to the Google Groups "emscripten-discuss" group.
To unsubscribe from this group and stop receiving emails from it, send an email to emscripten-disc...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/emscripten-discuss/f487f253-f75e-4159-b770-9265f4705b7cn%40googlegroups.com.

Jukka Jylänki

unread,
Aug 28, 2023, 3:59:30 PM8/28/23
to emscripte...@googlegroups.com
Adjusting render frame rate from the default requestAnimationFrameRate() is unfortunately brittle and unreliable across browsers. You can read more about it in https://github.com/whatwg/html/issues/8031

Unfortunately there has not been much progress in that area.

Unity implements frame rate control as native refresh rate (requestAnimationFrame) if you set 0 (default for Unity projects) or 60 as the refresh rate for the Application.

If you set a value that divides into 60 evenly (that is, 30, 20, 15, 10, ...), then Unity will use requestAnimationFrame (EM_TIMING_RAF) with decimation, i.e. it will render only every Nth tick of the main loop. This decimation implementation is provided by the emscripten_set_main_loop_timing mechanism in Emscripten.

If you specify any other value, then EM_TIMING_SETTIMEOUT timing mode is used, which results in a setTimeout() loop being implemented to target the given frame rate.

Unfortunately none of these methods works well on the web due to the whatwg issue 8031, so it is a "pick your bugs" situation.

If you use EM_TIMING_RAF without decimation, you will get whatever the native refresh rate of the display is, i.e.  60hz, 90hz, 120hz, 144hz or so on. So that does not allow you to customize a specific target.

Likewise if you use EM_TIMING_RAF with decimation, since it is not possible to query the display refresh rate from JavaScript, Emscripten/Unity assume that all displays are 60hz. So this means if you set 30 hz as target, and user is on a 144hz, then Emscripten will calculate a target frame decimation parameter of 2, thinking 60hz/2 = 30hz, but since the user is on a 144hz, they will instead get 144hz/2=72hz. Also in Firefox, this decimation has been observed to throw off Firefox timing calculations of when next rAF() should fire, resulting in a lot of stuttering. (haven't measured recently, not sure if they have fixed the issue)

If you use EM_TIMING_SETTIMEOUT instead, the content will not be rendered in the proper native browser display composition slot, and as result will be decoupled from vsync, and there will be a large amount of frame arrival stuttering (+/- 5 msecs roughly give or take).

Not quite sure how you are seeing the behavior to have changed recently, though I know nothing in Emscripten nor in Unity has changed here in the recent few *years* - this code is all very stable. So if something has changed, then it suggests that some browser behavior may have recently been adjusted (or you had a hardware change?)

Dominik Klooz

unread,
Aug 31, 2023, 4:59:45 PM8/31/23
to emscripten-discuss
Thank you for the informative answers!

I haven't had any luck yet with requestAnimationFrame, there are these two functions in the framework code from Unity builds (logging by me):

fakeRequestAnimationFrame: function (func) {
    console.log("fakeRAF");
    var now = Date.now();
    if (Browser.nextRAF === 0) {
        Browser.nextRAF = now + 1e3 / 30;
    } else {
        while (now + 2 >= Browser.nextRAF) {
            Browser.nextRAF += 1e3 / 30;
        }
    }
    var delay = Math.max(Browser.nextRAF - now, 0);
    setTimeout(func, delay);
},
requestAnimationFrame: function (func) {
    console.log("RAF");
    if (typeof requestAnimationFrame === "function") {
        requestAnimationFrame(func);
        return;
    }
    var RAF = Browser.fakeRequestAnimationFrame;
    RAF(func);
},


I forced the fakeRequestAnimationFrame one to be used by commenting out the if clause in requestAnimationFrame, but still get the screen refresh rate as measured fps, also when changing the hardcoded 1e3 / 30 delay. I guess that's the mentioned unreliability (tested in Brave).

What puzzles me as well though is that I can't reproduce the behaviour from this paragraph:
"Likewise if you use EM_TIMING_RAF with decimation, since it is not possible to query the display refresh rate from JavaScript, Emscripten/Unity assume that all displays are 60hz. So this means if you set 30 hz as target, and user is on a 144hz, then Emscripten will calculate a target frame decimation parameter of 2, thinking 60hz/2 = 30hz, but since the user is on a 144hz, they will instead get 144hz/2=72hz."
I'm now testing with a screen with 60Hz refresh rate and believe we are talking about this case in _emscripten_set_main_loop_timing (which is triggered):
else if (mode == 1) {
    Browser.mainLoop.scheduler = function Browser_mainLoop_scheduler_rAF() {
        Browser.requestAnimationFrame(Browser.mainLoop.runner);
    };
    Browser.mainLoop.method = "rAF";
}


and, more specifically in Browser.mainLoop.runner = function Browser_mainLoop_runner() in setMainLoop, this:

if (Browser.mainLoop.timingMode == 1 && Browser.mainLoop.timingValue > 1 && Browser.mainLoop.currentFrameNumber % Browser.mainLoop.timingValue != 0) {
    console.log("Browser.mainLoop.timingMode == 1"); // constantly printed
    Browser.mainLoop.scheduler();
    return;
}

Eventhough the body of that last if-clause is only called every second iteration (verified it through logging just to be sure), I still get ~60 fps instead of 30, same with timingValue set to 4 where I would expect ~15 fps but still get 60.
Am I misunderstanding something / looking in the wrong places? Or is it possible the "Frame Rendering Stats" overlay in Chromium-based browsers displays the base main loop rate and not the actual rendering rate after decimation?

Otherwise this is what I claim has changed sometime in the last few months, and I can rule out hardware changes being the reason (also see the same on multiple systems, but interestingly also across Brave, Edge and Firefox). I have always set the target frame rate to 30 in Unity, which should lead to the requestAnimationFrame (EM_TIMING_RAF) way skipping every 2nd frame I now manually enforced by directly changing the generated framework code. I'm fairly certain this used to work, but doesn't anymore.

Jukka Jylänki

unread,
Aug 31, 2023, 6:23:04 PM8/31/23
to emscripte...@googlegroups.com
> is it possible the "Frame Rendering Stats" overlay in Chromium-based browsers displays the base main loop rate and not the actual rendering rate after decimation?

That is correct - Chrome will always show the native requestAnimationFrame() rate, it does not report the rate after decimation, since it thinks that all rAF() calls were potential render calls.

Reply all
Reply to author
Forward
0 new messages