浮点线性插值

54

为了在两个变量a和b之间进行线性插值,给定一个分数f,我目前使用的代码如下:

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

我认为可能有更有效的方法。我正在使用一个没有浮点单元(FPU)的微控制器,所以浮点运算是在软件中完成的。它们非常快,但是加法或乘法仍需要大约100个时钟周期。

有什么建议吗?

n.b. 为了清晰起见,在上面的代码中,我们可以省略将1.0指定为显式浮点文字。

7个回答

43

正如Jason C在评论中指出的那样,你发布的版本很可能是最好的选择,因为它在边缘情况下具有更高的精度:

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

如果我们暂时不考虑精度问题,我们可以将表达式简化如下:

    a(1 − f) × (ba)
 = aaf + bf
 = a + f(ba)

这意味着我们可以这样写:

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

在这个版本中,我们减少了一次乘法,但失去了一些精度。


1
@Sneftel:你能详细说明一下针对“1-f”的优化吗?我碰巧也处于这种情况,很好奇:D - Levi Morrison
1
@LeviMorrison 基本上,这保证了 f 的指数为非正数(当然也保证了 1 的尾数和指数的固定值),从而削减了减法例程中的大部分分支。 - Sneftel
2
@coredump 很抱歉没有注意到您在2年前的评论(呵呵……)。特别是,如果算法中 f *(b-a) 的数量级与 a 明显不同,则 OP 的方法仍然更精确,因为它就会导致加法出现问题。正如上面所说的,即使使用 OP 的方法,如果 f 相对于 1.0f 太大,则也可能失败,因为对于非常大的 f1.0f - f 可能等价于 -f。因此,如果您正在处理巨大的 f 值,则需要认真考虑一下数学问题。问题在于,您会遇到这样的情况:1.0 + 1.0e800 == 1.0e800 - Jason C
3
将浮点数看作是定点数的尾数和指数(实际上比这更加复杂),这样看有助于发现许多问题。如果超出了尾数的精度,信息就会开始丢失。这个概念类似于我们不能用只有两位有效数字的十进制表示1,230,000一样(最接近的是1.2 * 10^6),如果你只有两位有效数字,那么1,200,000 + 30,000就会丢失那个30,000。 - Jason C
2
虽然经常是更好的选择,但 OP 的算法并不总是更好的选择。考虑当 a == b 时的情况:该算法将始终返回正确的答案,但根据 t 的值,OP 的算法可能会失去加法左侧和右侧的精度,并且它们不会相加等于初始值。 - nemetroid
显示剩余3条评论

9

假设浮点数计算可用,原作者的算法是一个很好的选择,始终优于备选方案a + f * (b - a),因为当ab的数量级相差较大时,存在精度损失。

例如:

// OP's algorithm
float lint1 (float a, float b, float f) {
    return (a * (1.0f - f)) + (b * f);
}

// Algebraically simplified algorithm
float lint2 (float a, float b, float f) {
    return a + f * (b - a);
}

在该示例中,假设使用32位浮点数,lint1(1.0e20, 1.0, 1.0)将正确返回1.0,而lint2将错误地返回0.0。

由于操作数在数量级上有显著差异时,大部分精度损失发生在加法和减法运算符中。在上述情况中,罪魁祸首是b - a中的减法和a + f * (b - a)中的加法。由于组件在相加之前完全乘以,所以OP的算法不会受到此影响。


对于a = 1e20,b = 1的情况,这是不同结果的示例。测试程序:

#include <stdio.h>
#include <math.h>

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

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

int main () {
    const float a = 1.0e20;
    const float b = 1.0;
    int n;
    for (n = 0; n <= 1024; ++ n) {
        float f = (float)n / 1024.0f;
        float p1 = lint1(a, b, f);
        float p2 = lint2(a, b, f);
        if (p1 != p2) {
            printf("%i %.6f %f %f %.6e\n", n, f, p1, p2, p2 - p1);
        }
    }
    return 0;
}

格式略微调整后的输出:

    f            lint1               lint2             lint2-lint1
0.828125  17187500894208393216  17187499794696765440  -1.099512e+12
0.890625  10937500768952909824  10937499669441282048  -1.099512e+12
0.914062   8593750447104196608   8593749897348382720  -5.497558e+11
0.945312   5468750384476454912   5468749834720641024  -5.497558e+11
0.957031   4296875223552098304   4296874948674191360  -2.748779e+11
0.972656   2734375192238227456   2734374917360320512  -2.748779e+11
0.978516   2148437611776049152   2148437474337095680  -1.374390e+11
0.986328   1367187596119113728   1367187458680160256  -1.374390e+11
0.989258   1074218805888024576   1074218737168547840  -6.871948e+10
0.993164    683593798059556864    683593729340080128  -6.871948e+10
1.000000                     1                     0  -1.000000e+00

4
有趣的是,OP的版本并不总是优越的。我曾经这样想,但是被这个例子证明错了:lerp(0.45, 0.45, 0.81965185546875)。显然应该得到0.45,但至少在双精度下,我得到的结果是0.45000000000000007,而当a==b时,采用明显的a + (b-a)*f版本则会得到a。我希望看到一个算法,它具有这样的属性:当 f==0 时返回 a,当 f==1 时返回 b,并且当 f 在 [0,1] 范围内时保持在 [a,b] 范围内。 - Ben
3
首先,你需要使用 if a == b -> return a 条件语句。然而,确切的0.45无法在双精度或浮点精度中表示,因为它不是2的精确幂。在你的示例中,在函数调用内部,所有参数a,b,f都存储为double类型,返回a永远不会返回完全相等于0.45的值。(对于显式类型的语言如C语言)。 - Benjamin R
1
@Don Well;事实之所以相关,是因为这是Ben观察的关键所在;被忽视的是它与lerp实现的连接是一个误导:是的,lerp(a, a, anything)应该返回a,但0.45无法表示,因此超出了该函数的定义域,因此谈论它是没有意义的。请注意,两个版本的lerp都不会完全返回0.45。即使return 0.45也不会返回0.45。使用这些语言的程序员通常不会在对话中提到这一点,因为它通常是隐含且无趣的。 - Jason C
1
@JasonC 重新陈述Ben的观察,为了避免关于0.45不在定义域内的错误引导:令a = 0.450000000000000011102230246251565404236316680908203125和t = 0.819651855468749968025576890795491635799407958984375。a和t都可以精确地表示为双精度(当在C程序中分别输入0.45和0.81965185546875时得到的结果)。使用OP的实现时,lerp(a,a,t)应该恰好返回a,但事实并非如此。因此,这是反驳你声称OP的算法总是优于替代方案的反例。 - Don Hatch
1
@LorahAttkins 尽管C++标准规定 std::lerp 计算 $a+t(b-a)$,但这仅用作函数计算的数学定义。 标准还对 std::lerp 的实现提出了更多限制:必须单调,对于 $t\in{0,1}$ 和 $a = b$ 必须精确。 这意味着 lint1lint2 都不是 std::lerp 的有效实现。 因此没有人会使用 std::lerp,因为它太分支和慢了。 - CAD97
显示剩余3条评论

