Description
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:
bevy/crates/bevy_time/src/fixed.rs
Lines 235 to 248 in a9ca849