这两个 lerp 函数有什么不同之处?

9
浮点线性插值中,一位用户提出了这个lerp的实现方法:
float lerp(float a, float b, float f) 
{
    return (a * (1.0 - f)) + (b * f);
}

另一个用户提出了这个 lerp 的实现:

float lerp(float a, float b, float f)
{
    return a + f * (b - a);
}

显然,由于浮点精度损失,后一种实现方式较差。

我随后在 维基百科 上查看,它说针对前一种实现方式:

// Precise method, which guarantees v = v1 when t = 1. This method is monotonic only when v0 * v1 < 0.
// Lerping between same values might not produce the same value

并且对于后者:
// Imprecise method, which does not guarantee v = v1 when t = 1, due to floating-point arithmetic error.
// This method is monotonic. This form may be used when the hardware has a native fused multiply-add instruction.

这让我有几个问题:

  1. Wikipedia states that "Lerping between same values might not produce the same value" I thought that except for floating point accuracy the functions are identical. Isn't

    (a * (1.0 - f)) + (b * f)=a + f * (b - a)
    

    a mathematical identity?

    If not what would be values that would give different results in both functions?

  2. What does Wikipedia mean by monotonic? Why is one implementation monotonic while the other is not?

  3. Are there other common implementations of lerp?

编辑: 如果后一种实现在没有真正更快的情况下遭受浮点精度问题,那么它为什么存在呢?


1
即使两个表达式在数学上是等价的,但对于浮点数学来说并非如此。每个操作都可能产生舍入误差(参见 https://dev59.com/G3RB5IYBdhLWcg3wj36c)。只有一些操作总是精确的,例如 x*1.0 == xx+0.0==x,以及对于有限的 xx*0.0 == 0.0x-x == 0.0(我忽略了 +0.0-0.0 之间的差异)。 - chtz
2
是的,还有其他常见的lerping变体。另请参阅伴随网站数学堆栈交换上关于准确线性插值的问题。有关单调性的定义,请参见例如Math World。 - njuffa
我的第一个问题是它们是否具有身份?因为维基百科让人感觉好像不是这样。 - tempdev nova
随着值的增加,浮点表示变得越来越粗,因此当您减去非常接近且值足够高时,结果的误差可能会出乎意料地高。 - MatG
好的,我认为我犯了错误的假设,即浮点算术和数学总是可以互换使用。我一直认为浮点不精确只是需要注意的小事情,没有真正的影响,但现在看来我是非常错误的。 - tempdev nova
1个回答

9
  1. Wikipedia states that "Lerping between same values might not produce the same value" I thought that except for floating point accuracy the functions are identical. Isn't

    (a * (1.0 - f)) + (b * f)=a + f * (b - a)
    

    a mathematical identity?

If not what would be values that would give different results in both functions?

a • (1.0−f) + (bf) = a + f • (ba)是一个数学恒等式,但计算a*(1.0-f) + b*f的结果并不总是等于计算a + f*(b-a)的结果。

例如,考虑一个带有小数点基数且有效数字为三位的浮点格式。让a为123,b为223,f为.124。

那么,1.0-f是.876。然后,a * .876的实数算术结果将为107.748,但由于结果必须舍入为三个有效数字,所以会得到108。对于b * f,我们将得到27.652,但是实际上会产生27.7。然后,108 + 27.7将产生135.7,但实际上会产生136。

另一方面,b-a产生100。然后,f*100产生12.4。然后,a + 12.4将产生135.4,但实际上会产生135。

因此,左侧和右侧计算的结果136和135并不相等。

  1. Wikipedia中的单调是什么意思?

如果更大的参数产生更大的结果,则函数f(x)是严格上升的: x0 < x1暗示着f(x0) < f(x1)。如果x0 < x1暗示着f(x0) ≤ f(x1),则它是弱上升的。如果x0 < x1暗示着f(x0) > f(x1)或f(x0) ≥ f(x1),则它是严格或弱下降的。如果它严格上升或严格下降,则函数是严格单调的。如果它是弱上升或弱下降,则它是弱单调的。

当一个函数被称为单调时,作者的意思是它是严格单调或者弱单调的,但没有上下文或明确说明不清楚。在浮点运算的背景下,通常指的是弱单调性,因为浮点舍入通常会破坏强单调性。
在这个用法中,维基百科的意思是,在将其视为t的函数时,v0 + t * (v1 - v0)是单调的,而(1-t) * v0 + t * v1则不是。
为什么一个实现是单调的而另一个不是呢?
为了看出为什么v0 + t * (v1-v0)是单调的,请考虑当t变化时,v1-v0是固定的。然后t * (v1-v0)是一些常数ct * c。由于浮点舍入的性质,它在t上是单调的:如果t增加,则t * c的实数算术结果增加(对于正数c;对于负数c有对称的论证),它必须舍入到相同或更高的数字。例如,如果我们将其舍入为整数并考虑3、3.1、3.2、3.3、3.4等数字,则这些数字都会舍入为3。然后,3.5舍入为4(使用四舍五入,平均值向偶数),3.6舍入为4,依此类推。舍入的结果随着其参数的增加而增加;它是单调的。因此,浮点乘法是单调的。
同样,浮点加法也是单调的;v0 + d随着d的增加而增加。因此,v0 + t * (v1-v0)是单调的。
(1-t) * v0 + t * v1中,1-t是单调的,但是它是下降的。因此,我们现在正在添加一个下降的函数(1-t) * v0到一个上升的函数t * v1中。这就打开了一个机会:下降的函数跳到一个新值,但上升的函数没有或者变化不如前者大。使用我们的三位格式,例如,当v0=123,v1=223时,t=.126或.127时就会出现这种情况。
t = .126 t = .127
1-t .874 .873
(1-t) * v0 107.502 → 108 107.379 → 107
t * v1 28.098 → 28.1 28.321 → 28.3
(1-t) * v0 + t * v1 136.1 → 136 135.3 → 135
  1. 是否有其他常见的lerp实现方法?

正如njuffa在评论中指出的,某些实现可能使用融合乘加(fused-multiply add)操作,它可以用一个机器误差计算ab+c,而传统的乘法和加法则需要两个误差值。这种操作在标准C库函数fma中定义,但在没有该硬件支持的平台上可能较慢。因此,线性插值也可以通过fma(t, v1, fma(-t, v0, v0))计算,该公式标准地计算t*v1 + (-t*v0 + v0)

代数上来看,这等价于t*v1 + (1-t)*v0,但我手头没有任何有关该fma计算的数学性质的评论。

如果后者的实现存在浮点精度问题而且速度也不快,那为什么它还要存在?

两种方法都会遇到浮点舍入问题。v0 + t * (v1 - v0)的问题在于,当t为1时,它可能不等于v1,因为v1-v0可能发生舍入误差,导致v0 + (v1 - v0)无法恢复v1的值。另一种问题在于,如上所示,它可能不是单调的。


通过观察(而非数学证明),基于简单的fma()的lerp提供了精确舍入的结果,在t为{0,1}时命中任一端点,但不是单调的。这种变体的性能在具有FMA硬件支持的平台上是一个重要的优势。 - njuffa

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接