9
如果您使用的是没有FPU的微控制器,则浮点运算将非常昂贵。浮点运算速度可能会慢二十倍。最快的解决方案是只使用整数进行所有计算。
固定二进制点(http://blog.credland.net/2013/09/binary-fixed-point-explanation.html?q=fixed+binary+point)后的位数为:XY_TABLE_FRAC_BITS。
这里是我使用的一个函数:
inline uint16_t unsignedInterpolate(uint16_t a, uint16_t b, uint16_t position) {
    uint32_t r1;
    uint16_t r2;

    /* 
     * Only one multiply, and one divide/shift right.  Shame about having to
     * cast to long int and back again.
     */

    r1 = (uint32_t) position * (b-a);
    r2 = (r1 >> XY_TABLE_FRAC_BITS) + a;
    return r2;    
}

使用内联函数后,大约需要10-20个周期。如果您有32位微控制器,您将能够使用更大的整数并获得更大的数字或更高的准确性而不影响性能。此函数在16位系统上使用。

1
我已经阅读了网站,但仍然有点困惑于应该是什么位置。这是0到0xFFFF的值吗?还是0到0xFFFE?另外,XY_TABLE_FRAC_BITS是多少?8? - jjxtra
@jjxtra: XY_TABLE_FRAC_BITS只是一个(不太好的)整数常量的名称,其值指定了在使用定点整数值时假定的二进制点所在的位置(因为它不像浮点数一样在其中“浮动”)。 - martineau

6
值得注意的是,标准线性插值公式f1(t)=a+t(b-a),f2(t)=b-(b-a)(1-t),以及f3(t)=a(1-t)+bt,在使用浮点运算时不能保证表现良好。即使a!=b,也不能保证f1(1.0)==b或者f2(0.0)==a;而当a==b时,当0
在需要结果表现良好且准确命中端点时,我通常会在支持IEEE754浮点数的处理器上使用该函数(我使用双精度,但单精度也应该可以使用)。
double lerp(double a, double b, double t) 
{
    if (t <= 0.5)
        return a+(b-a)*t;
    else
        return b-(b-a)*(1.0-t);
}

在C++20中,他们添加了std::lerp,它保证单调行为。 - 0kcats
这似乎是我见过的最好的解决方案。我想看到一个证明它是单调的。(它似乎是单调的,因为我找不到反例,但我不知道为什么。) - Don Hatch
@DonHatch 按照您的要求修改了措辞。谢谢! - 0kcats
@DonHatch 我暂时从答案中删除了“单调性”,因为我没有证据。 - 0kcats
哦,但单调性是最好的部分! :-) 显然,两个部分 f1 和 f2 是单调的,现在需要证明在切换点 t=0.5 也是单调的。我认为它是单调的(仅从我的反例搜索失败的事实中),只是还没有证明。也许这会是一些更理论化的网站,如cs.stackechange.com上的一个好问题。请注意,那里有一个相关的问题:https://cs.stackexchange.com/questions/59625/numerical-stability-of-linear-interpolation - Don Hatch

3

自 C++20 起,您可以使用 std::lerp(),它很可能是针对您目标的最佳实现。


2
在我看来,std::lerp 应该完全不使用。实际上,你很少需要插值和外推,再加上大量的分支行为,以及数值不稳定的内部实现。我对 std::lerp 的实现方式有很多异议,因此很难推荐它。 - jeremyong
1
@jeremyong,你能举一个std::lerp表现不佳的案例吗?它的契约在几个重要方面看起来确实很好:单调性、lerp(a,b,0)==a、lerp(a,b,1)==b(这两个事实意味着它在t∈[0,1]范围内保持在[a,b]范围内)、lerp(a,a,t)==a。因此,通常的抱怨似乎都被覆盖了。 - Don Hatch

3

如果你在为没有浮点运算的微控制器编码,那么最好不要使用浮点数,而是使用定点算术


我计划迁移到定点,但浮点已经相当快了。 - Thomas O

0
如果你想要最终结果是整数,使用整数作为输入可能会更快。
int lerp_int(int a, int b, float f)
{
    //float diff = (float)(b-a);
    //float frac = f*diff;
    //return a + (int)frac;
    return a + (int)(f * (float)(b-a));
}

这个操作包括两次类型转换和一次浮点数乘法。如果在您的平台上,类型转换比浮点数加减法更快,并且整数答案对您有用,那么这可能是一个合理的替代方案。


对于 f * (b - a),类型提升将会使得 (b - a) 类型被提升为 float,因为 f 的类型是 float。因此,在 (float)(b-a) 中的显式转换到 (float) 最多只是说明性质的,实际上并不必要,对吧? - Scheff's Cat
1
@Scheff - 是的,你说得对,浮点转换只是为了引起注意,编译器会自动插入它。 - mtrw

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