bevyengine / bevy

A refreshingly simple data-driven game engine built in Rust
https://bevyengine.org
Apache License 2.0
36.1k stars 3.56k forks source link

Add or Swap to a Simpler Fixed Update Strategy #12465

Open aarthificial opened 7 months ago

aarthificial commented 7 months ago

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

Bevy's fixed update loop by default uses a truly fixed timestep interval. The time simulated in each frame is always a multiple of the defined interval with any oversteps being accumulated for later. This allows the simulated time to be out-of-sync with the virtual time in the main update loop which makes it less than practical for physics simulation.

For example, if you were to calculate the positions of rigid bodies during the fixed update, they would appear to randomly speed up and slow down. This is a known problem and the solutions include:

Both of them require a lot of work and state syncing/bookkeeping.

What solution would you like?

Some engines avoid this problem by treating the declared fixed interval as the maximum. So the actual interval used cannot be longer, but it can be shorter.

A good example would be Unreal Engine's sub-stepping. The developer defines the "Max Substep Delta Time" which is then used to divide each frame into substeps. The actual interval used by the fixed loop is then calculated as follows:

const MAX_SUBSTEPS: f32 = 80.0;
const MAX_SUBSTEP_DELTA_TIME: f32 = 0.1;

fn fixed_step_delta(virtual_delta: f32) -> f32 {
    let max_delta = MAX_SUBSTEP_DELTA_TIME * MAX_SUBSTEPS;
    let delta = virtual_delta.min(max_delta);
    let substeps = (delta / MAX_SUBSTEP_DELTA_TIME).ceil();
    let substeps = substeps.min(MAX_SUBSTEPS).max(1.0);
    let fixed_step_delta = delta / substeps;

    return fixed_step_delta;
}

The original C++ source code (requires access to the EpicGames organization)

Here's a visual comparison between the current and proposed implementations. The frame took 15ms while the fixed interval is set to 4ms:

Current:
             15ms                           15ms             
+-----------------------------+-----------------------------+ main loop
+-------+-------+-------+-----|-+-------+-------+-------+     fixed loop
   4ms     4ms     4ms        | 3ms behind

Proposed:
             15ms                           15ms             
+-----------------------------+-----------------------------+ main loop
+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ fixed loop
  3ms   3ms   3ms   3ms   3ms | in sync

Benefits

While this solution is not truly fixed (the interval may vary slightly) it still achieves the framerate independence necessary to keep any simulation stable. On top of that:

Drawbacks

Long intervals

This would make it impossible to have a fixed interval that's longer than the duration of a frame. However, a setup like this is most often used for sparse updates and not for simulations. For example, one could want to update the AI pathfinding every 100ms instead of every frame. This can be implemented by running a system every N frames and does not require precise time intervals. I think the simplification in the context of physics simulation far outweighs the additional work in these cases.

Netcode

Networking relies on the fixed timestep to make sure the physics is replicated correctly on both sides. Other features that rely on the reproducibility of the simulation, such as recording the player's input and replaying it back, also require a fixed interval.

Conclusion

Given the information above, I think it's reasonable to make the variable timestep the default with an option to switch to the fixed timestep if necessary. The idea is to provide the most practical defaults for people. Right now, if a developer sets up a new project in the bevy ecosystem, they'll end up with the worst configuration possible. bevy_rapier runs in PostUpdate by default with a custom update loop that's tied to the framerate. On top of that, it caps the delta time at 60Hz, so drops in framerate below 60 FPS will result in the simulation visually slowing down. Configuring Rapier to run in FixedUpdate will result in the same speed up/slow down issues described above.

One could argue that it's a Rapier problem, and while it's true that they could implement a more complex internal update loop, this is just a workaround. Ideally, a physics engine should be able to "trust" Bevy's timing mechanism and run only once per FixedUpdate. This gives developers a lot of helpful guarantees, like FixedPreUpdate always running before each simulation tick and FixedPostUpdate not being bombarded with all collision events at once.

With a variable timestep, most physics engines could be run once per FixedUpdate with no changes on their side necessary. And games that rely on a fixed timestep could easily opt-in to that.

What alternative(s) have you considered?

This could be implemented on the user side with a custom schedule. But I'm opening this issue because I believe the proposed solution would be a better default for most people.

If anyone's interested, here's a simple plugin that replaces the current fixed update loop with the proposed solution:

