std::lerp y por qué probablemente no quieras usarlo
Publicado el 20 de abril de 2020
C++20 introduce
std::lerp
, una función que dados tres números a, b y t, calcula la interpolación lineal entre a y b en t. Incluye overloads para todas las combinaciones de tipos numéricos en el lenguaje y hace lo que tiene que hacer. Es más, lo hace muy bien. Lo hace tan bien que para todo t entre 0 y 1 el resultado es
siempre
correcto.

Parte 1:
std::lerp
puede ser innecesariamente lento

Si implementamos esta función, lo más probable es que escribamos algo así:
C++
float lerp(float a, float b, float t)
{
	return a + (b – a) * t;
}
Este código parece correcto. Bien, ¿lo es? Lo es en todos los casos excepto cuando el valor intermedio (b - a) es menor de lo que un float puede representar. En ese caso, el cálculo es incorrecto.
std::lerp
en cambio calcula correctamente este caso y devuelve el resultado correcto incluso ante situaciones de valores intermedios no representables. Sabiendo esto, uno pensaría que
std::lerp
es perfecto. No sólo nos viene ya hecho, sino que encima es más correcto que la versión que escribiríamos nosotros. ¿Dónde está el problema entonces?
Bien, el problema es simple. Esta garantía tiene un coste. Para poder asegurarse de que no va a caer en una situación en la que uno de los valores intermedios no es representable,
std::lerp
analiza sus parámetros y decide qué fórmula va a usar para calcular la interpolación lineal de forma que siempre elija una correcta. Esto hace de
std::lerp
significativamente más lento que nuestra versión. Esto se puede ver muy bien en el assembly.
nuestro_lerp(float, float, float):
        subss   xmm1, xmm0
        mulss   xmm1, xmm2
        addss   xmm0, xmm1
        ret




std_lerp(float, float, float):
        xorps   xmm3, xmm3
        ucomiss xmm3, xmm0
        jb      .LBB0_2
        ucomiss xmm1, xmm3
        jae     .LBB0_4
.LBB0_2:
        ucomiss xmm0, xmm3
        jb      .LBB0_5
        xorps   xmm3, xmm3
        ucomiss xmm3, xmm1
        jb      .LBB0_5
.LBB0_4:
        mulss   xmm1, xmm2
        movss   xmm3, dword ptr [rip + .LCPI0_0] # xmm3 = mem[0],zero,zero,zero
        subss   xmm3, xmm2
        mulss   xmm3, xmm0
        addss   xmm1, xmm3
        movaps  xmm0, xmm1
        ret
.LBB0_5:
        ucomiss xmm2, dword ptr [rip + .LCPI0_0]
        jne     .LBB0_6
        jp      .LBB0_6
        movaps  xmm0, xmm1
        ret
.LBB0_6:
        movaps  xmm3, xmm1
        subss   xmm3, xmm0
        mulss   xmm3, xmm2
        ucomiss xmm2, dword ptr [rip + .LCPI0_0]
        setbe   al
        ucomiss xmm1, xmm0
        addss   xmm3, xmm0
        seta    cl
        xor     cl, al
        jne     .LBB0_7
        minss   xmm3, xmm1
        movaps  xmm1, xmm3
        movaps  xmm0, xmm1
        ret
.LBB0_7:
        maxss   xmm3, xmm1
        movaps  xmm1, xmm3
        movaps  xmm0, xmm1
        ret
Mientras que nuestro lerp sólo hace una resta, una multiplicación y una suma, podemos ver como
std::lerp
tiene una serie de saltos condicionales y cuatro ramas en las que puede terminar. Tras algunas mediciones, dependiendo de la capacidad de la CPU para predecir las condiciones,
std::lerp
es entre un 10% y un 40% más caro. Este incremento del coste debería hacernos preguntarnos cómo de útil es en realidad esta garantía adicional que nos brinda, y basar nuestra decisión en torno a esto.
Una de las situaciones más comunes en las que se usa la interpolación lineal es en animaciones de sistemas de partículas y esqueletos. En estos, lerp es la operación más común, y se calcula potencialmente varios miles (o millones) de veces por fotograma. También, por lo general se opera con valores conocidos de antemano, decididos a mano por un diseñador, un animador o un artista de efectos especiales, de forma que se sabe que nunca se va a dar el caso de estos valores tan grandes que no son representables por el tipo de dato que se está usando. En estos casos, la decisión de usar std::lerp es errónea, ya que se está introduciendo un coste adicional en el código sin que esto reporte ningún beneficio a cambio.
No estoy diciendo que
std::lerp
esté mal ni que no haya que usarlo. Si eso, está mal especificado, o mal entendido. Tal vez mal nombrado. La cualidad que define a std::lerp no es la de calcular la interpolación lineal, sino la de hacerlo correctamente en todas las situaciones, y debería usarse únicamente cuando existe la posibilidad de que se de un caso en el que la implementación de lerp mostrada arriba se rompa. En los casos en los que se sabe de antemano que este tipo de errores no van a darse, usar
std::lerp
es una decisión equivocada.

Parte 2:
std::lerp
está incorrectamente abstraído

La interpolación lineal es una operación definida sobre un conjunto V que define la suma y el producto escalar. Este conjunto es, evidentemente, un espacio vectorial. Sin embargo,
std::lerp
está definido únicamente para números. Esto nos impide interpolar linealmente vectores, polinomios, cuaterniones o colores RGB, todos ellos candidatos muy frecuentes a la interpolación lineal en aplicaciones reales. Una definición correcta de lerp en C++, asumiendo los conceptos VectorSpace y Real, sería
C++
template <VectorSpace V, Real R>
V lerp(V a, V b, R t)
{
	return a + (b – a) * t;
}
Sospecho que la implementación actual se da debido al hecho de tener que llevar a cabo estas comprobaciones de los casos especiales, que no sería posible sobre un espacio vectorial arbitrario.
Logo of RSS.