Skip to content
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

improved doc for MathUtils sin lookup table #5807

Closed
wants to merge 1 commit into from

Conversation

mgsx-dev
Copy link
Contributor

@mgsx-dev mgsx-dev commented Oct 2, 2019

As you can see in graphs below, using large angle values can give bad precisions :

This one is using high values unbounded (360000 to 360360)

This one is using same values but bounded (using % 360 before lookup table) which is the same as (0 to 360) range.

Thanks to @tommyettinger for these graphs and his help.

@intrigus
Copy link
Contributor

intrigus commented Oct 2, 2019

What are these graphs even showing?
Some information about what the x axis/y axis is showing would be good.

@mgsx-dev
Copy link
Contributor Author

mgsx-dev commented Oct 2, 2019

X axis : input angle
Y axis : MathUtils.sin output

It illustrates precision loss when converting from an angle to lookup table index.

@intrigus
Copy link
Contributor

intrigus commented Oct 2, 2019

I'd be very happy to have a graph with a proper scale :)
Right now there could be a huge precision loss or only a small precision loss, I can't tell from the data.

@tommyettinger
Copy link
Member

Sure, I can get on that. The main difference is obvious: some values are produced more often than others when the input is large, vs. when it is small. In particular, the correct graph should produce 0 (the center of the x axis is 0) as the least frequent value, but it's either the most frequent or second-most frequent value when using large degree inputs.

@tommyettinger
Copy link
Member

tommyettinger commented Oct 3, 2019

OK, here's a "good case" for MathUtils.sinDeg(), where all inputs are in the range 0-360 (inclusive lower, exclusive upper).
Good case within range
This is one million calls to sinDeg(), shown so the center of the graph increases when a value is within 1/510 of 0 in either direction, the left of the graph is similar for a result close to -1, and the right for 1. Here, the inputs are incremented by MathUtils.PI and any assignment is followed by a modulus by 360. It is graphed so one thousand results in a 1/255-wide area cause an increase of one pixel for that area. Ten thousand results in an area like that result in an increase of 10 pixels, which corresponds to a red tick mark at left. These red tick marks are spaced every 10,000 results, with the ticks widening from their minimum width to their maximum width over 50,000 results.

Using the same rules, here's a "bad case" for MathUtils.sinDeg(), where inputs are allowed to increase very far past 360:
Bad case out of range
Here, the inputs are again incremented by MathUtils.PI but they are not limited by any modulus. That has inputs ranging from pi to pi * 1000000, which pushes the limits of float precision. It isn't immediately obvious that this behavior results from floating-point precision, especially since Math.sin() doesn't have any issues at this scale, so I think it's important to document this so users don't fall into a trap of the same few results showing up for what should be a smooth sine calculation.

@tommyettinger
Copy link
Member

tommyettinger commented Oct 3, 2019

Also, for comparison, here's a "good case" using a float sine approximation in degrees, but without an LUT:
Good case without LUT

@@ -59,22 +59,26 @@
}
}

