Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Merged by Bors] - add time wrapping to Time #5982

Closed
wants to merge 12 commits into from

Conversation

IceSentry
Copy link
Contributor

Objective

  • Sometimes, like when using shaders, you can only use a time value in f32. Unfortunately this suffers from floating precision issues pretty quickly. The standard approach to this problem is to wrap the time after a given period
  • This is necessary for [Merged by Bors] - add globals to mesh view bind group #5409

Solution

  • Add a seconds_since_last_wrapping_period method on Time that returns a f32 that is the seconds_since_startup modulo the max_wrapping_period

Changelog

Added seconds_since_last_wrapping_period to Time

Additional info

I'm very opened to hearing better names. I don't really like the current naming, I just went with something descriptive.

@IceSentry IceSentry added A-Time Involves time keeping and reporting C-Enhancement A new feature C-Usability A simple quality-of-life change that makes Bevy easier to use labels Sep 14, 2022
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Copy link
Contributor

@superdump superdump left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like the global max wrapping period should be considered a global default and the method should take an optional wrapping period argument.

As for the name and documentation, I’ve seen lots of uses of time since startup being cast to f32 without consideration and indeed I’ve done it myself without thinking. It would be good if documentation either pointed you to this method and/or perhaps the method name would suggest when to use it. I thought of seconds_since_startup_f32_wrapped() for example. This should also show up in autocompletion lists.

@superdump superdump requested a review from cart September 14, 2022 02:19
@superdump
Copy link
Contributor

I think @cart should probably review this too.

@IceSentry
Copy link
Contributor Author

I originally started with seconds_since_startup_wrapped but since it's not actually since startup it felt a bit misleading.

I feel like the global max wrapping period should be considered a global default and the method should take an optional wrapping period argument.

Interesting idea, I didn't do this because originally it wasn't just a modulo, but when I realized I was pretty much just reimplementing a less efficient modulo I went with this instead. I don't like that most use of this function would probably just be passing it a None value though. Also, if we go this way, we'll probably want an enum with Default, Custom(Duration), Max instead of an optional param.

Copy link
Contributor

@afonsolage afonsolage left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just minor formatting suggestions and a nit on assert_float_eq

crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
@superdump
Copy link
Contributor

I originally started with seconds_since_startup_wrapped but since it's not actually since startup it felt a bit misleading.

It is the second since startup, but wrapped. :)

I feel like the global max wrapping period should be considered a global default and the method should take an optional wrapping period argument.

Interesting idea, I didn't do this because originally it wasn't just a modulo, but when I realized I was pretty much just reimplementing a less efficient modulo I went with this instead. I don't like that most use of this function would probably just be passing it a None value though. Also, if we go this way, we'll probably want an enum with Default, Custom(Duration), Max instead of an optional param.

Hahaha. I was going to suggest the same with the enum. And if only we could have optional function arguments. :B

IceSentry and others added 3 commits September 14, 2022 09:50
Co-authored-by: Afonso Lage <lage.afonso@gmail.com>
@IceSentry
Copy link
Contributor Author

I added the enum with a max duration of 1 day, not sure what that value should be, but this seems like a safe max value. f32 have about 6-7 significant numbers and a day is 86400 seconds. Add a few decimal places for millis and you already start to get errors.

@maniwani
Copy link
Contributor

maniwani commented Sep 14, 2022

Moving the conversation about this in #5752 back here.

I understand its motivation, but putting this "global wrapping period" on Time feels like a hack. I don't mean to be dismissive (I know wrapping is a good practice), but why do shaders and other applications even use "total elapsed app time"? Is it just because it's continuous and updated every frame?

Wouldn't the most idiomatic pattern be to use a repeating Timer with your specified period? If you want to provide that globally, I think it would be clearer signalling to provide it as a separate resource. Because we would never completely remove access to the non-wrapping values, and having "non-wrapping f32" convenience methods will actually help us warn users about precision loss and point them to your solution.

@IceSentry
Copy link
Contributor Author

Is it just because it's continuous and updated every frame?

Pretty much, yes, but it's also important to be continuous for a long time and for that time to be configurable.

The wrapping functions are intended as convenience methods too, not to replace the seconds_since_startup function. I'm not sure why you consider that hacky.

The main reason I wanted this on Time and not just something separate is that I needed this information to be globally available for shaders and I wanted to make this easily discoverable for anyone that needs a wrapped time value. Having a GlobalWrappedTimer or whatever it would be called would also work, but it would be harder to discover.

@IceSentry
Copy link
Contributor Author

@superdump I just realized, using an enum doesn't work because you want to be able to configure how long the period is globally if you want to change the value passed to the shaders, but with an enum like I did here it would just always use the Default value in shaders. I guess I could add a Res to configure this in my other PR.

@maniwani
Copy link
Contributor

maniwani commented Sep 14, 2022

I'm not sure why you consider that hacky.

I didn't mean in a super negative way. More in the sense of clutter. I think it's "hacky" because Time is a clock. It's purpose is "tracking the time elapsed since startup and since the last update", and this wrapped value is neither of those things.

It would only exist to address the specific scenario of "I need a monotonically increasing time-related value, and app elapsed time usually fits the bill, but it loses significant precision over time as an f32".

Having a GlobalWrappedTimer or whatever it would be called would also work, but it would be harder to discover.

Glad you think it would work too. Honestly, I could go either way. Time::*_wrapped methods are clear enough. This just struck me as something you'd normally use a Timer for, so it felt more appropriate. But I'll defer to @alice-i-cecile.

@alice-i-cecile
Copy link
Member

Having a GlobalWrappedTimer or whatever it would be called would also work, but it would be harder to discover.

I think that a WrappedTime resource is actually clearer here, and marginally reduces the size of the resource. IMO if we use that in our examples + have docs pointing to it from Time I think it'll be discoverable enough.

seconds_since_startup should simply be removed as part of this PR; it can be recovered from time_since_startup for users who really care, and is effectively just a footgun.

Copy link
Contributor

@maniwani maniwani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some doc suggestions.

crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
Co-authored-by: Cameron <51241057+maniwani@users.noreply.github.com>
@maniwani
Copy link
Contributor

maniwani commented Sep 14, 2022

seconds_since_startup should simply be removed as part of this PR; it can be recovered from time_since_startup for users who really care, and is effectively just a footgun.

This method exists because converting a Duration into an f64 isn't free and the compiler isn't smart enough to avoid it if you repeatedly call the method (see here).

IMO it's beneficial to keep anyway (as would adding a non-wrapped f32 method). Gives us more places to document this situation and makes it easier to identify when users encounter it. The only other thing I would do is log a message when the time since startup reaches a point where its f32 precision falls below a certain threshold.

@IceSentry
Copy link
Contributor Author

Yeah, I'd prefer not touch the current existing apis and let #5752 deal with updating the apis. I'll think about it a bit more, but I'm warming up to the idea of just having a WrappedTime resource and link it from the docs on Time

Copy link
Contributor

@Nilirad Nilirad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I don't think it's needed, since the same effect can be achieved by doing a modulo operation from user code. Anyway, I left some comments.

crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
crates/bevy_time/src/time.rs Show resolved Hide resolved
crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
crates/bevy_time/src/time.rs Outdated Show resolved Hide resolved
@IceSentry
Copy link
Contributor Author

@Nilirad just to clarify a little. The point is to have a globally available value that can be used for shaders and for other parts of the app to want the same wrapped value if they need to.

The implementation was also changed which made a few things inconsistent and after what @maniwani said I was already planning to change most of this to use a separate Resource instead.

As for it not being necessary because it's just a modulo, sure, that's true that it's easy enough to do without a specific api, but the point is to have something easy to use and consistent. Not everyone is familiar with the modulo operator, especially not beginners. I know it took me a few years before I could just intuitively think about just using a modulo to wrap around like this.

@Nilirad
Copy link
Contributor

Nilirad commented Sep 15, 2022

The point is to have a globally available value

I struggle to understand the need of having a global wrapped time value. From what I understand, a wrapped time should only be used in periodic phenomena, like animations and shader uniforms, but such periods are wildly variable by nature. Also, giving the user the responsibility to specify the duration (I know there is a “default”, but the method still requires to provide an argument) does not transmit that intent.

@IceSentry
Copy link
Contributor Author

IceSentry commented Sep 15, 2022

The period for an animation in a shader needs to be configurable, but it needs to be bound to a monotonically increasing value that is limited to an f32. In every game engine I'm aware of (and shadertoy) this value is the time since startup as an f32, the main issue here being that this f32 becomes very imprecise after a few hours which would cause shader based animations to behave strangely. The point of the period here is for the global period to wrap at an expected time, not to be the period of an animation.

