#Best Practices for Communicating Between Bevy ECS and JavaScript: Events and Global Mutex?

1 messages ยท Page 1 of 1 (latest)

proper fractal
#

๐Ÿ‘‹ Hello everyone,
I'm exploring how to efficiently communicate between the Bevy ECS and the outside world (Javascript in this case).
I'm interested in passing events both ways -> getting events from the Javascript layer into the ECS,
and also emitting events from the ECS to the Javascript layer.

In my current implementation I use a global Mutex<Vec<JsEvent>>, like so:

pub static JS_EVENT_QUEUE: Mutex<Vec<JsEvent>> = Mutex::new(Vec::new());

And then draining this queue in a wasm-bound function.

But I was wondering whether there is a "better" way
or what the recommended way to make such communication happen is?

Thanks for any insights ๐Ÿ™‚

use bevy_ecs::system::Resource;
use serde::Serialize;
use std::sync::Mutex;
use wasm_bindgen::{prelude::*, JsValue};

// Static JS_EVENT_QUEUE serves as a global state to hold events for JS execution.
// We use a Mutex for safe concurrent modification. This way, events can be added to
// the queue from multiple parts of the Rust code, and later polled and drained for JS handling.
pub static JS_EVENT_QUEUE: Mutex<Vec<JsEvent>> = Mutex::new(Vec::new());

#[derive(Debug, Serialize, Clone)]
pub enum JsEvent {
    SomeEvent(String),
}

#[derive(Resource, Default, Debug)]
pub struct JsEventQueue;

impl JsEventQueue {
    pub fn push_event(&mut self, event: JsEvent) {
        let mut js_event_queue = JS_EVENT_QUEUE.lock().unwrap();
        js_event_queue.push(event);
    }
}

#[wasm_bindgen]
pub fn poll_js_event_queue() -> JsValue {
    let mut event_queue = JS_EVENT_QUEUE.lock().unwrap();
    let events = event_queue.drain(..).collect::<Vec<_>>();
    return serde_wasm_bindgen::to_value(&events).unwrap();
}

Github Discussion: https://github.com/bevyengine/bevy/discussions/10172

GitHub

๐Ÿ‘‹ Hello everyone, I'm exploring how to efficiently communicate between the Bevy ECS and the outside world (Javascript in this case). I'm interested in passing events both ways -> getting...

torn folio
#

Have you considered using a std::mpsc::channel? This seems more in line with an event system than a mutex

proper fractal
proper fractal
# proper fractal Thanks for the reply ๐Ÿ™‚ Actually yes but since WASM only works on a single threa...
use bevy_ecs::system::Resource;
use serde::Serialize;
use std::sync::{
    mpsc::{channel, Receiver, Sender, TryRecvError},
    Mutex,
};
use wasm_bindgen::{prelude::*, JsValue};

// Static receiver and sender for JS poll to use
static RECEIVER: Mutex<Option<Receiver<JsEvent>>> = Mutex::new(None);
static SENDER: Mutex<Option<Sender<JsEvent>>> = Mutex::new(None);

#[derive(Debug, Serialize, Clone)]
pub enum JsEvent {
    SomeEvent(String),
}

#[derive(Resource, Debug)]
pub struct JsEventQueue {
    sender: Sender<JsEvent>,
}

impl Default for JsEventQueue {
    fn default() -> Self {
        Self::new()
    }
}

impl JsEventQueue {
    pub fn new() -> Self {
        let mut receiver = RECEIVER.lock().unwrap();
        let mut sender = SENDER.lock().unwrap();

        if receiver.is_none() || sender.is_none() {
            let (tx, rx) = channel();
            *receiver = Some(rx);
            *sender = Some(tx);
        }
        Self {
            // The sender endpoint can be copied
            sender: sender.clone().unwrap(),
        }
    }

    // Adds the incoming event via the Sender.
    // Sending over a channel is thread-safe.
    pub fn push_event(&self, event: JsEvent) {
        self.sender.send(event).unwrap();
    }
}

#[wasm_bindgen]
pub fn poll_js_event_queue() -> JsValue {
    let mut events = Vec::new();
    let receiver = RECEIVER.lock().unwrap();

    if let Some(receiver) = &*receiver {
        loop {
            match receiver.try_recv() {
                Ok(event) => events.push(event),
                Err(TryRecvError::Empty) => break,
                Err(TryRecvError::Disconnected) => break,
            }
        }
    }

    serde_wasm_bindgen::to_value(&events).unwrap()
}
#

Note RECEIVER & SENDER are global as I want to have the JsEventQueue in different sub apps as bevy resource while sharing the same queue ๐Ÿ™‚

torn folio
#

Yeah, using a static for this is fine. Though instead of an Option I think you could use a OnceLock

#

But in this case maybe you don't need the sender/receiver pattern. Just a buffer you can push to when receiving from Javascript and drain when reading in the bevy system

#

btw, where does bevy lands in here?

#

What's your usecase for different subapps?

proper fractal
# torn folio Yeah, using a static for this is fine. Though instead of an Option I think you c...

Ok thanks for the intel ๐Ÿ™‚ Iโ€™m relatively new to Rust and donโ€™t know all the goodies yet, thus thanks for sharing ๐Ÿ™‚ Since you are a more senior Rust programmer what approach would be a better best practice / fit for my use case?

Why a subapp: Iโ€™m just using bevy_ecs & bevy_app (for Plugin setup) and thus need my own rendering setup which I extracted in a subapp (with scalability in mind like bevy does too). Currently its kept simple and just communicates to the Javascript layer to render the entities with a Shape component as svg (similar to two.js). Thus I need the event system to communicate with Javascript the layer.

