Skip to content

Add or Swap to a Simpler Fixed Update Strategy #12465

Open
@aarthificial

Description

@aarthificial

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:

  • Manually applying a smoothing algorithm to the transforms
  • Keeping track of the two most recent transforms and interpolating between them with Time<Fixed>::overstep_fraction() (mathematically accurate but causes the visible state to be one fixed interval behind)

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:

  • The FixedMain schedule is invoked at least once each frame, eliminating a lot of footguns related to events.
  • The simulation time is kept in sync with the virtual time, removing the need for custom smoothing, etc.
  • [Opinion] It feels more useful and approachable for the common use cases.

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
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::<NoUserData>::pixels_per_meter(24.0)
                    .in_fixed_schedule(),
            )
            // The fixed timestep will be used as the maximum.
            .insert_resource(Time::<Fixed>::from_hz(120.0));
    }
}

fn run_fixed_main_schedule(world: &mut World) {
    let time_virtual = world.resource::<Time<Virtual>>();
    let delta = time_virtual.delta();
    let elapsed = time_virtual.elapsed();
    let timestep = world.resource::<Time<Fixed>>().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>>();
            time_fixed.advance_by(fixed_delta);
            *world.resource_mut::<Time>() = time_fixed.as_generic();
            schedule.run(world);
        }

        let mut time_fixed = world.resource_mut::<Time<Fixed>>();
        // Account for the potential loss of nanoseconds due to the division above.
        time_fixed.advance_to(elapsed);
        *world.resource_mut::<Time>() = time_fixed.as_generic();
        schedule.run(world);
    });

    *world.resource_mut::<Time>() =
        world.resource::<Time<Virtual>>().as_generic();
}

Additional context

Here's the current implementation of the fixed update loop:

pub fn run_fixed_main_schedule(world: &mut World) {
let delta = world.resource::<Time<Virtual>>().delta();
world.resource_mut::<Time<Fixed>>().accumulate(delta);
// Run the schedule until we run out of accumulated time
let _ = world.try_schedule_scope(FixedMain, |world, schedule| {
while world.resource_mut::<Time<Fixed>>().expend() {
*world.resource_mut::<Time>() = world.resource::<Time<Fixed>>().as_generic();
schedule.run(world);
}
});
*world.resource_mut::<Time>() = world.resource::<Time<Virtual>>().as_generic();
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-NetworkingSending data between clients, servers and machinesA-TimeInvolves time keeping and reportingC-FeatureA new feature, making something new possibleD-ModestA "normal" level of difficulty; suitable for simple features or challenging fixesS-Ready-For-ImplementationThis issue is ready for an implementation PR. Go for it!X-BlessedHas a large architectural impact or tradeoffs, but the design has been endorsed by decision makers

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions