插值


目录


前言

生活中,插值很实用,但是我们似乎没意识到。你在使用的PhotoShop、Krita等绘图软件绘制线条时,平滑的线条实际上就经过了插值处理。随着短视频的流行,剪映的出现,大家可以简单地剪辑视频,视频中过渡时也会用到插值。游戏开发过程中,实现一个角色的平滑移动,也用到了插值。本文将从游戏开发的角度介绍插值。 本项目可以从这里获取:插值


一、线性插值

插值:在两个值之间取一个值。(这个定义怎么这么粗略XD)

插值公式,万变不离其宗。

\(p_{current} = (1-u) \times p_{start} + u \times p_{end}\)

上面的是最常用的插值公式,\(p_{current}\)为当前值,\(p_{start}\)为起始值,\(p_{end}\)为最终值,\(u\)为进度。

u是什么的进度?我的见解是该插值过程的进度。线性内插值中,u的取值范围从0到1。

若令u = 0,那么当前值等于起始值,也就意味着插值进展为0,还没开始;

若令u = 1,那么当前值等于最终值,也就意味着插值进展为100%(1),已经完成了。

如果我想让一个物体在指定时间内到达指定位置,应该如何做呢?


// 绑定至小方块上
public class Interpolation : MonoBehaviour
{
    public Vector3 curPos; // 当前位置
    public Vector3 startPos; // 起点位置
    public Vector3 endPos = new Vector3(0,0,10); // 最终点的位置
    public float startTime; // 开始运动的起始时间

    public Transform testCubeTransform; // 这个小方块将从初始位置运动到(0,0,10)

    public float timeDuration = 1f; // 插值过程(小方块移动到鼠标的位置这个过程)持续时间

    public bool canMove = false; // 是否可以移动

    private void Start()
    {
        // 获取小方块的Transform组件
        testCubeTransform = this.GetComponent();
    }

    private void Update()
    {
        // 当按下空格键,可以开始测试;按下P键,退出测试
        if (Input.GetKeyDown(KeyCode.Space))
        {
            canMove = true;
            // 记录开始运动的时间
            startTime = Time.time;
        }
        if (Input.GetKeyDown(KeyCode.P))
        {
            canMove = false;
        }

        // 没开始测试就不计算这些插值
        if (!canMove)
        {
            return;
        }

        // 记录起始点
        startPos = testCubeTransform.position;

        // 计算u值
        float u = (Time.time - startTime) / timeDuration;

        // 限制u的值
        if (u >= 1)
        {
            u = 1;
            canMove = false;
        }

        // 计算当前位置
        curPos = (1 - u) * startPos + u * endPos;

        testCubeTransform.position = curPos;
        
    }
}
          

在Unity中创建一个Cube对象,并设置其位置为(0,0,0),将上面的脚本附加到该对象上,点击运行后,按下空格即可开始测试,大概效果如图。

01线性插值运动的小方块.gif

线性插值运动的小方块


二、使用插值让一个对象跟随另一个对象移动

小插曲:你听说过Zeno悖论吗?有一只青蛙,每次向前跳跃都只能到达距离前方的墙一半的位置,它永远也无法跳到墙上……

话不多说,直接附上代码。


public class FollowMouse : MonoBehaviour
{
    public Transform cube; // 小方块的transform组件


    private void Update()
    {
        Vector3 mouseScreenPos = Input.mousePosition; // 鼠标屏幕坐标
        mouseScreenPos.z = 10; // 手动设置一下
        if (Input.GetKeyDown(KeyCode.S))
            Debug.Log(mouseScreenPos);

        Vector3 mouseWorldPos = Camera.main.ScreenToWorldPoint(mouseScreenPos); // 将屏幕坐标转换成世界坐标
        if(Input.GetKeyDown(KeyCode.W))
            Debug.Log(mouseWorldPos);

        float u = 0.5f;

        cube.position = (1 - u) * cube.position + u * mouseWorldPos;
        


    }
}
          

效果如下:

12.gif

方块跟随鼠标移动


三、更平滑的插值

1. 缓入;2. 缓出;3. 缓入缓出;4. Sin;5. Sin缓入;6. Sin缓出。

1. 缓入

缓入呈现出的效果是开始的运动较慢,逐渐加快运动。实现缓入的方法是通过令\(u = u^{easingModify}\),其中easingModify指的是缓入的平滑程度。接下来我将修改代码,让物体实现缓入的效果。

当easingModify为1时,实际上和前面提到的线性插值没有区别。

以下为修改后的代码:


// 绑定至小方块上
public class Interpolation : MonoBehaviour
{
    public Vector3 curPos; // 当前位置
    public Vector3 startPos; // 起点位置
    public Vector3 endPos = new Vector3(0,0,10); // 最终点的位置
    public float startTime; // 开始运动的起始时间

    //----------新增的代码----------//
    public float easingModify = 2; // 缓动的修饰值
    //----------新增的代码----------//

    public Transform testCubeTransform; // 这个小方块将从初始位置运动到(0,0,10)

    public float timeDuration = 1f; // 插值过程(小方块移动到鼠标的位置这个过程)持续时间

    public bool canMove = false; // 是否可以移动

    private void Start()
    {
        // 获取小方块的Transform组件
        testCubeTransform = this.GetComponent();
    }

    private void Update()
    {
        // 当按下空格键,可以开始测试;按下P键,退出测试并回到原点
        if (Input.GetKeyDown(KeyCode.Space))
        {
            canMove = true;
            // 记录开始运动的时间
            startTime = Time.time;
        }
        if (Input.GetKeyDown(KeyCode.P))
        {
            canMove = false;
            testCubeTransform.position = new Vector3(0, 0, 0);
        }

        // 没开始测试就不计算这些插值
        if (!canMove)
        {
            return;
        }

        // 记录起始点
        startPos = testCubeTransform.position;

        // 计算u值
        float u = (Time.time - startTime) / timeDuration;

        

        // 限制u的值
        if (u >= 1)
        {
            u = 1;
            canMove = false;
        }

        //----------新增的代码----------//
        float u2 = Mathf.Pow(u, easingModify);
        //----------新增的代码----------//

        // 计算当前位置,将u换成u2测试缓动
        curPos = (1 - u2) * startPos + u2 * endPos;

        testCubeTransform.position = curPos;
        
    }
}
          

来看看效果如何,以下分别为easingModify为2和3的时候的效果:

02easingModify=2.gif

easingModify = 2, \(u2 = u^{easingModify}\)

03easingModify=3.gif

easingModify = 3, \(u2 = u^{easingModify}\)

2. 缓出

缓出的效果是物体快到达终点时速度变缓。实现的方式是令\(u=1-(1-u)^{easingModify}\),请定位到这一句代码并修改。


            // 修改前的代码
            float u2 = Mathf.Pow(u, easingModify);

            // 修改后的代码
            float u2 = 1 - Mathf.Pow(1-u,easingModify);
          

由于是缓出,为了更明显地看出区别,我将endPos设为了(0,0,30),timeDuration设为了4。读者可以调整至合适的数值以观察出区别。

04easingModify=1.gif

easingModify = 1, \(u2=1-(1-u)^{easingModify}\)

05easingModify=2.gif

easingModify = 2, \(u2=1-(1-u)^{easingModify}\)

当easingModify=1时,是最原始的线性插值;当easingModify=2时,是缓出更平滑,可以看到刚开始运动得快一些。

3. 缓入缓出

当\(u<=0.5\)时,令\(u=0.5 \times \frac{(2u)^{easingModify}}{2}\);当\(u>0.5\)时,令\(u=0.5 + 0.5 - \frac{[1-2(u-0.5)]^{easingModify}}{2}\)。

请修改u2的值。


float u2 = u;
if (u <= 0.5)
{
    u2 = Mathf.Pow(u, easingModify);
}
else if(u>0.5)
{
    u2 = 1 - Mathf.Pow(1 - u, easingModify);
}
          

下面是效果展示:

06easingModify=1.gif

easingModify = 1, 当\(u<=0.5\)时,令\(u=0.5 \times \frac{(2u)^{easingModify}}{2}\);当\(u> 0.5\)时,令\(u=0.5 + 0.5 - \frac{[1-2(u-0.5)]^{easingModify}}{2}\)

07easingModify=2.gif

easingModify = 2, 当\(u<=0.5\)时,令\(u=0.5 \times \frac{(2u)^{easingModify}}{2}\);当\(u> 0.5\)时,令\(u=0.5 + 0.5 - \frac{[1-2(u-0.5)]^{easingModify}}{2}\)

可以明显发现缓入缓出在开始和快到达终点时都较慢。

4. Sin

Sin,令\(u2=u+easingModify \times \sin(2\pi u) \)。

修改代码如下:


float u2 = u + easingModify * Mathf.Sin(Mathf.PI * u * 2);
          

效果如下图所示,timeDuration=1,endPos = (0,0,5),分别展示了easingModify = 1, easingModify =-0.2的情形。

08easingModify=1.gif

easingModify = 1, \(u2=u+easingModify \times \sin(2\pi u) \)

09easingModify=-0.2.gif

easingModify = -0.2, \(u2=u+easingModify \times \sin(2\pi u) \)

可以发现,当easingModify = -0.2时,可以看见小方块先往后移动了一小段距离再往前运动,有一种蓄力助跑的感觉。

5. Sin缓入

Sin缓入,令\(u2=1-\cos(\frac{\pi}{2}u)\),你应该注意到了,这个效果中没有使用easingModify。

修改代码如下:


float u2 = 1 - Mathf.Cos(Mathf.PI * 0.5f * u);
          
10.gif

\(u2=1-\cos(\frac{\pi}{2}u)\)

你能观察到效果还不错。

6. Sin缓出

Sin缓出,令\(u2=sin(\frac{\pi}{2}u)\)

修改代码如下:


float u2 = Mathf.Sin(Mathf.PI * 0.5f * u);            
          
11.gif

\(u2=sin(\frac{\pi}{2}u)\)

效果还行,开始比较快。

四、贝塞尔曲线

贝塞尔曲线实际上实现的是三个及以上的点的插值,有点像俄罗斯套娃。

举个例子,有三个点,分别为P1、P2和P3点。过程如下:

  1. 使用公式\(p_{current} = (1-u) \times p_{start} + u \times p_{end}\)计算P1点和P2点间的插值,记为P-12。
  2. 同理计算P2点和P3点间的插值,记为P-23。
  3. 最后计算P-12和P-23之间的插值,作为最终结果。

实际上这个过程进行了3次插值运算,再举个例子,有三个点,分别为P1、P2、P3和P4。插值过程如下:

  1. 使用公式\(p_{current} = (1-u) \times p_{start} + u \times p_{end}\)计算P1点和P2点间的插值,记为P-12。
  2. 同理计算P2点和P3点间的插值,记为P-23。
  3. 同理计算P3和P4间的插值,记为P-34。
  4. 接着计算P-12和P-23间的插值,记为P-123。
  5. 接着计算P-23和P-34间的插值,记为P-234。
  6. 最后计算P-123和P-234间的插值,记为P-1234,作为最终结果。

可以看到,四个点进行了6次插值运算。

接下来在Unity中看看效果如何。


public class InterpolationBezier : MonoBehaviour
{
    public Transform cube; // 小方块

    // 三个点均在检查器面板设置
    public Vector3 pos1;
    public Vector3 pos2;
    public Vector3 pos3;

    public float startTime;
    public float timeDuration;
    public bool canMove;

    private void Update()
    {
        // 开始测试
        if (Input.GetKeyDown(KeyCode.Space))
        {
            canMove = true;
            startTime = Time.time;
        }
        // 回到原点
        if (Input.GetKeyDown(KeyCode.P))
        {
            cube.position = Vector3.zero;
            canMove = false;
        }
        if (!canMove)
            return;

        float u = (Time.time - startTime) / timeDuration;

        if (u >= 1)
        {
            u = 1;
            canMove = false;
        }

        Vector3 pos12 = (1 - u) * pos1 + u * pos2;
        Vector3 pos23 = (1 - u) * pos2 + u * pos3;

        Vector3 pos123 = (1 - u) * pos12 + u * pos23;

        cube.position = pos123;

    }
}
          
13.gif

贝塞尔曲线,(0,0,0)->(10,0,10)->(0,0,30),timeDuration=1

效果还不错XD

总结

本文介绍了几种线性插值,利用这些插值实现平滑的移动吧!