以正确的姿势在Unity中使用Lerp

807 阅读2分钟

有潜在问题的错误方式使用Lerp

我们可能会在Update中写出如下代码:

private void Update(){
    a = Mathf.Lerp(a, b, Time.deltaTime);
}

同样的,第3个参数可能会填一个固定值比如.1f,这样确实可以得到类似缓动的效果,然而这样存在一个问题,每一次执行Update的间隔是不固定的,而且每一个用户的设备刷新帧率各不相同,因此在不同的设备上表现效果不一样,有的快有的慢,更一般的情况是快慢不均,即过渡效果不丝滑。

加速度均匀的移动

我们先从一个简单的例子开始思考如何实现平滑过渡效果

private float speed = 1;

private void Update(){
    velocity += speed;
}

以上代码执行效果是velocity以每一次1的速率增加,当用户帧率是24f/s时,1s后速度是24m/s;当用户帧率是120f/s时,1s后速度是120m/s。如何控制在不同的设备上,1s后达到相同的速度呢?

private float speed = 1; // 请注意,这里的单位是1m/s,这个数值与单位的定义非常重要!

private void Update(){
    velocity += speed * Time.deltaTime;
}

设初速度为0,当用户帧率是10f/s时,Time.deltaTime = .1f1s内执行10次累加,末速度是1m/s;当用户帧率是120f/s时,Time.deltaTime = .008333f1s内执行120次累加,末速度依然是1m/s

为什么乘以Time.deltaTime后就能达到我们想要的效果呢?Time.deltaTime的单位可以看作是s/f(秒/帧),speed的单位是m/s,乘积的结果是m/f,我们把速度从和时间有关转为与帧率有关的数值。

深入研究Lerp

我们很容易知道a = Mathf.Lerp(a, b, Time.deltaTime)aa的变化不是简单线性变化的,因此这里第3个参数用Time.deltaTime或者固定值如.1f并不能抵消掉不同帧率的影响,所以我们的目标是为第3个参数找到一个合适的与Time.deltaTime有关的表达式来代替。

我们首先看看这样使用Lerp究竟是一种怎样的变化。

// F: Factor,插值系数,常量
a = Mathf.Lerp(a, b, F)
// 等价于
a = (1 - F) * a + F * b

由此,我们很容易得到第n+1n+1次与第nn次之间关系的表达式:

f(0)=af(0) = a
f(n+1)=(1F)f(n)+Fbf(n+1) = (1-F)*f(n)+F*b

这是一个递推公式,转换为执行次数nn的表达式(计算过程略)为:

f(n)=(ab)(1F)n+bf(n) = (a-b)(1-F)^{n} + b

已知执行次数nn转换为与时间tt有关的表达式,可以表述为:

n=t/Δtn = t/Δt

代入上述式子可以得到关于时间tt的表达式:

f(t)=(ab)(1F)t/Δt+bf(t) = (a-b)(1-F)^{t/Δt} + b

变量tt在指数上,底数是常数,因此我们知道这是呈指数变化。我们考虑指数函数的一般形式:

f(t)=eatf(t) = e^{at}

aa确定时,指数函数就确定了,那么我们该如何确定aa应该是多少呢?换一种更简单直白的问法,即我们需要如何定义不同设备不同帧率下相同的变化呢,就像上述定义速度在1s后到达1m/s

当然有无数的定义方法,这里选2种较简单的定义:

  • rr是介于(0,1)(0, 1)的常数,f(1)f(1)时刻即1s后从aa变化到bb的剩余进度是r100%r*100\%
  • f(t12)=12f(t_{\frac{1}{2}}) = \frac{1}{2} 意义是在t12t_{\frac{1}{2}}时刻从aa变化到bb的进度是50%50\%

我们先看第一种定义,我们可以得到如下式子:

f(1)bab=r\frac{f(1)-b}{a-b} = r

f(1)=(ab)(1F)1/Δt+bf(1) = (a-b)(1-F)^{1/Δt} + b代入上式并化简,可得:

F=1rΔtF = 1 - r^{Δt}

将此FF的表达式带入f(t)f(t)中,可得:

f(t)=(ab)rt+bf(t) = (a-b)*r^{t} + b

我们把以rr为底的指数转为ee为底数的形式,得到:

F=1eΔtlnrf(t)=(ab)etlnr+bF = 1-e^{Δt\ln{r}} \\ f(t) = (a-b)*e^{t\ln{r}} + b

这里转为以ee为底数的原因是后续我们要在代码里使用Mathf.Exp方法,而不是Mathf.Pow方法,因为指数计算代价昂贵,而有关自然数ee的相关计算是经过底层优化的,性能较好。

我们再看看第二种定义,我们可以得到如下式子:

f(t12)bab=12\frac{f(t_{\frac{1}{2}})-b}{a-b} = \frac{1}{2}

f(t12)=(ab)(1F)t12/Δt+bf(t_{\frac{1}{2}}) = (a-b)(1-F)^{t_{\frac{1}{2}}/Δt} + b代入上式并化简,可得:

F=12Δtt12F = 1 - 2^{-\frac{Δt}{t_{\frac{1}{2}}}}

将此FF的表达式带入f(t)f(t)中,可得:

f(t)=(ab)2tt12+bf(t) = (a-b)*2^{-\frac{t}{t_{\frac{1}{2}}}} + b

公式总结

Math.Lerp方法等价表达式
原式a = Mathf.Lerp(a, b, F)a=(1F)a+Fba = (1 - F) * a + F * b
定义r是常量,意义是1s后变化的剩余进度是r100%r * 100\%a = Mathf.Lerp(a, b, 1 - Mathf.Pow(r, Δt))a=(ab)rΔt+ba = (a-b)*r^{Δt} + b
同上a = Mathf.Lerp(a, b, 1 - Mathf.Exp(Δt * Mathf.Log(r)))a=(ab)eΔtlnr+ba = (a-b)*e^{Δt\ln{r}} + b
定义t是常量,意义是t秒后变化进度是50%50\%a = Mathf.Lerp(a, b, 1 - Mathf.Pow(2, -Δt/t))a=(ab)2Δtt+ba = (a-b)*2^{\frac{-Δt}{t}} + b

实践示例

private readonly float remainder = .2f; // 1s后,a的值距离b还有(.2f * 100)%
private float decay => Mathf.Log(remainder); // 衰变,物理学里的原子核衰变、半衰期等与自然数、指数函数关系密切

private void Update(){
    a = Mathf.Lerp(a, b, 1 - Mathf.Exp(Time.deltaTime * decay));
}

或者

private readonly float halfTime = .5f; // .5s后,a的值位于当前a与b一半的位置

private void Update(){
    a = Mathf.Lerp(a, b, 1 - Mathf.Pow(2, -Time.deltaTime / halfTime));
}