Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web Support #88

Closed
cart opened this issue Aug 5, 2020 · 42 comments
Closed

Web Support #88

cart opened this issue Aug 5, 2020 · 42 comments
Labels
A-Build-System Related to build systems or continuous integration C-Feature A new feature, making something new possible O-Web Specific to web (WASM) builds

Comments

@cart
Copy link
Member

cart commented Aug 5, 2020

It should be possible to run Bevy Apps on the web

@takkuumi
Copy link

yeap, plz, i am waiting for, i am using the pathfinder now

@karroffel karroffel added A-Build-System Related to build systems or continuous integration C-Feature A new feature, making something new possible labels Aug 12, 2020
@lwansbrough
Copy link
Contributor

lwansbrough commented Aug 16, 2020

The most significant blocker for full web support is likely to be multithreading. An MVP for this probably needs to be single threaded.

Some thought should be put into understanding what Bevy changes would be required to support asynchronous thread waiting, as it is not possible to block the main thread in web browsers.

A couple alternative solutions may exist to this and I’ll leave them here for future discussion: Binaryen Asyncify, and rendering off the main thread using a worker and some combination of an offscreen canvas API and WebGPU.

@takkuumi
Copy link

@lwansbrough what is about run thread in web work?

@lwansbrough
Copy link
Contributor

lwansbrough commented Aug 17, 2020

@NateLing With the Canvas API we can transfer control from the main thread to a web worker. According to this comment WebGPU will (may already?) have the same capability to transfer control to a worker. Edit: I see now that WebGPU uses a canvas like canvas.getContext('gpupresent'); so it works the same way in theory. This transfer of control works like this for Canvas:

