Edit/update:
Unity defines Vector3 equality/equivilence using this class operator, Unity - Scripting API: Vector3.operator ==, which states two vectors are equal if they fall within a certain range of each other. Regardless of the purpose (optimization, help beginners, whatever…), this is now a mathematical definition.
In order to maintain the consistency of this definition, it is necessary to specifically check against it, during some operations. As an example, the normal operation will always need to check if the vector we are normalizing has a magnitude of zero, because Vector3.zero is the only invalid vector we can input, (and in order to avoid a divide by zero error). Though one CAN check the magnitude against the value 0.0f, we could equivalently express the conditional, as checking to see if the input vector is EQUAL to Vector3.zero.
So, in order to be consistent with the unity definition of vector3 equality, but avoid extra operations, the normalize function checks the magnitude directly against the defined equality range.
(Never would have seen this without Owen’s help.)
Additional FP computation details, and my moaning, remain, below.
End edit:
Normalizing a Vector is a universally standard mathematical operation. It is clearly defined, the same way, in all fields of math.
Except in Unity.
Unity decided to change this universally accepted formula, such that arbitrarily “small” vectors return Vector3.zero when normalized. Apparently, this is not a bug, it’s “by design”. (see Eric’s answer for the actual unity code.)
I have yet to get a good reason for this choice. But, since I don’t trust myself enough; if I get more than let say… 4 up-votes on this answer, and no “good reason” is provided, I WILL submit it as a bug. Please down-vote this answer if you think I’m wrong, and there is a good reason for this limit (though a detailed explanation on why, would be most appreciated.)
After much analysis, research and tests, I have concluded that the generalized claim that this is a “32 bit floating-point limitation”: is simply incorrect! (Sorry Eric.) Sample code in my OP question provides a limited proof of this, code in other professional-level software projects lends weight to this argument, and a rigorous analysis of the FP operations involved confirms it. (Analysis details below).
Another possible reason provided is that this is intended to somehow help us detect inaccurate FP values. But this requires a whole bunch of assumptions about the vector being passed in to the normalize function. These assumptions turned out to be incorrect, in at LEAST one real-world case (which is how I ran across this issue).
Both explanations seem inconsistent with the rest of Unity; everywhere else in my code, it is MY duty to ensue FP accuracy. I agree with this philosophy; and ensuring FP accuracy is most certainly NOT the duty of a normalize vector function.
It seems unbelievable, but if you want a function that performs the STANDARD vector normalization function, consistently, you actually DO need to provide your own. (Easy 'nuff to do, just very surprised it’s necessary.)
Some FP analysis details:
The limit applied by unity affects a Vector3 with a magnitude of less than 1e-5, which is FAR greater than where FP limits are actually hit.
The 32 FP limit is 2^-126, or approximately 1.17e-38: this is the smallest number we can represent, with maximum precision, using a 32bit-FP.
0x0080 0000 = 1.0 × 2−126 ≈ 1.175494351 × 10−38 (min normalized positive value in single precision)
This means that if we square a number (part of the normalize operation) that is smaller than (sqrt(2^-126)), or around 1.32e-19 in decimal, the result will be too small to be represented, with full accuracy, by a FP.
So, if we compared say… 2e-19 against the original vector components, we could determine if any of them might be too small to be squared with full accuracy.
For the normalization operation, there is a safe exception we can make. We don’t mind when accuracy-loss happens to components that are only a tiny fraction of the other components. What matters, is the RELATIVE values of the vector components. In other words, if one component is “small enough” relative to another, it’s contribution is less than the least significant digit of the input vector’s magnitude. In this case, we don’t care if the smaller component loses accuracy; because its value is simply not significant enough to affect the result at all.
This “significance” is, obviously, related to the number of “significant” digits a float can store: 7.2 decimal, lets round up to 8 for worst-cases. The value at which our 2e-19 is too small to be significant, is 2e(-19 + -8) = 2e-11.
So those are our limitations, and exceptions:
- Exception: If any component is
greater than 2e-11, you will not lose
accuracy.
- Limit: Otherwise, if any
component is less than 2e-19, you might
lose accuracy.
Guesses on what unity is doing:
One COULD use the vector’s magnitude to check for the exception: If the vector to be normalized has a magnitude that is greater than a certain value, we KNOW, at least one component is so large that any possible loss-of-accuracy, in other components will NOT be significant. The “certain value” magnitude to check against should also be 2e-11, since 2e-11= sqrt(2e-11^2+0+0).
So perhaps, Unity is checking for the exception, and for optimization purposes, using it as the limit?
Even then, I’m still not sure where the 1e-5 comes from. The only relation I can see to this number, is that it happens to be the approximate value of the least significant digit of a 32bit-float, when using an exponent value of 0. I guess this number DOES represent the LSB value of the normalized output vector’s largest component, but the value is actually being compared against the input vector, not the output vector: so, NOT actually relevant to the computation.
IMHO it’s NOT actually the job of a normalize function to check my FP accuracy, but still, I HOPE I’m wrong about this FP stuff, since it’s the only possibly-valid reason I can think of for this limitation. A sample Vector3 value, that my analysis would say works with full accuracy, but actually fails to be properly processed by a standard normalize function would be the best proof I’m wrong.