-1
\$\begingroup\$

I'm working on a time system in Unity.

All I want from the system is to update the time every minute in addition to:

  • The time update is not affected by the FPS game.
  • The time update occurs strictly so that if the duration of the day = 1 minute, the system must finish the passage of the day within 60 seconds.
  • Not affecting performance because I will use it on phones.

So I used FixedUpdate and the time is updated in minutes according to the timeMultiplier "calculated by the duration of the day".

When I tried the system, I found that the day ends in 59.94 seconds instead of 60 seconds (dayDuration = 1 minute).

Note: Measured by debugging and Time.time when hours = 24

I monitored the performance and found that the Fixed Timestep = 0.02 (Time.fixedDeltaTime) and the function (UpdateTime) is called 2998 times per minute instead of 3000 when dayDuration = 1 minute.

and when dayDuration = 0.1 minute the function (UpdateTime) is called 300 times and the day ends in 5.98.

Why does the day pass faster than it should be? or is there a better way to do that?

Code:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TimeManager : MonoBehaviour
{
    public static TimeManager Instance { get; private set; }

    #region Time Properties

    [SerializeField] private float dayDuration = 48f;
    [HideInInspector] public float timeMultiplier;

    private float elapsedTime;
    public int hour, minute, totalMinutes;

    #endregion

    void Awake()
    {
        #region Singleton

        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            Instance = this;
        }

        #endregion

        timeMultiplier = 1440 / dayDuration;
        elapsedTime = 0;
    }

    void FixedUpdate()
    {
        UpdateTime();
    }

    void UpdateTime()
    {
        elapsedTime += Time.fixedDeltaTime * timeMultiplier;

        while (elapsedTime > 0)
        {
            minute++;
            elapsedTime -= 60f;

            if (minute >= 60)
            {
                minute = 0;
                hour++;

                if (hour >= 24)
                {
                    hour = 0;
                }
            }

            totalMinutes = hour * 60 + minute;
        }
    }
}
\$\endgroup\$
2
  • \$\begingroup\$ 59.94 Hz is the standard NTSC screen refresh rate. \$\endgroup\$
    – Adam
    Commented Aug 31 at 19:50
  • \$\begingroup\$ You should not be using floats or doubles for (accurate) "time". It's all ticks, ms, seconds, etc. \$\endgroup\$ Commented Oct 13 at 3:53

2 Answers 2

3
\$\begingroup\$

You've fallen into the common beginner trap with floating point numbers, which is to treat them like they're real numbers.

With real numbers, 0.02 + 0.02 + 0.02 + 0.02 + 0.02 = 0.1 (5 fixed update ticks at 20 ms each account for one tenth of a second in total). But that's not true of floats!

