-
Notifications
You must be signed in to change notification settings - Fork 39
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
Add lerp
to the standard math
library
#54
Conversation
Could we get this merged? Doesn't seem like theres any objections to it. |
Suggested wording change has never been applied. I would say there's no rush in getting it in. Additionally, the implementation is hard to follow with respect to the guarantees presented and whether or not we need them. Feels like that the developers will be better off implementing their usual custom implementation of a simple Here's the objection you wanted :) |
|
||
## Drawbacks | ||
|
||
As mentioned in the [Design](##design) section, the naïve implementation of `lerp` may introduce precision error, and all of the edge-cases will need to be accounted for. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lerp(a0, a1, t)
:
t
can be much higher precision when near0
- so when
t
is near0
, we don't ever want to have a function of the formA + (1 - t)*B
- so when
- we want exactness,
lerp(a0, a1, 0) == a0
andlerp(a0, a1, 1) == a1
- when
t
is closer to0
, we want higher accuracy arounda0
- when
t
is closer to1
, we want higher accuracy arounda1
- when
- we want functions of the form
A + t*B
, as this guarantees monotonic behavior
Some things to consider for building intuition:
-- (100000000 samples)
-- given x, y = sortabs(random()*randomsign(), random()*randomsign()):
-- x - y + y ~= x: ~49.2%
-- y - x + x ~= y: ~20.6%
-- given x, y = sortabs(random(), random()):
-- x - y + y ~= x: ~30.1%
-- y - x + x ~= y: ~3.98%
-- given x, y = sortabs(random()*1.5, random()*1.5):
-- x - y + y ~= x: ~25.4%
-- y - x + x ~= y: ~1.63%
-- given floorlog2(x) == floorlog2(y) >= floorlog2(y - x)
-- x - y + y ~= x: 0%
-- y - x + x ~= y: 0%
Taking all this into account, we build our first example:
- Obvious solution is to split when
t < 1/2
- at the interchange point,
t = 1/2
, we want to guarantee monotonic behavior - so we bound the result with respect to
(a0 + a1)/2
- at the interchange point,
--[[
guarantees exactness because
a0 + 0*(a1 - a0) = a0
a1 + (1 - 1)*(a1 - a0) = a1 + 0*(a1 - a0) = a1
guarantees consistency because
a + (a - a)*t = a + 0*t = a
guarantees monotonicity because
we bound the results such that
lerp(a0, a1, 1/2 - 2^-54) <= lerp(a0, a1, 1/2)
]]
local function lerp1(a0, a1, t)
local m = (a0 + a1)/2
if t < 1/2 then
local a = a0 + (a1 - a0)*t
if a0 < a1 then
return math.min(m, a)
else
return math.max(m, a)
end
else
local a = a1 + (a1 - a0)*(t - 1)
if a0 < a1 then
return math.max(m, a)
else
return math.min(m, a)
end
end
end
Then we build a simpler case, to make sure what we are doing is necessary:
- The relevant question is
- can
a0 + (1/2 - 2^-54)*(a1 - a0) > a1 - 1/2*(a1 - a0)
ever be true? - From a lot of random sampling, it appears to be impossible, or at least very rare
- can
-- this guarantees exactness and consistency
-- this appears to guarantee monotonicity intrinsically
local function lerp2(a0, a1, t)
if t < 1/2 then
return a0 + (a1 - a0)*t
else
return a1 + (a1 - a0)*(t - 1)
end
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so from that, seems like at most all we'd need is to conditionally swap a
<-> b
and invert t
to reach most/all the guarantees we wanted? aka no reason to go more complex than lerp2
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
blending in @/vegorov-rbx's comments, would it even be worth special casing anything? would the only issue with using a0 + (a1 - a0) * t
be that t
has less precision around 1
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
t
has less precision around 1
, but there's no way to extract more precision from t
in the first place, what they give is what we get to work with.
Edit:
using only a0 + (a1 - a0)*t
is problematic because often times, when t = 1
, it does not return a1
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From a user perspective, if I want more accuracy around a1
than a0
, I would need to find some accurate way of generating s = 1 - t
directly, and then use math.lerp(a1, a0, s)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh right this is about the precision of (a1 - a0)
, right? We want to take advantage of the higher density of floating point numbers near 0 so we would rather "multiply down". e.g.: x * 0.01
will be more precise than x - (x * 0.99)
.
All makes sense - lerp2
seems like the nicest "correct" solution. I suppose it just depends on whether we would want to use a conditional or whether that's deemed too heavy - will let the Luau folk speak to that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my experience as a user, the most important guarantees lerp
can have is for
lerp(a0, a1, 0) == a0
and lerp(a0, a1, 1) == a1
If conditionals are not allowed, then the optimal solution seems to be
(1 - t)*a0 + t*a1
but this is neither monotonic nor consistent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh this is actually really clever, so the only "inaccuracy" we get is near 0.5
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for this new insight, I will try and push this forward :)
Some thoughts:
However, the decision has been made to merge math.map; as such, I think math.lerp should be added: lerp is useful more often than map, math was added with no regard for any numerical properties, and while map can be converted into lerp by using 0,1 limits, that use is not ergonomic and not as performant (short of codegen cases where in theory map can be optimized optimally via partial constant folding). So I support merging some RFC here in principle.
a. exactness: lerp(0) = a, lerp(1) = b C++ implementation satisfies all 5. However, the result is not useful wrt performance. In my opinion, determinacy is not particularly interesting as violating it requires fp overflow which is too strong of a constraint and not practically useful; exactness and consistency are important; boundedness is sometimes important; monotonicity is nice to have, as I do not know of practical applications where monotonicity is required. This makes me wonder: why not choose C++ implementation in https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p0811r3.html is basically that for a/b of the same sign, with an extra case for t=1+eps; I don't fully understand the value of monotonicity outside of the 0..1 interval, and as I mentioned above I don't know why monotonicity is important inside the interval, but certainly that property is not worth the extra branches. I've empirically validated that the implementation above works well on 32-bit floats by bruteforcing one of a/b/t and fixing a few other values (for example, I validated boundedness by fixing a, fixing t at prevfloat(1), and bruteforcing all b >= a). This SO question https://math.stackexchange.com/questions/907327/accurate-floating-point-linear-interpolation has some discussion in the comments and comes to the same conclusion afaict. I like the t<0.5 form in principle (note that it's mentioned in the comments of the issue linked above), but it has a steep implementation cost: you need to do two conditional selects on t<0.5 condition (switching a/b and t/t-1), plus compute t-1, and load two constants (0.5 & 1) into registers. A branch around 0.5 will behave well on microbenchmarks but cause branch mispredictions in some cases. Compared to this, t==1 is just one conditional select & load of just one constant, so it's cheaper.
|
Oh and cost & properties of a few implementations; lerp 0 is
|
I've submitted #86 which intends to replace this RFC while providing the same functionality via |
Closing this as #86 has a better implementation if we were to include it. |
Add
lerp
to the standardmath
library