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

Decouple *what* plugins do from *when* they do it #14412

Closed
dubrowgn opened this issue Jul 21, 2024 · 1 comment
Closed

Decouple *what* plugins do from *when* they do it #14412

dubrowgn opened this issue Jul 21, 2024 · 1 comment
Labels
A-App Bevy apps and plugins C-Feature A new feature, making something new possible S-Needs-Design This issue requires design work to think about how it would best be accomplished

Comments

@dubrowgn
Copy link
Contributor

dubrowgn commented Jul 21, 2024

Background

I've been developing a deterministic lockstep multiplayer game which requires multiple non-standard game loops:

  1. Player input runs on its own semi-fixed update cadence; It can run more or less often based on packet loss (aka time dilation)
  2. Simulation runs on a separate fixed update cadence, with a limited budget per rendered frame to prevent lockups
  3. Render runs as often as possible up to some optional frame cap

What problem does this solve or what need does it fill?

On the one hand, there are tons of amazing plugins to use! On the other hand, Plugin strongly couples systems (what is done) to specific schedule (when it's done) through the build() API. Unfortunately, for anyone using non-standard game loops, this almost immediately makes most plugins useless out of the box.

Take for example something as core as the bevy provided InputPlugin:

impl Plugin for InputPlugin {
    fn build(&self, app: &mut App) {
        app
            // keyboard
            .add_event::<KeyboardInput>()
        ...
    }
}

// app.rs
pub fn add_event<T>(&mut self) -> &mut Self
where
  T: Event,
{
    if !self.world.contains_resource::<Events<T>>() {
        self.init_resource::<Events<T>>().add_systems(
            First,
            bevy_ecs::event::event_update_system::<T>
                .run_if(bevy_ecs::event::event_update_condition::<T>),
        );
    }
    self
}

Here, we see that InputPlugin adds event maintenance to the First schedule, which is part of the render loop. In this case, the coupling extends all the way down into app.rs. If I wanted to reuse this logic for my own input polling loop, I can't use InputPlugin. I can't even use add_event. At best, I must copy/paste large chunks of code from across bevy into my project using my schedules instead. This is less than idea because it's otherwise unnecessary work, but more so because I now have to keep that code in sync with upstream changes and improvements to bevy's. This process then repeats for each useful plugin I would like to leverage.

My personal feeling is the progress being made on FixedUpdate isn't actually all that valuable so far primarily because of this coupling issue. Improvements keep coming, but we can't quite manage to close issues like, Inputs can be missed (or duplicated) when using a fixed time step. Plugins are tightly coupled specifically to the bevy versions of the Update or FixedUpdate loops, making anything even slightly different immediately very tricky and/or painful.

What solution would you like?

I would love to somehow decouple what plugins do from when they do it. This doesn't inherently require any breaking changes, but I'm not set on a specific solution. Ultimately, those two aspects just need to be exposed in an (ideally easily) consumable way.

One potential solution I've been playing around with is to simply expose system sets for each logical chuck of systems, plus an "init". For example, this the the interface to my copy/pasted version of bevy's InputPlugin:

pub fn init(app: &mut App) -> &mut App {
    app
        .init_resource::<Events<GamepadButtonChangedEvent>>()
        .init_resource::<Events<KeyboardInput>>()
        .init_resource::<Events<MouseButtonInput>>()
        .init_resource::<InputEvents>()
        .init_resource::<Gamepad>()
        .init_resource::<Keyboard>()
        .init_resource::<Mouse>()
}

pub fn register(app: &mut App) -> &mut App {
    app
        .register_type::<InputEvents>()
        .register_type::<Gamepad>()
        .register_type::<Keyboard>()
        .register_type::<Mouse>()
}

pub fn systems_tick_input_collect() -> SystemConfigs {
    (
        sys_collect_gamepad_events,
        sys_collect_keyboard_events,
        sys_collect_mouse_events,
    ).into_configs()
}

pub fn systems_tick_input_gc() -> SystemConfigs {
    (
        sys_clear_input_events,
    ).into_configs()
}

pub trait TickInputExt {
    fn init_tick_input(&mut self) -> &mut Self;
    fn register_tick_input(&mut self) -> &mut Self;
}

impl TickInputExt for App {
    fn init_tick_input(&mut self) -> &mut Self { init(self) }
    fn register_tick_input(&mut self) -> &mut Self { register(self) }
}

Consumers are then free to add the exposed system sets to whatever schedules that meet their needs. One could even theoretically use the same logic on multiple schedules at the same time this way (e.g. local UI input vs deterministic player input). Granted, you would need to work around resources being World global.

// similar behavior to bevy's InputPlugin
app
    .init_tick_input()
    .register_tick_input()
    .add_systems(First, systems_tick_input_collect())
    .add_systems(Last, systems_tick_input_gc());

// or, custom behavior uses the exact same logic
app
    .init_tick_input()
    .register_tick_input()
    // keep collecting input events every time Update schedule runs
    .add_systems(First, systems_tick_input_collect())
    // clean them up if the tick schedule actually ran
    .add_systems(TickLast, systems_tick_input_gc());

Plugin could trivially be implemented on top of this for the same, nice out-of-the-box experience provided today.

Also notice how I don't have to guess how long to keep events around for anymore, because the consumer specifies that information. Additionally, if systems_tick_input_collect is never used, no memory leak occurs.

Summary

Anyway, like I said, I'm not particularly attached to a specific implementation. I would love to hear other ideas. Mostly, I hope to get people thinking about ways "mechanism" can be decoupled in bevy, because I think it will make bevy all the better.

Thanks for reading, and thanks for working on bevy!

Edit: fixed logic error in schedule example

@dubrowgn dubrowgn added C-Feature A new feature, making something new possible S-Needs-Triage This issue needs to be labelled labels Jul 21, 2024
@alice-i-cecile alice-i-cecile added A-App Bevy apps and plugins S-Needs-Design This issue requires design work to think about how it would best be accomplished and removed S-Needs-Triage This issue needs to be labelled labels Jul 22, 2024
@alice-i-cecile
Copy link
Member

I agree with your points here! I think that this is best considered as part of #2160 :) This has been a problem for a long time, and your detailed write-up of your use case is really helpful.

@alice-i-cecile alice-i-cecile closed this as not planned Won't fix, can't repro, duplicate, stale Jul 22, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-App Bevy apps and plugins C-Feature A new feature, making something new possible S-Needs-Design This issue requires design work to think about how it would best be accomplished
Projects
None yet
Development

No branches or pull requests

2 participants