Skip to content

Instantly share code, notes, and snippets.

@AxisAngles
Created November 16, 2024 01:52
Show Gist options
  • Save AxisAngles/e3c6ad3f57044480b69ff378477db03a to your computer and use it in GitHub Desktop.
Save AxisAngles/e3c6ad3f57044480b69ff378477db03a to your computer and use it in GitHub Desktop.

map(a, b, x, y, t) maps [a, b] -> [x, y] wrt t

in a map function, most importantly we want exactness:

  • map(a, b, x, y, a) = x
  • map(a, b, x, y, b) = y

another nice guarantee to have is consistency:

  • map(a, b, x, x, t) = x

because many algorithms assume going only forward or backward in time, monotonicity:

  • d/dt map(a, b, x, y, t) is strictly >= 0 or <= 0

finally, if a or b and t are close to 0, we want to maintain full precision. For example:

  • map(1, 0, 1, 0, 1e-300) = 1e-300

first, consider the lerp function

lerp(x, y, t) maps [0, 1] -> [x, y] wrt t

the following lerp algorithm has all the properties above:

function lerp(x, y, t)
    if t < 1/2 then
        return x + (y - x)*t
    else
        return y + (y - x)*(t - 1)
    end
end

guarantees exactness because:

  • x + 0*(y - x) = x
  • y + (1 - 1)*(y - x) = y + 0*(y - x) = y

guarantees consistency because:

  • a + (a - a)*t = a + 0*t = a

The argument for monotonicity is harder. The following is my attempt to show the worst case by simple floating point error terms, however it fails to take into account that these calculations are not independent from each other. In reality, testing billions of pairs of randomly generated numbers has yielded no case where lerp(x, y, 1/2 - 2^-54) > lerp(x, y, 1/2)

under floating point, any expression a + b*t will be monotonic wrt t.
this means that the only time we need to worry about monotonicity is at the exchange point around t = 1/2.
specifically, we want to show that this is always true:
    x + (y - x)*(1/2 - 2^-54) <= y + (y - x)*(-1/2)

y + (y - x)/2:
    y - x worst case rounds to         (y - x)*(1 + 2^-54)
    (y - x)/2 worst case rounds to     (y - x)/2*(1 + 2^-54)
    y - (y - x)/2 worst case rounds to (y - (y - x)/2*(1 + 2^-54))*(1 - 2^-54)
    after expansion, this becomes      (x + y)/2 - 2^-54*y - 2^-109*x + 2^-109*y/2
    dropping inconsequential terms     (x + y)/2 - 2^-54*y

x + (y - x)*(1/2 - 2^-54):
    y - x worst case rounds to                     (y - x)*(1 + 2^-54)
    multiplication by (1/2 - 2^-54) guarantees division by two
        and the subtraction of the last bit with ulp^2 (none) error
    (y - x)*(1/2 - 2^-54) worst case rounds to     (y - x)*(1/2 - 2^-54)*(1 + 2^-54)
    x + (y - x)*(1/2 - 2^-54) worse case rounds to (x + (y - x)*(1/2 - 2^-54)*(1 + 2^-54))*(1 + 2^-54)
    after expansion, this becomes                  (x + y)/2 + 2^-54*x + 3*2^-109*(x - y) + 2^-162*(x - y)
    dropping inconsequential terms                 (x + y)/2 + 2^-54*x
    
the above does not take into account the fact that these caluculations are dependent on choice of x and y.
here is one such case:
    
when y/2 < x < y
    (y - x)/2     has no error
    y - (y - x)/2 also has no error
    x + (y - x)/2 also has no error

the following correctness test never seems to fail:

for i = 1, 10000000 do
    local x = math.random()*2^math.random(-1023, 1024)*(math.random() < 1/2 and -1 or 1)
    local y = math.random()*2^math.random(-1023, 1024)*(math.random() < 1/2 and -1 or 1)
    if y < x then x, y = y, x end

    local t0 = 1/2 - 2^-54
    local t1 = 1/2

    local m0 = lerp(x, y, t0)
    local m1 = lerp(x, y, t1)
    if m0 > m1 then
        print("interpolation is not monotonic")
        print("x, y", x, y)
        print("t:", t0, t1)
        print("m:", m0, m1)
        return
    end
end

the following map algorithm has all the properties above:

local function map(a, b, x, y, t)
    local s = (t - a)/(b - a)
    local r = (b - t)/(b - a)
    if s < 1/2 then
        return x + s*(y - x)
    elseif r < 1/2 then
        return y + r*(x - y)
    else
        return (x + y)/2
    end
end

guarantees exactness because

  • lerp guarantees exactness
  • (a - a)/(b - a) = 0
  • (b - b)/(b - a) = 0

guarantees consistency because

  • lerp guarantees consistency

guarantees monotonicity because

  • lerp guarantees monotonicity
  • (t - a)/(b - a) is monotonic
  • we take care to make sure the interchange point is when s, i == 1/2 - 2^-54
  • and give an intermediate value, (x + y)/2, which will always lie equal to or between the previous branches.

In the end, the following test for correctness never seems to fail:

for i = 1, 10000000 do
    local x = math.random()*2^math.random(-1023, 1024)*(math.random() < 1/2 and -1 or 1)
    local y = math.random()*2^math.random(-1023, 1024)*(math.random() < 1/2 and -1 or 1)
    local a = math.random()*2^math.random(-1023, 1024)*(math.random() < 1/2 and -1 or 1)
    local b = math.random()*2^math.random(-1023, 1024)*(math.random() < 1/2 and -1 or 1)
    if y < x then x, y = y, x end
    if b < a then a, b = b, a end

    local t0, t1, t2, t3, t4, t5
    if a + b > 0 then
        t0 = (a + b)/2*(1 - 2*2^-53)
        t1 = (a + b)/2*(1 - 1*2^-53)
        t2 = (a + b)/2*(1 - 0      )
        t3 = (a + b)/2*(1 + 1*2^-52)
        t4 = (a + b)/2*(1 + 2*2^-52)
    else
        t4 = (a + b)/2*(1 - 2*2^-53)
        t3 = (a + b)/2*(1 - 1*2^-53)
        t2 = (a + b)/2*(1 - 0      )
        t1 = (a + b)/2*(1 + 1*2^-52)
        t0 = (a + b)/2*(1 + 2*2^-52)
    end

    local m0 = map(a, b, x, y, t0)
    local m1 = map(a, b, x, y, t1)
    local m2 = map(a, b, x, y, t2)
    local m3 = map(a, b, x, y, t3)
    local m4 = map(a, b, x, y, t4)
    if m0 > m1 or m1 > m2 or m2 > m3 or m3 > m4 then
        print("interpolation is not monotonic")
        print("a, b, x, y", a, b, x, y)
        print("t:", t0, t1, t2, t3, t4)
        print("m:", m0, m1, m2, m3, m4)
        print("cases:", m0 > m1, m1 > m2, m2 > m3, m3 > m4)
        return
    end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment