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

[Feature Request]: Ability to make event_loop async to satisfy WGPU on WASM in Resumed Event #3560

Open
lexi-the-cute opened this issue Mar 3, 2024 · 15 comments
Labels

Comments

@lexi-the-cute
Copy link

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

@lexi-the-cute
Copy link
Author

I see something called EventLoop::spawn. Still trying to figure out if I can somehow make that async

@notgull
Copy link
Member

notgull commented Mar 3, 2024

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.

@lexi-the-cute
Copy link
Author

@daxpedda
Copy link
Member

daxpedda commented Mar 7, 2024

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.

@lexi-the-cute
Copy link
Author

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

@lukexor
Copy link

lukexor commented Apr 29, 2024

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 wgpu surface, adapter, device, and queue are all None
  • Resumed event received, window is created and a future is is created to be run inside wasm_bindgen_futures::spawn_local to send the surface, request adapter and device/queue instances over a channel once they're created
  • Eventually a UserEvent is 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

@lukexor
Copy link

lukexor commented Apr 30, 2024

Here's a heavily trimmed down snippet of what I have working. It has three states:

  • Suspended: Waiting for a Resumed event
  • 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
    }
}

@kchibisov
Copy link
Member

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.

@lukexor
Copy link

lukexor commented Apr 30, 2024

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?

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.

@kchibisov
Copy link
Member

https://github.com/notgull/async-winit

Though, maybe @notgull wants to bring it more up to date.

@lukexor
Copy link

lukexor commented Apr 30, 2024

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.

@kchibisov
Copy link
Member

kchibisov commented Apr 30, 2024

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.

@lukexor
Copy link

lukexor commented May 2, 2024

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.

@daxpedda
Copy link
Member

daxpedda commented May 3, 2024

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.

@lukexor
Copy link

lukexor commented May 3, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Development

No branches or pull requests

5 participants