The trouble begins with the fact that 0.02 isn't perfectly representable as a floating point number - you can tell because the denominator in \$\frac 2 {100}\$ is not a power of two. The closest we can store in a 32-bit floating point variable is 0.0199999995529651641845703125, which is about \$4.5 \times 10^{-10}\$ too small. That's a very small error, but an error nonetheless. (You can use this tool to check out what's the closest number we can actually store in a float)

The gaps between the numbers a float can represent get bigger the farther the number gets from zero. (See why loss of floating point precision makes rendered objects vibrate / table of available precision in various ranges shown here) So as you accumulate these small increments, not only do the rounding errors in each increment add up, but also the precision available to store the total gets worse as the total increases in value.

So, trying to add a bunch of small increments to a running total is a recipe for snowballing error. Adding up 60 seconds' worth of 0.02s steps yields a total of 60.0011826 - note how we've spilled over to an over-estimate, even though each increment was an under-estimate! We can try to combat this with specialized summation algorithms, but this just reduces the error - it doesn't eliminate it.

But instead of fighting this, we should ask ourselves, "why am I trying to invent my own clock?" The operating system and the game engine already have perfectly good clocks we can use!

Unity internally reads the high-precision timers provided by the CPU via the OS to do its time-keeping. We can benefit from that instead of trying to add-up time increments ourselves.

using UnityEngine;

public class TimeManager : MonoBehaviour {

    [SerializeField] float _dayDurationSeconds = 48f;

    [SerializeField]
    int _day, _hour, _minute;

    // Exposing values as getters, so other scripts can't
    // change the time and cause it to de-sync.
    public int Hour => _hour;
    public int Minute => _minute;
    public int TotalMinutes => (_hour * 60) + _minute;

    // Using double for these to keep max precision.
    double _timeMultiplier;
    double _startTime;

    void Start() {
        // Store our "zero" time.
        _startTime = Time.fixedTimeAsDouble;

        // 60 minutes per hour * 24 hours per day / (x seconds per day)
        // = game minutes per sim second.
        _timeMultiplier = 60 * 24 / _dayDurationSeconds;
    }

    void FixedUpdate() {
        int oldHour = _hour;

        // Total game minutes elapsed since start, including fractional minutes.
        double timeDelta = (Time.fixedTimeAsDouble - _startTime) * _timeMultiplier;

        // integer cast rounds to zero.
        int minutesSinceStart = (int)timeDelta;

        // Compute quotient and remainder in one pass.
        _hour = System.Math.DivRem(minutesSinceStart, 60, out _minute);

        // Wrap _hour in the range 0-23.
        _hour = _hour % 24;

        if (oldHour > 0 && _hour == 0) {
            _day++;
            Debug.Log($"Day {_day}: {Time.fixedTimeAsDouble - _startTime}s");
        }
    }
}

Unity handles ensuring that Time.fixedTimeAsDouble contains the total game time elapsed as of this FixedUpdate() tick, correctly rounded to the available precision of a double, and that 64-bit floating point number has enough precision to handle your game running nonstop for a lifetime and then more. Since this is not computed by accumulating small increments, we don't have the snowballing precision problem here.

If you run this code, you'll notice that the times reported at each day crossing are within one 0.02s timestep of the expected 48, 96, 144... progression (i.e. as close as we can get with code that only executes on that interval), and the difference does not drift or grow as the days pile on.

You'll also notice that this code is shorter and doesn't require iterating through a while loop. We've let the engine do the heavy lifting for us, using the code it's already running every tick anyway!

Note that we can still overflow here when calculating minutesSinceStart, but only if your game has been running continuously for over two years (or you use Time.timeScale to run in extreme fast-forward). If you're using code like this on a server that needs to support the game running continuously for multiple real-world years, upgrade to a long here. 😉

\$\endgroup\$
5
  • \$\begingroup\$ Thank you for the answer, I tried to modify the code to suit my needs and edited the quesion, take a look, and now the time in the script is updated very accurately, When _dayDuration = 0.1m the day is ends at fixedTime = 6s. \$\endgroup\$
    – Ahmed Dyaa
    Commented Sep 1 at 17:41
  • \$\begingroup\$ Remember that solutions always go in Answer posts — never edit a solution into the question post. You can then click the ✅ icon next to the answer to mark it as "accepted" to show that the question has been answered to your satisfaction. \$\endgroup\$
    – DMGregory
    Commented Sep 1 at 18:14
  • \$\begingroup\$ Yes I know, I just wanted to show you the code after the modification and there is no way to put the code as a comment so after your comment on the new code I was going to click the ✅ icon. \$\endgroup\$
    – Ahmed Dyaa
    Commented Sep 1 at 18:46
  • \$\begingroup\$ You can put the code in your own answer. It's OK to post answers to your own question. \$\endgroup\$
    – DMGregory
    Commented Sep 1 at 19:43
  • \$\begingroup\$ Thank you, good to know \$\endgroup\$
    – Ahmed Dyaa
    Commented Sep 1 at 20:30
0
\$\begingroup\$

I modified the code and the time is updated in a more accurate way and the day ends most of the time exactly on time or less by 0.02, but most of the time the day ends on time, even if the duration of the day is one second.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TimeManager : MonoBehaviour
{
    public static TimeManager Instance { get; private set; }

    #region Time Properties

    [SerializeField] private float _dayDuration = 48f;

    int _hour, _minute, _totalMinutes;
    float _timeMultiplier, _updateRate, _accumulatedTime;

    #region Getters

    public int Hour => _hour;
    public int Minute => _minute;
    public int TotalMinutes => _totalMinutes;
    public float TimeMultiplier => _timeMultiplier;

    #endregion

    #endregion

    void Awake()
    {
        #region Singleton

        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            Instance = this;
        }

        #endregion
    }

    void Start()
    {
        _timeMultiplier = 1440f / _dayDuration;
        _updateRate = 60f / _timeMultiplier;
    }

    void FixedUpdate()
    {
        _accumulatedTime += Time.fixedDeltaTime;

        if (_accumulatedTime >= _updateRate)
        {
            int minutesToAdd = (int)(_accumulatedTime / _updateRate);
            UpdateGameTime(minutesToAdd);
            _accumulatedTime -= minutesToAdd * _updateRate;
        }
    }

    void UpdateGameTime(int minutesToAdd)
    {
        _minute += minutesToAdd;

        if (_minute >= 60)
        {
            _minute -= 60;
            _hour++;

            if (_hour >= 24)
            {
                _hour = 0;
            }
        }

        _totalMinutes = _hour * 60 + _minute;
    }
}
\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .