Open aarthificial opened 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.)
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.
@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.
@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.
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.)
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.
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
.
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.
Thanks for the excellent write-up. I missed this at the time, but I'm on board with this direction as an alternative approach.
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:
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:
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 to4ms
: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:
FixedMain
schedule is invoked at least once each frame, eliminating a lot of footguns related to events.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 inPostUpdate
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 inFixedUpdate
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, likeFixedPreUpdate
always running before each simulation tick andFixedPostUpdate
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::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