/** Returns the sine in radians from a lookup table. */
/** Returns the sine in radians from a lookup table. For optimal precision, use radians that are not drastically more positive
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the same as: "For optimal precision use radians in the range [-PI2, PI2]."?
If so in my opinion this would be more understandable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phrasing is going to be tricky here. I can try to figure out where it starts losing precision, but it's probably somewhere greater than PI2 or less than -PI2. The lookup table has 18 bits of precision I think, with 65536 entries corresponding to one quadrant, but it could be lower. That might be just 16 bits of precision, depending on how you measure it. A float has 23 mantissa bits, and those represent larger and larger units as the exponent increases, so you'd only run out of mantissa outside (-32,32) or (-128,128), and I'll need to run some tests to make sure. Even then, the difference is small around that limit, but if you don't somehow limit how high or low the radians argument goes, it is very easy to exceed the precise range.

But yeah, I like your comment text better except for the quirk where not every school system teaches that syntax for a closed interval (it might be like the local difference between 1,000,000 and 1.000.000, and I don't know specifics other than sometimes people don't understand when I use that syntax). Maybe "For optimal precision, use radians between -PI2 and PI2, both inclusive."

@obigu
Copy link
Contributor

obigu commented Oct 8, 2019

Would it make sense to apply the bounding (% 360) in the implementation itself? Performance wise would probably be unnoticeable.

@MobiDevelop
Copy link
Member

I was going to suggest that as well.

@tommyettinger
Copy link
Member

tommyettinger commented Oct 9, 2019

The modulus, done internally or externally, does help up to a point. It doesn't improve the speed at all though, and with recent Java versions Math.sin(angle) outperforms MathUtils.sin(angle % MathUtils.PI2) on both speed and quality (without any LUT). The main reason why MathUtils' trig approximations are still useful is that Java 8 has a dreadfully slow implementation for all trig functions, with about 1/10 the throughput of MathUtils on Java 8, even with mod. I'm sure Android, iOS, and GWT have their own quirks. The inverse trig functions are still slow on recent Java versions, like Math.asin(float), and I have some very small approximations of those that perform well and seem precise enough for map projection tasks, which use inverse trig functions a lot. I also found that libGDX's MathUtils.atan2(float, float) isn't precise enough for a rotating world map's projection, and I now use a "less approximate approximation". If you want a PR of two inverse trig functions (I don't have atan because I haven't needed it), I can submit it, but the code is tiny:

    public static float asin(float a) {
        return (a * (1f + (a *= a) * (-0.141514171442891431f + a * -0.719110791477959357f))) /
                (1f + a * (-0.439110389941411144f + a * -0.471306172023844527f));
    }
    public static float acos(float a) {
        return 1.5707963267948966f - (a * (1f + (a *= a) * (-0.141514171442891431f + a * -0.719110791477959357f))) /
                (1f + a * (-0.439110389941411144f + a * -0.471306172023844527f));
    }

The idea of putting a note about the domain MathUtils.sin(float) expects, in this function's docs, is to hopefully tell users that they shouldn't chuck 1000000.0f at this approximation and expect a meaningful result. If user code can be designed so that it doesn't pass large numbers to MathUtils.sin(), then it might not need any modulus, which would be best.

By the way, passing any integer larger than 823550 to MathUtils.sin() will produce the same value, -1.915137E-4. If you go a little higher, to 1000000, and use a modulus when you run that 1 million through 1000000.0f % MathUtils.PI2, then the loss is small, producing -0.3757638 when it should produce -0.34999350217129294. For angles of 50000 or less, there's almost no loss at all, with or without modulus. Specifically, MathUtils.sin(50000.0f) produces -0.9998341 when it should produce -9998401890897896 . My code is here; if copying it into your own project, you should remove line 10 (a call to MathUtils.initialize(), a method I needed to add for some benchmarks to run at all) and also remove any references to NumberTools.

@NathanSweet
Copy link
Member

Generally LUTs should be avoided unless working around a performance issue, in which case you wouldn't want modulus 360 on the input.

Javadoc improvements look good. acos/asin is interesting, is there anything we can add to the javadocs to give users a better idea of how to use them?

@tommyettinger
Copy link
Member

Uh sure, the Javadocs in the project that I originally put them in weren't great, but the behaviors for those two are supposed to be the same as Math.asin() and Math.acos(). It may make sense to refer to the docs for those JDK methods. For asin(), it takes a float from -1 to 1 inclusive and returns a float from -PI/2f to PI/2f inclusive. For acos(), it takes a float from -1 to 1 inclusive and returns a float from 0 to PI inclusive. It doesn't check that input is in range, but asin() and acos() are only defined for inputs from -1 to 1 in math. If you want to give credit to the guy who found this approximation, both approximations use formula number 201 in Dennis Kjaer Christensen's unfinished math work on arc sine approximation. That's just a formula, and I wrote the gnarly Java two-liners, but the magic numbers this time are not mine, they're Christenson's.

@mgsx-dev mgsx-dev deleted the doc/mathutils-lut branch October 10, 2019 20:34
@NathanSweet
Copy link
Member

Thanks for the extra information. More specifically, it may help people use the functions vs Math if they have some idea about the accuracy of the approximations.

@tommyettinger
Copy link
Member

The most exact info I have is from Christensen, this for asin() on doubles:

Function number = 201
Description = A*X^5+B*X^4+C*X^4+D*X^2
Polynomial = (A*X*X*X*X*X + B*X*X*X + X) / (C*X*X*X*X + D*X*X + 1)
MinMaxAbsError = 0,014983778065889487
MinMaxAbsErrorRel = 0,021995196986764453

But, I'll also get some graphs up for MathUtils.asin() relative to Math.sin() and also for acos(). The error will probably be worse than the double version because MathUtils uses floats.

@tommyettinger
Copy link
Member

tommyettinger commented Oct 15, 2019

So I initially tried graphing without a scale and got very low absolute error values, so I scaled this up by 100. The x axis is the domain of arcsine, so -1 to 1, mapped onto 512 1px columns. Each column uses the results of 100 different values drawn linearly from the domain, where the value we'll call x; the height is determined by the sum of 100.0 * Math.abs(Math.asin(x) - MathUtils.asin(x)) for each x in the column. That value is rounded and used as the height in pixels.

Absolute error of MathUtils.asin() relative to Math.asin():
Absolute error of MathUtils.asin() relative to Math.asin()

The relative error graph is certainly interesting. This graphs the sum of 100 calls to Math.asin(x) - MathUtils.asin(x) per column, which is positive when Math gives a greater result than MathUtils. I didn't graph where the MathUtils' values were above Math.asin()'s result, but you can look at the absolute error graph and see that it's symmetrical when absolute value is applied. So without that absolute value, positive x has larger results than Math.asin() except for one small area, and negative x more often has smaller results than Math.asin().

Relative error of MathUtils.asin() relative to Math.asin():
Relative error of MathUtils.asin() relative to Math.asin()

I'd attach an absolute error graph of MathUtils.acos() but it seems exactly the same as asin(); maybe a few pixels are off by one. The relative error graph is flipped, negative x to positive x:

Relative error of MathUtils.acos() relative to Math.acos():
Relative error of MathUtils.acos() relative to Math.acos()

A different measurement is used for measuring the absolute error of sin() and cos(), since their domain is larger; since everyone here seems to agree on -PI2 to PI2 being the best safe range, that's what I used. This makes the same number of calls to MathUtils.sin() as it does to asin() above, 51200 for each, but because that makes each column refer to a larger span on the number line (larger by a factor of PI * 2, which would make clusters of bad results much less visible), the height of the error is also scaled by a factor of PI * 2. It still doesn't show the error of sin() as at all significant:

Absolute error of MathUtils.sin() relative to Math.sin():
Absolute error of MathUtils.sin() relative to Math.sin()

The relative error shows that for most of its "safe and correct" domain, MathUtils.sin() produces larger numbers than Math.sin(), with negative values (where libGDX gave a larger result) not graphed.
Relative error of MathUtils.sin() relative to Math.sin():
Relative error of MathUtils.sin() relative to Math.sin()
Approximating cos() uses the same scale as sin(), and the only thing to note is that it isn't the same as sin(), but the error is equally minor. The relative error isn't the same either, but it also tends toward larger results for MathUtils than for Math.

Absolute error of MathUtils.cos() relative to Math.cos():
Absolute error of MathUtils.cos() relative to Math.cos()
Relative error of MathUtils.cos() relative to Math.cos():
Relative error of MathUtils.cos() relative to Math.cos()

Both MathUtils.asin() and acos() have higher error in general than MathUtils.sin() and MathUtils.cos(), but I haven't been able to find a case in practice where the error matters. At worst, it's about 0.011% 0.011 off per call in either block of 100 calls that's almost at -1.0 or almost at 1.0, with some calls probably doing worse (I'm guessing the calculation of 0.014983778065889487 is right) and some better. EDIT: the percent mark was a mistake; the absolute value of the difference between what MathUtils.asin() returns and the correct value is about 0.011 just before this returns PI or -PI, and less everywhere else. 0.014983778065889487 / PI2 == 0.002384742345378231, so the worst error seems a little less than 1/4 of a percentage point. 21x higher than the initial (way too low) percentage estimate, but still low enough not to be noticeable in games, I'm guessing.

Also, because asin() and acos() don't do any bounds checks or wrapping (like the mathematical functions themselves, which aren't defined for inputs outside -1 to 1 inclusive), and avoid branching or indirection, they benchmark very well. They don't seem quite as fast as MathUtils.sin() or cos(), except on OpenJDK 13 with OpenJ9, where the asin() and acos() methods are 1.5x faster than MathUtils.sin() and cos(). That last result is very strange, but may be due to differences in OpenJ9 all around that make MathUtils.sin() half the speed on OpenJ9+JDK13 compared to HotSpot+JDK8. On all platforms, they blur past Math.asin() and Math.acos(), which get maybe 3M calls a second compared to over 35M for MathUtils.asin(). Results may be different on some Linux OSes where Java is allowed to use different platform-specific floating-point math calls (that's the reason why the obscure strictfp keyword exists), but I think those only apply to doubles.

WickedShell pushed a commit to WickedShell/libgdx that referenced this pull request Nov 20, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants