有潜在问题的错误方式使用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 = .1f,1s内执行10次累加,末速度是1m/s;当用户帧率是120f/s时,Time.deltaTime = .008333f,1s内执行120次累加,末速度依然是1m/s。
为什么乘以Time.deltaTime后就能达到我们想要的效果呢?Time.deltaTime的单位可以看作是s/f(秒/帧),speed的单位是m/s,乘积的结果是m/f,我们把速度从和时间有关转为与帧率有关的数值。
深入研究Lerp
我们很容易知道a = Mathf.Lerp(a, b, Time.deltaTime),的变化不是简单线性变化的,因此这里第3个参数用Time.deltaTime或者固定值如.1f并不能抵消掉不同帧率的影响,所以我们的目标是为第3个参数找到一个合适的与Time.deltaTime有关的表达式来代替。
我们首先看看这样使用Lerp究竟是一种怎样的变化。
// F: Factor,插值系数,常量
a = Mathf.Lerp(a, b, F)
// 等价于
a = (1 - F) * a + F * b
由此,我们很容易得到第次与第次之间关系的表达式:
这是一个递推公式,转换为执行次数的表达式(计算过程略)为:
已知执行次数转换为与时间有关的表达式,可以表述为:
代入上述式子可以得到关于时间的表达式:
变量在指数上,底数是常数,因此我们知道这是呈指数变化。我们考虑指数函数的一般形式:
当确定时,指数函数就确定了,那么我们该如何确定应该是多少呢?换一种更简单直白的问法,即我们需要如何定义不同设备不同帧率下相同的变化呢,就像上述定义速度在1s后到达1m/s。
当然有无数的定义方法,这里选2种较简单的定义:
- 是介于的常数,时刻即
1s后从变化到的剩余进度是 - 意义是在时刻从变化到的进度是
我们先看第一种定义,我们可以得到如下式子:
将代入上式并化简,可得:
将此的表达式带入中,可得:
我们把以为底的指数转为为底数的形式,得到:
这里转为以为底数的原因是后续我们要在代码里使用Mathf.Exp方法,而不是Mathf.Pow方法,因为指数计算代价昂贵,而有关自然数的相关计算是经过底层优化的,性能较好。
我们再看看第二种定义,我们可以得到如下式子:
将代入上式并化简,可得:
将此的表达式带入中,可得:
公式总结
| Math.Lerp方法 | 等价表达式 | |
|---|---|---|
| 原式 | a = Mathf.Lerp(a, b, F) | |
定义r是常量,意义是1s后变化的剩余进度是 | a = Mathf.Lerp(a, b, 1 - Mathf.Pow(r, Δt)) | |
| 同上 | a = Mathf.Lerp(a, b, 1 - Mathf.Exp(Δt * Mathf.Log(r))) | |
定义t是常量,意义是t秒后变化进度是 | a = Mathf.Lerp(a, b, 1 - Mathf.Pow(2, -Δt/t)) |
实践示例
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));
}