top of page

When A Number Doesn't Equal Itself

Updated: Oct 30, 2023

The short version of this article is a fairly simple statement: Never use == or != with a float-based variable, unless the value you're checking against is one you explicitly set yourself. Unpacking the hows and whys of that sentence is going to take some explaining, but if you're pressed for time, you have the option of just taking my word for it and using that sentence as your guide.

Still with me? Alright, let's break it down.

How Computers Store Numbers

I can see you raising your hand through the computer screen. "It's binary, right? Ones and zeroes!" Right you are! One of the first lessons in every computer-related class (for some reason) tells us that computers store data as ones and zeroes. I've really never understood how that information is relevant to using Microsoft Word, but it's there in the first lesson of that class nonetheless. But dealing with floating point inaccuracy is one of the few times where that information actually matters.

When you have a whole number, binary is pretty simple. Each bit is one slot, its value doubled each time, and you just fill in the bits until all the slots add up to the correct number. In a four-bit number, those slots are 8, 4, 2, and 1. Zero is represented as 0000, while 5 is 0101 - four plus one. Pretty straightforward. If you want to allow for negatives, you reserve one bit at the beginning for positive vs negative.


But how do you represent decimals?


It turns out that scientific notation is the answer. You can't store 1.234 directly as binary, but you can store 1234 * 10^-3, or (as a handheld calculator would notate it) 1234e-3. Because 1234 and -3 are both whole numbers, they can be stored in binary. Incidentally, this is where the term "floating point" (and the data type name float) comes from.


