Understanding Unity 6's RationalTime: Precision, Accuracy, and Use Cases

Posted by : on

Category : Unity

Introduction

Unity 6 introduces a new data type for representing time: RationalTime. This type, designed for rational numbers, helps avoid many of the precision errors commonly encountered with floating-point numbers. In this article, I’ll provide an overview of RationalTime, explore its use cases, discuss its advantages and limitations, and review some code examples.

Before we begin, it’s important to understand what a Rational number is. In short, a rational number is any number that can be expressed as the fraction p/q, where p and q are integers. This includes whole numbers, fractions, and decimal numbers that either terminate or repeat. However, it does not include numbers like π or e.

Precision, Accuracy, and Determinism

When working with time in a video game, three key factors must be considered: precision, accuracy, and determinism.

Determinism Determinism is straightforward: we cannot guarantee it. While computers are inherently deterministic, we expect the same input under the same conditions to yield the same result. Algorithmic determinism is possible, but the execution environment is never truly identical.

Games run on different hardware, and even if we could ensure the same hardware and operating system, we cannot control other processes running alongside our game. The OS dynamically allocates resources based on current demands, and this variability, caused by background processes like update checks and scheduled jobs, makes millisecond-level determinism impossible.

Accuracy Accuracy can be achieved to a certain extent. Unity’s Timer class returns the timestamp of the start of the current frame, which is usually sufficient since code execution cannot occur more frequently than once per frame. If higher accuracy is required, we can use C#’s built-in timing methods (see my post on what are the differences in the timers in C#), but this is rarely necessary. The precision of these timers is comparable to the time it takes to execute a frame. Even if we needed greater accuracy, we would have to use native interop to call the Windows multimedia timer, which has higher precision but introduces significant performance costs. Furthermore, this approach would require maintaining separate implementations for different operating systems.

Precision Precision issues arise from two sources:

  1. Precision loss in floating-point arithmetic (e.g., 0.1f + 0.2f does not exactly equal 0.3f).

  2. Precision loss due to rounding (e.g., 10/3).

Both problems can be mitigated by using a data type that represents rational numbers exactly, such as RationalTime.

The RationalTime Struct

The RationalTime struct in Unity allows us to create rational numbers from a double-precision floating-point value using the FromDouble static method. It has two primary properties:

  • Count – Represents the number of ticks as a long integer.

  • Ticks – Represents the number of ticks per second.

The Ticks property returns a TicksPerSecond struct, which has:

  • Numerator – The numerator of the rational number representing the number of discrete time slices per second.

  • Denominator – The denominator of that rational number.

A commonly used default is DefaultTicksPerSecond, which ensures lossless representation for most frame rates.

DefaultTicksPerSecond is set to 141120000, but let’s consider a simpler example: suppose we define TicksPerSecond as 25. This means each second consists of 25 ticks. If the Count property equals 60, we can determine the represented time as follows:

  • 60 / 25 = 2 seconds and 10 ticks

  • Or 2 seconds and 10/25 of a second

  • Or 2.4 seconds

Using RationalTime

The benefits of RationalTime become evident when performing many calculations involving floating-point arithmetic. Instead of using floating-point numbers, we can operate with RationalTime and convert to a float only when necessary, probably as a final operation.

However, if we were to replace the float type for counting time, with RationalTime in one of my various timer implementations in Unity, we wouldn’t see much improvement. In that implementation, I avoid floating point arithmetic precision errors, by checking the timer via subtracting the calculated trigger time from the current time, a single operation that doesn’t introduce significant precision loss.

Here’s an example of how the MinimalTimer could be implemented using the RationalTime data type:

public readonly struct MinimalIntegerTimer
{
    public static MinimalIntegerTimer Start(double durationInSeconds) => new(durationInSeconds);
    public bool IsCompleted => Time.timeAsRational.Subtract(_triggerTime).Count >= 0 && _triggerTime.Count != 0;

    private readonly RationalTime _triggerTime;
    private MinimalIntegerTimer(double durationInSeconds)
    {
        var rationalTime = RationalTime.FromDouble(durationInSeconds, RationalTime.TicksPerSecond.DefaultTicksPerSecond);
        _triggerTime = Time.timeAsRational.Add(rationalTime);
    }
}

As we can see, the Time class in Unity has been updated to include a timeAsRational property. However, the only floating-point calculations performed here are a single addition in the Time.timeAsRational.Add(rationalTime) operation and a single subtraction in the IsCompleted property: Time.timeAsRational.Subtract(_triggerTime).

The real advantage of RationalTime becomes evident when performing numerous floating-point operations, such as continuously adding Time.deltaTime to a floating-point variable in every Update call. Below is an example that also compares the precision errors of floats and doubles:

public class RationalTimeExample : MonoBehaviour
{
    private const double EPSILON = 0.000_001;
    private double timeAsDouble;
    private float timeAsFloat;
    private RationalTime integerTime;

    private void Awake() => integerTime = RationalTime.FromDouble(0, RationalTime.TicksPerSecond.DefaultTicksPerSecond);

    private void Update()
    {
        timeAsDouble += Time.deltaTime;
        timeAsFloat += Time.deltaTime;
        integerTime = integerTime.Add(RationalTime.FromDouble(Time.deltaTime, RationalTime.TicksPerSecond.DefaultTicksPerSecond));

        if (Math.Abs(timeAsDouble - integerTime.ToDouble()) >= EPSILON)
        {
            Debug.Log($"Float measurement: {timeAsFloat:F15}");
            Debug.Log($"Double measurement: {timeAsDouble:F15}");
            Debug.Log($"Rational time measurement: {integerTime.ToDouble():F15}");
            Debug.Break();
        }
    }
}

Here, we add Time.deltaTime to three different data types: a float, a double, and a RationalTime. The EPSILON value acts as a precision error threshold, pausing execution in the Unity editor when the difference between the rational number and the double exceeds this value.

The only precision loss for the rational number occurs when converting it to a floating-point value, as not all rational numbers can be precisely represented by a double. However, beyond that, each addition using integerTime = integerTime.Add(RationalTime.FromDouble(Time.deltaTime, RationalTime.TicksPerSecond.DefaultTicksPerSecond)) incurs no precision loss, unlike timeAsDouble += Time.deltaTime, which accumulates errors over time.

When the program pauses due to a precision loss greater than one microsecond, the exact timing depends on the system and the number of operations running in the Update method. On my machine, with only this code running in the Unity editor, the pause occurs after approximately 1.6 seconds. This equates to a precision loss of about 1 second every 18.5 days or around 20 seconds per year.

Conclusion

The RationalTime data type is valuable for operations requiring extremely high precision, as demonstrated in the example above. While such precision is necessary in some cases, a double is usually sufficient.

Importantly, despite its name, RationalTime is not limited to representing time values. It can be used for any scenario where floating-point numbers are involved, particularly when performing numerous calculations and aiming to minimize precision loss. For example, in distance calculations, if multiple floating-point operations are required, RationalTime can be used to maintain accuracy, with precision loss occurring only in the final conversion to the required data type.

Thank you for reading, and if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: