算法、数据结构、与设计模式等在游戏开发中的运用 (三):插值(Interpolation)

作者:Compasslg(李涵威)

1. 什么是插值

插值(Interpolation)其实是数学中的一种常用概念,他是利用一种给定函数来连接点的方式。在数学中,插值被用于通过将离散的点数据连接成连续的曲线,来达到补全函数图像的目的。而在游戏开发中,插值则常常被运用于实现动画(Animation)和 移动(motion)

所谓插值,代表的是在离散点之间通过插入连续的“估值”来连接他们的概念,而不同的插值方法可以达到不同的连接效果。常用的插值有线性插值,三角函数插值,样条插值等。不同的插值类型会造成在关键点附近图像的平滑程度有所区别,但总的而言,给定的数据点都一定会在图像上,这也是插值与数学中另一个常常被拿来讨论的概念 拟合(Curve Fitting) 的区别。

  • 线性插值是直接利用直线来连接点
    算法、数据结构、与设计模式等在游戏开发中的运用 (三):插值(Interpolation)-LMLPHP


  • 非线性插值产生的图像斜率变化得更为平滑
    算法、数据结构、与设计模式等在游戏开发中的运用 (三):插值(Interpolation)-LMLPHP


2. 如何实现和使用插值

插值的类型很多,但调用方式都大同小异,基本上都是给定数据点(起点和终点)以及当前自变量的值为参数,然后返回这个自变量所对应的插值。由于这篇博文主要讨论的是插值在游戏中的应用而非每个插值的实现原理,这里我只以最简单的线性插值和利用三角函数实现的非线性插值为例进行代码实现。

线性插值的实现非常简单,你可以把他想象成路程为(起点 - 终点),总时间为1的匀速直线运动。以下为范例代码:

float LinearInterpolate(float startVal, float endVal, float t){
	return startVal + t * (endVal - startVal);
}

非线性插值的主要优势在于在比线性插值在数据点附近会更为平滑,实现例如在起点附近加速,终点附近减速的效果;但他同样是t从0到1,返回值从起点运动到终点。也就是说,只要对t稍加处理,只要两端的0和1不变,就可以达到这个平滑的效果。

我们都知道cos(t$\pi$)的函数图像在$t \in [0, 1]$中y值是“平滑”的从1运动-1,在t = 0 附近加速变化,t=1附近减速变化,如下图所示
算法、数据结构、与设计模式等在游戏开发中的运用 (三):插值(Interpolation)-LMLPHP



所以我们只要稍加变化, 用 (1 - cos(t$\pi$)) / 2 就可以得到我们想要的效果,平滑的从起点运动到终点,如下图所示

算法、数据结构、与设计模式等在游戏开发中的运用 (三):插值(Interpolation)-LMLPHP



以下为范例代码:

float CosInterpolate(float startVal, float endVal, float t){
    float t_cos = (1 - Mathf.Cos(t * Mathf.PI)) / 2;
    return startVal + t_cos * (endVal - startVal);
}

在unity以及各种有向量概念的游戏引擎中,你也可以直接将数据点参数改成向量类型。由于实现方式除了使用的数据类型以外基本相同,这里就不重复了。

Vector3 interpolate(Vector3 startpoint, Vector3 endpoint, float t);

插值函数具体的调用方法会在下面介绍。

3. 游戏开发中的应用(Unity)

在游戏开发中,插值主要被运用在下列几个方面:

  1. 将时间作为参数,通过插值来补充某个数据(坐标点、颜色等)来实现平滑的直线运动或者颜色渐变的效果

算法、数据结构、与设计模式等在游戏开发中的运用 (三):插值(Interpolation)-LMLPHP



算法、数据结构、与设计模式等在游戏开发中的运用 (三):插值(Interpolation)-LMLPHP



以下是Unity中用线性插值实现线性移动和渐变的代码(非线性插值的使用同理,只要改变调用的函数即可;除了上一部分实现的cos插值以外,很多游戏引擎本身也有提供类似的函数,感兴趣的可以去了解一下Unity中的SmoothStep 和 SmoothDamp)。

