-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[raymath] Make every normalize function similar #3847
Conversation
@planetis-m This change modifies multiple functions and it's quite sensible, did you implement some test example to verify everything works exactly the same way? Some test cases would be really useful in this situation. |
I don't have tests cases for all the functions I changed, they're quite a lot, however my justification is that the number with square root 0 can only be 0. So delaying the call to the function sqrtf after the "if value is 0" statement doesn't make any difference in the result. In fact it was already done like that in the ClampValue functions. |
Relevant SO question: https://stackoverflow.com/questions/73988574/given-x0-is-it-possible-for-sqrtx-0-to-give-a-floating-point-error Maybe it's preferable to remove the check altogether? |
I have tried the example in the SO answer and the condition #include <stdio.h>
#include <math.h>
int main() {
double x = 0x1p-1021;
double y = 0x2p-1020; // or y = 0
double xxyy = x * x + y * y;
if (xxyy == 0) printf("caught\n");
printf("%a %a\n", x, y);
printf("%a %a\n", xxyy, sqrt(xxyy));
return 0;
} But I can switch the checks to abs(xxyy) <= EPSILON, if it's any better. |
@planetis-m This PR changes many functions and it should be carefully tested to verify every new implementation behaves exactly like previous one. Also note that comparing float values with |
I notice there are some mismatches checking the length > 0.0f some places and != 0.0f in other places. Regardless, most of these functions follow the same pattern of Calculate length using sqrtf and divide each vector member by length. In reality you may have V.X * Y / LEN, but since Y is 1.0f, it becomes V.X / Len. Each function could therefore be reduced to: // Normalize provided vector
RMAPI Vector2 Vector2Normalize(const Vector2 v)
{
const float length = sqrtf((v.x*v.x) + (v.y*v.y));
return (length > 0.0f) ? CLITERAL(Vector2){v.x / length, v.y / length} : CLITERAL(Vector2){0};
} Step 1: Calculate Length I may be wrong, so further observation is required. Edit: Currently no cliteral and compound literal would look like: // Normalize provided vector
RMAPI Vector2 Vector2Normalize(const Vector2 v)
{
const float length = sqrtf((v.x*v.x) + (v.y*v.y));
Vector2 result = {0};
if (length > 0.0f) {
result.x = v.x / length;
result.y = v.y / length;
}
return result;
} I'm not sure what your thoughts are on const-correctness and conciseness here @raysan5 , since Raylib is also for educational use, a smaller, let's call it "more clever" way of doing things is not necessarily better for teaching and learning. |
TODO: All affected functions should be properly tested and compared to current ones, in this case some unit tests would be required. I will keep this issue open for some time in case anyone wants to do that work. If not, I will just close the issue because current implementation is proved to be working correctly and the benefits of this PR are dubious. |
Sorry I do not know how to make comprehensive tests. Because I do not know what exactly to test for! Are we doing unit testing or integration testing? Are there any other tests files I can draw inspiration from? I did another test though with the example raymath_vector_angle and used I would just remove the condition |
float length = sqrtf(q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w); | ||
if (length == 0.0f) length = 1.0f; | ||
float ilength = 1.0f/length; | ||
float lengthSq = q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w; |
Check notice
Code scanning / CodeQL
Commented-out code Note
float length = sqrtf(axis.x*axis.x + axis.y*axis.y + axis.z*axis.z); | ||
if (length == 0.0f) length = 1.0f; | ||
float ilength = 1.0f/length; | ||
float lengthSq = axis.x*axis.x + axis.y*axis.y + axis.z*axis.z; |
Check notice
Code scanning / CodeQL
Commented-out code Note
Sokol: https://github.com/floooh/sokol/blob/c54523c078e481d3084fa0b4630d2ce3d3e1e74f/util/sokol_gl.h#L3158 Not to say that I could still be wrong, but please to advance the conversation, try to show me actual breakage, it's only fair to ask as I did so much research in the topic. |
PPS: In reviewing more of this, I see that you did explain that you were avoiding PS: There are differences in edge cases here and, with a quick look, it seems as if that is what the proposed changes are about. The screening of float values for exactly I think this could have been explained better. I withdraw my previous comments. |
Ok I took the time to write a fuzz target that you can compile with: #include <math.h>
#include <stddef.h>
#include <stdint.h>
#include <cstring>
#include <iostream>
// Definition of the Vector2 struct
typedef struct Vector2 {
float x;
float y;
} Vector2;
// Implementation of the Vector2Normalize function
Vector2 Vector2Normalize(Vector2 v) {
Vector2 result = { 0 };
float length = sqrtf((v.x*v.x) + (v.y*v.y));
if (length > 0) {
float ilength = 1.0f/length;
result.x = v.x*ilength;
result.y = v.y*ilength;
}
return result;
}
Vector2 Vector2Normalize2(Vector2 v) {
Vector2 result = { 0 };
float lengthSq = (v.x*v.x) + (v.y*v.y);
if (lengthSq == 0.0f) lengthSq = 1.0f;
float ilength = 1.0f/sqrtf(lengthSq);
result.x = v.x*ilength;
result.y = v.y*ilength;
return result;
}
// Fuzz target function
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (size < sizeof(Vector2)) {
// Not enough data to form a valid Vector2
return 0;
}
// Create a Vector2 object from the input data
Vector2 v;
memcpy(&v, data, sizeof(Vector2));
// // Call the function under test
// Vector2 result = Vector2Normalize(v);
// // Optionally, you can add some checks here to validate the result
// // For example, you could check that the result vector has a length of 1 (normalized)
// float result_length = sqrtf((result.x * result.x) + (result.y * result.y));
// if (result_length < 0.99f || result_length > 1.01f) {
// // Report an error if the result is not properly normalized (allowing some tolerance)
// std::cerr << "v: (" << v.x << ", " << v.y << ")\n";
// std::cerr << "result: (" << result.x << ", " << result.y << ")\n";
// abort();
// }
// Call both normalization functions
Vector2 result1 = Vector2Normalize(v);
Vector2 result2 = Vector2Normalize2(v);
// Check that the results are equal
// const float tolerance = 0.001f;
// if (fabs(result1.x - result2.x) > tolerance || fabs(result1.y - result2.y) > tolerance) {
if ((result1.x != result2.x) || (result1.y != result2.y)) {
// If the results differ significantly, print the vectors and abort
std::cerr << "Mismatch:\n";
std::cerr << "v: (" << v.x << ", " << v.y << ")\n";
std::cerr << "result1: (" << result1.x << ", " << result1.y << ")\n";
std::cerr << "result2: (" << result2.x << ", " << result2.y << ")\n";
abort();
}
return 0; // Indicate successful execution
} There's a mismatch with very small numbers:
The first function returns the zero vector while the modified one, the original vector itself. Both are not unit vectors obviously, so the function's contract is broken regardless. I also caught other cases that produce different results based on the compilation flags:
Produces either itself or (0, 0) depending if Every vector with a nan component is also causing trouble:
If your calling code checks the results with a tolerance there's no change in output. But if it doesn't I guess there's some difference. But does it really break your code, please let me know. |
Yes, the problem is what should always be returned is a unit vector. There are two problem cases: Underflow of the squares and overflow of the squares summed. I don't think There is mathematically only one edge case for which there is no answer. That's for (0,0). There's no way to assign a direction of a true 0-length vector.
I suspect these cases do not come up in correct usages of I do think it is a defect not to address this. And the edge-case result should be made known and part of the interface contract. PS: It seems that returning (0,...,0) is the most popular result for the case where there is no normalization. https://stackoverflow.com/questions/722073/how-do-you-normalize-a-zero-vector |
To add to what @orcmid said, I think the proper check for the function is this: let unit_vector = normalize(v);
let tmp = length(unit_vector);
if (tmp < 0.99f || tmp > 1.01f) {
// Handle fail case
} which works in both cases. Or: let unit_vector = normalize(v);
if (unit_vector == {0}) {
// Handle fail case
} Which only works with the original approach. Let's please close this PR either way. It's been dragged for too long. |
If we think about this some more, we can recognize that the only situation of which a division by zero can occur in these functions are on Zero-vectors only. By virtue of squaring, all negative values will be positive, and both positives are summed, ergo no value can cancel each other out ending up in a square root of zero. The only place this can happen is if both parts of the vector are already zero. This means that we can by contract assert that either part of the argument vector has to be non-zero, and calling the function with a Zero-vector is illegal. This will result in a much smaller function, but will require handling of the contract (by using assert) and users will have to opt-out of the assert check for well-formed programs after verification (using -DNDEBUG flag) Resulting code (Godbolt link): Vector2 Vector2Normalize(const Vector2 v) {
assert(v.x > 0.0f || v.y > 0.0f);
const float length = sqrtf((v.x*v.x) + (v.y*v.y));
Vector2 result = {
v.x / length,
v.y / length,
};
return result;
} @planetis-m I don't know if you can fuzz this with Zero-vector being illegal, but that would be very cool! |
@JayLCypher @planetis-m @raysan5 Let's review the bidding here. I believe there are three factors to consider.
Applying these ideas leads to a flavor of the @planitis-m recommendation in raylib style: RMAPI Vector2 Vector2Normalize(Vector2 v)
{
Vector2 result = { 0 };
float lengthSQ = (v.x*v.x) + (v.y*v.y);
if (lengthSQ > (FLT_EPSILON)*(FLT_EPSILON))
{
float ilength = 1.0f/sqrtf(lengthSQ);
result.x = v.x*ilength;
result.y = v.y*ilength;
}
return result;
} I favor the idea of returning exactly |
Right, I made an error in reasoning for the values of the range (-1, 1), where the product of squaring actually becomes smaller. One could almost be forgiven to introduce a separate handling of this special edge case. Regardless, an easy fix is to clamp the value to FLT_EPSILON / Raymath EPSILON / some minimal value, accepting certain inaccuracies. Doing so with the squared value using fmaxf or a ternary. More observations:
|
Your feedback has been invaluable! This discussion has not only enhanced my understanding, but helped grow as a programmer. Based on the conversation, it seems that either Also what about every function that inlines normalize, like Vector3Rotate, MatrixLookAt, etc which use:
Should that be changed to:
PS. To ignore certain inputs when fuzzing all you need to do is check for them and return early. |
I'm closing this PR. If it requires another review in the future for functions alignment, feel free to open a new PR. |
Follow up to #2982 Makes normalize functions consistent, moved sqrtf call after the if statement which causes its assembly to be slightly better in both clang 17 and gcc 13. It now uses one rsqrtss instruction instead of sqrtss + divss combo https://godbolt.org/z/5K475o88n plus in clang it's branchless.