As far as to why it needs to be global, well, that's because when passed to shaders it will always pass the same value to every shaders and therefore is a global value in this context. This is also something that every game engine supports because it's a generally useful thing to have and many people have expressed the need for it already. (This is already done in #5409, it's just missing the wrapping part)

Based on this, it would make sense to only add a resource to configure the period, since it would support making it globally configurable.

Based on what @maniwani recently talked about on discord though, it made me realize that we also need to be able to pause that value. Pausing time, while not currently supported will be supported as soon as #5752 is merged. We need to be able to pause this value, otherwise if someone pauses the global time, but shader based animation keep moving it would look really weird. Of course we'll need to have a variant that ignores pauses.

The last reason why I wanted to add it directly on Time is that precision loss happens fairly quickly when using f32 so offering a way to access an f32 value that wraps and correctly reacts to pausing and unpausing seems useful enough to just be added directly on Time as a convenience method alongside non wrapping methods instead of just adding a line in the Time docs to avoid casting to an f32 without wrapping it first.

Taking all of this into account. I see a few possibility.

  1. Close this PR and simply add a period configuration that is strictly used for shaders in the related PR and just handle everything separately from Time
  2. Add the wrap period as a public field on Time and add utility functions to get a wrapped f32 directly on Time
    • Default to 1 hour because it's a safe value and is also what godot uses
    • Making the period globally configurable is important, but I don't think it actually needs a way to change the period locally like how the current PR is doing. If you need a smaller period for an animation you should handle that directly.
  3. Add the wrap period configuration on Time, don't add convenience functions but recommend people to use the wrap period with a modulo when casting to an f32

I would like to hear which approach would be preferred here. Personally, I'd tend to go with 2, since I think this is still strongly related to Time and not strictly just a value that constantly goes up so it seems like that's where it should be handled, but clearly there's some pushback.

@superdump
Copy link
Contributor

The goal here with wrapping of elapsed time is for when you need an elapsed time value that does not suffer loss of precision beyond some threshold. I was thinking originally that one might say ‘give me the elapsed time in seconds, but wrap it around to give me a value with at most this amount of error’ and it would wrap accordingly. I see @Nilirad ’s point though about wanting to specify a wrapping period that matches a period of use such as for sinusoidal mapping of time. Hmm.

This all came because we want to have time in shaders and there we can only have f32 and then we’ll get noticeable loss of precision pretty quickly. Suggestions for solutions definitely welcome.

@superdump
Copy link
Contributor

I agree with everything @IceSentry wrote there though I’m not sure about whether there are more available options nor which to choose at this moment.

@cart any input?

@maniwani
Copy link
Contributor

maniwani commented Sep 15, 2022

I'm okay with putting it on Time, but let me clarify the separate resource idea.

You should be able to run a system like this right after the system that updates Time.

#[derive(Resource)]
pub struct WrappedTime {
    // repeating timer
    timer: Timer,
    elapsed_secs_f32: f32,
    elapsed_secs_f64: f64,
}

impl WrappedTime {
    pub(crate) fn update(&mut self, time: &Time) {
        self.timer.reset();
        // pretty sure this is O(1)
        self.timer.tick(time.elapsed());
        self.elapsed_secs_f32 = self.timer.elapsed().as_secs_f32();
        self.elapsed_secs_f64 = self.timer.elapsed().as_secs_f64();
    }

    /* ... */
}

// system
fn updated_wrapped_time(
    time: Res<Time>,
    mut wrapped_time: ResMut<WrappedTime>,
) {
    wrapped_time.update(time);
}

Like this, as long as Time itself does the right thing, you shouldn't see any issues, pausing or not. You'll always see the current elapsed time modulo the wrapping period. (And with global time scaling, we would just add another set of fields.)

But yeah, I'm cool with putting this inside Time for convenience/fewer system params. Probably easier that way if you want to change the wrapping period mid-frame.

@IceSentry
Copy link
Contributor Author

I updated the PR to use option 2. until we have more feedback.

@Nilirad
Copy link
Contributor

Nilirad commented Sep 18, 2022

Ok, I understand the ergonomics of using a global shader value. It is clearly more important than the minor visual glitch that can be introduced by the modulo operation.

We can document somewhere (better on the bevy_render crate) that using the global time in shaders may cause visual artifacts in the moment the time wraps. This way, a user can choose a customized solution for some prominent animations (e.g. item enchanting glow in Minecraft).

We can also make the wrapping period more friendly towards trigonometric functions (which is the most common case) by wrapping every 1146 * PI seconds by default, which is approximately equal to 3600.26518.

@superdump
Copy link
Contributor

Generally I would expect people to scale and possibly offset the time value before taking the sine or cosine to adjust the frequency and phase. I haven’t thought so hard but my gut says that would mess up basically any attempts to use a helpful wrapping value, right? I’d expect people to use something like sin(2 pi f t).

@Nilirad
Copy link
Contributor

Nilirad commented Sep 20, 2022

I haven’t thought so hard but my gut says that would mess up basically any attempts to use a helpful wrapping value, right?

Hmm, yes, you're definitely right. Scaling the period by a non-integer factor will create a discontinuity even if the wrapping value is an integer multiple of 2π. Furthermore, the longer is the wrapping period, the more chaotic the discontinuity will be across small period changes. Sure it is a price we have to pay if we want a global shader uniform for time. Still better on the longer side that letting the time value go adrift, but using a local uniform does not have this issue.

PS: a user could solve the discontinuity problem by setting the global time wrapping period as the least common multiple of all animation periods, but it could easily grow to a huge number.

@cart
Copy link
Member

cart commented Sep 22, 2022

Sorry for the delay. I've been fully focused on getting specific areas merged (such as stageless and "components as bundles") and I'm (once again) behind on my discord notifications.

I agree that "option 2" feels best. I'll give this a quick review.

@cart
Copy link
Member

cart commented Sep 23, 2022

bors r+

bors bot pushed a commit that referenced this pull request Sep 23, 2022
# Objective

- Sometimes, like when using shaders, you can only use a time value in `f32`. Unfortunately this suffers from floating precision issues pretty quickly. The standard approach to this problem is to wrap the time after a given period
- This is necessary for #5409

## Solution

- Add a `seconds_since_last_wrapping_period` method on `Time` that returns a `f32` that is the `seconds_since_startup` modulo the `max_wrapping_period`

---

## Changelog

Added `seconds_since_last_wrapping_period` to `Time`

## Additional info

I'm very opened to hearing better names. I don't really like the current naming, I just went with something descriptive.

Co-authored-by: Charles <IceSentry@users.noreply.github.com>
@bors bors bot changed the title add time wrapping to Time [Merged by Bors] - add time wrapping to Time Sep 23, 2022
@bors bors bot closed this Sep 23, 2022
@IceSentry IceSentry deleted the time-wrapping branch September 28, 2022 05:55
james7132 pushed a commit to james7132/bevy that referenced this pull request Oct 19, 2022
# Objective

- Sometimes, like when using shaders, you can only use a time value in `f32`. Unfortunately this suffers from floating precision issues pretty quickly. The standard approach to this problem is to wrap the time after a given period
- This is necessary for bevyengine#5409

## Solution

- Add a `seconds_since_last_wrapping_period` method on `Time` that returns a `f32` that is the `seconds_since_startup` modulo the `max_wrapping_period`

---

## Changelog

Added `seconds_since_last_wrapping_period` to `Time`

## Additional info

I'm very opened to hearing better names. I don't really like the current naming, I just went with something descriptive.

Co-authored-by: Charles <IceSentry@users.noreply.github.com>
james7132 pushed a commit to james7132/bevy that referenced this pull request Oct 28, 2022
# Objective

- Sometimes, like when using shaders, you can only use a time value in `f32`. Unfortunately this suffers from floating precision issues pretty quickly. The standard approach to this problem is to wrap the time after a given period
- This is necessary for bevyengine#5409

## Solution

- Add a `seconds_since_last_wrapping_period` method on `Time` that returns a `f32` that is the `seconds_since_startup` modulo the `max_wrapping_period`

---

## Changelog

Added `seconds_since_last_wrapping_period` to `Time`

## Additional info

I'm very opened to hearing better names. I don't really like the current naming, I just went with something descriptive.

Co-authored-by: Charles <IceSentry@users.noreply.github.com>
ItsDoot pushed a commit to ItsDoot/bevy that referenced this pull request Feb 1, 2023
# Objective

- Sometimes, like when using shaders, you can only use a time value in `f32`. Unfortunately this suffers from floating precision issues pretty quickly. The standard approach to this problem is to wrap the time after a given period
- This is necessary for bevyengine#5409

## Solution

- Add a `seconds_since_last_wrapping_period` method on `Time` that returns a `f32` that is the `seconds_since_startup` modulo the `max_wrapping_period`

---

## Changelog

Added `seconds_since_last_wrapping_period` to `Time`

## Additional info

I'm very opened to hearing better names. I don't really like the current naming, I just went with something descriptive.

Co-authored-by: Charles <IceSentry@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Time Involves time keeping and reporting C-Enhancement A new feature C-Usability A simple quality-of-life change that makes Bevy easier to use
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants