bevy icon indicating copy to clipboard operation
bevy copied to clipboard

Add time scaling and simplify fixed timestep use

Open maniwani opened this issue 4 years ago • 13 comments

Objective

  • Fill out Time API.
  • Make using FixedTimestep less prone to user error.
  • Implement time scaling.
  • Improve documentation.

Solution

  • Ensure the "delta" and "elapsed" methods on Time all have f32, f64, and Duration variants.
    • The f32 and f64 values are cached, but derived from the Duration value to minimize drift from rounding errors.
  • Add an explicit FixedTime counterpart to Time for people to use in their fixed timestep systems.
  • Remove the FixedTimesteps resource (HashMap<String, FixedTimestepState>).
    • There's only one FixedTimestepState now (only need to read it when you want to interpolate something).
    • Having more than one instance was a big footgun.
  • Implement time scaling.
    • Fixed time follows scaled time in fixed increments.

Setting the step rate isn't quite as ergonomic as before but it's still pretty easy to do.

Changelist

  • Renamed Time::time_since_startup to Time::elapsed_since_startup.
  • Added method to get the Instant of the first Time update (there's some delay after startup).
    • Time::first_update
  • Added methods to control time scaling.
    • Time::relative_speed
    • Time::set_relative_speed
  • Added methods that ignore time scaling (i.e. for diagnostics).
    • Time::raw_delta
    • Time::raw_elapsed_since_startup
  • Added FixedTime resource with similar API.
    • FixedTime::startup (should give same value as Time::startup)
    • FixedTime::first_update
    • FixedTime::last_update
    • FixedTime::delta
    • FixedTime::set_delta
    • FixedTime::steps_per_second
    • FixedTime::set_steps_per_second
    • FixedTime::elasped_since_startup
  • Changed FixedTimestepState to be step-size agnostic so it works seamlessly with time scaling.
  • Removed FixedTimesteps resource. (explained below)
    • The builtin run criteria is now just FixedTimestep::step. Set the base step size/rate through FixedTime.

Why only one built-in fixed timestep?

tl;dr

  • FixedTimestep is for repeatable, deterministic behavior
  • having two or more will mess up system ordering, which is non-deterministic and defeats the purpose
  • no other engine has this footgun and we don't need to either
  • users wanting precise time intervals need to use another thread, FixedTimestep won't work for that

A fixed timestep is "context" that wraps a block of systems and sort-of-but-not-really-decouples it from the main frame rate. (It's still inside the frame loop, so nothing is really decoupled.) The systems inside the block use your hardcoded dt value, and FixedTimestep just makes it so the average time between runs is dt. It'll regularly loop several times in a single frame to achieve that.

So your systems always see a constant dt while the actual dt between runs varies wildly. Great for getting consistent game physics, but useless if you wanted an actual fixed frequency (if you need that, your only option is a dedicated thread).

Anyway, FixedTimestep only works correctly when it wraps a single system/stage/sub-schedule. You can't use a bunch of them in different places without running into some subtle side effects.

For example, if you had two of FixedTimestep instances, their steps wouldn't run in a consistent order. E.g. if you had a 50ms timestep A and a 100ms timestep B, you'd probably expect them to interleave like this...

A AB A AB A AB...

...which might often be the case. However, if the app stutters even a little (e.g. one frame takes half a second), A and B would get several steps queued up, and then their order on the next frame would be completely screwed up (because of how they catch up):

AAAAAA BBB...

So a single long frame could cause very subtle bugs.

Note: You can subdivide one timestep into longer ones by counting. For example, you can get a 1 second timestep from a 100ms timestep just by counting to 10 and having run criteria detect that. That gives variable—but still coarse—rate limiting without messing up the system order.

I still left FixedTimestepState pub so people can build their own HashMap<Key, FixedTimestepState> resource if they need it for something exotic.

maniwani avatar Oct 21 '21 22:10 maniwani

I can't seem to find any accumulated time / fixed time. Is there a reason for that?

No, I just forgot.

Can we add time()?

That's time.time_since_startup(). It's the accumulated time since startup, equivalent to Unity's Time.time.

Btw, Time.time and Time.fixedTime aren't strictly decoupled. They both derive from Time.deltaTime. The former just adds it directly while the latter accumulates them to update in discrete increments. Just want to make sure that's clear.

maniwani avatar Nov 04 '21 14:11 maniwani

Okay, I made edits to the fn comments, got rid of the "live" getters for the raw, unscaled time, and added a field to track the "current time" of the fixed loop.

Now I'm wondering if separating the fixed stuff into a FixedTime resource might be better. No matter what, it'd be functionally coupled to Time (because of the accumulator), but a system using FixedTime::delta might express intent more clearly than Time::fixed_delta.

AFAIK you wouldn't need both time resources in a single system, so having that split can still have clean ergonomics. Well, UI systems that produce stuff consumed by the fixed-step logic might need both, idk. Something to think about.

maniwani avatar Nov 06 '21 22:11 maniwani

Nice! Probably FrameTimeDiagnosticsPlugin and LogDiagnosticsPlugin should use raw time?

jamesbeilby avatar Nov 12 '21 21:11 jamesbeilby

I think splitting makes sense, especially since accessing both is a code smell. The overall clock will be the latest but the fixed clock could be arbitrarily behind depending on the situation.

Guvante avatar Jan 08 '22 19:01 Guvante

Decided to go ahead and split things into a separate FixedTime resource, so this could probably use another review.

(Also I messed up merging changes from main and had to rebase. Sorry if anyone was depending on my branch.)

maniwani avatar Mar 04 '22 07:03 maniwani

bors try

maniwani avatar Mar 07 '22 16:03 maniwani

try

Build failed:

bors[bot] avatar Mar 07 '22 16:03 bors[bot]

#4187 would move these types into a separate crate.

maniwani avatar Mar 11 '22 21:03 maniwani

I'm not sure how I feel about limiting this to only one FixedTimestep. I get that this is less error prone, but I'm not sure how it would like if people wanted to have multiple steps.

What I was trying to explain above is that multiple FixedTimestep instances will always jumble steps unless your frame-rate is literally perfect. That might be OK if the systems in each timestep are logically independent from the others, but I've never seen that. (edit: AFAIK most people use fixed timesteps for more determinism, not less.)

Maybe adding an example that pretty much does what you are describing in the PR description would be enough to show how to do this.

Good suggestion! I'll try to add one (might be a while, though).

maniwani avatar Mar 12 '22 23:03 maniwani

What users should do instead is make their one timestep small enough to derive any longer periods by counting.

Caveat: when two step rates are semi relatively prime, this introduces a lot of new updates.

Example:

Consider a 10Hz update (e.g. a physics fixed timestep) and a 132bpm clock (2.2Hz, a standard allegro). This gives a LCM of 110Hz (~9ms delay) clock by my math, the majority of which are just updating the counters to divide it into the two useful clocks.

A much better solution imho would be to do this automatically and interleave the multiple fixed timesteps automatically. Very roughly, rather than update each fixed timestep until its caught up, update each timestep that has the chronologically first next tick and then queue its next tick, until no ticks are queued for this frame. Handling runaway and catchup is of course an unconsidered difficulty with multiple timesteps, but also has to be solved in the manual counting solution.

I'm not an expert on timesteps, but the fixed timestep implementation is in a better position to interleave multiple fixed timesteps than implementing subdivision on top of a single timestep, because it can skip/batch all of the unused timing steps.

CAD97 avatar Mar 13 '22 17:03 CAD97

A much better solution imho would be to do this automatically and interleave the multiple fixed timesteps automatically. Very roughly, rather than update each fixed timestep until its caught up, update each timestep that has the chronologically first next tick and then queue its next tick, until no ticks are queued for this frame.

FixedTimestep can never give you a fixed frequency in practice (because it's subject to frame delays). That would require a dedicated headless thread, but then you'd use a timer and sleep for rate limiting and not FixedTimestep (to avoid polling).

It's better to think of this as a "fixed integration step size" or exchange rate than an actual frequency. We queue a step for every dt, but the actual steps have almost no real periodicity. Some frames we'll run 0 steps, others we'll run 2.

Handling runaway and catchup is of course an unconsidered difficulty with multiple timesteps, but also has to be solved in the manual counting solution.

Subdivision would handle catching up automatically. It actually can't get it wrong. The counters and the systems using them would still be "wrapped inside" the base timestep, so their order wouldn't change while it loops.

For the interleaving you mention (seems similar to deficit round-robin with a priority queue) to even work, we still have to carve out a single block in the schedule where all the fixed timestep stuff happens so we can loop over it properly.

So it's essentially the same, except then we'd need to explain that property instead of it being intrinsic to the lone timestep.

But, like I said, the real pacing will never be that precise, so "more precision" is not worth the extra complexity.

I'm not an expert on timesteps, but the fixed timestep implementation is in a better position to interleave multiple fixed timesteps than implementing subdivision on top of a single timestep, because it can skip/batch all of the unused timing steps.

fwiw, I'm just looking at usage:

  • 99.9% of usage is consistent, repeatable game physics.
  • The major game engines have just one, and I've never seen anyone complain about being held back by it.
  • Developers regularly misunderstand how a fixed timestep works.
  • No one has come here and said, "I needed this."

Honestly, I'm tired of all the yak shaving on this particular point and am firmly "scream test." Everything is pub if some users have an exotic use case requiring multiple instances, but I doubt anyone does. I don't think subdivision will be common either. I expect "tick timers" for stuff like ability cooldowns and that's about it.

maniwani avatar Mar 13 '22 22:03 maniwani

To be clear: I know how a fixed time step works and how it ~~lies~~ bends the truth about time. I also agree that the default support being for a single fixed step is better than multiple which don't interleave.

The bpm example may've thus been a poor example, but it fits for syncing gameplay to music, even if not for coordinating talking to the audio middleware[^1].

[^1]: Completely off-topic, I'd love to see bevy integration for fmod and/or wwise. Maybe I'll tackle that...

Subdivision would handle catching up automatically. It actually can't get it wrong.

What I'm trying to say here is that if you're interleaving, there's no obviously correct way to both interleave and run with a larger step in order to facilitate catch-up. (Not just the case of running it multiple times, but when you're best effort recovering from a hitch by running a larger update.) Effectively, in order to apply a larger step, you have to break proper interleaving.

The major game engines have just one, and I've never seen anyone complain about being held back by it.

I honestly don't know how it's implemented (and even if I did, that'd be under the Unreal Source NDA), but Unreal has Quartz, whose purpose is specifically setting up gameplay event notifications on an arbitrary fixed time step, for the purpose of syncing to audio.

I expect "tick timers" for stuff like ability cooldowns and that's about it.

On my end, I'd expect ability cooldowns to be on the frame update. Overgeneralizing, fixed updates are for physics/simulation updates, frame updates are for input/rendering.

I'm sure you've seen fix your timestep. It's still relevant and definitely still hard. Basically there's no right answer, only different flavors of ~~wrong~~ approximation.

CAD97 avatar Mar 13 '22 23:03 CAD97

The bpm example may have been a poor example, but it fits for syncing gameplay to music, even if not for coordinating talking to the audio middleware.

Ah, okay. Since you had mentioned bpm, I just assumed it was headed in the direction of "let's use FixedTimestep like a metronome", which just wouldn't work. But yeah, rhythm games and other audio-sensitive mechanics probably need their "time" driven by the audio hardware. Your understanding on how we might map game time to, like, track playback position is probably way better than mine.

In that case, I think that how the fixed timestep works is pretty orthogonal to all that, since regardless of how many there are, they would all derive from Time. Even if it turns out audio engines like Quartz use this same struct internally, I'd imagine we would expose it as a separate type/resource that has no relation to FixedTimestep so we don't confuse users about their respective roles.

Overgeneralizing, fixed updates are for physics/simulation updates, frame updates are for input/rendering.

Yes, this is my rule of thumb as well.

maniwani avatar Mar 14 '22 02:03 maniwani

Time scaling added by #5752. I'll add FixedTime in another PR.

maniwani avatar Oct 24 '22 14:10 maniwani