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:
Precision loss in floating-point arithmetic (e.g., 0.1f + 0.2f does not exactly equal 0.3f).
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 and10
ticksOr
2
seconds and10/25
of a secondOr
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.