Why svg and why rendering in DOM? Because I want to keep it simple for now and focus on the editor core part and not the rendering part (Currently no plan of OpenGL and stuff). Since I want to use a HTML UI anyway this event system comes also handy if I want to sync like values with the UI.

https://github.com/dyndotart/monorepo/blob/7-plan-research-rendering-engine-dtom/packages/dtom/rust/src/js_event_queue.rs

Thanks ๐Ÿ™‚

GitHub

Contribute to dyndotart/monorepo development by creating an account on GitHub.

torn folio
#

A yes. Then maybe a channel is not that bad. I don't think you need to guard it with a Mutex though, since send and recieve take &self, no mutation required.

#

Note that bevy is moving away from using the RenderWorld, preferring to store handles in HashMaps in Resources.

#

Though I think even then, we'll keep as a sort of "skeleton world" so that rendering can be run concurrently with the main app

#

Note that this is moot on web since it's not multithreaded

proper fractal
# torn folio A yes. Then maybe a channel is not that bad. I don't think you need to guard it ...

Thanks for the intel ๐Ÿ™‚ I guarded them with Mutex as the Receiver doesnโ€™t implement the Sync trait but I just noticed that the Sender does so I onlyโ€™ve to guard the Receiver with a Mutex and clone the Sender when creating a new JsEventQueue?

`std::sync::mpsc::Receiver<JsEvent>` cannot be shared between threads safely
within `std::option::Option<std::sync::mpsc::Receiver<JsEvent>>`, the trait `Sync` is not implemented for `std::sync::mpsc::Receiver<JsEvent>`
shared static variables must have a type that implements `Sync`
proper fractal
# torn folio Note that bevy is moving away from using the RenderWorld, preferring to store ha...

Ok interesting I copied the render sub_app concept as I like the separation of concern although its probably an overkill. Since for my current target environment: the web; I canโ€™t do non hacky multithreading anyway.

The decision has probably performance reasons or are the reasons rooted deeper? Is there a proposal/discussion issue? Just curious since Iโ€™m still in the experimentation phase and can easily throw away the render sub_app and change the architecture. As I'm learning by doing and am not experienced in game & design editor architecture. Thanks a lot ๐Ÿ™‚

torn folio
#

yep performance. Spawning components is fairly expensive, and the render world needs to be rebuilt from scratch each frame, and for what we use them for, it was definitively very overkill

proper fractal
# torn folio yep performance. Spawning components is fairly expensive, and the render world n...

Thanks for the insights so will the render stuff be integrated into the main world into the MainSchedule then or will it keep its own schedule I guess?

In my case I donโ€™t spawn any components which makes the sub_app maybe even more obsolete but I like the separate schedule and separation I guess. Iโ€™m just extracting changed components from the main world and push them into a resource registered at the render_app and then in the โ€œRenderโ€ step push these changes to the Javascript layer. What are your thoughts on this approach. feel free to roast it as learning & improving by doing ๐Ÿ™‚

fn extract_paths(
    mut changed: ResMut<ChangedComponents>,
    query: Extract<Query<(Entity, &Path), (With<Shape>, Changed<Path>)>>,
) {
    query.for_each(|(entity, path)| {
        let change_set = changed.changes.entry(entity).or_insert(vec![]);
        change_set.push(Change::Path(path.clone()));
    });
}

fn send_to_frontend(mut changed: ResMut<ChangedComponents>, mut event_queue: ResMut<JsEventQueue>) {
    let change_sets: Vec<ChangeSet> = changed
        .changes
        .iter()
        .map(|(entity, changes)| ChangeSet {
            entity: entity.clone(),
            changes: changes.clone(),
        })
        .collect();
    event_queue.push_event(JsEvent::RenderUpdate(change_sets));

    changed.changes.clear();
}
torn folio
#

That's roughly how bevy 0.12 will work for rendering, so you are good :D

#

Difference that we use the render graph vs pushing to a Javascript layer

#

If ChangedCompoents.changes is a Vec<ChangeSet>, you can use std::take(&mut changed.changes) to extract into change_sets and removing the data from changed.changes without allocation.

#

oh I see actually it's probably a HashMap<Entity, Change>

#

You can use drain(..) instead of iter() here to clear the hashmap and not have to clone the values.

proper fractal
# torn folio If `ChangedCompoents.changes` is a `Vec<ChangeSet>`, you can use `std::take(&mut...

Thanks ๐Ÿ™‚ appreciate your feedback & help a lot ๐Ÿ™

Yeah change_sets is a Hashmap so that I can group changes by entity to batch send them to the Javascript layer in the render step.. I could also sort them then in the โ€œRenderโ€ step but I feel like Hashmap is more performant (as its O(1)) than Vec + sorting & grouping later. The drain(..) would indeed be better. Thanks ๐Ÿ™‚

proper fractal
proper fractal
#

Hi @torn folio ,
Hope you are doing good ๐Ÿ™‚

Iโ€™ve updated the js_event_queue.rs a bit and am now directly calling a WASM bound Javascript function from within the ECS instead of polling from the Javascript layer. Thus I could get rid of all the static stuff. What are your thoughts on that approach and is it a bad practice to call Javascript functions from within the ECS?

The only trouble I had was to get an unique Id into the JsEventQueue resource that also the WASM bound Editor has access to. So that I can identify from which World & Editor the changes are in the JS layer. Right now Iโ€™m making use of the WorldId but since I canโ€™t pass it through the WASM_Bindgen (no primitive type) Iโ€™ve to dirty transform it into a usize.

Would appreciate your thoughts ๐Ÿ™‚ Thanks a lot ๐Ÿ™