But we need to stop thinking in base ten, because your computer doesn't think in base ten. Binary is base two. But human suck at base two math, so we're going to have to abstract it away a little bit. The important thing to know is that 1.234 gets converted to a binary-fractional number analogous to a decimal (you can do that, even if your computer can't natively store it), and then turned into A * 2^B format, where A and B are whole (binary) numbers, and can get shuffled into the computer's memory. For example, 0.5 gets turned into a binary 0.1; 0.25 becomes a binary 0.01; 0.375 becomes a binary 0.011. Each bit deeper into the number halves the value of that place, compared to base ten, where each digit deeper divides it by 10.


The process of converting these numbers into binary-fractional numbers is where the trouble lies. The numbers I gave above were simple, but try converting 0.6 into a binary fraction. It becomes 0.100100100.... but then it keeps going. Just like you can't turn 1/3 cleanly into a decimal number (0.333 repeating), you can't turn 0.6 cleanly into a binary number (0.100 repeating). So it does what any calculator does: works it out to a certain point, then truncates it (just as your calculator will only show you 0.3333333, not an infinite number of 3's)


Taking the parallel process a little further, trying to do math with fractions converted into decimals can leave you with tiny inaccuracies. For example, 1/3 * 3 = 0.333 repeating * 3 = 0.999 repeating. Obviously we humans know that 1/3 * 3 = 1, but for computer-like math, it's 0.999999999 out to however far it has truncated. Now, take that same logic, and apply it to 6/10 converted into base 2 - the converted number is going to be a little bit wrong, and as a result, any math done with with will be a little bit wrong.


If you're comparing the same number to itself, this is actually fine:

float x = 2.6f;
if (x == 2.6f)

Because the computer has simply converted 2.6 into binary twice, it's going to get the same result, and we've got no problem. After all, even if 0.33333 isn't 1/3, 0.33333 is 0.33333. And this gives you the "unless" in the original statement: if it's a value you've explicitly set yourself, you can rely on it.


It's when you do math that things get hairy.

float x = 2.6f;
if (x + 0.4f == 3f)

That's a big problem there. To be honest, I have no idea whether that particular if statement will run or not. I haven't plugged it into my compiler to check. The important thing is that I can't rely that it will run correctly, therefore it should be avoided.


User input, physics & deltaTime

While floating point inaccuracy is the toughest inaccuracy problem to wrap your head around, it's certainly not the only one, especially in gaming. I've seen plenty of newbies make this mistake:

float elapsedTime = 0f;

void Update() {
    elapsedTime += Time.deltaTime;
    if (elapsedTime == 5f) {
        Debug.Log("Five seconds have passed.");
    }
}

In this case, the problem isn't floating point inaccuracy. Well, it might be that too, but there's a bigger problem: Time.deltaTime! deltaTime is the time between the last frame and the current frame, and its value is very much not any sort of even, neat number (if it was, it just wouldn't work.)


The problem in using it with == is that games are divided into discreet frames. In this case, elapsedTime is going to go from 4.9896546 to 5.011243432 or something like that - it'll pretty much never land exactly on any given numerical value.

You get the same story anytime you do any movement that is dependent on Time.deltaTime (which, if you're doing things right, is just about all of your movement). And even if you're not, if your movement is relative to its old position, then you're still going to have floating point issues.


And there are other kinds of issues. Just about any kind of user input is going to have some imprecision - mouse movement and the oft-required raycasts, and VR in particular will give you unpredictable tiny fractions of movement. And anything involving physics will have these tiny variations.


Ultimately, you basically have to regard anything that's a float as a smooth, continuous scale of values. so how do you detect particular numbers on it?


Approximating/Distance

You can use Mathf.Approximately to account for floating point errors. Not much more to say about that, except that it's only useful for floating point issues - anything based on the other inaccuracies described here will fall well outside the range of .Approximately.


You can do more or less the same thing yourself with a larger range that may catch those larger inaccuracies using the following method:

float maxRange = 0.01f;
if (Mathf.Abs(currentValue - targetValue) < maxRange) {
    Debug.Log("We're close");
}

If you're comparing positions, you can use this code to accomplish the same thing: (I've used .sqrMagnitude here for speed)

float maxRange = 0.01f;
if ((currentPos - targetPos).sqrMagnitude < maxRange * maxRange) {
    Debug.Log("We're close");
}

This has limited utility - mainly that it only pushes the problem a little further out. If we're using the code above with time.deltaTime and waiting for a target time of 5 seconds, there's absolutely nothing saying that the time can't go from 4.98 to 5.02, and still skip over our range. The same is true of any range we give it - especially on slower systems. Your game might not be fun to play at 10 or 15 fps, but it shouldn't outright break the game. And a hiccup in performance can occur at any time, even if the framerate is otherwise smooth. And of course, making the range much larger (like 0.5) simply causes it to be inaccurately timed.


Ultimately this method is best used when something is approaching a target - for example, a pathfinding agent nearing its next node. That way you can be fairly confident it's not going to pass over your target.


If it might pass over your target, here are some better options.


Threshold Crossing

There will come a time when something goes from below a number to above a number. Often, that's the time to trigger whatever it is you're triggering.

private bool isAboveThreshold = false;
private float elapsedTime = 0f;

void Update() {
    elapsedTime += Time.deltaTime;
    bool newAboveThreshold = (elapsedTime > 5f);
    if (newAboveThreshold != isAboveThreshold) {
        Debug.Log("Five seconds have passed");
    }
    isAboveThreshold = newAboveThreshold;
}

It's worth noting that the code written above would also detect the moment elapsedTime crosses back across the threshold of 5. That's not possible since it's always increasing by deltaTime, but in another example (say, position-based), it might be useful.


Nearest Approach

The equivalent approach to threshold crossing for Vector3's is checking for the nearest approach. The idea is this: Your object will be moving in a line towards your target, its distance decreasing each frame. At some point, it'll pass it, and its distance will start to increase. The instant it starts to increase, trigger it. (If your object won't necessarily be moving in a straight line, you may want to put some max range on it as described above.)

private float lastDistSq = 999999f;
private bool wasTriggered = false;
void Update() {
    float thisDistSq = (transform.position - targetPosition).sqrMagnitude;
    if (thisDistSq > lastDistSq && !wasTriggered) {
        Debug.Log("Triggered");
        wasTriggered = true;
    }
    lastDistSq = thisDistSq;
}

תגובות


bottom of page