bevyengine / bevy

A refreshingly simple data-driven game engine built in Rust
https://bevyengine.org
Apache License 2.0
35.15k stars 3.46k forks source link

`winit::TabLeft`/`Event::Suspended` doesn't function as intended on WASM #13486

Open simbleau opened 3 months ago

simbleau commented 3 months ago

Bevy version

0.13

What went wrong

JavaScript is an interpreted language, and the browser will essentially suspend code execution completely if the tab is hidden. Bevy's systems are tied to requestAnimationFrame, which is also completely paused when the user is tabbed away from the window.

In other words: On wasm, the main thread gets suspended when it is hidden (e.g. when the user switches tabs). This means that the app.update() function will not be called, because bevy's scheduler only runs app.update() when the browser's requestAnimationFrame is called (and that happens only when the tab is visible).

What problem does this cause?

There's a very long discussion on how this problem affects lightyear available here. We eventually come to a solution, which I'll propose below.

The part about winit::TabLeft

So yeah, there is technically events like Event::Suspended in winit that are supposed to fire when the tab gets left. However, as aforementioned, no code executes when the tab is hidden. As a result, this event is handled only when you return to the tab!

< user leaves tab >

... 30 seconds

< user goes back to the tab >

--> new event: Event::Suspended --> new event: Event::Resumed

And what use is that? It's delivering no value currently. I'd call this a bug.

The solution

A solution, credited to @Nul-led , is to add a very small plugin (~10 lines of code) to spawn a web worker which sends a message every 1 second (configurable time). A callback is added to the web browser's DOM events to execute the main schedule exactly 1 time.

The code can be seen here: https://github.com/cBournhonesque/lightyear/pull/371/files#diff-5ec95d0ed493b4b63bba9ae693c990e6e1612d2fbe6309df678bf21fb75fbc1b

What I want

Selfishly, I want to see this in bevy. It's a very small amount of code but could be massively beneficial to many crates who want to keep a continuous run in the background when the tab is inactive.

Specifically, I think this should be a

pub struct WebExecutionPlugin {
    callback_ms: f32 // default = 250.0 ms
}

that can be added to any application.

Furthermore, it would be nice to have a

#[derive(States)]
pub enum WindowFocusState {
    Inactive,
    Active,
}

that changes, depending on whether the window or tab is visible.

Nul-led commented 3 months ago

Im not really sure if this should be part of bevy to be honest. The solution mentioned uses the fact that web workers aren't throttled by major browsers (unlike timers in content scripts), however this is (as far as im aware) not standardized and up to the browser for implementation. So it is essentially a temporary fix not a solution as browsers could very well change this behavior at any point. Which leads me to believe that it is not a good idea to rely on this in bevy.

However it might be a good idea to run app.update() whenever the browser fires an visibilitychange event.

Additionally my solution relies on unsafe casting and will only work in a single threaded environment so it isn't entirely future proof in that aspect either (see multithreading proposal for WebAssembly).

Tldr; I believe this should be implemented in an independent crate or at least with an optional feature.

Nul-led commented 3 months ago

Published https://crates.io/crates/bevy_web_keepalive if someone needs a solution until this is fixed upstream (if this gets fixed at all)

Nul-led commented 3 months ago

tho @happydpc this might not work for you as this only runs the Main schedule, so the renderer subapp wouldn't be affected.

simbleau commented 3 months ago

Yeah my issues are fixed with that plugin

daxpedda commented 3 months ago

I was investigating if there is an issue in Winit or if Winit can do something here, just dropping my results here.

So my assumption is that the reason why Event::Suspended is only received when going back to the tab is because in general all Winit events in Bevy are cached in handled in WindowEvent::RedrawRequested. But this event has to be handled in the event loop directly and not when WindowEvent::RedrawRequested is reached (which would be in the next browser event loop tick).

Unfortunately, the spec doesn't specify anymore in which case a browser is allowed or not allowed to freeze a page (and discard it afterwards). To prevent browsers based on Chromium (the only engine currently implementing the Page Lifecycle API) from freezing the page, see this document by Chromium outlining their heuristic.

In the future the spec might officially introduce an opt-out method with the Screen Wake Lock API, see https://github.com/WICG/page-lifecycle/issues/31.

simbleau commented 3 months ago

In the meantime, for anyone visiting this issue, just use https://crates.io/crates/bevy_web_keepalive

It solves the issue for all browsers by deploying a Web worker which runs the Main schedule once every (configurable) interval when the window is suspended.