Source Code ```rs use bevy::{ app::{FixedMain, RunFixedMainLoop}, ecs::schedule::ExecutorKind, prelude::*, }; use bevy_rapier2d::prelude::*; pub struct PhysicsPlugin; impl Plugin for PhysicsPlugin { fn build(&self, app: &mut App) { let mut fixed_main_schedule = Schedule::new(RunFixedMainLoop); fixed_main_schedule.set_executor_kind(ExecutorKind::SingleThreaded); app.add_schedule(fixed_main_schedule) .add_systems(RunFixedMainLoop, run_fixed_main_schedule) // Rapier can be configured to run in FixedUpdate: .add_plugins( RapierPhysicsPlugin::::pixels_per_meter(24.0) .in_fixed_schedule(), ) // The fixed timestep will be used as the maximum. .insert_resource(Time::::from_hz(120.0)); } } fn run_fixed_main_schedule(world: &mut World) { let time_virtual = world.resource::>(); let delta = time_virtual.delta(); let elapsed = time_virtual.elapsed(); let timestep = world.resource::>().timestep(); let steps = ((delta.as_secs_f64() / timestep.as_secs_f64()).ceil() as u32).max(1); let fixed_delta = delta / steps; let _ = world.try_schedule_scope(FixedMain, |world, schedule| { for _ in 1..steps { let mut time_fixed = world.resource_mut::>(); time_fixed.advance_by(fixed_delta); *world.resource_mut::

Additional context

Here's the current implementation of the fixed update loop: https://github.com/bevyengine/bevy/blob/a9ca8491aa3f2dc99116e63d67a0625afe6e738d/crates/bevy_time/src/fixed.rs#L235-L248

cBournhonesque commented 7 months ago

Hi, that's an interesting issue! In my networking library I had to add some code to perform visual interpolation (which does cost the visual state to be 1 tick behind the actual tick). (I use 'tick' to designate the current index of FixedUpdate schedule run).

1) So if the frame rate varies (for example between 15ms-18ms) then the number or duration of FixedUpdate steps will vary as well?

2) What about a system where we run the FixedUpdate schedule n-1 times with the full duration dt, and then the last time runs with a partial duration smaller than dt? We would make that duration available to users so that they could adjust their code (for example the integration logic in rapier, etc.)

maniwani commented 7 months ago

The current implementation of Bevy's fixed update loop is quite different from other game engines.

Hmm, what other engines implement (or promote) sub-stepping besides Unreal?

The fixed update loop advancing time in fixed increments (i.e. "fixed timestep") is how it works in Godot and in Unity and in the popular "Fix Your Timestep!" article. Our implementation is extremely similar to Unity's actually.

Sub-stepping won't advance time in fixed increments unless the frame rate drops below the desired tick rate (the reciprocal of Max Substep Delta Time), but even then the final tick in a frame still advances time by a variable amount.

So outright removing fixed timestep support and replacing it with sub-stepping would not be good.

removes the need for custom smoothing

To me, this is the whole essence of why an engine would consider doing this. But in a way, it's kinda just kicking the can down the road for apps that still want a fixed timestep.

Like you've pointed out, if an app wants a fixed timestep and smooth rendering, getting both requires producing a "render-only-copy" of the state by interpolating or by sub-stepping. IMO the engine does have some responsibility to make it easy to produce such copies and support the "fixed timestep + smooth rendering" case.

netcode

Most real-time multiplayer apps do rely on a fixed timestep, so that physics has reproducible behavior and so that they can number each "tick" and have that number refer to the same period of time everywhere.

aarthificial commented 7 months ago

@cBournhonesque Thanks!

So if the frame rate varies (for example between 15ms-18ms) then the number or duration of FixedUpdate steps will vary as well?

Yes, in the worst-case scenario, when the defined timestep is equal to the target fps, the actual timestep could go as low as half of its desired duration, with the range getting smaller the lower the timestep: Target FPS (ms) Fixed Timestep (ms) Possible Timestep Range (ms)
16 16 16 - 8
16 8 8 - 5.333
16 4 4 - 3.2

What about a system where we run the FixedUpdate schedule n-1 times with the full duration dt, and then the last time runs with a partial duration smaller than dt?

The problem is that it would allow the last timestep to be as small as 1 nanosecond which I believe would cause issues with the integration.

aarthificial commented 7 months ago

@maniwani

Hmm, what other engines implement (or promote) sub-stepping besides Unreal?

Thanks for the correction! For some reason, I assumed that Unity uses substeps. I'll edit the issue to be more precise.

When it comes to Godot, it's a bit more complicated. They start with a fixed timestep but then do some stabilization that tries to keep the number of steps consistent. Here's a simple empirical test I did:

normal: 0.04666666666667
fixed:  0.01666666666667
fixed:  0.01666666666667
fixed:  0.01666666666667

normal: 0.04666666666667
fixed:  0.01666666666667
fixed:  0.01666666666667
fixed:  0.01666666666667

normal: 0.04666666666667
fixed:  0.01666666666667
fixed:  0.01666666666667
fixed:  0.01666666666667

normal: 0.04666666666667
fixed:  0.01666666666667
fixed:  0.01666666666667

normal: 0.04666666666667

You can see that the simulation time oversteps the virtual time multiple times (3 * 16ms > 46ms) before eventually lowering the number of steps to 2.

At first glance, it may look like they simply keep the simulation time ahead instead of behind the virtual time, but there's a bit more going on. Here's the source code if anyone would like to decipher the actual algorithm: https://github.com/godotengine/godot/blob/da945ce6266ce27ba63b6b08dc0eb2414594f7cb/main/main_timer_sync.cpp#L343-L361

but even then the final tick in a frame still advances time by a variable amount.

That's not true, it's what @maniwani asked about. But the UE's algorithm that I'm proposing splits the virtual delta into equal timesteps:

What you mean:
             15ms                           15ms             
+-----------------------------+-----------------------------+ main loop
+-------+-------+-------+-----+-------+-------+-------+-----+ fixed loop
   4ms     4ms     4ms    3ms | in sync

The proposed solution:
             15ms                           15ms             
+-----------------------------+-----------------------------+ main loop
+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ fixed loop
  3ms   3ms   3ms   3ms   3ms | in sync

Most real-time multiplayer apps do rely on a fixed timestep, so that physics has reproducible behavior and so that they can number each "tick" and have that number refer to the same period of time everywhere.

Alright, reading through the article you've mentioned it seems that this is the main reason for wanting a truly fixed timestep: Networking and/or reproducibility of the simulation (recording player input for playback, for example). I'll add that to the drawbacks section.

maniwani commented 7 months ago

But the UE's algorithm that I'm proposing splits the virtual delta into equal timesteps.

I see, thanks for clarifying this. So the loop would work like this:

let max_delta = MAX_SUBSTEPS * MAX_SUBSTEP_DT;

// clamp (i.e. slow time down if necessary)
let delta = virtual_delta.min(max_delta);

// divide into equal steps (no time leftover)
let substeps = (delta / MAX_SUBSTEP_DT).ceil().min(MAX_SUBSTEPS).max(1.0);
let substep_delta = delta / substeps;

// tick
for _ in 0..substeps {
    step(substep_delta);
}

I also found another article that had a good description.

[Opinion] It feels more useful and approachable for the common use cases.

It feels wrong to approach this as an either-or thing.

Sub-stepping can't replace a fixed timestep, especially for those (very high profile) use cases, so I don't think arguing to remove it is a defensible position. Being able to choose sub-stepping as an update strategy though (i.e. change a setting to change the loop) sounds like something that can only benefit the engine. I see no reason to not try to support both.

(edit: Like, the update strategy is opaque to the systems inside FixedUpdate. Time<Fixed>::overstep_fraction() would just always be zero under sub-stepping, for example.)

(Could still argue in favor of making sub-stepping the default. Might be a little disingenuous to call it a "fixed timestep" tho.)

aarthificial commented 7 months ago

So it works like this

Yes, there's a Rust snippet already in my issue showcasing the algorithm.

It feels wrong to approach this as an either-or thing.

Apologies for the confusion. As I said in the issue:

I'm opening this issue because I believe the proposed solution would be a better default for most people

I don't think we should get rid of the current strategy entirely. Ideally, there would be some sort of an enum in the Fixed struct that dictates the update strategy. And I'd also argue that the variable timestep should be the default. But removing the current strategy, especially now that I know how important it is for networking, is definitely out of the question.

maniwani commented 7 months ago

Apologies for the confusion. As I said in the issue:

I'm opening this issue because I believe the proposed solution would be a better default for most people.

Sorry. I think I saw the S-Controversial label first and just read too much into the title(s) of this issue. I think "Add sub-stepping as a fixed update strategy" would be a more neutral, non-controversial one.

And I'd also argue that the variable timestep should be the default.

Yeah, I figured. I won't argue against that, but my point about user confusion around the meaning of "fixed timestep" still stands. The docs should have a nice intro illuminating the difference since neither strategy really dominates among the other engines.

Ideally, there would be some sort of an enum in the Fixed struct that dictates the update strategy.

That sounds good. Something analogous to TimeUpdateStrategy.

aarthificial commented 7 months ago

I can see how that's confusing. I expanded the issue with a Conclusions section that clearly states the goal and explains my rationale a bit more.

alice-i-cecile commented 2 months ago

Thanks for the excellent write-up. I missed this at the time, but I'm on board with this direction as an alternative approach.