const offscreen = document.querySelector('canvas').transferControlToOffscreen();
const worker = new Worker('myworkerurl.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);

from: https://developers.google.com/web/updates/2018/08/offscreen-canvas#unblock_main_thread (This page has several examples.)

Then I suppose Bevy would then spawn additional WebAssembly threads from this main worker. I don't know what Bevy needs in order to work, but it sort of seems to me like the required browser features exist.

Edit:

So the issue here actually seems to be what happens once you start doing work in workers. Work done by the Amethyst team showed that browsers will not execute tasks until control is returned to the main thread. You can follow that issue here and Azriel made a demo which you can find here.

@azriel91
Copy link

Heya, one thing I'd like to share:

It's quite difficult to integrate with the browser event loop unless one uses async Rust, but it's also difficult to share source for both a native app and WASM app using winit (because winit's event loop is non-async).

So, is it possible to use winit, async, and WASM all at the same time?

The answer is yes -- before I went back to work, I made this thing: https://nginee.rs/examples/event_loop_rate_limit.html

Also, worth noting that that currently uses a single threaded pool to run the tasks (futures), I hadn't tried integrating with web workers yet

(not intending to promote -- please look at the code for how you would convince Rust to run async event handlers inside a synchronous winit event loop, and not freeze the browser)

@kettle11
Copy link
Contributor

An approach to working with async in the browser is to have a Vec of events that are passed to an async function. If the async function is blocked, then enqueue the event for the async function to receive later.

There's an incomplete but workable implementation of this idea in kapp: https://github.com/kettle11/kapp/blob/main/src/async_application.rs

It allows code like the following example to be written:

async fn run(app: Application, events: Events) {
    let mut _window = app.new_window().build().unwrap();

    // Loop forever!
    loop {
        match events.next().await {
            Event::WindowCloseRequested { .. } => app.quit(),
            Event::Draw { .. } => {}
            _ => {}
        }
    }
}

@lwansbrough
Copy link
Contributor

lwansbrough commented Aug 25, 2020

Yet another approach is to run the entire engine off the main thread. This makes blocking a non-issue, as WASM Threads (Web Workers) are allowed to block.

This is an example using WebGPU to render in an offscreen canvas hosted by a web worker. This should work once WebGPU contexts are supported by offscreen canvases. It sits beside a main thread canvas which is rendered using WebGPU. That part is working today in Chrome Canary with --enable-unsafe-webgpu enabled.

This is an active area of development. Support for WebGPU in offscreen canvases in Chrome is being tracked here and is currently blocked by a refactor.

@Waridley
Copy link
Contributor

Waridley commented Sep 4, 2020

FYI the issue fixed by uuid-rs/uuid#477 was blocking compilation to WASI for several of Bevy's crates, but just got merged today.

@smokku
Copy link
Member

smokku commented Sep 13, 2020

I attempted porting https://github.com/amethyst/web_worker WorkerPool implementation as a replacement TaskPool - it looked very promising and the changes are straightforward.

The main issue I stumbled is that wasm_bindgen::JsValue and wasm_bindgen::closure::Closure are not Send nor Sync, thus such TaskPool cannot become a Resource. It may be an issue for any implementation using wasm-bindgen provided bindings.

You can look at my change at: https://github.com/smokku/bevy/commit/2ad15d8e1830b891e76b73c92dab28a2dd911ee5
Just an experimental PoC, so a bit messy at the moment. See README on how to compile.

@dlight
Copy link

dlight commented Sep 14, 2020

The main issue I stumbled is that wasm_bindgen::JsValue and wasm_bindgen::closure::Closure are not Send nor Sync, thus such TaskPool cannot become a Resource. It may be an issue for any implementation using wasm-bindgen provided bindings.

Is wrapping each of them in an Arc a problem?

@smokku
Copy link
Member

smokku commented Sep 14, 2020

Is wrapping each of them in an Arc a problem?

Unfortunately the internals of these types are the issue. Arc protects the type, but not internals.

@smokku
Copy link
Member

smokku commented Sep 15, 2020

Single threaded implementation works fine though. https://github.com/smokku/bevy/commit/72d672515584e7c865915c649a8759dcf3460b1b

I will make it into a proper PR soon - just have a question:

  • Should I make the TaskPool changes a separate feature?
    i.e. Legion has parallel feature (enabled by default). I think there may be other contexts where single threaded implementation might be useful.

@cart
Copy link
Member Author

cart commented Sep 15, 2020

I'd say if making TaskPools a separate feature makes this easier, definitely go for it. But if we can somehow make TaskPools work on web, then being able to assume that TaskPools are always available (an all platforms) would be nice. Web is the only platform on my radar that might have issue with threads (that i know of).

This is really exciting. Great work 😄

@smokku
Copy link
Member

smokku commented Sep 15, 2020

It definitely is not easier. Once a Cargo feature is enabled, it is no way to disable it. We would have to maintain parallel default features sets for different targets. Been there - ugly.
The code would be sprinkled with cfg macros anyway, no difference whether these check for feature or target.

So, I will just make the cfg on target arch and if there comes a need for parallel feature, we could change in future.

P.S. I would love to get wasm threads running too, but it currently is way over my league. This is just to get the wasm story started.

@lwansbrough
Copy link
Contributor

@smokku

The main issue I stumbled is that wasm_bindgen::JsValue and wasm_bindgen::closure::Closure are not Send nor Sync, thus such TaskPool cannot become a Resource. It may be an issue for any implementation using wasm-bindgen provided bindings.

Would transmuting the JsValue solve the problem? https://github.com/RSSchermer/web_glitz.rs/blob/8730f44108f89844eb303057f1b13118604a3e4f/web_glitz/src/util.rs#L10

@memoryruins
Copy link
Contributor

Would transmuting the JsValue solve the problem?

It doesn’t look like it. JsValues do not document any guarantees (instead mentions it is subject to change), and JsValues are not repr(C).

@lwansbrough
Copy link
Contributor

lwansbrough commented Sep 24, 2020

I attempted porting https://github.com/amethyst/web_worker WorkerPool implementation as a replacement TaskPool - it looked very promising and the changes are straightforward.

The main issue I stumbled is that wasm_bindgen::JsValue and wasm_bindgen::closure::Closure are not Send nor Sync, thus such TaskPool cannot become a Resource. It may be an issue for any implementation using wasm-bindgen provided bindings.

The point where the TaskPool is converted to a resource is here (line 46). Send + Sync is required because it is expected that the TaskPool should be made available to all threads in the pool. In the context of the web, this implies that the TaskPool would be available as a resource to all web workers ("WASM threads").

The problem that arises is that as you discovered, JsValues are not Send/Sync. Why are JsValues not Send/Sync? Because JsValues (read: Javascript objects) are not allowed to be shared between web workers, with a few exceptions I believe (some primitives?) The documented ways around this are message passing via postMessage and of course the notorious SharedArrayBuffer.

But lets get back to why this is a problem to begin with: why do you need to be sending JsValues between threads? I took a look at your code. The issue is you have an internal reference to a collection of Worker types. Of course, these cannot be Send because they are JsValues. And it doesn't make any sense to copy these types, because they represent complex entities. But do they need to be sent at all? I believe it follows logically that if the task pool orchestration happens on the main thread (which it does I believe via Bevy's ParallelExecutor?), then the only context that needs access to the underlying workers is the main thread.

Following this, one solution to the problem is to create a place for the Worker pool to live outside of the worker-based TaskPool instance, such as a static on the implementation. This removes the Worker pool from the memory that will be sent to each web worker, allowing the TaskPool to be Send + Sync.

One issue I can foresee is that workers may in the course of their execution be asked to create new workers. For example, a system may want to parallelized some asynchronous tasks. In this case (probably? I'm not familiar with the async task API), it would expect to be able to obtain a reference to the TaskPool so it can spin up new workers. It would likely be necessary for the web worker based TaskPool to defer worker creation to the main thread, so that these workers are not accidentally instantiated as grandchild workers (parented by the worker instead of the main thread.) Although again I'm not sure if this is a bad thing -- it probably depends on the expected lifetime of the workers.

@mrk-its
Copy link
Member

mrk-its commented Sep 25, 2020

Regarding rendering support: with this little change: mrk-its@6145d85 (it removes spirv-reflect and bevy-glsl-to-spirv as @cart suggested some time ago) it is possible to build bevy with bevy_render. It allows enabling bevy_sprite or turning on HeadlessRenderResourceContext. Of course it doesn't enabling any rendering itself, but it allows to use regular bevy sprite components and reuse systems creating sprites / computing sprite positions, so web-specific rendering is much simpler now (this is how I'm doing it now: mrk-its/bevy-robbo@3f7ec98#diff-dd449560f9dded07a56207afe117d24e) I'm going to play now with adding very basic 2d rendering support for wasm32 target, with features required by bevy-robbo (SpriteAtlas support) (on start implemented with webgl2 on web_sys probably, as I'm most familar with it now).

@azriel91
Copy link

azriel91 commented Nov 8, 2020

Dropping by to say that https://github.com/chemicstry/wasm_thread exists, in case people were exploring this.

@razaamir
Copy link

razaamir commented Nov 8, 2020 via email

@memoryruins
Copy link
Contributor

Dropping by to say that https://github.com/chemicstry/wasm_thread exists, in case people were exploring this.

@azriel91 thanks for checking in and with a tip! We haven't kept this issue updated as well as we could.
There is a proof of concept of using wasm_thread in #631 thanks to @chemicstry :)

For an update on @mrk-its 's comment

I'm going to play now with adding very basic 2d rendering support for wasm32 target, with features required by bevy-robbo (SpriteAtlas support) (on start implemented with webgl2 on web_sys probably, as I'm most familar with it now).

The current progress of bevy_webgl2 is in #613. @mrk-its uploaded a showcase of bevy examples using it on https://mrk.sed.pl/bevy-showcase/ , where 2d rendering, 3d rendering, and gltf loading work!

@mrk-its
Copy link
Member

mrk-its commented Nov 16, 2020

I just closed #613 as bevy_webgl2 is available as external plugin now (and should work with current bevy master branch): https://github.com/mrk-its/bevy_webgl2

@skhameneh
Copy link

Great work @mrk-its

I'd like to share some findings:

  • No audio support. Features bevy/bevy_audio, and bevy/mp3 do not build with --target wasm32-unknown-unknown
  • I was unable to get wasm-pack to send --target web to bindgen, wasm-bindgen from crate wasm-bindgen-cli must be used.
  • bevy/dynamic_plugins is not supported for wasm.

Here's an example of Cargo.toml settings that may facilitate efforts:

[features]
default = [
    #"bevy/bevy_audio",
    "bevy/bevy_gltf",
    "bevy/bevy_winit",
    "bevy/bevy_wgpu",
    "bevy/render",
    #"bevy/dynamic_plugins",
    "bevy/png",
    "bevy/hdr",
    #"bevy/mp3",
    #"bevy/x11",
]

# cargo build --target wasm32-unknown-unknown --no-default-features --features web
# wasm-bindgen --out-dir target --out-name wasm --target web --no-typescript target/wasm32-unknown-unknown/debug/CRATE_NAME.wasm
web = [
    "bevy_webgl2",
    #"bevy/bevy_audio",
    "bevy/bevy_gltf",
    "bevy/bevy_winit",
    "bevy/render",
    #"bevy/dynamic_plugins",
    "bevy/png",
    "bevy/hdr",
    #"bevy/mp3",
    #"bevy/x11",
]

[dependencies]
bevy = { version = "0.4.0", default-features = false }
bevy_webgl2 = { version = "0.4.0", optional = true }
wasm-bindgen-cli = "0.2.69"

@alice-i-cecile
Copy link
Member

@True Doctor on Discord had a fantastic series of comments on Discord describing how they got threading working in web assembly: link.

@zicklag
Copy link
Member

zicklag commented Apr 10, 2021

If it's not too much trouble, could somebody copy the comments by @True Doctor to here or in a GitHub Gist for the sake of me who can't get to Discord, but may try to work on threading. 😁

@alice-i-cecile
Copy link
Member

Also relevant: bevy-cheatbook/bevy-cheatbook#23

@alice-i-cecile
Copy link
Member

alice-i-cecile commented Apr 10, 2021

@zicklag

[4:28 PM] Cole Poirier: @true Doctor hi welcome to the bevy discord! Since bevy is very focussed on parallelism and thus would want to run in WASM multithreaded, can you share what you had to do to get your own wasm library to work multithreaded?
[4:33 PM] True Doctor: We took a similar approach to the way amethyst tries to implement multithreading for their engine. We basically create a pool of web workers and use those as a rayon pool to parallelize the execution of systems
True Doctor: We also use a threading model for our logic and rendering
[4:38 PM] True Doctor:
[see diagram below
[4:39 PM] True Doctor: The logic [1] thread maintains the threadpool and runs asyncronysly to the main thread
[4:40 PM] True Doctor: Other than that implementation we use atomic operations instead of js callbacks to improve performance
[4:42 PM] True Doctor: Actually instantating multiple instances of wasm code can be tricky, because everything is loaded into a shared memory
[4:45 PM] True Doctor: We currently pack all "execution threads" into one binary, that avoids memory fragmentation and allows us to actually use a decent allocator but because every program instance has the same wasm code, they even get instantiated with the same stackpointer
[4:47 PM] True Doctor: Our current workaround is to instantiate the wasm module, modify the stackpointer form javascript and then run the webworker.
[4:48 PM] True Doctor: We provide the spawn_webworker function to wasm to allow us to dynamically start new threads from within the rust code
[4:50 PM] True Doctor: @cole Poirier I hope that covers the basics please ask if something is unclear
image

@skhameneh
Copy link

Since threading has been brought up, see atomics and bulk-memory in the Parallel Raytracing documentation for wasm-bindgen .

@zicklag
Copy link
Member

zicklag commented Apr 11, 2021

Thanks guys! Unfortunatley it looks like that's too in-depth for me to get into for now being that all current solutions require shared array buffers and for my game I'm trying to stay compatible with Safari ( 🙄 ) because I want it to work on iPhones. ( why must so popular a platform have such poor browser feature surpport )

@smokku
Copy link
Member

smokku commented Apr 11, 2021

[4:33 PM] True Doctor: We took a similar approach to the way amethyst tries to implement multithreading for their engine. We basically create a pool of web workers and use those as a rayon pool to parallelize the execution of systems

Is this approach PoC only or is it tested under real load?

My experiments with https://github.com/smokku/wrte shows that browsers severely limit the number of WebWorkers running at the same time. I was unable to reliably use it as a multiprocessing mechanism.

@K0bin
Copy link

K0bin commented Apr 11, 2021

Isn't the offscreen canvas also problematic because only Chromium supports that?

https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas#browser_compatibility

@zicklag
Copy link
Member

zicklag commented Apr 12, 2021

So, the ideal solution for multi-threading on web is one in which we can parallelize the Bevy scheduler parallel on web workers, but the more pressing need, in my case, is the fact that there are occasionally tasks that I need to run that cannot complete in the course of one frame, and that causes frame lags whenever I need to do them during gameplay. In my particular case I couldn't parse an OGG fast enough to prevent lags in the browser.

In order to solve this particular problem, we don't need fully parallelized scheduler, we just need a blocking work pool. I think I can reasonably get a setup where you can spawn functions on a web worker pool as long as they have the signature fn(Box<dyn Any + Serialize + Deserialize + 'static>) and that would require no fancy browser features ( aka. anything not supported on Safari on iOS anyway, which is pretty much the least-featured common browser target ).

The question is, would such a middle-ground be useful for Bevy? I can work it into my game without having to include it in Bevy, but I wanted to check whether or not that sounds like something we should try to include in Bevy.

Conceptually it's pretty much similar to the AsyncComputePool Bevy already has, but it would work on web and would have a more restrictive function signature requirements so that the job could be sent to the web worker without needing share memory or anything like that.

Also, in my case I need to use the pool inside of an asset loader implementation because that is where I parse the OGG files, so having it as a Bevy resource might not be suitable for that use case: it might need to be a lazy static or a once_cell or something.

Maybe we should create a separate issue to discuss this, but I figured I'd post here first because it's relevant to web support.

@alice-i-cecile
Copy link
Member

the fact that there are occasionally tasks that I need to run that cannot complete in the course of one frame, and that causes frame lags whenever I need to do them during gameplay

This seems closely related to the async user story (see #1393), which we need to solve a bit more cleanly for users regardless of their platform.

@Pauan
Copy link

Pauan commented Apr 12, 2021

Note that Workers are quite heavyweight, and most things must be done on the main thread and thus cannot be parallelized. And the cost of synchronization can also be quite heavy, so in many cases single threaded code will be faster.

In addition, threading in wasm-bindgen is extremely experimental, so you definitely should not be building anything on top of it right now.

So I think it would be best to focus on making single threaded work, and add in multi-threading later. And even after multi-threading is enabled, there should still be a single threaded mode.

If you use target_feature = "atomics" then you can have two implementations (single threaded and multi threaded) and Rust will automatically select the appropriate one at compile time. This technique is used in wasm-bindgen-futures. It may be helpful to study its implementation.

Also note that wasm-bindgen-futures is the officially supported way to spawn Futures on Wasm (using spawn_local). It uses a Rust event loop and fully integrates with JavaScript Promises.

@lwansbrough
Copy link
Contributor

Btw I've been going through @True Doctor's multithreading code in their game and web worker repos. I am woefully underqualified to be touching this stuff but I figured if I can set the wheels in motion it may inspire someone with more experience to come and correct my misdeeds. That's why I've started putting together some code in public: https://github.com/lwansbrough/bevy/blob/wasm/crates/bevy_tasks/src/web_worker_task_pool.rs

The goal here is to provide a drop in replacement for the single-threaded task pool used by WASM currently (which itself is a drop in replacement for the regular task pool.)

How this works is it creates a pool of web workers which each share both the compiled module code and memory space with the main thread application. Assigning work requires obtaining a stack pointer for the desired work item (ie. a function) and setting the stack pointer for the instance of the application running on a given worker. The worker then runs the code to completion and returns itself to the worker pool. This should be very low overhead and quite fast. Actually putting it all together is where I'm at now and it's a struggle since I don't know any aspect of this very well. Once you understand what's happening it's less mad-sciencey than it sounds so I would encourage anyone with some experience with Bevy's task pool to take a look at the code.

@zicklag
Copy link
Member

zicklag commented Apr 12, 2021

This seems closely related to the async user story (see #1393), which we need to solve a bit more cleanly for users regardless of their platform.

It's somewhat related it seems, but at the same time, in my exact use-case, I need to find a way to run blocking code in the AssetLoader, not in a user system, so it makes it maybe slightly different. Because it's blocking, CPU-bound code, it actually needs to be run outside of the async executor so that it doesn't block other asset tasks.

That said, I haven't fully understood how the async systems in #1393 work yet.


Note that Workers are quite heavyweight, and most things must be done on the main thread and thus cannot be parallelized. And the cost of synchronization can also be quite heavy, so in many cases single threaded code will be faster.

Yes, the use-case I have right now I'm not trying to use workers to speed up the game but avoid blocking the main loop when doing compute-heavy tasks that can't complete in 1/60th of a second.

Even if it were just a single extra worker I could send blocking, CPU-bound tasks to, that would be enough.

In addition, threading in wasm-bindgen is extremely experimental, so you definitely should not be building anything on top of it right now.

Yes, I would be avoiding WASM threading and just using normal Workers and postMessage() to send jobs to them.

So I think it would be best to focus on making single threaded work, and add in multi-threading later...Also note that wasm-bindgen-futures is the officially supported way to spawn Futures on Wasm (using spawn_local). It uses a Rust event loop and fully integrates with JavaScript Promises.

Bevy actually already uses spawn_local to properly handle async on WASM. The main loop and the asset loader all work properly with browser async as it stands today, this issue is just when you need to do something that takes a while, is CPU bound, and has no await points, such as parsing an OGG file.


Btw I've been going through @true Doctor's multithreading code in their game and web worker repos.

That sounds cool. :)

That does require the experimental browser features, though, so I feel like there's still a need for a simple blocking work pool. I just don't know if there's a super great place in Bevy to put it, and it feels a little bit like a hack-ish workaround that's only needed if you are targeting web.

I'm thinking that I'll just put my blocking work pool inside of my game/library for now and then if it seems like it could be useful for Bevy I could copy it into Bevy after I use it and see what it feels like.

@skhameneh
Copy link

Do note:

  • SharedArrayBuffer is in Safari/WebKit behind feature flags, I hope it will be supported in the not too distant future (probability/when for support is anyone's guess).
    See Safari Technology Preview Release 117 and Changeset 269531 in webkit
  • Ideally SharedArrayBuffer would be used first, with an alternative fallback when it's not supported; the performance hit for not using it when available is substantial.

@alice-i-cecile
Copy link
Member

It should be possible to run Bevy Apps on the web

It is possible to run Bevy Apps on the web ;)

@colepoirier
Copy link
Contributor

colepoirier commented Mar 21, 2022

@alice-i-cecile I'm not sure this issue should be closed until Bevy's web support is as first class as our support for desktop. As it stands there are myriad issue that a user will encounter trying to run a bevy app on wasm, varying with required features like audio and logging. Most importantly, I don't think the current single-threaded 'make sure not to block the main thread'/AsyncTasks don't run on a separate thread, current level of support warrants the closing of this issue.

@alice-i-cecile
Copy link
Member

Those are absolutely valid issues, but they can be addressed more conveniently in dedicated issue threads.

To me, large threads such as this are for "feature MVP": web apps do work with Bevy now, just with some caveats.

@cart
Copy link
Member Author

cart commented Mar 21, 2022

I agree with @alice-i-cecile. Better to have issues for specific problems at this point.

@colepoirier
Copy link
Contributor

I see your point, and am now in agreement with closing this issue. In discussing this on discord https://discord.com/channels/691052431525675048/692572690833473578/955299725123407872 @alice-i-cecile suggested that scoped issues regarding bevy's concrete web issue be organized into a project board. I think her proposal is a much better solution than a tracking issue, and have raised an issue about this here #4282.

Alice also proposed that broader web platform UX issues that are not well-focused specific issues or are longer term - for example those that are waiting on the maturation of aspects of the web platform like WebGPU - are better handled in a discussion. I created a discussion for this here #4279, and will be migrating the insights from the still relevant comments on this issue to the new discussion so that we do not lose them.

jakobhellermann pushed a commit to jakobhellermann/bevy that referenced this issue Jan 24, 2023
tim-blackbird pushed a commit to tim-blackbird/bevy that referenced this issue Jan 24, 2023
Added `on_enter`, `on_update` and `on_exit` methods to `System(Set)Config`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Build-System Related to build systems or continuous integration C-Feature A new feature, making something new possible O-Web Specific to web (WASM) builds
Projects
None yet
Development

No branches or pull requests