public class Mover : MonoBehaviour
{
    // 在Inspector中设置起点和终点的位置
    public Vector3 startpoint, endpoint;
    // 从起点运动到终点所需要的时间(周期)
    public float period;
    // 当前时间参数t
    private float t;
    private SpriteRenderer spriteRenderer;
    // Start is called before the first frame update
    void Start()
    {
        t = 1;
        spriteRenderer = GetComponent<SpriteRenderer>();
    }

    // Update is called once per frame
    void Update()
    {
        // 按下空格键开始移动
        if(Input.GetKeyDown(KeyCode.Space)){
            t = 0;
        }

        // 更新当前时间,并通过插值获取位置
        if(t < period){
            t += Time.deltaTime;
            // 这里使用的是Unity Vector3中自带的线性插值函数,效果相同只是直接作用在Vector3上
            // 以时间为参数,用插值获得起点到终点之间的位置
            // 由于插值默认时间t在0..1之间(period = 1),这里需要用 t/period 来转化成动画播放的实际周期
            transform.position = Vector3.Lerp(startpoint, endpoint, t / period);

            // UnityEngine.Color也同样有线性插值函数Lerp,实现方法一样只是作用与Color(r,g,b,a)
            //这里展示的是在移动中从白色转变为黑色的过程
            spriteRenderer.color = Color.Lerp(Color.white, Color.black, t / period);
        }
    }
}

  1. 假设你的精灵表单(Spritesheet)中有n个精灵(Sprite)是用于某一个动画中的,那么你可以将时间作为参数,用插值的方法来获得[0, n]之间的精灵索引值(Index)来控制精灵图片的切换速率,实现精灵动画(Sprite Animation)效果

算法、数据结构、与设计模式等在游戏开发中的运用 (三):插值(Interpolation)-LMLPHP

算法、数据结构、与设计模式等在游戏开发中的运用 (三):插值(Interpolation)-LMLPHP




以下为Unity中的范例代码

public class SpriteSheetAnimator : MonoBehaviour
{
    // spritesheet中所有的sprites
    public Sprite[] sprites;

    // 动画播放一遍所需要的时间(周期)
    public float period;

    // 当前动画播放时间
    private float t;

    private SpriteRenderer spriteRenderer;
    // Start is called before the first frame update
    void Start()
    {
        t = period;
        spriteRenderer = GetComponent<SpriteRenderer>();
    }

    // Update is called once per frame
    void Update()
    {

        // 按下空格键重制时间,从头播放
        if(Input.GetKeyDown(KeyCode.Space)){
            t = 0;
            Debug.Log("Start");
        }

        // 更新 当前时间t 和 当前精灵图片
        if(t < period){
            t += Time.deltaTime;

            // 利用获得以0为起点,(sprites总数-1)为终点的插值来计算当前精灵图片的index
            // 由于插值默认时间t在0..1之间(period = 1),这里需要用 t/period 来转化成动画播放的实际周期
            int curIndex = Mathf.FloorToInt(interpolate(t / period, 0, sprites.Length - 1));
            spriteRenderer.sprite = sprites[curIndex];
        }
    }

    public float interpolate(float startVal, float endVal, float t){
	    return startVal + t * (endVal - startVal);
    }
}

除了上述两个被详细介绍的方面以外,插值也可以用于碰撞检测(实际上是通过使用参数方程来计算碰撞点是否存在,以后开单章介绍)以及曲线运动(基本和连接函数图像一样,这里就不赘述了)。同时,插值在图形学中也有很多妙用。不过对于游戏开发来说,图形学涉及的部分已经非常底层,这里也同样略过了。

4. 总结

总体而言,插值在实现简单的动态效果上是非常实用的,更何况他的实现方式也并不复杂,而且大部分游戏开发工具都会自带插值函数。不过要学习更多有趣的插值函数和运用方法,不是短短一篇博客可以解决的,这篇文章也旨在帮助大家了解插值在游戏开发中最常用到的地方,所以如果有兴趣进一步了解的话还是需要自己去学习。

04-25 03:05