winit
winit copied to clipboard
[Feature Request]: Ability to make event_loop async to satisfy WGPU on WASM in Resumed Event
I would like to request the ability to .await code within the event loop, such as inside the Resumed event. This'll allow initializing libraries such as WGPU which require async code to be ran on platforms without thread blocking such as WASM. Right now the only way to run async functions without blocking is to do it outside of the event loop and to keep asyncing everything up to the original function that the program starts at (with wasm_bindgen)
I'm trying to support 3 platforms. The platforms are Desktop, Android, and Web Browser. I have the first two platforms down which both wait for the Resumed event to be called before creating the window. This is the only thing preventing me from supporting the web browser
I see something called EventLoop::spawn. Still trying to figure out if I can somehow make that async
It has been previously discussed to add an async layer on top of winit for these purposes. Unofficially I've manifested this dream as async-winit, although I haven't had the energy to update it to winit v0.29.
this is what i'm trying to work with atm https://github.com/lexi-the-cute/catgirl-engine/blob/e383587c190d7446f69f470951c21465a29d881e/client/src/game/game_loop.rs#L71-L78
I do plan to experiment with adding a platform-specific extension for an async event loop to Web in Winit.
You should consider handling async stuff out of band, e.g. use wasm_bindgen_futures::spawn_local() and send a user event when you are done if you still want to handle the result inside the event loop.
I do plan to experiment with adding a platform-specific extension for an async event loop to Web in Winit.
You should consider handling async stuff out of band, e.g. use
wasm_bindgen_futures::spawn_local()and send a user event when you are done if you still want to handle the result inside the event loop.
thanks! i ended up learning about mutexes and made a static mutex to handle this
Is there any official recommendation or direction on how to a properly setup wgpu on Resumed event for WASM or is it intended that native and web have different code paths for window/rendering initialization?
Short of doing something like a static mutex, it's a real struggle to figure out how to create these resources inside the event_loop even with channel passing without a convoluted multi-phase setup with a bunch of Options. For example,
- On initial startup,
window, and wgpusurface,adapter,device, andqueueare allNone Resumedevent received,windowis created and a future is is created to be run insidewasm_bindgen_futures::spawn_localto send thesurface, requestadapteranddevice/queueinstances over a channel once they're created- Eventually a
UserEventis received with the created wgpu resources
This means your application has to operate with all these three intermediate optional states and enforces that the UserEvent can't be Clone or PartialEq and many other traits besides. I can't seem to find any solid examples of anyone else doing this. for example,eframe in egui has a completely separate web backend from their wgpu integration, sidestepping the issue.
The documentation seems to suggest this is the recommended approach for Web, but fails to address any possible issues of async:
Web
On Web, the Resumed event is emitted in response to a pageshow event with the property persisted being true, which means that the page is being restored from the bfcache (back/forward cache) - an in-memory cache that stores a complete snapshot of a page (including the JavaScript heap) as the user is navigating away.
To clarify, this is primarily targeted at 0.29.0
Here's a heavily trimmed down snippet of what I have working. It has three states:
- Suspended: Waiting for a
Resumedevent - Pending GPU Resources - Window is created, and waiting on wgpu futures for resources
- Running - All resources created and normal rendering/event handling
There's an extra channel involved so that the AppEvent type is a plain enum that can be Clone, PartialEq, etc. In my full application these are required because these events are shared across threads.
If anyone has other ideas how to do this in a cleaner way without as many runtime checks on Options I'd love to hear them!
code
use crate::{config::Config, renderer::Renderer};
use anyhow::{anyhow, Context};
use crossbeam::channel::{self, Receiver};
use std::{future::Future, sync::Arc};
use winit::{
event::Event,
event_loop::{EventLoop, EventLoopBuilder, EventLoopProxy, EventLoopWindowTarget},
window::{Fullscreen, Window, WindowBuilder},
};
struct App {
/// Set during initialization, then taken and set to `None` when running because
/// `EventLoopProxy` can only be created on the initial `EventLoop` and not on
/// `&EventLoopWindowTarget`.
pub(crate) init_state: Option<(Config, EventLoopProxy<AppEvent>)>,
pub(crate) state: Option<State>,
}
enum State {
PendingGpuResources {
window: Arc<Window>,
rx: Receiver<anyhow::Result<GpuResources>>,
},
Running(Running),
}
struct Running {
cfg: Config,
tx: EventLoopProxy<AppEvent>,
window: Arc<Window>,
renderer: Renderer,
// additional running state
}
#[derive(Debug)]
pub enum AppEvent {
GpuResourcesUpdate,
Other, // ...other events
}
impl App {
/// Create app and start event loop.
fn run(cfg: Config) -> anyhow::Result<()> {
let event_loop = EventLoopBuilder::<AppEvent>::with_user_event().build()?;
let mut app = App::new(cfg, &event_loop)?;
event_loop.run(move |event, window_target| app.event_loop(event, window_target))?;
Ok(())
}
/// Create a new app.
fn new(cfg: Config, event_loop: &EventLoop<AppEvent>) -> anyhow::Result<Self> {
let tx = event_loop.create_proxy();
Ok(Self {
init_state: Some((cfg, tx)),
state: None,
})
}
/// Create a window and request GPU resources. Transitions from `state` `None` to
/// `Some(PendingGpuResources { .. })`.
pub fn create_window(
&mut self,
event_loop: &EventLoopWindowTarget<AppEvent>,
) -> anyhow::Result<()> {
let (cfg, tx) = self.init_state.as_ref().expect("config already taken");
let window_size = cfg.window_size();
let texture_size = cfg.texture_size();
let window = WindowBuilder::new()
.with_active(true)
.with_inner_size(window_size)
.with_min_inner_size(texture_size)
.with_title(Config::WINDOW_TITLE)
.with_fullscreen(
cfg.renderer
.fullscreen
.then_some(Fullscreen::Borderless(None)),
)
.with_resizable(true)
.build(event_loop)?;
let window = Arc::new(window);
let rx = GpuResources::request(tx.clone(), Arc::clone(&window));
self.state = Some(State::PendingGpuResources { window, rx });
Ok(())
}
/// Initialize the running state after a window and GPU resources are created. Transitions
/// `state` from `Some(PendingGpuResources { .. })` to `Some(Running { .. })`.
fn init_running(
&mut self,
event_loop: &EventLoopWindowTarget<AppEvent>,
) -> anyhow::Result<()> {
match self.state.take() {
Some(State::PendingGpuResources { window, rx }) => {
let resources = rx.try_recv()??;
let (cfg, tx) = self
.init_state
.take()
.expect("config unexpectedly already taken");
let renderer = Renderer::init(
tx.clone(),
Arc::clone(&window),
event_loop,
resources,
cfg.clone(),
)?;
let running = Running {
cfg,
tx,
window,
renderer,
};
self.state = Some(State::Running(running));
}
Some(State::Running(_)) => tracing::warn!("already running"),
None => anyhow::bail!("must create window and request gpu resources first"),
}
Ok(())
}
fn event_loop(
&mut self,
event: Event<AppEvent>,
event_loop: &EventLoopWindowTarget<AppEvent>,
) {
match event {
Event::Resumed => {
if self.state.is_none() {
if let Err(err) = self.create_window(event_loop) {
tracing::error!("failed to create window: {err:?}");
event_loop.exit();
}
};
}
Event::UserEvent(event) => {
if let Some(State::PendingGpuResources { .. }) = &mut self.state {
if let AppEvent::GpuResourcesUpdate = event {
if let Err(err) = self.init_running(event_loop) {
tracing::error!("failed to initialize running state: {err:?}");
event_loop.exit();
return;
}
}
}
}
_ => (), // ...handle other events
}
}
}
pub struct GpuResources {
surface: wgpu::Surface<'static>,
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
}
pub fn spawn<F>(future: F)
where
F: Future<Output = ()> + 'static,
{
#[cfg(target_arch = "wasm32")]
wasm_bindgen_futures::spawn_local(future);
#[cfg(not(target_arch = "wasm32"))]
pollster::block_on(future)
}
impl GpuResources {
pub fn request(
proxy_tx: EventLoopProxy<AppEvent>,
window: Arc<Window>,
) -> Receiver<anyhow::Result<Self>> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::default());
// Channel passing to do async out-of-band within the winit event_loop since wasm can't
// execute futures with a return value
let (tx, rx) = channel::bounded(1);
spawn({
async move {
let surface = match instance
.create_surface(Arc::clone(&window))
.context("failed to create wgpu surface")
{
Ok(surface) => surface,
Err(err) => {
proxy_tx.send_event(AppEvent::GpuResourcesUpdate).unwrap();
tx.send(Err(err)).unwrap();
return;
}
};
let Some(adapter) = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
else {
proxy_tx.send_event(AppEvent::GpuResourcesUpdate).unwrap();
tx.send(Err(anyhow!("failed to find a wgpu adapter")))
.unwrap();
return;
};
// WebGL doesn't support all of wgpu's features, so if
// we're building for the web we'll have to disable some.
let mut required_limits = if cfg!(target_arch = "wasm32") {
wgpu::Limits::downlevel_webgl2_defaults()
} else {
wgpu::Limits::downlevel_defaults()
};
// However, we do want to support the adapters max texture dimension for window size to
// be maximized
required_limits.max_texture_dimension_2d =
adapter.limits().max_texture_dimension_2d;
proxy_tx.send_event(AppEvent::GpuResourcesUpdate).unwrap();
tx.send(
adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("app"),
required_features: wgpu::Features::CLEAR_TEXTURE,
required_limits,
},
None,
)
.await
.context("failed to request wgpu device")
.map(|(device, queue)| Self {
surface,
adapter,
device,
queue,
}),
)
.unwrap();
}
});
rx
}
}
Our solution for stuff like that is that you create an async trait shim that glues things together, since I don't think there should be an issue?
Keep in mind that all of that is not new, in particular. You can also just use pollster like all WGPU examples do.
Our solution for stuff like that is that you create an
asynctrait shim that glues things together, since I don't think there should be an issue?
Do you have an example somewhere? I posted a more detailed example and while it works, it's far from ideal. I especially dislike requiring one channel to notify that another channel has data ready to get around the derived trait limitations.
https://github.com/notgull/async-winit
Though, maybe @notgull wants to bring it more up to date.
Thanks! However, that doesn't seem compatible with my needs as it doesn't appear to support UserEvents as it's being used for the Wake events internally. Not to mention the additional overhead and dependencies. I'm already pushing 300 because egui/wgpu have so many downstream dependencies.
I know that since 0.31 there will be way more freedom because a lot of things will become &dyn and one could design very thin inline wrappers on top or have completely async backend in the first place.
Though, it's much easier to wrap sync in async than the other way around and doing so is challenging, so the core interface always remain sync, but it'll be really thin callback.
I'm not sure any amount of wrapping would help. I've tried various iterations - the core of the issue is that during the Resumed event you need to call several async wgpu functions and it'd be nice if that could be awaited and yield, resuming once it's complete. Current native implementations rely on block_on to execute async code within the sync event_loop closure - which is all a wrapper would be able to do as well (and exactly what async-winit does) - but blocking isn't possible in WASM.
What would be nice would be to be able to cleanly return control from the scheduled event loop callback, let the microtick of wasm_bindgen::spawn_local execute, and then resume. With only a onetime async call during Resumed, doing this manually is clunky, but works (as outlined above). Each additional async call would require the same temporary state - essentially re-implementing futures manually. I'm not sure that it's specifically a winit issue to resolve or if wpgu should instead provide sync methods for wasm, or what other options there are.
The prime example where this gets extra hairy is when using egui viewports with the egui_wgpu crate. If multiple viewport support is possible in wasm (like having multiple canvases), each viewport would need to call an async method every frame to set the correct WindowId on the egui_wgpu Painter. See https://docs.rs/egui-wgpu/latest/egui_wgpu/winit/struct.Painter.html#method.set_window.
There is no official recommendation from Winit on how exactly to do this, but your example is basically what I am using as well. It might be a good idea to add an Winit + wgpu example to just cover this.
That would be great! I also discovered during this development that chrome panics sometimes on page refresh with a BorrowMutError inside set_listener that didn't happen prior to initializing wgpu on Resumed (previously it was being done before event_loop was started) - some sort of re-entrant issue when unloading the wasm module I'm guessing. I'll be filing an issue with the stack trace later today.